epics_base_rs/server/record/record_instance.rs
1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::Mutex as StdMutex;
4use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5
6use crate::error::{CaError, CaResult};
7use crate::server::event_queue::{EventReader, EventUser};
8use crate::server::pv::{MonitorEvent, Subscriber};
9use crate::server::recgbl::EventMask;
10use crate::server::snapshot::{
11 ControlInfo, DisplayInfo, EnumInfo, EnumStringForm, PropertySupport,
12};
13use crate::types::{DbFieldType, EpicsValue, PvString, c_parse};
14
15use super::alarm::{AlarmSeverity, AnalogAlarmConfig};
16use super::common_fields::CommonFields;
17use super::link::{
18 ParsedLink, out_link_discards_cp, parse_forward_link_v2, parse_link_v2, parse_output_link_v2,
19};
20use super::menu_choices::MenuBound;
21use super::record_trait::{
22 AuxPostMask, CommonFieldPutResult, FieldDeclaration, FieldDesc, ProcessSnapshot, Record,
23 RecordProcessResult, SubroutineFn,
24};
25use super::scan::{ScanType, SimModeScan};
26
27/// Every client-visible `special(SPC_NOMOD)` field of `dbCommon.dbd:13-190`.
28///
29/// These are common fields — no record's `field_list` declares them — so the
30/// declaration names them here. The remaining `SPC_NOMOD` entries in
31/// `dbCommon.dbd` (MLOK, MLIS, BKLNK, ASP, PPN, PPNR, SPVT, RSET, DSET, DPVT,
32/// RDES, LSET, BKPT) are `DBF_NOACCESS`: they have no field API in this port at
33/// all, and C refuses them one level earlier, at `dbNameToAddr`.
34///
35/// TIME is `DBF_NOACCESS` in C too (`dbpf REC.TIME` → "failed"), but this port
36/// resolves `.TIME`, so the declaration must cover it.
37///
38/// Read only through [`RecordInstance::is_no_mod`].
39const DBCOMMON_NOMOD: &[&str] = &[
40 "NAME", "STAT", "SEVR", "AMSG", "NSTA", "NSEV", "NAMSG", "ACKS", "ACKT", "LCNT", "PACT",
41 "PUTF", "RPRO", "TIME", "UTAG",
42];
43
44/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
45/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
46///
47/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
48/// originating (put-target) record AND every record reached through its
49/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
50/// has finished processing. A single wait-set owns the completion
51/// oneshot; only it fires, and only when the last chain member leaves.
52///
53/// Counting convention: [`Self::new`] arms `pending = 1` for the
54/// originating record (which always joins). Every additional PP target
55/// that will process under the active notify [`Self::enter`]s on join
56/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
57/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
58/// the `leave` that drops `pending` to zero.
59pub struct NotifyWaitSet {
60 pending: AtomicUsize,
61 tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
62}
63
64impl NotifyWaitSet {
65 /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
66 /// starts at 1 for the originating record — its completion `leave`s
67 /// that implicit slot, so a put with no chain targets fires
68 /// immediately on the originating record's own completion.
69 pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
70 Arc::new(Self {
71 pending: AtomicUsize::new(1),
72 tx: StdMutex::new(Some(tx)),
73 })
74 }
75
76 /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
77 /// one [`Self::leave`].
78 pub fn enter(&self) {
79 self.pending.fetch_add(1, Ordering::AcqRel);
80 }
81
82 /// A record finished its contribution (C `dbNotifyCompletion`). Fires
83 /// the completion oneshot on the `leave` that empties the set.
84 pub fn leave(&self) {
85 let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
86 debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
87 if prev == 1 {
88 if let Some(tx) = self.tx.lock().unwrap().take() {
89 let _ = tx.send(());
90 }
91 }
92 }
93
94 /// True once every chain member has left (the completion has fired).
95 /// Used by the put entry to decide synchronous (return `None`) vs
96 /// async-pending (return the receiver) completion.
97 pub fn completed(&self) -> bool {
98 self.pending.load(Ordering::Acquire) == 0
99 }
100}
101
102/// A put-notify (`dbPutNotify` — CA WRITE_NOTIFY, `caput -c`) that landed on a
103/// PACT record and was therefore deferred WHOLE.
104///
105/// C `processNotifyCommon` (dbNotify.c:225-231) tests `precord->pact` above
106/// `ppn->putCallback`, so nothing is written and nothing is marked: the record
107/// joins the notify's wait list in state `notifyRestartInProgress`, and when the
108/// async cycle completes the put is replayed against a record that is no longer
109/// active — value written, record processed, callback fired only after THAT
110/// process finishes. So a client's "callback returned" still means "the value I
111/// sent has been processed".
112///
113/// softIoc 7.0.10.1-DEV, `ASY` (calcout, `ODLY=4`, `A=5`), `caput -c ASY.A 7`
114/// issued 1 s into the async cycle:
115///
116/// ```text
117/// t=1s A=5 PACT=1 <- cycle in flight
118/// t=2s A=5 PACT=1 RPRO=0 <- put-notify pending: nothing written
119/// t=4s A=7 PACT=1 <- cycle done; the put is replayed
120/// callback returns at t=6.9s: A=7 VAL=7 <- after the RESTARTED process
121/// ```
122pub struct DeferredNotifyPut {
123 /// The field the client wrote (already upper-cased).
124 pub field: String,
125 /// The value it wrote — held here, unwritten, until the restart.
126 pub value: crate::types::EpicsValue,
127 /// The client's completion channel. The replayed put builds its wait-set
128 /// around this sender, so the callback fires on the restarted process, not
129 /// on the in-flight cycle.
130 pub completion: crate::runtime::sync::oneshot::Sender<()>,
131}
132
133/// The PACT→idle transition, as a value.
134///
135/// # Invariant
136///
137/// `RecordInstance::deferred_notify_put.is_some()` ⟹ PACT is held. A
138/// put-notify is parked ONLY on the `is_processing()` arm of the put entry
139/// ([`RecordInstance::park_notify_put`]), and PACT can be released ONLY by
140/// [`RecordInstance::leave_pact`], which takes the park with it. So the
141/// deferral cannot outlive the PACT window it was parked in — the strand is
142/// unrepresentable rather than guarded against.
143///
144/// The token is `#[must_use]`: a release site cannot silently drop the parked
145/// put. Its single consumer is `PvDatabase::apply_pact_exit`, called from the
146/// released cycle's `recGblFwdLink` tail — C `recGbl.c:295` (`if (pdbc->ppn)
147/// dbNotifyCompletion(pdbc)`), which is where C queues the restart callback
148/// (`dbNotify.c:466-469`, state `notifyRestartInProgress`). Holding the token
149/// to the tail rather than replaying at the `pact = FALSE` store is what keeps
150/// the replay behind the rest of the cycle, exactly as C's queued callback is.
151#[must_use = "a put-notify parked on this record is stranded unless the PactExit \
152 reaches PvDatabase::apply_pact_exit"]
153pub struct PactExit(Option<DeferredNotifyPut>);
154
155impl Drop for PactExit {
156 /// Last-resort canary. Every release site hands its token to
157 /// `PvDatabase::apply_pact_exit`; a token reaching here still holding a
158 /// parked put means a PACT release path was added without a tail. The
159 /// client is not left hanging (dropping `completion` errors its receiver),
160 /// but the put's value IS lost, so say so.
161 fn drop(&mut self) {
162 if self.0.is_some() {
163 tracing::error!(
164 "PactExit dropped with a put-notify still parked: a PACT release \
165 path is not routed through PvDatabase::apply_pact_exit"
166 );
167 }
168 }
169}
170
171impl PactExit {
172 /// The put-notify this release freed, if one was parked.
173 pub(crate) fn into_deferred(mut self) -> Option<DeferredNotifyPut> {
174 self.0.take()
175 }
176
177 /// Fold two releases of the same cycle into one token.
178 ///
179 /// A simulated SDLY continuation releases PACT inside `check_simulation_mode`
180 /// and again at the `is_continuation` arm; the second release finds PACT
181 /// already clear and carries nothing, because the first took the park.
182 pub(crate) fn merge(mut self, mut other: PactExit) -> PactExit {
183 debug_assert!(
184 self.0.is_none() || other.0.is_none(),
185 "a parked put-notify can be released only once per cycle"
186 );
187 PactExit(self.0.take().or_else(|| other.0.take()))
188 }
189
190 /// A cycle that released no PACT (and therefore freed no put-notify).
191 pub(crate) fn none() -> PactExit {
192 PactExit(None)
193 }
194}
195
196/// Cached metadata for a record.
197///
198/// Stores the result of `populate_display_info` / `populate_control_info` /
199/// `populate_enum_info` so subsequent `snapshot_for_field` /
200/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
201/// cache is invalidated whenever a metadata-class field is written
202/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
203///
204/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
205/// every snapshot needs full metadata for NTScalar serialization, the
206/// cache eliminates redundant per-event populate work.
207#[derive(Clone, Default)]
208pub(crate) struct MetadataSnapshot {
209 pub display: Option<DisplayInfo>,
210 pub control: Option<ControlInfo>,
211 pub enums: Option<EnumInfo>,
212}
213
214/// Returns true if this field is property-class — the C `prop(YES)`
215/// dbd attribute: writing a changed value posts `DBE_PROPERTY` to the
216/// record's subscribers AND invalidates the metadata cache. Field name
217/// is expected uppercase.
218///
219/// **Every field read by `populate_display_info`,
220/// `populate_control_info`, or `populate_enum_info` MUST be in this
221/// set** — otherwise the cache serves stale metadata until some other
222/// tracked field is written. The reverse does not hold: a field may be
223/// property-class without being a cache source (e.g. the motor fields
224/// below feed the live-computed `field_metadata_override`, never the
225/// cache — its invalidation on their write is harmless).
226///
227/// Currently uncovered (because it is not yet populated by any
228/// `populate_*` function): `DESC` (would map to `display.description`
229/// — populate hook missing). The `Q:form` info tag is now wired
230/// (`populate_display_info` -> `display.form`), but as an immutable
231/// load-time info tag — not a runtime field — it needs no cache
232/// invalidation and so is intentionally absent from this field set.
233fn is_metadata_field(name: &str) -> bool {
234 matches!(
235 name,
236 // Display info (analog + integer + motor) — `prop(YES)` in
237 // ai/ao/longin/longout DBDs.
238 "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
239 // Alarm limits (used by both display and the analog_alarm config) —
240 // ai/ao/longin/longout `prop(YES)`.
241 | "HIHI" | "HIGH" | "LOW" | "LOLO"
242 // Alarm severities for the four limit thresholds —
243 // ai/ao/longin/longout `prop(YES)` per upstream DBDs
244 // (`aiRecord.dbd.pod` lines 357-388).
245 | "HHSV" | "HSV" | "LSV" | "LLSV"
246 // Output ctrl limits — ao/longout `prop(YES)`.
247 | "DRVH" | "DRVL"
248 // motor `prop(YES)` (`motorRecord.dbd` 154/161/289/361/368):
249 // VBAS/VMAX bound VELO's range, MRES the RVAL/RRBV raw range,
250 // DHLM/DLLM the DVAL/DRBV range — all served per field by
251 // `Record::field_metadata_override` (C get_graphic_double /
252 // get_control_double). HLM/LLM/EGU/PREC and the alarm limits
253 // are motor `prop(YES)` too, already listed above.
254 | "VBAS" | "VMAX" | "MRES" | "DHLM" | "DLLM"
255 // bi/bo/busy enum strings — `prop(YES)`.
256 | "ZNAM" | "ONAM"
257 // bi/bo state severities — `biRecord.dbd.pod` / `boRecord.dbd.pod`
258 // `prop(YES)` for ZSV/OSV/COSV (zero / one / change-of-state).
259 | "ZSV" | "OSV" | "COSV"
260 // mbbi/mbbo state strings (16 levels) — `prop(YES)`.
261 | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
262 | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
263 )
264}
265
266/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
267/// severity threshold is enabled, `NaN` otherwise. Mirrors C
268/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN` — a NONZERO
269/// test on the raw ordinal, so an out-of-range severity still enables the
270/// limit.
271fn gated(severity: i16, limit: f64) -> f64 {
272 if severity != 0 { limit } else { f64::NAN }
273}
274
275/// Extract the RAW stored ordinal a put lands in a `menu(menuAlarmSevr)`
276/// severity field (`HHSV`/`HSV`/`LSV`/`LLSV`/`UDFS`/`DISS`), WITHOUT clamping
277/// to the 0..=3 valid range.
278///
279/// C's numeric menu put stores whatever `(epicsEnum16)` the value truncates to
280/// (`dbConvert.c::putDoubleEnum` = `*pfield = (epicsEnum16)*psrc`), so
281/// `caput REC.HSV 4` keeps `4` and `caput REC.HSV -1` keeps `65535` — both
282/// wire-visible (served signed as `-1`) and both used verbatim to derive the
283/// alarm. The carrier is `i16` so the 16-bit pattern round-trips; the alarm
284/// meaning is read back with [`AlarmSeverity::from_u16`] and the C nonzero
285/// enable with `!= 0`.
286///
287/// A numeric value has already been wrapped to `epicsEnum16` upstream
288/// (`EpicsValue::convert_to(Enum)`, the one owner of C's double→enum cast); this
289/// only reinterprets its bit pattern. A `String` is a db-load / internal-link
290/// label (a client string put is rejected-or-resolved by `putStringMenu`
291/// upstream), resolved to its ordinal here.
292fn menu_ordinal_raw(value: &EpicsValue) -> i16 {
293 match value {
294 EpicsValue::String(s) => match s.as_str_lossy().as_ref() {
295 "NO_ALARM" => 0,
296 "MINOR" => 1,
297 "MAJOR" => 2,
298 "INVALID" => 3,
299 other => other
300 .parse::<i64>()
301 .ok()
302 .map(|n| n as u16 as i16)
303 .unwrap_or(0),
304 },
305 other => other.to_f64().unwrap_or(0.0) as i64 as u16 as i16,
306 }
307}
308
309/// Coerce a db-loaded `String` for a numeric/menu **common** field to that
310/// field's canonical DBF type before [`RecordInstance::put_common_field`]
311/// dispatches on it.
312///
313/// The db loader applies a record's own fields with the typed
314/// `EpicsValue::parse(desc.dbf_type, value_str)` (`db_loader::apply_fields`),
315/// but a field absent from `field_list` is pushed to the common-field path as
316/// a raw `EpicsValue::String` — it has no `FieldDesc` to parse against. The
317/// numeric common-field arms in `put_common_field` match only their typed
318/// variant, so without this step a `.db` `field(PHAS, "1")`,
319/// `field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`, `field(DISA, "1")`, … is
320/// silently dropped at IOC load. Routing the String through the same
321/// `EpicsValue::parse` the record-field path uses handles the numeric *and*
322/// menu-label forms uniformly, so the arm receives the value it expects.
323///
324/// Only fields whose canonical type is numeric/menu are listed; the
325/// Port of libcom `epicsParseInt32(str, &to, 10, NULL)`
326/// (`libcom/src/misc/epicsStdlib.c:26-53,245-261`), which is how pvxs parses
327/// the `nsec:lsb:` digit count. Returns `None` for every status the C
328/// returns non-zero for:
329///
330/// - `S_stdlib_noConversion` — `strtol` consumed nothing (empty / no digits)
331/// - `S_stdlib_extraneous` — trailing non-space bytes with `units == NULL`
332/// - `S_stdlib_overflow` — outside `epicsInt32`
333///
334/// Leading and trailing ASCII whitespace and a leading `+`/`-` sign are
335/// accepted, matching `epicsParseLong`'s `isspace` skips and `strtol`.
336fn epics_parse_int32_base10(s: &str) -> Option<i32> {
337 // `while ((c = *str) && isspace(c)) ++str;` then `strtol(str, &endp, 10)`.
338 let body = s.trim_start_matches(|c: char| c.is_ascii_whitespace());
339 let (sign, digits) = match body.strip_prefix(['+', '-']) {
340 Some(rest) if body.starts_with('-') => (-1i64, rest),
341 Some(rest) => (1i64, rest),
342 None => (1i64, body),
343 };
344 let end = digits
345 .find(|c: char| !c.is_ascii_digit())
346 .unwrap_or(digits.len());
347 if end == 0 {
348 return None; // endp == str → S_stdlib_noConversion
349 }
350 // `if (c && !units) return S_stdlib_extraneous;` after skipping trailing
351 // whitespace.
352 if !digits[end..]
353 .trim_start_matches(|c: char| c.is_ascii_whitespace())
354 .is_empty()
355 {
356 return None;
357 }
358 // ERANGE from `strtol`, then the explicit `epicsInt32` range check.
359 let magnitude: i64 = digits[..end].parse().ok()?;
360 i32::try_from(sign * magnitude).ok()
361}
362
363/// The STORED type of a `dbCommon` field — the variant its
364/// [`RecordInstance::put_common_field_bounded`] arm binds, which is not always
365/// the type the `.dbd` DECLARES it as (a `menu()` field is declared `DBF_MENU`
366/// and served `DBR_ENUM`, but held here as its bare index).
367///
368/// String-typed common fields (DESC, ASG, OUT, TSEL, …) have no entry: their
369/// arms take the string verbatim.
370fn stored_common_field_type(name: &str) -> Option<DbFieldType> {
371 Some(match name {
372 "SCAN" | "SSCN" | "PINI" => DbFieldType::Enum,
373 "TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" | "ACKS"
374 | "SEVR" | "STAT" | "NSEV" | "NSTA" => DbFieldType::Short,
375 // The analog-alarm limits and the hysteresis margin — `DBF_DOUBLE` in
376 // every dbd that declares them (`aiRecord.dbd.pod:357-388`,
377 // `calcRecord.dbd.pod:716-744`, `sCalcoutRecord.dbd:479-531`). A field
378 // missing from this table reaches its arm as whatever variant the caller
379 // built, and an arm that binds a typed variant then drops it: that is
380 // how `field(HYST,"2")` silently became 0 on every record whose
381 // hysteresis lives in `common.hyst`
382 // (calc/calcout/scalcout/ai/ao/longin/int64in).
383 "HIHI" | "HIGH" | "LOW" | "LOLO" | "HYST" => DbFieldType::Double,
384 // The `DBF_UCHAR` flags. `bool` here, a NUMBER in C.
385 "DISP" | "UDF" | "TPRO" | "RPRO" | "BKPT" | "PROC" => DbFieldType::Char,
386 _ => return None,
387 })
388}
389
390/// **The single owner of "what type does a `dbCommon` field hold"**, run on
391/// EVERY put before the typed arms below see the value — so an arm may bind one
392/// variant and know the put cannot have arrived in another.
393///
394/// This is not a string-parsing convenience. A common field is reached by three
395/// writers with three different ideas of the value's shape: the db loader hands
396/// every field over as a raw `String`; a `dbPut` arrives coerced to the field's
397/// DECLARED type (`DBF_MENU` → `Enum` for PRIO, `DBF_UCHAR` → `Char` for DISP);
398/// an internal link delivers whatever its source stored. Before this ran on the
399/// non-`String` shapes too, each arm's single-variant `if let` was a silent
400/// drop for the other two writers — `caput REC.PRIO HIGH` resolved its label to
401/// `Enum(2)` and then vanished at the arm, leaving PRIO at 0.
402///
403/// An unparseable String is returned as-is so the arm drops it, and a menu
404/// field's bad label FAILS the put (`S_db_badChoice`) rather than landing as
405/// index 0.
406fn coerce_common_field(name: &str, value: EpicsValue, bound: MenuBound) -> CaResult<EpicsValue> {
407 let Some(dbf) = stored_common_field_type(name) else {
408 return Ok(value);
409 };
410 let EpicsValue::String(s) = &value else {
411 // Already typed: project onto the stored type through the one
412 // value-coercion owner. `convert_to` short-circuits a value that is
413 // already `dbf`, so the common case costs nothing.
414 return Ok(value.convert_to(dbf));
415 };
416 let text = s.as_str_lossy();
417 // A `DBF_MENU` common field resolves its label against THAT field's own
418 // menu through the one converter every menu-field string put uses
419 // (C `dbConvert.c::putStringMenu`: exact label, else an index below
420 // `nChoice`, else `S_db_badChoice`) — the same rule the record-specific
421 // menu fields follow in `coerce_write_value`. The failure PROPAGATES: the
422 // field-blind `EpicsValue::parse` fallback below must never see a menu
423 // field, or `caput REC.PRIO Bogus` lands as index 0 instead of failing.
424 //
425 // SCAN/SSCN/PINI are menu fields like any other and go through the same
426 // converter. They used to each carry a hand-written `from_str` that drifted
427 // from C: `ScanType::from_str` case-folded and invented `"0.5 second"`
428 // aliases for menuScan's `".5 second"` (and mapped any out-of-range index
429 // to Passive), `SimModeScan::from_str` took any u16, `PiniMode::from_str`
430 // trimmed. C has ONE converter and it does none of that.
431 if let Some(choices) = super::menu_choices::shared_menu_choices(name) {
432 return super::menu_choices::resolve_menu_field_string_bounded(
433 name, choices, dbf, &text, bound,
434 );
435 }
436 // Numeric (non-menu) common field: C's `dbPut` runs the string through the
437 // SAME `epicsParse*` (`dbConvert.c` `putString*`) the record data fields use,
438 // and a non-zero status REFUSES the whole put (`dbAccess.c:1362`, mapped to
439 // `ECA_PUTFAIL`). Route it through the single owner of that conversion —
440 // [`c_parse::put_string`] — instead of the field-blind `EpicsValue::parse`,
441 // which wrapped (`256 as u8 == 0`) and swallowed the error (`Err(_) =>
442 // Ok(value)`), so `caput REC.PROC 256` and `caput REC.PROC notanumber` were
443 // accepted where C rejects them.
444 //
445 // Key the parse on the field's C-DECLARED width, not its stored variant: the
446 // `DBF_UCHAR` flags (DISP/UDF/TPRO/RPRO/BKPT/PROC, `dbCommon.dbd`) are held in
447 // the signed `Char` variant here but C parses them with `epicsParseUInt8`, so
448 // `caput REC.PROC 255` and `caput REC.PROC -1` (→255) are accepted and only
449 // `256`+/non-numeric refused. `put_string` returns the value in the declared
450 // variant; project it back onto the stored variant through the one
451 // value-coercion owner (byte-identity for `UChar`→`Char`).
452 let declared = match dbf {
453 DbFieldType::Char => DbFieldType::UChar,
454 other => other,
455 };
456 let Some(target) = c_parse::NumericField::of(declared) else {
457 // Unreachable: every numeric `stored_common_field_type` (Char/Short/
458 // Double, all with a numeric row) reaches here; the `Enum` menu types
459 // returned above. Keep the pre-parse value rather than panic.
460 return Ok(value);
461 };
462 c_parse::put_string(name, target, text.trim()).map(|parsed| parsed.convert_to(dbf))
463}
464
465/// The alarm-acknowledge request types C's `dbPut` dispatches on
466/// (`dbAccess.c:1331-1335`): `DBR_PUT_ACKT` and `DBR_PUT_ACKS`.
467///
468/// Acknowledgement is a *request type*, not a field write — the two handlers
469/// run above the `SPC_NOMOD` gate that refuses every ordinary put to ACKT/ACKS.
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub enum AlarmAck {
472 /// `DBR_PUT_ACKT` → `putAckt`: set transient-alarm acknowledgement.
473 Transient,
474 /// `DBR_PUT_ACKS` → `putAcks`: acknowledge an alarm of this severity.
475 Severity,
476}
477
478/// A type-erased record instance stored in the database.
479pub struct RecordInstance {
480 pub name: String,
481 pub record: Box<dyn Record>,
482 pub common: CommonFields,
483 pub subscribers: HashMap<String, Vec<Subscriber>>,
484 // Link parse cache
485 pub parsed_inp: ParsedLink,
486 pub parsed_out: ParsedLink,
487 pub parsed_flnk: ParsedLink,
488 pub parsed_sdis: ParsedLink,
489 pub parsed_tsel: ParsedLink,
490 // Device support
491 pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
492 // Subroutine (for sub records)
493 pub subroutine: Option<Arc<SubroutineFn>>,
494 /// PACT (C `precord->pact`) — the re-entrancy guard, and the record's
495 /// "busy" state for every put that lands on it.
496 ///
497 /// PRIVATE by construction: entered through [`RecordInstance::enter_pact`]
498 /// and released ONLY through [`RecordInstance::leave_pact`], which hands
499 /// back the [`PactExit`] carrying any put-notify parked on this PACT window.
500 /// A `pact.store(false)` open-coded at a release site is what stranded the
501 /// deferral on the ODLY/SDLY paths; it is no longer expressible.
502 pact: AtomicBool,
503 // Put-notify wait-set this record currently belongs to (C
504 // `precord->ppn`). Set when the record joins an active put-notify
505 // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
506 // taken + `leave`d when the record's processing completes. `None`
507 // outside any put-notify. See [`NotifyWaitSet`].
508 pub notify: Option<Arc<NotifyWaitSet>>,
509 /// A put-notify that arrived while this record was PACT, deferred WHOLE —
510 /// C `processNotifyCommon` (dbNotify.c:225-231) tests `precord->pact`
511 /// ABOVE `putCallback`, so a `dbPutNotify` onto a busy record writes
512 /// nothing: no value, no RPRO. The record is parked on the notify's wait
513 /// list and the entire put — value, process, callback — is restarted once
514 /// the async cycle completes. See [`DeferredNotifyPut`] and
515 /// `PvDatabase::restart_deferred_notify_put`, the single owner that applies
516 /// it. `None` outside that window.
517 ///
518 /// PRIVATE, and paired with [`Self::pact`] by the [`PactExit`] invariant:
519 /// parked only by [`Self::park_notify_put`] (reachable only from the
520 /// PACT arm of the put entry), taken only by [`Self::leave_pact`].
521 deferred_notify_put: Option<DeferredNotifyPut>,
522 /// The value of each subscribed field as ALREADY PUBLISHED to that
523 /// field's `DBE_VALUE`/`DBE_LOG` subscribers. The generic
524 /// change-detection loop in every snapshot builder posts a field only
525 /// when its current value differs from this — so this map is what
526 /// C's per-record `*_lst` / MARK state is to `monitor()`.
527 ///
528 /// # Invariant (CONTRACT)
529 ///
530 /// A field's value MUST NOT be published twice by the framework.
531 /// Concretely: every value-class post (a `db_post_events` carrying
532 /// `DBE_VALUE` and/or `DBE_LOG`) MUST advance this map for the field it
533 /// posts; an alarm-only / property-only post MUST NOT (those classes do
534 /// not deliver the value to a `DBE_VALUE`/`DBE_LOG` subscriber, so the
535 /// change is still owed to them).
536 ///
537 /// In C, `dbPut` (dbAccess.c:1407-1414) is the record's ONLY post for a
538 /// put: `db_post_events(precord, pfieldsave, DBE_VALUE|DBE_LOG)`. No
539 /// record's `monitor()` re-posts that field — it posts a closed set and
540 /// compares against its own `*_lst` fields. A framework that posts on the
541 /// put and then change-detects the same field on the next process cycle
542 /// sends an event C never sends.
543 ///
544 /// # Owner
545 ///
546 /// [`RecordInstance::record_value_post`] is the SINGLE writer. The field
547 /// is private so no path outside this module can advance (or fail to
548 /// advance) it: the snapshot builders read it through
549 /// [`RecordInstance::posted_value`] and every poster —
550 /// [`RecordInstance::notify_field_with_origin`] included — advances it
551 /// through the owner.
552 last_posted: HashMap<String, EpicsValue>,
553 /// The live store for a field the record's `.dbd` DECLARES but the record
554 /// struct has no `put_field` arm / no storage for — the WRITE analog of the
555 /// read-side [`Self::declared_default`] fallback.
556 ///
557 /// C makes every `.dbd` field not just readable but WRITABLE: `dbPutField`
558 /// resolves the field from its `dbFldDes` and `dbPut` writes the incoming
559 /// value into record memory, whether or not any record code ever reads it
560 /// back — a `caput dfanout.HOPR 10` sticks even though `dfanoutRecord.c`
561 /// never touches HOPR. A Rust record models only the fields it has
562 /// behaviour for, so a field it declares but never stores had nowhere for a
563 /// put to land: [`Self::put_common_field`]'s catch-all reported
564 /// `S_dbLib_fieldNotFound` and the client's put was refused, while a READ of
565 /// the same field succeeded through `declared_default`. This map is that
566 /// missing storage — one uniform mechanism for the whole family, not a
567 /// per-field struct member on each record type.
568 ///
569 /// Keyed by upper-case field name, holding the value already coerced to the
570 /// field's C-declared DBF type (the same projection `declared_default` and
571 /// the read path serve). [`Self::resolve_field`] reads it BEFORE
572 /// `declared_default`, so a read reflects a prior write and an untouched
573 /// field still reads its `.dbd` initial. Empty for a record whose declared
574 /// fields are all modeled.
575 declared_overrides: HashMap<String, EpicsValue>,
576 /// Set by `check_deadband_ext` for waveform/aai/aao when their
577 /// content hash changed this cycle (C `monitor()` On Change mode,
578 /// waveformRecord.c:310-319). The snapshot builders read it to post
579 /// `HASH` with a literal `DBE_VALUE` event, independent of the VAL
580 /// post mask. False for every record without the MPST/APST/HASH
581 /// mechanism.
582 pub(crate) array_hash_changed: bool,
583 /// One-shot "skip the registered subroutine this cycle" signal for aSub
584 /// `LFLG=READ`. The async processing path resolves the `SUBL` link before
585 /// taking this lock; when the resolved name is bad (C `fetch_values` ->
586 /// `S_db_BadSub`) or the link read failed, C `process` runs `do_sub` only
587 /// on `!status`, so the subroutine is skipped. Set by the resolution
588 /// apply, consumed (and cleared) by [`Self::run_registered_subroutine`];
589 /// `false` for every record without a pending bad re-resolution.
590 pub(crate) suppress_subroutine_run: bool,
591 /// Generation counter for ReprocessAfter timer cancellation.
592 /// Bumped each process cycle. Spawned timers check this to avoid
593 /// stale re-processes from accumulated timers.
594 pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
595 /// Generation counter for the monitor watchdog
596 /// ([`Record::watchdog_interval`] / [`Record::watchdog_fire`]), bumped by
597 /// each `PvDatabase::arm_watchdog` so a re-arm supersedes the tick already
598 /// in flight — C `callbackRequestDelayed` replacing an outstanding delayed
599 /// callback. Deliberately NOT `reprocess_generation`: C's histogram wdog is
600 /// its own `epicsCallback`, independent of the record's SDLY/async
601 /// re-entry, so an SDLY defer must not cancel the watchdog nor vice versa.
602 pub watchdog_generation: Arc<std::sync::atomic::AtomicU64>,
603 /// Per-record info tags from `info("key", "value")` directives in
604 /// the .db file (epics-base info(...) grammar). Consumers include
605 /// asyn (`asyn:READBACK`), record-as-PV bridge tags
606 /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
607 /// records loaded without info(...) clauses.
608 pub info: HashMap<String, String>,
609 /// Cached metadata (display/control/enums) — `None` means stale or
610 /// not yet built. Populated lazily by `snapshot_for_field` /
611 /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
612 /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
613 /// is written.
614 ///
615 /// Wrapped in `std::sync::Mutex` for interior mutability — the
616 /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
617 /// `PvDatabase`, and snapshot construction holds a read lock; the
618 /// inner Mutex lets us still mutate the cache from a `&self` method.
619 ///
620 /// # Cache invariant (CONTRACT)
621 ///
622 /// The cache is **only correct under the following contract**: every
623 /// code path that mutates a metadata-class field (the set defined in
624 /// the file-private `is_metadata_field` predicate) MUST call
625 /// [`RecordInstance::notify_field_written`] (or
626 /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
627 ///
628 /// All current write paths in `field_io.rs` already do this. If you
629 /// add a new code path that:
630 ///
631 /// - calls `instance.record.put_field(...)` directly, OR
632 /// - mutates record fields from inside `Record::process()`,
633 /// `Record::on_put`, or `Record::special` and that mutation could
634 /// touch a metadata-class field, OR
635 /// - lets a `Box<dyn Record>` implementation expose its own
636 /// mutation methods that change metadata fields,
637 ///
638 /// then call `instance.notify_field_written(field_name)` to keep the
639 /// cache consistent. Forgetting will produce a stale snapshot —
640 /// monitors will continue to see the old EGU/PREC/limits until the
641 /// next legitimate metadata-field write triggers invalidation.
642 ///
643 /// # Symmetric note for `populate_*` extensions
644 ///
645 /// If a future change adds a new field to `populate_display_info`,
646 /// `populate_control_info`, or `populate_enum_info` (e.g. populating
647 /// `display.description` from DESC), the new source field name MUST
648 /// also be added to `is_metadata_field` so writes to it invalidate
649 /// the cache. (The `Q:form` -> `display.form` mapping is exempt: it
650 /// reads an immutable load-time info tag, not a runtime field.)
651 pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
652}
653
654/// The cycle status [`RecordInstance::run_registered_subroutine`] reports when
655/// `do_sub` was skipped — C's `fetch_values` failure / `S_db_BadSub` path, which
656/// leaves `process`'s `status` non-zero (aSubRecord.c:216-224).
657const SUBROUTINE_STATUS_SKIPPED: i64 = -1;
658/// No subroutine is bound: C `do_sub` returns `S_db_BadSub` (aSubRecord.c:255).
659const SUBROUTINE_STATUS_NO_SUB: i64 = -2;
660/// The bound subroutine returned `Err` — no C counterpart (a C subroutine
661/// returns a `long`), and a failed cycle either way.
662const SUBROUTINE_STATUS_ERROR: i64 = -3;
663
664/// C `monitor()`'s post of the deadband field, as assembled by the single owner
665/// [`RecordInstance::deadband_post`].
666pub(crate) struct DeadbandPost {
667 /// C's `monitor_mask` for this cycle. Also the mask the
668 /// [`Record::fields_posted_with_value_mask`] secondaries ride: C posts them
669 /// from INSIDE the `if (monitor_mask)` guard, with the same mask.
670 pub mask: EventMask,
671 /// The deadband field's own post — `(field, value)`. `None` when no class
672 /// fired (C's `if (monitor_mask)` skips the post) or the field does not
673 /// resolve.
674 pub field: Option<(String, EpicsValue)>,
675}
676
677/// A value's `DBR_STRING` form, for a source whose field metadata is NOT
678/// reachable (an external CA/PVA link, a constant, an lnkCalc result) — the
679/// fallback half of [`RecordInstance::field_as_dbr_string`], which is also the
680/// whole rule once the local choice table has had its say.
681///
682/// A pvalink NTEnum still resolves its label here: the carrier brings its own
683/// `choices` (pvxs `pvalink_lset.cpp:344-356` — a `DBR_STRING` target copies
684/// `choices[index]`). A bare `Enum` index from a link whose labels the port
685/// cannot reach falls back to its decimal form, like the CA `*_STRING` encoder.
686/// **The** declaration lookup: a field's `dbFldDes`, in C's terms.
687///
688/// The record type's declaration is [`FieldDeclaration::field_list`] — the
689/// generated `.dbd` table where one exists and the hand-written table where it
690/// does not, never both. `dbCommon` is asked last, so a record-specific field
691/// shadows the common one.
692///
693/// A free function, not just a [`RecordInstance`] method, because the sites that
694/// need the declaration do not all hold an instance — a constant-link seed, a
695/// link write, the db loader all have `&dyn Record`.
696///
697/// `None` for a field with no declaration at all: a virtual field (`RTYP`,
698/// `TIME`, ...), which C answers from dbStaticLib rather than from a `dbFldDes`.
699pub(crate) fn field_desc_of<R: Record + ?Sized>(
700 record: &R,
701 field: &str,
702) -> Option<&'static FieldDesc> {
703 let named = |t: &'static [FieldDesc]| t.iter().find(|f| f.name.eq_ignore_ascii_case(field));
704 named(record.field_list()).or_else(|| named(super::dbd_generated::DB_COMMON_FIELDS))
705}
706
707/// **The single owner of "which choice list does this field resolve against"**,
708/// asked by BOTH sides of a menu field:
709///
710/// * the READ side ([`RecordInstance::enum_string_form_for`]) — C `getMenuString`,
711/// which renders the stored index as its choice;
712/// * the WRITE side ([`crate::server::record::coerce_put_value`]) — C
713/// `putStringMenu`, which resolves an incoming label to that same index.
714///
715/// They MUST see the same list or the field is not round-trippable. The write
716/// side used to ask only [`Record::menu_field_choices`] (the record's hand
717/// table), so an `aSub`'s `caput FTA LONG` found no menu, fell through to the
718/// numeric parse and landed as index 0 — while the read side, on the `.dbd`
719/// menu, rendered index 0 as `STRING`.
720///
721/// Order is C's: the field's own `menu()` from the declaration, then the
722/// record's hand table where the `.dbd` does not reach (the downstream crates'
723/// record types), then the `dbCommon` menus.
724///
725/// The last step — [`shared_menu_choices`](super::menu_choices::shared_menu_choices)
726/// — is a heuristic keyed on the field NAME (`OSV`, `SIMS`, `HHSV`, …), so it is
727/// consulted ONLY when the field's declaration does not already pin a non-menu
728/// type: a menu is served as `DBR_ENUM`, so a field DECLARED `DBF_STRING` is
729/// never a menu. Without this gate scalcout's string `OSV` ("Output string
730/// value") matched the name-based `menuAlarmSevr` entry a same-named bi/bo
731/// severity field owns, and `caput SCALCOUT.OSV <string>` was rejected with
732/// `S_db_badChoice` where C accepts the string.
733pub(crate) fn menu_choices_of<R: Record + ?Sized>(
734 record: &R,
735 field: &str,
736) -> Option<&'static [&'static str]> {
737 // `DTYP` is `DBF_DEVICE`: its choices are the record type's DEVICE menu
738 // (C `dbDeviceMenu`, built from the `device()` declarations), which is
739 // per-record-type and so cannot live in the shared `dbCommon` FieldDesc.
740 if field.eq_ignore_ascii_case("DTYP") {
741 return super::dbd_generated::device_menu(record.record_type());
742 }
743 let desc = field_desc_of(record, field);
744 desc.and_then(|f| f.menu)
745 .or_else(|| record.menu_field_choices(field))
746 .or_else(|| {
747 // Name-based fallback — but the declared type wins: a `DBF_STRING`
748 // field is not a menu even when a same-named field elsewhere is.
749 if desc.is_some_and(|f| f.dbf_type == DbFieldType::String) {
750 None
751 } else {
752 super::menu_choices::shared_menu_choices(field)
753 }
754 })
755}
756
757/// The `DBF_*` type `field` is SERVED as, for a caller that holds only a
758/// `&dyn Record` — the free-function form of
759/// [`RecordInstance::declared_field_type`], and the ONLY way any site outside
760/// the instance may turn a [`FieldDesc`] into a type.
761///
762/// A [`FieldDesc::runtime_typed`] field (`waveform.VAL` typed by `FTVL`, an
763/// `aSub`'s `A`..`U` typed by `FTA`..`FTU`) has NO type in its declaration: C's
764/// `cvt_dbaddr` overwrites `paddr->field_type` from record state, so the `.dbd`
765/// entry is a placeholder and the value the record stores is the answer. Every
766/// caller falls back to that value, which is why this returns `None` rather than
767/// the placeholder — handing out `DBF_DOUBLE` for a `FTVL=CHAR` waveform is how
768/// a string written down an output link became `0.0`.
769pub(crate) fn declared_field_type_of<R: Record + ?Sized>(
770 record: &R,
771 field: &str,
772) -> Option<DbFieldType> {
773 let desc = field_desc_of(record, field)?;
774 (!desc.runtime_typed).then_some(desc.dbf_type)
775}
776
777pub(crate) fn value_as_dbr_string(value: &EpicsValue) -> Option<PvString> {
778 match value {
779 EpicsValue::String(s) => Some(s.clone()),
780 EpicsValue::Enum(v) => Some(PvString::from(v.to_string())),
781 EpicsValue::EnumWithChoices { index, choices } => Some(
782 choices
783 .get(*index as usize)
784 .cloned()
785 .unwrap_or_else(|| PvString::from(index.to_string())),
786 ),
787 other => match other.clone().convert_to(DbFieldType::String) {
788 EpicsValue::String(s) => Some(s),
789 _ => None,
790 },
791 }
792}
793
794impl RecordInstance {
795 pub fn new(name: String, record: impl Record) -> Self {
796 Self::new_boxed(name, Box::new(record))
797 }
798
799 pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
800 let rtype = record.record_type();
801 let analog_alarm = match rtype {
802 // C parity: every record type whose dbd carries
803 // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
804 // config slot. Previously calc / calcout were missing —
805 // their put_field for those fields silently no-op'd
806 // because `self.common.analog_alarm` was None at the
807 // mutation site. Confirmed via
808 // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
809 // calcoutRecord.dbd.pod:1103+ (same). `sub` carries the same
810 // HIHI/HIGH/LOLO/LOW + HHSV/HSV/LSV/LLSV set
811 // (subRecord.dbd.pod:569-642) and runs the analog `checkAlarms`.
812 // `scalcout` declares the identical set (`sCalcoutRecord.dbd:479-531`
813 // HIHI/LOLO/HIGH/LOW/HHSV/LLSV/HSV/LSV/HYST + `:858` LALM) and its
814 // `checkAlarms` (`sCalcoutRecord.c:699-751`) is the same ladder, run
815 // BEFORE the OOPT switch (`:371`) precisely so a limit excursion can
816 // drive IVOA. Without the slot the record had no alarm surface at
817 // all: `caput scalc.HIHI 5` was a `FieldNotFound` and a scalcout
818 // could never go MINOR/MAJOR on its own result.
819 //
820 // **This match is the single owner of "which records have the analog
821 // ladder"** — `evaluate_alarms` runs it off the slot's presence, so a
822 // record added here gets the ladder and one absent cannot.
823 "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
824 | "sub" | "scalcout" => Some(AnalogAlarmConfig::default()),
825 _ => None,
826 };
827 let mut common = CommonFields::default();
828 common.analog_alarm = analog_alarm;
829
830 Self {
831 name,
832 record,
833 common,
834 subscribers: HashMap::new(),
835 parsed_inp: ParsedLink::None,
836 parsed_out: ParsedLink::None,
837 parsed_flnk: ParsedLink::None,
838 parsed_sdis: ParsedLink::None,
839 parsed_tsel: ParsedLink::None,
840 device: None,
841 subroutine: None,
842 pact: AtomicBool::new(false),
843 notify: None,
844 deferred_notify_put: None,
845 last_posted: HashMap::new(),
846 declared_overrides: HashMap::new(),
847 array_hash_changed: false,
848 suppress_subroutine_run: false,
849 reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
850 watchdog_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
851 info: HashMap::new(),
852 metadata_cache: StdMutex::new(None),
853 }
854 }
855
856 /// **The owner of a record's init passes** — C `iocInit.c::doInitRecord0`
857 /// (`:508-536`) and `doInitRecord1`. Nothing else may call
858 /// `Record::init_record`.
859 ///
860 /// C runs a prologue on EVERY record before pass 0, and it is the reason
861 /// this is one function instead of two `init_record` calls at each caller:
862 ///
863 /// ```c
864 /// /* Reset the process active field */
865 /// precord->pact = FALSE;
866 ///
867 /// /* Initial UDF severity */
868 /// if (precord->udf && precord->stat == UDF_ALARM)
869 /// precord->sevr = precord->udfs;
870 /// ```
871 ///
872 /// A record is born `udf = 1`, `stat = UDF_ALARM` (dbCommon.dbd
873 /// `initial("UDF")`), `udfs = INVALID` — so after `iocInit` a record that
874 /// has NEVER processed advertises `STAT=UDF SEVR=INVALID`, not
875 /// `NO_ALARM`. That is what makes an `MS` consumer inherit
876 /// `LINK`/`INVALID` from a not-yet-processed source, the IOC-startup
877 /// ordering case MS exists for (softIoc-verified). A record whose
878 /// `init_record` or device support defines the value clears UDF and the
879 /// severity goes away on its first process.
880 ///
881 /// `name` is used only for the init-failure diagnostics C sends to errlog.
882 ///
883 /// Crate-private on purpose: the passes must run against the record's FINAL
884 /// loaded field set (the initial UDF severity is a function of UDF/STAT/
885 /// UDFS, and a `.db` `field(VAL,…)` clears UDF at load — C
886 /// `dbStaticLib.c:2653-2661`). The one caller is the creation sink,
887 /// [`crate::server::database::PvDatabase::add_loaded_record`], which takes
888 /// the load and the record together so no path can init a half-loaded
889 /// record.
890 pub(crate) fn run_init_passes(&mut self, name: &str) {
891 // C's `precord->pact = FALSE` — a record cannot be mid-process at init,
892 // so this release provably frees nothing: no client put has run, so the
893 // `PactExit` invariant (`deferred_notify_put.is_some()` ⟹ PACT) makes a
894 // parked put-notify here unreachable.
895 let deferred = self.leave_pact().into_deferred();
896 debug_assert!(
897 deferred.is_none(),
898 "a record cannot hold a parked put-notify at init"
899 );
900 drop(deferred);
901 if self.common.udf != 0
902 && self.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM
903 {
904 self.common.sevr = AlarmSeverity::from_u16(self.common.udfs as u16);
905 }
906 if let Err(e) = self.record.init_record(0) {
907 eprintln!("init_record(0) failed for {name}: {e}");
908 }
909 if let Err(e) = self.record.init_record(1) {
910 eprintln!("init_record(1) failed for {name}: {e}");
911 }
912 // The UDF tail of pass 1. `init_record` cannot reach UDF (a common
913 // field), so the record types whose C `init_record` ends in
914 // `prec->udf = FALSE` — histogram's `clear_histogram`, aao's constant
915 // DOL, mbboDirect's B0..B1F fold (epics-base dabcf89) — deliver it
916 // through this hook instead. It lives HERE, inside the init owner,
917 // because it is part of the same C pass: a creation path that ran the
918 // passes but skipped the tail (iocsh `dbLoadRecords` did) left those
919 // records UDF=1 where C has UDF=0.
920 // The `post_init_finalize_undef` hook is a cross-crate record-trait API
921 // over a `bool` (histogram/aao/mbboDirect implement it); bridge the raw
922 // `u8` carrier through it here at the single init owner.
923 let mut udf = self.common.udf != 0;
924 if let Err(e) = self.record.post_init_finalize_undef(&mut udf) {
925 eprintln!("post_init_finalize_undef failed for {name}: {e}");
926 }
927 self.common.udf = udf as u8;
928 // C `init_record` that ends in `prec->udf = 0; recGblResetAlarms(prec)`
929 // — the asyn record, defined and no-alarm the moment it loads. The born
930 // `UDF`/`INVALID` (and the UDF-severity derivation above) are overwritten
931 // here: at init `nsta`/`nsev` are 0, so `rec_gbl_reset_alarms` transfers
932 // `STAT`/`SEVR` to `NO_ALARM`. Runs after `post_init_finalize_undef` so
933 // it is the final word on this record's initial alarm state.
934 if self.record.init_resets_alarms() {
935 self.common.udf = 0;
936 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
937 }
938 // C `init_record` can END with `prec->pact = TRUE` to disable a record
939 // it cannot process (`subRecord.c:119-123`, an empty SNAM). PACT has one
940 // owner, so the record declares the fact and the owner parks it — after
941 // the passes, so the `leave_pact()` above cannot undo it. There is
942 // nothing to release later: `dbProcess` takes the PACT-active branch on
943 // every scan from here on, which is exactly the point.
944 if self.record.init_record_parks_pact() {
945 self.enter_pact();
946 }
947 }
948
949 /// SINGLE OWNER of the DTYP -> soft-output-dset mapping. The dset table
950 /// decides what a soft OUT-link write carries; no caller may re-derive it.
951 ///
952 /// C ships two soft output dsets per output record type and DTYP picks one:
953 /// `devXxxSoft.c::write_xxx` puts VAL/OVAL on the OUT link, while
954 /// `devXxxSoftRaw.c::write_xxx` puts the RAW word — `dbPutLink(&prec->out,
955 /// DBR_LONG, &prec->rval, 1)` (`devAoSoftRaw.c:44`, `devBoSoftRaw.c:65`) or
956 /// `data = prec->rval & prec->mask` (`devMbboSoftRaw.c:71-75`,
957 /// `devMbboDirectSoftRaw.c:71-75`).
958 ///
959 /// `Record::raw_soft_output_value` IS the SoftRaw column of that table:
960 /// `Some` exactly for the record types C ships a SoftRaw dset for. A record
961 /// type C has no SoftRaw dset for keeps the plain soft-channel value —
962 /// `DTYP="Raw Soft Channel"` on a `longout` is a `.db` error C rejects at
963 /// init ("no device support"), and the port's lenient reading of it (the
964 /// same one [`crate::server::device_support::is_soft_dtyp`] already applies
965 /// on the input side) must not turn the write into a silent no-op.
966 ///
967 /// `None` means DTYP names device support that owns the write — real
968 /// hardware, or "Async Soft Channel", whose dset is a registered async
969 /// device.
970 pub fn soft_output_value(&self) -> Option<Option<EpicsValue>> {
971 if self.common.dtyp == "Raw Soft Channel" {
972 return Some(
973 self.record
974 .raw_soft_output_value()
975 .or_else(|| self.record.output_link_value()),
976 );
977 }
978 if self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel" {
979 return Some(self.record.output_link_value());
980 }
981 None
982 }
983
984 /// Set a single `info("key", "value")` tag on this record. Last
985 /// write wins. Used by the .db loader (`info(...)` directive) and
986 /// `dbpf`-style tools.
987 pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
988 self.info.insert(key.into(), value.into());
989 }
990
991 /// Look up a single info tag. Returns `None` when the record has
992 /// no tag with that key.
993 pub fn get_info(&self, key: &str) -> Option<&str> {
994 self.info.get(key).map(|s| s.as_str())
995 }
996
997 /// The value of `field` already published to its `DBE_VALUE`/`DBE_LOG`
998 /// subscribers, or `None` when the framework has never published one.
999 /// The read side of the `last_posted` contract — see the field's docs.
1000 pub(crate) fn posted_value(&self, field: &str) -> Option<&EpicsValue> {
1001 self.last_posted.get(field)
1002 }
1003
1004 /// SINGLE OWNER of `last_posted`: record that `value` has been published
1005 /// to `field`'s `DBE_VALUE`/`DBE_LOG` subscribers, so no later cycle
1006 /// change-detects and re-publishes it.
1007 ///
1008 /// Every value-class post — the snapshot builders' change-detected posts,
1009 /// the intermediate async-notify posts, and the put-time
1010 /// [`Self::notify_field_with_origin`] post that C makes from `dbPut`
1011 /// (dbAccess.c:1414) — routes through here. Alarm-only / property-only
1012 /// posts MUST NOT call it: they deliver nothing to a value-class
1013 /// subscriber, so the value is still owed.
1014 pub(crate) fn record_value_post(&mut self, field: &str, value: EpicsValue) {
1015 if let Some(slot) = self.last_posted.get_mut(field) {
1016 *slot = value;
1017 } else {
1018 self.last_posted.insert(field.to_string(), value);
1019 }
1020 }
1021
1022 /// Invalidate the metadata cache. Called after writing any
1023 /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
1024 /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
1025 /// cache from the new values.
1026 pub fn invalidate_metadata_cache(&self) {
1027 if let Ok(mut guard) = self.metadata_cache.lock() {
1028 *guard = None;
1029 }
1030 }
1031
1032 /// Hook called by the database after a field is written. If the
1033 /// field is in the metadata-class set, the cache is invalidated so
1034 /// the next snapshot picks up the new value.
1035 ///
1036 /// Field name is automatically uppercased.
1037 pub fn notify_field_written(&self, field: &str) {
1038 let upper = field.to_ascii_uppercase();
1039 if is_metadata_field(&upper) {
1040 self.invalidate_metadata_cache();
1041 }
1042 }
1043
1044 /// Like [`notify_field_written`] but skips the invalidation when
1045 /// the put did not actually change the field's value. Mirrors
1046 /// epics-base `faac1df1` — `DBE_PROPERTY` events fire only on
1047 /// real changes, not on idempotent writes (the C path compares
1048 /// `paddr->pfield` against the converted payload before setting
1049 /// the `propertyUpdate` flag).
1050 ///
1051 /// `prev` is the value captured BEFORE the put. Callers that
1052 /// don't need the change-detection (e.g. internal writers that
1053 /// know the field is non-metadata) can keep using
1054 /// [`notify_field_written`].
1055 // must post EventMask::PROPERTY to all field subscribers when metadata changes
1056 pub fn notify_field_written_if_changed(&mut self, field: &str, prev: Option<&EpicsValue>) {
1057 let upper = field.to_ascii_uppercase();
1058 if !is_metadata_field(&upper) {
1059 return;
1060 }
1061 let now = self.record.get_field(&upper);
1062 if prev != now.as_ref() {
1063 self.invalidate_metadata_cache();
1064 // mirror C dbAccess.c:1396-1397 db_post_events(precord, NULL, DBE_PROPERTY).
1065 // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
1066 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
1067 for f in fields {
1068 self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
1069 }
1070 }
1071 }
1072
1073 /// Returns the cached MetadataSnapshot, building and storing it on
1074 /// the first call (or after invalidation). Used by both
1075 /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
1076 /// cost is paid at most once per metadata-stable interval.
1077 fn cached_metadata(&self) -> MetadataSnapshot {
1078 // Fast path: cache hit
1079 if let Ok(guard) = self.metadata_cache.lock()
1080 && let Some(cached) = guard.as_ref()
1081 {
1082 return cached.clone();
1083 }
1084
1085 // Cache miss: build a fresh metadata snapshot
1086 let mut tmp = super::super::snapshot::Snapshot::new(
1087 EpicsValue::Double(0.0),
1088 0,
1089 0,
1090 std::time::SystemTime::UNIX_EPOCH,
1091 );
1092 self.populate_display_info(&mut tmp);
1093 self.populate_control_info(&mut tmp);
1094 self.populate_enum_info(&mut tmp);
1095
1096 let meta = MetadataSnapshot {
1097 display: tmp.display,
1098 control: tmp.control,
1099 enums: tmp.enums,
1100 };
1101
1102 // Store back; ignore poisoning (cache is best-effort).
1103 if let Ok(mut guard) = self.metadata_cache.lock() {
1104 *guard = Some(meta.clone());
1105 }
1106 meta
1107 }
1108
1109 /// C `dbChannelSpecial(chan) == SPC_NOMOD` — **the single owner of the
1110 /// no-modify declaration**, for every consumer that needs to know whether a
1111 /// field can be written.
1112 ///
1113 /// C declares it once, in the `.dbd`, and reads it in two unrelated places:
1114 ///
1115 /// * `dbPut` (`dbAccess.c:123-126`, via `dbPutSpecial(paddr, 0)`) refuses
1116 /// the write — the port's `check_no_mod` gate;
1117 /// * `rsrvCheckPut` (`rsrv/camessage.c:2540-2551`) — `if
1118 /// (dbChannelSpecial(pciu->dbch) == SPC_NOMOD) return 0;` — which feeds
1119 /// the CA `ACCESS_RIGHTS` write bit (`camessage.c:1123-1124`) as well as
1120 /// both put paths, so a client sees `Access: read, no write` and never
1121 /// sends the doomed write.
1122 ///
1123 /// Only the first consumer existed in the port, so every dbCommon NOMOD
1124 /// field advertised WRITE on the wire (`caput N1.SEVR 2` was refused
1125 /// server-side, after the client had already sent it, with an async
1126 /// exception instead of C's clean client-side "Write access denied").
1127 ///
1128 /// Three sources, one answer:
1129 ///
1130 /// 1. the dbCommon `SPC_NOMOD` set below — common fields, so no record's
1131 /// `field_list` declares them;
1132 /// 2. the record type's **declaration**, resolved by [`Self::field_desc`] —
1133 /// the vendored `.dbd` whenever one exists, and only for a record type
1134 /// that has no `.dbd` at all (`motor`, `optics`, `scaler`, `std`) the
1135 /// record's own hand-written table, which for those Tier 3 types
1136 /// genuinely *is* their declaration;
1137 /// 3. [`Record::field_no_mod`] — an SPC_NOMOD a record's `cvt_dbaddr`
1138 /// raises from its own state (compress VAL under BALG=LIFO,
1139 /// `compressRecord.c:398-407`), which a static `FieldDesc` cannot
1140 /// express.
1141 ///
1142 /// `field` may be any case.
1143 pub fn is_no_mod(&self, field: &str) -> bool {
1144 if DBCOMMON_NOMOD.iter().any(|f| f.eq_ignore_ascii_case(field)) {
1145 return true;
1146 }
1147 if self.field_desc(field).is_some_and(|f| f.read_only) {
1148 return true;
1149 }
1150 self.record.field_no_mod(field)
1151 }
1152
1153 /// Check if the record is currently processing (PACT equivalent).
1154 pub fn is_processing(&self) -> bool {
1155 self.pact.load(std::sync::atomic::Ordering::Acquire)
1156 }
1157
1158 /// C `prec->pact = TRUE` — the record goes busy for an async device
1159 /// round-trip, an SDLY simulation defer, or an ODLY reprocess window.
1160 pub fn enter_pact(&self) {
1161 self.pact.store(true, std::sync::atomic::Ordering::Release);
1162 }
1163
1164 /// C `prec->pact = FALSE` — the ONLY release of PACT.
1165 ///
1166 /// Every PACT→idle transition takes the put-notify parked on that window
1167 /// with it (C `dbNotifyCompletion`, reached from `recGblFwdLink` on every
1168 /// path that ends a cycle). The returned [`PactExit`] is `#[must_use]`, so
1169 /// a release site cannot strand the deferral the way the open-coded
1170 /// `processing.store(false)` at the ODLY continuation and the three SIM/SDLY
1171 /// releases did.
1172 pub fn leave_pact(&mut self) -> PactExit {
1173 self.pact.store(false, std::sync::atomic::Ordering::Release);
1174 PactExit(self.deferred_notify_put.take())
1175 }
1176
1177 /// True when a put-notify already owns this record — an in-flight wait-set
1178 /// (C `precord->ppn`) or a park from a previous PACT arm. C
1179 /// `processNotifyCommon` (dbNotify.c:213-217) queues a second notify on the
1180 /// record's restart list; the port refuses it (`S_db_Blocked` /
1181 /// `ECA_PUTCBINPROG`) — see [`Self::park_notify_put`].
1182 pub fn put_notify_busy(&self) -> bool {
1183 self.notify.is_some() || self.deferred_notify_put.is_some()
1184 }
1185
1186 /// Park a put-notify that landed on a PACT record (C `processNotifyCommon`,
1187 /// dbNotify.c:225-231). `Err(put)` hands the put back when another
1188 /// put-notify already owns the record.
1189 ///
1190 /// The [`PactExit`] invariant — `deferred_notify_put.is_some()` ⟹ PACT —
1191 /// is established here: this is the only park, and it is only reachable
1192 /// from the caller's `is_processing()` arm.
1193 pub fn park_notify_put(&mut self, put: DeferredNotifyPut) -> Result<(), DeferredNotifyPut> {
1194 debug_assert!(
1195 self.is_processing(),
1196 "a put-notify may be parked only on a PACT record"
1197 );
1198 if self.put_notify_busy() {
1199 return Err(put);
1200 }
1201 self.deferred_notify_put = Some(put);
1202 Ok(())
1203 }
1204
1205 /// Unified field resolution: record fields → common fields → virtual fields.
1206 pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
1207 let name = name.to_ascii_uppercase();
1208 self.record
1209 .get_field(&name)
1210 .or_else(|| self.get_common_field(&name))
1211 .or_else(|| self.get_virtual_field(&name))
1212 .or_else(|| self.declared_overrides.get(&name).cloned())
1213 .or_else(|| self.declared_default(&name))
1214 }
1215
1216 /// The value a field that is DECLARED by the `.dbd` but has no live store
1217 /// on this record serves: its `initial(...)`, or a type-zero.
1218 ///
1219 /// C makes *every* `.dbd` field addressable — `dbNameToAddr` resolves the
1220 /// field from its `dbFldDes` and `dbGet` reads it out of record memory,
1221 /// which the dbd loader seeded with `initial()` (or left zero). A Rust
1222 /// record implements only the fields it has behaviour for, so a field it
1223 /// declares but never touches — `aSub.OVAL`, `sub.LA`, `sel.HOPR` — had no
1224 /// channel at all: [`Self::resolve_field`]'s three accessors all returned
1225 /// `None` and CA create-channel answered `S_dbLib_recNotFound`.
1226 ///
1227 /// The declared table is the contract for *which* fields exist; this is the
1228 /// last resort for the *value* of one with no runtime accessor, and it is
1229 /// exactly what an unprocessed C record on an empty `.db` returns —
1230 /// [`apply_dbd_initials`](crate::server::db_loader) seeds the same
1231 /// `initial()` into the fields the record *does* store, from the same
1232 /// generated table, so the two paths agree by construction.
1233 fn declared_default(&self, name: &str) -> Option<EpicsValue> {
1234 let desc = self.field_desc(name)?;
1235 // A `runtime_typed` field (`VAL`/`BG`, re-typed from `FTVL`/`SDEF`) is
1236 // record-owned by definition and its placeholder `dbf_type` is not what
1237 // it serves; never synthesise one here — the record itself answers it.
1238 if desc.runtime_typed {
1239 return None;
1240 }
1241 let initial = desc.initial.unwrap_or("");
1242 if let Some(choices) = desc
1243 .menu
1244 .or_else(|| self.record.menu_field_choices(name))
1245 .or_else(|| super::shared_menu_choices(name))
1246 {
1247 // A menu field with no `initial(...)` is index 0, exactly as an
1248 // empty numeric field is 0 below.
1249 if initial.is_empty() {
1250 return Some(EpicsValue::Enum(0));
1251 }
1252 return super::resolve_menu_field_string_db_load(name, choices, desc.dbf_type, initial)
1253 .ok();
1254 }
1255 // `parse` maps an empty string to the declared type's zero, so this one
1256 // call serves both `initial(...)` and no-initial fields.
1257 EpicsValue::parse_bytes(desc.dbf_type, initial.as_bytes()).ok()
1258 }
1259
1260 /// Resolve a field for EPICS `$` long-string (character-array) access.
1261 ///
1262 /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
1263 /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
1264 /// array of `field_size` elements, a link field a char array of
1265 /// `PVLINK_STRINGSZ`, and every other field type is rejected with
1266 /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
1267 /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
1268 /// bytes and NUL-terminates them back into a string
1269 /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
1270 ///
1271 /// Both `DBF_STRING` fields and link fields resolve to an
1272 /// [`EpicsValue::String`] in this database (a link resolves to its
1273 /// textual form, see [`Self::get_common_field`]), so a field is
1274 /// `$`-eligible exactly when it resolves to a string value. Returns
1275 /// that string value for an eligible field, or `None` for a field the
1276 /// `$` modifier cannot view as a char array (the
1277 /// `S_dbLib_fieldNotFound` case) — the single owner of the
1278 /// dbChannel `$`-eligibility rule for the channel-resolution layer.
1279 pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
1280 match self.resolve_field(name)? {
1281 v @ EpicsValue::String(_) => Some(v),
1282 _ => None,
1283 }
1284 }
1285
1286 /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
1287 /// the record's own record-specific menu
1288 /// ([`Record::menu_field_choices`](super::record_trait::Record::menu_field_choices)),
1289 /// else a shared menu keyed by field name
1290 /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
1291 /// The choices a `menu()` field serves as its `DBR_ENUM` labels.
1292 ///
1293 /// The `.dbd` declaration is the first and best answer: a generated
1294 /// [`FieldDesc`] carries the field's own `menu(...)` choices, which is what
1295 /// C's `dbGetFieldIndex` -> `pamapdbfType` -> menu lookup resolves. The two
1296 /// hand-maintained fallbacks below are for record types still on a
1297 /// hand-written table; they go away with the last of them.
1298 ///
1299 /// `shared_menu_choices` in particular keys on the field NAME alone, across
1300 /// every record type — which is only correct while no two record types give
1301 /// the same field name different menus. Asking the field's own descriptor
1302 /// first removes that assumption.
1303 fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
1304 menu_choices_of(self.record.as_ref(), field)
1305 }
1306
1307 /// The choices this record's `DTYP` selects among — C's `dbDeviceMenu` for
1308 /// the record type, in `.dbd` declaration order.
1309 ///
1310 /// C's DTYP field IS the index into this list, and an unset DTYP is index 0
1311 /// — which is why a bare `record(ai,"X"){}` serves `Soft Channel` and a
1312 /// `record(calc,"X"){}`, whose record type declares no device support at
1313 /// all, serves the empty string.
1314 ///
1315 /// The port stores the device NAME rather than the index, because the name
1316 /// is what the device-support registry dispatches on, and a name registered
1317 /// at runtime by a downstream crate (`asynInt32`) has no `device()` line in
1318 /// any vendored `.dbd`. Such a name is appended as its own slot, so the
1319 /// index and the string still name the SAME device support: there is no
1320 /// value of DTYP that renders as a device this record is not bound to.
1321 /// `None` when the record type declares NO device support at all — C's
1322 /// `dbDeviceMenu *pdevs = paddr->pfldDes->ftPvt; if (!pdevs) goto nostrs;`
1323 /// (`dbAccess.c:176-179`), which clears `DBR_ENUM_STRS` so the client is
1324 /// sent no choice list at all.
1325 ///
1326 /// C keeps that case DISTINCT from a device menu that exists but is empty,
1327 /// and says so at `dbAccess.c:205`: *"indicate option data not available.
1328 /// distinct from no_str==0"*. An empty-but-present menu is still marked,
1329 /// with `no_str = 0`; a missing menu is not marked. Returning `Vec` here
1330 /// and defaulting the missing menu to `[]` collapsed the two, so a
1331 /// `record(calc,"X"){}` — whose record type has no `device()` line — served
1332 /// `value.choices = {0}[]` where QSRV2 omits the leaf entirely.
1333 pub(crate) fn device_choices(&self) -> Option<Vec<PvString>> {
1334 let record_type = self.record.record_type();
1335 // Base's build-time menu (`epics-base-rs/dbd`), then the menus a
1336 // downstream crate whose device support has no vendored `device()` line
1337 // registered at runtime (asyn's `asynInt32`, `asynFloat64`, ...). C's
1338 // `dbDeviceMenu` is the concatenation of every `device()` the loaded
1339 // `.dbd` set declares, in load order — base first, then asyn — so the
1340 // merge appends the contributed choices AFTER the declared ones.
1341 // The C None-vs-empty distinction (`dbAccess.c:176-179` vs `:205`): the
1342 // menu is present iff the loaded `.dbd` set declares ANY `device()` for
1343 // this type. A type base declares none for but asyn does (structurally
1344 // possible, though none of asyn's are such) is therefore present, not
1345 // None; a type neither declares for (calc) stays None.
1346 if super::dbd_generated::device_menu(record_type).is_none()
1347 && super::contributed_device_menu(record_type).is_empty()
1348 {
1349 return None;
1350 }
1351 // `merged_device_menu` = declared + contributed, the SAME source the
1352 // CA-put validation (`coerce_put_value`'s DTYP branch) resolves against,
1353 // so a client can put exactly the DTYP names it can read here.
1354 let mut names: Vec<PvString> = super::merged_device_menu(record_type)
1355 .into_iter()
1356 .map(PvString::from)
1357 .collect();
1358 let dtyp = self.common.dtyp.as_str();
1359 if !dtyp.is_empty() && !names.iter().any(|n| n.as_str_lossy() == dtyp) {
1360 names.push(PvString::from(dtyp));
1361 }
1362 Some(names)
1363 }
1364
1365 /// C `dbPutFieldLink`'s link-type gate (`dbAccess.c:1125-1137`): a link
1366 /// written at RUNTIME is held to the same `dbCanSetLink` rule as one written
1367 /// by the `.db`, against the device support the record's CURRENT `DTYP`
1368 /// binds. Same rule, same owner — [`super::check_link_assignment`]; only the
1369 /// DTYP it is asked about differs (the record's, not the `.db` text's).
1370 ///
1371 /// [`MenuBound::DbLoad`] is exempt, and that is not a hole: on the db-load
1372 /// path C does not check a link as each field is parsed either. It checks
1373 /// once, at `iocInit`, over the record as loaded (`dbStaticLib.c:2178-2231`)
1374 /// — which is why `field(INP,…)` may precede `field(DTYP,…)` in a `.db` and
1375 /// still bind. `db_loader::check_link_types` is that pass, and it reads the
1376 /// record's DTYP out of the whole field set, so it does not depend on the
1377 /// order the `.db` happened to spell them in. Gating here as well would
1378 /// re-introduce exactly that order dependence.
1379 fn check_link_assignment(
1380 &self,
1381 upper_field: &str,
1382 text: &str,
1383 bound: MenuBound,
1384 ) -> CaResult<()> {
1385 if matches!(bound, MenuBound::DbLoad) {
1386 return Ok(());
1387 }
1388 super::check_link_assignment(
1389 self.record.record_type(),
1390 Some(self.common.dtyp.as_str()),
1391 upper_field,
1392 text,
1393 )
1394 }
1395
1396 /// The value of the `DTYP` field: the index of the bound device support in
1397 /// [`Self::device_choices`]. An unset DTYP is index 0, exactly as in C.
1398 pub(crate) fn dtyp_index(&self) -> u16 {
1399 let dtyp = self.common.dtyp.as_str();
1400 if dtyp.is_empty() {
1401 return 0;
1402 }
1403 // A record type with no device menu has no slot for any DTYP, so the
1404 // index stays 0 — the same answer the old `unwrap_or(&[])` gave.
1405 self.device_choices()
1406 .unwrap_or_default()
1407 .iter()
1408 .position(|c| c.as_str_lossy() == dtyp)
1409 .unwrap_or(0) as u16
1410 }
1411
1412 /// **The** owner of "what string does this enum-valued field render as" —
1413 /// C's `[DBF_*][DBR_STRING]` conversion row, chosen by the field's DBF
1414 /// class. Every path that renders an enum as a string goes through here:
1415 /// the CA/PVA encoders (via [`snapshot::EnumInfo::string_form`] on the
1416 /// snapshot this builds) and the db-link read
1417 /// ([`Self::field_as_dbr_string`]). There is exactly one such table per
1418 /// field, and no path may reconstruct a second one.
1419 ///
1420 /// C's dispatch, and this function's, in the same order:
1421 ///
1422 /// * `DBF_MENU` / `DBF_DEVICE` -> `getMenuString` / `getDeviceString`, the
1423 /// field's own choice list. Asked FIRST, because a menu field on a record
1424 /// whose `VAL` is an enum (`bo.OMSL`) must render its menu's choices, not
1425 /// the record's `ZNAM`/`ONAM`.
1426 /// * `DBF_ENUM` `VAL` -> `getEnumString` -> the record's `get_enum_str`
1427 /// rset ([`Record::enum_string_form`](super::record_trait::Record::enum_string_form)).
1428 ///
1429 /// `None` when the field has neither — C answers `S_db_noRSET`, an error;
1430 /// the port renders empty.
1431 ///
1432 /// Each class brings its own out-of-range rule with it (see
1433 /// [`EnumOverflow`](crate::server::snapshot::EnumOverflow)); the index is
1434 /// rendered as a number for a `DBF_MENU` and ONLY for a `DBF_MENU`.
1435 pub(crate) fn enum_string_form_for(&self, field: &str) -> Option<EnumStringForm> {
1436 if field.eq_ignore_ascii_case("DTYP") {
1437 // `None` propagates C's `goto nostrs` (`dbAccess.c:178`): a record
1438 // type with no `device()` declaration supplies no choice list, so
1439 // the leaf is omitted rather than marked empty.
1440 return self.device_choices().map(EnumStringForm::device);
1441 }
1442 if let Some(choices) = self.menu_choices_for(field) {
1443 return Some(EnumStringForm::menu(
1444 choices.iter().map(|c| PvString::from(*c)),
1445 ));
1446 }
1447 if field.eq_ignore_ascii_case("VAL") {
1448 return self.record.enum_string_form();
1449 }
1450 None
1451 }
1452
1453 /// Is `field` one of the DBF classes C's soft device support writes as
1454 /// `DBR_STRING`?
1455 ///
1456 /// `devsCalcoutSoft.c:128-130` (and its async twin, :83-85) switches the
1457 /// scalcout OUT put on the TARGET field's DBF type and sends `OSV` — the
1458 /// string result — for seven of them:
1459 ///
1460 /// ```c
1461 /// case DBF_STRING: case DBF_ENUM: case DBF_MENU: case DBF_DEVICE:
1462 /// case DBF_INLINK: case DBF_OUTLINK: case DBF_FWDLINK:
1463 /// status = dbPutLink(&pscalcout->out, DBR_STRING, &pscalcout->osv, 1);
1464 /// ```
1465 ///
1466 /// [`DbFieldType`] is the port's DBR *wire* type and cannot express
1467 /// `DBF_MENU` / `DBF_DEVICE` — C's DBF class is not the DBR type. The
1468 /// classification therefore lives here, with the record's field metadata,
1469 /// where each class is already known:
1470 ///
1471 /// * `DBF_STRING` and the three link classes — the port stores links and
1472 /// `DTYP` (C's only `DBF_DEVICE` field) as strings;
1473 /// * `DBF_ENUM` — an enum-typed field;
1474 /// * `DBF_MENU` — a menu-index field, i.e. one this record resolves choice
1475 /// labels for ([`Self::menu_choices_for`]): `PRIO`, `STAT`, `SEVR`,
1476 /// `DISS`, `ACKT`, `SCAN`, `IVOA`, `OMSL`, … The index is stored as a
1477 /// short, so a same-named field that is NOT a menu index (scalcout's
1478 /// string `OSV` shares a name with the alarm-severity menu) is
1479 /// classified by its own type, not by the name collision.
1480 ///
1481 /// Everything else (`DBF_DOUBLE`, `DBF_LONG`, `DBF_CHAR`, …) falls to the
1482 /// device support's `default:` arm.
1483 ///
1484 /// The question is about the target field's DECLARED class, so it is asked
1485 /// of the declaration ([`Self::declared_field_type`]) and not of the
1486 /// variant the record stores: C's `switch` is on `dbAddr.field_type`, which
1487 /// `dbNameToAddr` took from the `dbFldDes`. `DBF_MENU` and `DBF_DEVICE` both
1488 /// map to `DbFieldType::Enum` in the generated tables (`mapDBFToDBR`), and
1489 /// the three link classes to `DbFieldType::String`, so the seven C arms are
1490 /// exactly these two.
1491 pub(crate) fn field_puts_as_string(&self, field: &str) -> bool {
1492 let Some(declared) = self.declared_field_type(field) else {
1493 return false;
1494 };
1495 matches!(declared, DbFieldType::String | DbFieldType::Enum)
1496 }
1497
1498 /// The field's value as C `dbGetLink(plink, DBR_STRING, ...)` delivers it —
1499 /// the SOURCE side of an input link read with
1500 /// [`LinkReadAs::String`](super::record_trait::LinkReadAs::String).
1501 ///
1502 /// C converts at the source, through `dbConvert.c`'s
1503 /// `[field_type][DBR_STRING]` table: a `DBF_ENUM` field goes through
1504 /// `getEnumString` → the record's `get_enum_str` (mbbi's `ZRST`.., bi's
1505 /// `ZNAM`/`ONAM`) and a `DBF_MENU` field through `getMenuString` → the
1506 /// menu's choice string, i.e. the state LABEL in both cases, never the
1507 /// index. Only the record holds those tables, so the render lives here with
1508 /// the field metadata — the link-read owner has an index and nothing to
1509 /// resolve it with.
1510 ///
1511 /// The render goes through [`Self::enum_string_form_for`], the same owner
1512 /// the CA/PVA encoders use, so a link read and a `caget -t` of one field can
1513 /// never disagree about its string.
1514 pub(crate) fn field_as_dbr_string(&self, field: &str) -> Option<PvString> {
1515 let value = self.resolve_field(field)?;
1516 // A `DBF_ENUM` index, and a `DBF_MENU` index (stored as a short),
1517 // render through the field's string source. A short field that is
1518 // neither has no source and stays the plain number C converts it to.
1519 let idx = match value {
1520 EpicsValue::Enum(v) => Some(v),
1521 EpicsValue::Short(v) => u16::try_from(v).ok(),
1522 _ => None,
1523 };
1524 if let Some(idx) = idx
1525 && let Some(form) = self.enum_string_form_for(field)
1526 {
1527 return Some(form.render(idx));
1528 }
1529 value_as_dbr_string(&value)
1530 }
1531
1532 /// The field's declaration — its `dbFldDes`, in C's terms.
1533 ///
1534 /// The `.dbd` is the declaration, so the table generated FROM the `.dbd`
1535 /// ([`dbd_generated::record_fields`](super::dbd_generated::record_fields))
1536 /// is asked first, for every record type that has one. A record's own
1537 /// [`Record::field_list`](super::record_trait::Record::field_list) is a
1538 /// hand-written stand-in for that table, and it is consulted only for a
1539 /// record type the `.dbd` does not cover (`subArray`, and the record types
1540 /// the downstream crates add). It cannot be the primary answer: several of
1541 /// those tables are *derived from the record's Rust storage types* — the
1542 /// `#[derive(EpicsRecord)]` records type `longin.ADEL` `DBF_DOUBLE`
1543 /// because the struct member is an `f64`, where the `.dbd` says
1544 /// `DBF_LONG` — and reading the type off the storage is the whole defect
1545 /// this owner exists to close.
1546 ///
1547 /// `dbCommon` last, matching the order [`Self::resolve_field`] reads the
1548 /// value in, so a record-specific field always shadows the common one in
1549 /// both halves.
1550 ///
1551 /// `None` for a field with no declaration at all: a virtual field
1552 /// (`RTYP`, `TIME`, ...), which C answers from dbStaticLib rather than
1553 /// from a `dbFldDes`.
1554 pub(crate) fn field_desc(&self, field: &str) -> Option<&'static FieldDesc> {
1555 field_desc_of(self.record.as_ref(), field)
1556 }
1557
1558 /// The `DBF_*` type `field` is SERVED as — the single source of truth for
1559 /// the type on the wire, on every delivery path.
1560 ///
1561 /// This is the field's DECLARED type ([`FieldDesc::dbf_type`], from the
1562 /// `.dbd`), not the type of whatever variant the record happens to store.
1563 /// C resolves a channel's `field_type` from the `dbFldDes` at
1564 /// name-resolution time (`dbChannelCreate` -> `dbNameToAddr`,
1565 /// `dbAccess.c:184-205`) and every later `dbGet`/`db_post_events` converts
1566 /// the stored bytes to it — the storage is private to the record, the
1567 /// declaration is the contract.
1568 ///
1569 /// Two answers are NOT the declaration:
1570 ///
1571 /// * a [`FieldDesc::runtime_typed`] field — C's `cvt_dbaddr` overwrites
1572 /// `paddr->field_type` from record state (`FTVL`, `FTA`, `SDEF`), and
1573 /// this port's `cvt_dbaddr` is the variant the record stores;
1574 /// * a field with no `FieldDesc` at all (a virtual field).
1575 ///
1576 /// In both cases the value's own type is the answer, so this returns
1577 /// `None` and [`Self::project_to_declared_type`] leaves the value alone.
1578 pub fn declared_field_type(&self, field: &str) -> Option<DbFieldType> {
1579 declared_field_type_of(self.record.as_ref(), field)
1580 }
1581
1582 /// Project a field's stored value onto its declared type
1583 /// ([`Self::declared_field_type`]) — the single owner of "what type this
1584 /// field goes on the wire as", run by the CA create-channel path
1585 /// ([`Self::client_field_value`]), the GET path
1586 /// ([`Self::snapshot_for_field`]) and the MONITOR path
1587 /// ([`Self::make_monitor_snapshot`]), so all three announce and serve the
1588 /// same type.
1589 ///
1590 /// The projection is [`EpicsValue::convert_to`], the one value-coercion
1591 /// owner — the same routine `dbGet` converts through. Never re-derive a
1592 /// conversion here: C picks its routine from BOTH the source and the
1593 /// destination type, and only `convert_to` knows that table.
1594 ///
1595 /// Idempotent: a value already of its declared type is short-circuited by
1596 /// `convert_to`, and re-projecting a projected value is a no-op. That is
1597 /// what lets the CA path derive the native type from the value it is about
1598 /// to serve.
1599 pub fn project_to_declared_type(&self, field: &str, value: EpicsValue) -> EpicsValue {
1600 match self.declared_field_type(field) {
1601 Some(declared) => value.convert_to(declared),
1602 None => value,
1603 }
1604 }
1605
1606 /// The client-facing value of `field`: the resolved value projected onto
1607 /// the field's declared type ([`Self::project_to_declared_type`]), so a
1608 /// native type derived from the value — which is what the CA
1609 /// create-channel path does — is the DECLARED type, and matches the
1610 /// GET/MONITOR data byte for byte.
1611 pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
1612 let value = self.resolve_field(field)?;
1613 Some(self.project_to_declared_type(field, value))
1614 }
1615
1616 /// Attach a `DBF_MENU` field's `menu()` choice labels to a built snapshot,
1617 /// so the CA/PVA enum encoders present `"NO CONVERSION"` rather than `0`.
1618 ///
1619 /// The VALUE half of the `DBF_MENU` -> `DBR_ENUM` mapping is not here: the
1620 /// `.dbd` declares a menu field `DBF_MENU`, the generator types that
1621 /// `DbFieldType::Enum` (`mapDBFToDBR`), and
1622 /// [`Self::project_to_declared_type`] — which every delivery path runs —
1623 /// makes the served value an [`EpicsValue::Enum`] on that declaration
1624 /// alone. So the label table is all that is left to attach, and it is
1625 /// attached exactly when the served value came out an enum. A same-named
1626 /// field that is NOT a menu index (`scalcout.OSV`, declared `DBF_STRING`,
1627 /// shares a name with the alarm-severity menu) is served as its own
1628 /// declared string and gets no choice table.
1629 fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
1630 if !matches!(snap.value, EpicsValue::Enum(_)) {
1631 return;
1632 }
1633 // `VAL` is the one field whose two rset slots differ: C's
1634 // `get_enum_strs` (the `DBR_GR_ENUM` labels) is TRIMMED to `no_str`
1635 // while `get_enum_str` (the DBR_STRING form) indexes the untrimmed
1636 // state array. `populate_enum_info` owns that pair; every OTHER
1637 // enum-valued field is a menu or a device, whose one choice list
1638 // answers both (C `getMenuString`/`getDeviceString` index the same
1639 // `papChoiceValue` the GR_ENUM reply carries).
1640 if field.eq_ignore_ascii_case("VAL") {
1641 return;
1642 }
1643 let Some(form) = self.enum_string_form_for(field) else {
1644 return;
1645 };
1646 snap.enums = Some(super::super::snapshot::EnumInfo::with_string_form(
1647 form.slots.clone(),
1648 form,
1649 ));
1650 }
1651
1652 /// Build a Snapshot with full metadata for the given field.
1653 pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
1654 // The GET path serves the field at its DECLARED type, the same type
1655 // the CA create-channel path announced from `client_field_value` and
1656 // the same one the monitor path posts.
1657 let value = self.client_field_value(field)?;
1658 let mut snap = super::super::snapshot::Snapshot::new(
1659 value,
1660 self.common.stat,
1661 self.common.sevr as u16,
1662 self.common.time,
1663 );
1664 // Default the served `timeStamp.userTag` to the record's `utag`,
1665 // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
1666 // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
1667 // truncating to the low 32 bits — pvxs assigns the same uint64
1668 // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
1669 // nsec-LSB split below overrides this when configured, matching
1670 // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
1671 // (:247).
1672 snap.user_tag = self.common.utag as i32;
1673 // Carry the record's committed alarm message (`common.amsg`) so a
1674 // PVA read serves `alarm.message` from the record's own amsg
1675 // (pvxs `iocsource.cpp:230-236` prefers `meta.amsg`) rather than a
1676 // string re-synthesized from the condition code. Empty for records
1677 // that raise no message (C's plain `recGblSetSevr` clears namsg).
1678 snap.alarm.amsg = self.common.amsg.clone();
1679
1680 // Pull display/control/enums from the metadata cache (build on
1681 // first call, hit thereafter until invalidated by a metadata-class
1682 // field write).
1683 let meta = self.cached_metadata();
1684 snap.display = meta.display;
1685 snap.control = meta.control;
1686 snap.enums = meta.enums;
1687
1688 // The cache above is the record's VAL metadata. C routes PER FIELD, so
1689 // a non-VAL-class field does NOT get VAL's limits — see
1690 // [`Self::route_field_metadata`], which owns that decision.
1691 self.route_field_metadata(field, &mut snap);
1692
1693 // Per-field RSET metadata (C get_units/get_precision/
1694 // get_graphic_double/get_control_double/get_alarm_double key on
1695 // dbGetFieldIndex) patches the record-level cache for this field.
1696 self.apply_field_metadata_override(field, &mut snap);
1697
1698 // DBF_MENU field (a shared menu such as `SCAN`/`OMSL`/`HHSV`/... or
1699 // a record-specific menu such as `sel.SELM`): carry the menu index
1700 // as DBR_ENUM and attach its `menu()` choice labels. See
1701 // `attach_menu_enum`. This overrides any record VAL enum table
1702 // copied from the metadata cache above, because a menu field
1703 // carries its own menu's choices, not the record's VAL state
1704 // strings.
1705 self.attach_menu_enum(field, &mut snap);
1706
1707 // The metadata VALUES and the mask that says which of them this
1708 // channel actually supplies are assigned by the same owner, from the
1709 // settled value, so they cannot disagree.
1710 self.assign_property_support(field, &mut snap);
1711
1712 // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
1713 // `iocsource.cpp:239-248` publishes `nanoseconds & ~nsecMask` and
1714 // moves `nanoseconds & nsecMask` into `timeStamp.userTag`. The
1715 // split is applied to both `snap.timestamp` and `snap.user_tag` so
1716 // downstream encoders (NTScalar `timeStamp`, QSRV groups) all see
1717 // the same shape. A zero mask (tag absent or unparseable) is a
1718 // no-op inside the helper, exactly as pvxs's `if(info.nsecMask)`
1719 // gate is.
1720 crate::server::snapshot::apply_nsec_mask(&mut snap, self.qtime_nsec_mask());
1721
1722 Some(snap)
1723 }
1724
1725 /// Resolve `info(Q:time:tag)` to pvxs's `MappingInfo::nsecMask`.
1726 /// Returns 0 (the "no split" mask) when the tag is absent or does not
1727 /// parse — pvxs leaves `nsecMask` at its 0 initialiser in that case.
1728 ///
1729 /// pvxs `ioc/typeutils.cpp:79-88`:
1730 ///
1731 /// ```c
1732 /// if(auto val = ent.info("Q:time:tag")) {
1733 /// epicsInt32 dig = 0;
1734 /// if(strncmp(val, "nsec:lsb:", 9)==0 && !epicsParseInt32(&val[9], &dig, 10, nullptr)) {
1735 /// nsecMask = (uint64_t(1u)<<dig)-1u;
1736 /// }
1737 /// }
1738 /// ```
1739 ///
1740 /// The prefix test is a byte-exact `strncmp` — no case folding and no
1741 /// whitespace tolerance, so `NSEC:LSB:4` and `nsec: lsb: 4` do NOT
1742 /// match and leave the timestamp alone. There is no bounds clamp
1743 /// either: any `dig` `epicsParseInt32` accepts is shifted verbatim, so
1744 /// `nsec:lsb:31` yields the `0x7FFF_FFFF` mask pvxs actually serves.
1745 fn qtime_nsec_mask(&self) -> u64 {
1746 let Some(rest) = self
1747 .get_info("Q:time:tag")
1748 .and_then(|v| v.strip_prefix("nsec:lsb:"))
1749 else {
1750 return 0;
1751 };
1752 let Some(dig) = epics_parse_int32_base10(rest) else {
1753 return 0;
1754 };
1755 // C shifts `uint64_t(1u)` by an `epicsInt32`. A `dig` outside
1756 // `0..=63` is UB in C++; every ISA EPICS builds on (x86-64 `shlq`,
1757 // aarch64 `lsl`) takes the shift count modulo 64, which is what
1758 // `wrapping_shl` does — so `nsec:lsb:64` disables the split
1759 // (mask 0) and a negative `dig` shifts by `dig & 63`, the same
1760 // masks pvxs produces on those hosts.
1761 1u64.wrapping_shl(dig as u32) - 1
1762 }
1763
1764 /// Populate DisplayInfo from record fields if applicable.
1765 /// Resolve the `Q:form` info-tag value to a `display.form` menu index.
1766 ///
1767 /// pvxs publishes the fixed seven-entry form menu
1768 /// (Default/String/Binary/Decimal/Hex/Exponential/Engineering) for every
1769 /// numeric value and, for the VAL field only, sets `display.form.index`
1770 /// to the slot whose name equals the field's `Q:form` info tag
1771 /// (`iocsource.cpp:42-62`, case-sensitive). Unset or unrecognised ->
1772 /// `None` (form stays 0 = Default), exactly as pvxs leaves the index
1773 /// untouched on no match.
1774 fn q_form_index(&self) -> Option<i16> {
1775 const FORM_NAMES: [&str; 7] = [
1776 "Default",
1777 "String",
1778 "Binary",
1779 "Decimal",
1780 "Hex",
1781 "Exponential",
1782 "Engineering",
1783 ];
1784 let tag = self.info.get("Q:form")?;
1785 FORM_NAMES
1786 .iter()
1787 .position(|name| name == tag)
1788 .map(|i| i as i16)
1789 }
1790
1791 /// Stamp a built snapshot with the property mask THIS channel supplies —
1792 /// [`Self::property_support`] narrowed to the addressed field by C's
1793 /// second gate ([`PropertySupport::narrowed_to_field`]). Called by both
1794 /// snapshot builders once the value has settled (after
1795 /// [`Self::attach_menu_enum`] promoted a `DBF_MENU` field to its
1796 /// `DBR_ENUM` form), so the mask is read off the same value the client
1797 /// receives and no consumer has to re-derive either gate.
1798 fn assign_property_support(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
1799 snap.properties = self.record.property_support().narrowed_to_field(
1800 snap.value.db_field_type(),
1801 self.menu_choices_for(field).is_some(),
1802 );
1803 }
1804
1805 /// The property mask a channel on `field` supplies, without building a
1806 /// snapshot — what a PVA server needs to decide which NT leaves it may
1807 /// MARK for a channel it has not read yet (QSRV resolves a group's member
1808 /// masks once, at monitor start, rather than per event).
1809 ///
1810 /// Same two gates, same owner as [`Self::assign_property_support`]: an
1811 /// unknown field supplies nothing.
1812 pub fn property_support_for_field(&self, field: &str) -> PropertySupport {
1813 let Some(value) = self.client_field_value(field) else {
1814 return PropertySupport::NONE;
1815 };
1816 self.record.property_support().narrowed_to_field(
1817 value.db_field_type(),
1818 self.menu_choices_for(field).is_some(),
1819 )
1820 }
1821
1822 fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
1823 let rtype = self.record.record_type();
1824 match rtype {
1825 "ai" | "ao" | "calc" | "calcout" => {
1826 let egu = self
1827 .record
1828 .get_field("EGU")
1829 .and_then(|v| {
1830 if let EpicsValue::String(s) = v {
1831 Some(s)
1832 } else {
1833 None
1834 }
1835 })
1836 .unwrap_or_default();
1837 let prec = self
1838 .record
1839 .get_field("PREC")
1840 .and_then(|v| v.to_f64())
1841 .unwrap_or(0.0) as i16;
1842 let hopr = self
1843 .record
1844 .get_field("HOPR")
1845 .and_then(|v| v.to_f64())
1846 .unwrap_or(0.0);
1847 let lopr = self
1848 .record
1849 .get_field("LOPR")
1850 .and_then(|v| v.to_f64())
1851 .unwrap_or(0.0);
1852 snap.display = Some(super::super::snapshot::DisplayInfo {
1853 units: egu,
1854 precision: prec,
1855 upper_disp_limit: hopr,
1856 lower_disp_limit: lopr,
1857 ..Default::default()
1858 });
1859 }
1860 "longin" | "longout" | "int64in" | "int64out" => {
1861 let egu = self
1862 .record
1863 .get_field("EGU")
1864 .and_then(|v| {
1865 if let EpicsValue::String(s) = v {
1866 Some(s)
1867 } else {
1868 None
1869 }
1870 })
1871 .unwrap_or_default();
1872 let hopr = self
1873 .record
1874 .get_field("HOPR")
1875 .and_then(|v| v.to_f64())
1876 .unwrap_or(0.0);
1877 let lopr = self
1878 .record
1879 .get_field("LOPR")
1880 .and_then(|v| v.to_f64())
1881 .unwrap_or(0.0);
1882 snap.display = Some(super::super::snapshot::DisplayInfo {
1883 units: egu,
1884 precision: 0,
1885 upper_disp_limit: hopr,
1886 lower_disp_limit: lopr,
1887 ..Default::default()
1888 });
1889 }
1890 // waveform/aai/aao — HOPR/LOPR/PREC/EGU for VAL display limits.
1891 // (waveformRecord.c:251-252,239; aaiRecord.c:280-281,268; aaoRecord.c:283-284)
1892 "waveform" | "aai" | "aao" => {
1893 let egu = self
1894 .record
1895 .get_field("EGU")
1896 .and_then(|v| {
1897 if let EpicsValue::String(s) = v {
1898 Some(s)
1899 } else {
1900 None
1901 }
1902 })
1903 .unwrap_or_default();
1904 let prec = self
1905 .record
1906 .get_field("PREC")
1907 .and_then(|v| v.to_f64())
1908 .unwrap_or(0.0) as i16;
1909 let hopr = self
1910 .record
1911 .get_field("HOPR")
1912 .and_then(|v| v.to_f64())
1913 .unwrap_or(0.0);
1914 let lopr = self
1915 .record
1916 .get_field("LOPR")
1917 .and_then(|v| v.to_f64())
1918 .unwrap_or(0.0);
1919 snap.display = Some(super::super::snapshot::DisplayInfo {
1920 units: egu,
1921 precision: prec,
1922 upper_disp_limit: hopr,
1923 lower_disp_limit: lopr,
1924 ..Default::default()
1925 });
1926 }
1927 // compress — HOPR/LOPR/PREC/EGU for VAL display limits.
1928 // (compressRecord.c:478-479,464,455)
1929 "compress" => {
1930 let egu = self
1931 .record
1932 .get_field("EGU")
1933 .and_then(|v| {
1934 if let EpicsValue::String(s) = v {
1935 Some(s)
1936 } else {
1937 None
1938 }
1939 })
1940 .unwrap_or_default();
1941 let prec = self
1942 .record
1943 .get_field("PREC")
1944 .and_then(|v| v.to_f64())
1945 .unwrap_or(0.0) as i16;
1946 let hopr = self
1947 .record
1948 .get_field("HOPR")
1949 .and_then(|v| v.to_f64())
1950 .unwrap_or(0.0);
1951 let lopr = self
1952 .record
1953 .get_field("LOPR")
1954 .and_then(|v| v.to_f64())
1955 .unwrap_or(0.0);
1956 snap.display = Some(super::super::snapshot::DisplayInfo {
1957 units: egu,
1958 precision: prec,
1959 upper_disp_limit: hopr,
1960 lower_disp_limit: lopr,
1961 ..Default::default()
1962 });
1963 }
1964 "motor" => {
1965 let egu = self
1966 .record
1967 .get_field("EGU")
1968 .and_then(|v| {
1969 if let EpicsValue::String(s) = v {
1970 Some(s)
1971 } else {
1972 None
1973 }
1974 })
1975 .unwrap_or_default();
1976 let prec = self
1977 .record
1978 .get_field("PREC")
1979 .and_then(|v| v.to_f64())
1980 .unwrap_or(0.0) as i16;
1981 let hlm = self
1982 .record
1983 .get_field("HLM")
1984 .and_then(|v| v.to_f64())
1985 .unwrap_or(0.0);
1986 let llm = self
1987 .record
1988 .get_field("LLM")
1989 .and_then(|v| v.to_f64())
1990 .unwrap_or(0.0);
1991 snap.display = Some(super::super::snapshot::DisplayInfo {
1992 units: egu,
1993 precision: prec,
1994 upper_disp_limit: hlm,
1995 lower_disp_limit: llm,
1996 ..Default::default()
1997 });
1998 }
1999 _ => {}
2000 }
2001 // Apply the `Q:form` display-format hint. The match above builds
2002 // `snap.display` only for numeric record types — the same set for
2003 // which pvxs emits `display.form.choices`. This cache is
2004 // record-level (it is the VAL field's metadata); the VAL-only rule
2005 // pvxs applies to `display.form.index` (`iocsource.cpp:53`) is
2006 // enforced per served field in `apply_field_metadata_override`.
2007 if let Some(display) = snap.display.as_mut() {
2008 if let Some(form) = self.q_form_index() {
2009 display.form = form;
2010 }
2011 }
2012 }
2013
2014 /// Populate ControlInfo from record fields if applicable.
2015 fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
2016 let rtype = self.record.record_type();
2017 match rtype {
2018 // ao unconditionally uses DRVH/DRVL (aoRecord.c:356-357).
2019 "ao" => {
2020 let upper = self
2021 .record
2022 .get_field("DRVH")
2023 .and_then(|v| v.to_f64())
2024 .unwrap_or(0.0);
2025 let lower = self
2026 .record
2027 .get_field("DRVL")
2028 .and_then(|v| v.to_f64())
2029 .unwrap_or(0.0);
2030 snap.control = Some(super::super::snapshot::ControlInfo {
2031 upper_ctrl_limit: upper,
2032 lower_ctrl_limit: lower,
2033 });
2034 }
2035 // longout/int64out use DRVH/DRVL only when drvh > drvl, else HOPR/LOPR
2036 // (longoutRecord.c:282-287, int64outRecord.c:265-270).
2037 "longout" | "int64out" => {
2038 let drvh = self
2039 .record
2040 .get_field("DRVH")
2041 .and_then(|v| v.to_f64())
2042 .unwrap_or(0.0);
2043 let drvl = self
2044 .record
2045 .get_field("DRVL")
2046 .and_then(|v| v.to_f64())
2047 .unwrap_or(0.0);
2048 let (upper, lower) = if drvh > drvl {
2049 (drvh, drvl)
2050 } else {
2051 let hopr = self
2052 .record
2053 .get_field("HOPR")
2054 .and_then(|v| v.to_f64())
2055 .unwrap_or(0.0);
2056 let lopr = self
2057 .record
2058 .get_field("LOPR")
2059 .and_then(|v| v.to_f64())
2060 .unwrap_or(0.0);
2061 (hopr, lopr)
2062 };
2063 snap.control = Some(super::super::snapshot::ControlInfo {
2064 upper_ctrl_limit: upper,
2065 lower_ctrl_limit: lower,
2066 });
2067 }
2068 "motor" => {
2069 // Motor records use HLM/LLM as control limits
2070 let hlm = self
2071 .record
2072 .get_field("HLM")
2073 .and_then(|v| v.to_f64())
2074 .unwrap_or(0.0);
2075 let llm = self
2076 .record
2077 .get_field("LLM")
2078 .and_then(|v| v.to_f64())
2079 .unwrap_or(0.0);
2080 snap.control = Some(super::super::snapshot::ControlInfo {
2081 upper_ctrl_limit: hlm,
2082 lower_ctrl_limit: llm,
2083 });
2084 }
2085 // int64in uses HOPR/LOPR as control limits (int64inRecord.c:226-227)
2086 "ai" | "int64in" | "longin" | "calc" | "calcout" => {
2087 // Input records use HOPR/LOPR as control limits
2088 let hopr = self
2089 .record
2090 .get_field("HOPR")
2091 .and_then(|v| v.to_f64())
2092 .unwrap_or(0.0);
2093 let lopr = self
2094 .record
2095 .get_field("LOPR")
2096 .and_then(|v| v.to_f64())
2097 .unwrap_or(0.0);
2098 snap.control = Some(super::super::snapshot::ControlInfo {
2099 upper_ctrl_limit: hopr,
2100 lower_ctrl_limit: lopr,
2101 });
2102 }
2103 // Array records map their VAL control limits to HOPR/LOPR, exactly
2104 // like the display limits above (waveformRecord.c get_control_double
2105 // VAL case; aaiRecord.c:293-303; aaoRecord.c; compressRecord.c:487-501).
2106 // Without this arm an array DBR_CTRL collapses the control range to
2107 // 0/0 while the scalar records expose it.
2108 "waveform" | "aai" | "aao" | "compress" => {
2109 let hopr = self
2110 .record
2111 .get_field("HOPR")
2112 .and_then(|v| v.to_f64())
2113 .unwrap_or(0.0);
2114 let lopr = self
2115 .record
2116 .get_field("LOPR")
2117 .and_then(|v| v.to_f64())
2118 .unwrap_or(0.0);
2119 snap.control = Some(super::super::snapshot::ControlInfo {
2120 upper_ctrl_limit: hopr,
2121 lower_ctrl_limit: lopr,
2122 });
2123 }
2124 _ => {}
2125 }
2126 }
2127
2128 /// Populate EnumInfo — C rset `get_enum_strs`.
2129 ///
2130 /// The table comes from [`Record::enum_state_strings`], the SAME slot the
2131 /// string-put converter (`dbConvert.c::putStringEnum`) resolves against, so
2132 /// the choice list a client reads and the names it may write are one table
2133 /// by construction. It arrives already trimmed to C's `no_str` (bi/bo/busy
2134 /// drop an empty ONAM behind a set ZNAM — `boRecord.c:342-352`; mbbi/mbbo
2135 /// cut at the last non-empty state — `mbbiRecord.c:262-269`).
2136 ///
2137 /// The `DBR_STRING` half of the same channel is the record's OTHER rset slot
2138 /// (`get_enum_str`), which is not this trimmed list — see
2139 /// [`EnumStringForm`]. A record that has no such slot (every record but
2140 /// bi/bo/busy/mbbi/mbbo, including the downstream crates' own enum records)
2141 /// renders from its label list, which is what C's absent slot amounts to.
2142 fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
2143 if let Some(strings) = self.record.enum_state_strings() {
2144 snap.enums = Some(match self.record.enum_string_form() {
2145 Some(form) => super::super::snapshot::EnumInfo::with_string_form(strings, form),
2146 None => super::super::snapshot::EnumInfo::new(strings),
2147 });
2148 }
2149 }
2150
2151 /// Get a common field value.
2152 pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
2153 match name {
2154 "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
2155 "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
2156 "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
2157 "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
2158 // epics-base PR #568 / #566 — alarm message string.
2159 "AMSG" => Some(EpicsValue::String(self.common.amsg.clone().into())),
2160 "NAMSG" => Some(EpicsValue::String(self.common.namsg.clone().into())),
2161 "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
2162 // `ACKT` and `PINI` are `DBF_MENU` (`menuYesNo` /`menuPini`,
2163 // `dbCommon.dbd.pod:335,169`), not `DBF_UCHAR`: they carry a menu
2164 // index, which `promote_menu_value` lifts to `DBR_ENUM` with the
2165 // menu's choice strings. Storing them as `Short` is what makes
2166 // them eligible for that promotion — see `promote_menu_value`.
2167 "ACKT" => Some(EpicsValue::Short(if self.common.ackt { 1 } else { 0 })),
2168 // DBF_UCHAR: served as UChar (declared type) so the raw put byte
2169 // round-trips to the wire — see the DISP/TPRO comment above.
2170 "UDF" => Some(EpicsValue::UChar(self.common.udf)),
2171 "UDFS" => Some(EpicsValue::Short(self.common.udfs)),
2172 "SCAN" => Some(EpicsValue::Enum(self.common.scan.to_u16())),
2173 "SSCN" => Some(EpicsValue::Enum(self.common.sscn.to_u16())),
2174 // `OLDSIMM` is `DBF_MENU`/`menu(menuSimm)`, stored as the menu index
2175 // and promoted to `DBR_ENUM` with the NO/YES/RAW labels by
2176 // `promote_menu_value` (shared registry — the saved copy is ALWAYS
2177 // menuSimm, unlike the live SIMM). Written only by the simulation
2178 // owner (`rec_gbl_save_simm`); `special(SPC_NOMOD)` for clients.
2179 "OLDSIMM" => Some(EpicsValue::Short(self.common.oldsimm)),
2180 "PINI" => Some(EpicsValue::Short(self.common.pini)),
2181 // DISP/TPRO/RPRO/UDF are `DBF_UCHAR` in `dbCommon.dbd`. Serve them
2182 // as `UChar` — their DECLARED type — so `project_to_declared_type`
2183 // is identity and the raw put byte reaches the wire untouched (C
2184 // stores the byte and `caget` renders `DBR_CHAR` signed: 255 → -1).
2185 // Serving `Char` here instead routed the value through the lossy
2186 // `Char → UChar` projection (signed −1 clamped to 0), so a
2187 // `caput DISP 255` read back as 0 rather than C's -1. `BKPT` is
2188 // `DBF_NOACCESS`: no `FieldDesc`, no projection, served `Char`.
2189 "TPRO" => Some(EpicsValue::UChar(self.common.tpro)),
2190 "BKPT" => Some(EpicsValue::Char(self.common.bkpt)),
2191 "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
2192 // A record type whose C `.dbd` has no INP has no `.INP` channel
2193 // either — C's dbChannel resolution is the dbd, so `dbgf HI.INP` on
2194 // a histogram answers "PV 'HI.INP' not found". The port keeps INP on
2195 // `CommonFields` for every record, so `declares_inp_link()` is what
2196 // stands in for the dbd, and it must gate the read side as well as
2197 // the write side (`put_common_field`) — otherwise the field is
2198 // unloadable and unwritable yet still resolves as a channel.
2199 "INP" if self.record.declares_inp_link() => {
2200 Some(EpicsValue::String(self.common.inp.clone().into()))
2201 }
2202 "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
2203 // C's DTYP is `DBF_DEVICE`: an epicsEnum16 index into the record
2204 // type's device menu, NOT the name. The name is what this port
2205 // stores and dispatches on; the index is what the wire carries.
2206 "DTYP" => Some(EpicsValue::Enum(self.dtyp_index())),
2207 "TSE" => Some(EpicsValue::Short(self.common.tse)),
2208 "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
2209 // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
2210 // 64-bit value variant so values above i64::MAX round-trip.
2211 "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
2212 "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
2213 "ASL" => Some(EpicsValue::Char(self.common.asl)),
2214 "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
2215 "PHAS" => Some(EpicsValue::Short(self.common.phas)),
2216 "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
2217 "PRIO" => Some(EpicsValue::Short(self.common.prio)),
2218 "DISV" => Some(EpicsValue::Short(self.common.disv)),
2219 "DISA" => Some(EpicsValue::Short(self.common.disa)),
2220 "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
2221 "DISS" => Some(EpicsValue::Short(self.common.diss)),
2222 "HYST" => Some(EpicsValue::Double(self.common.hyst)),
2223 "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
2224 "DISP" => Some(EpicsValue::UChar(self.common.disp)),
2225 "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
2226 "RPRO" => Some(EpicsValue::UChar(self.common.rpro)),
2227 "PACT" => Some(EpicsValue::Char(if self.is_processing() { 1 } else { 0 })),
2228 // C `dbCommon.dbd`: `field(PROC,DBF_UCHAR)` — the raw put byte is
2229 // retained in `prec->proc` and served back SIGNED as `DBR_CHAR`
2230 // (`caput PROC 255` → `caget` = -1), exactly like DISP/RPRO. The
2231 // `pp(TRUE)` force-process is orthogonal: writing PROC still
2232 // reprocesses the record (put-path intercept), but the byte sticks.
2233 "PROC" => Some(EpicsValue::UChar(self.common.proc_field)),
2234 // Analog alarm fields
2235 "HIHI" => self
2236 .common
2237 .analog_alarm
2238 .as_ref()
2239 .map(|a| EpicsValue::Double(a.hihi)),
2240 "HIGH" => self
2241 .common
2242 .analog_alarm
2243 .as_ref()
2244 .map(|a| EpicsValue::Double(a.high)),
2245 "LOW" => self
2246 .common
2247 .analog_alarm
2248 .as_ref()
2249 .map(|a| EpicsValue::Double(a.low)),
2250 "LOLO" => self
2251 .common
2252 .analog_alarm
2253 .as_ref()
2254 .map(|a| EpicsValue::Double(a.lolo)),
2255 "HHSV" => self
2256 .common
2257 .analog_alarm
2258 .as_ref()
2259 .map(|a| EpicsValue::Short(a.hhsv)),
2260 "HSV" => self
2261 .common
2262 .analog_alarm
2263 .as_ref()
2264 .map(|a| EpicsValue::Short(a.hsv)),
2265 "LSV" => self
2266 .common
2267 .analog_alarm
2268 .as_ref()
2269 .map(|a| EpicsValue::Short(a.lsv)),
2270 "LLSV" => self
2271 .common
2272 .analog_alarm
2273 .as_ref()
2274 .map(|a| EpicsValue::Short(a.llsv)),
2275 // swait OUTN is aliased to common.out
2276 "OUTN" => {
2277 if self.record.record_type() == "swait" {
2278 Some(EpicsValue::String(self.common.out.clone().into()))
2279 } else {
2280 None
2281 }
2282 }
2283 _ => None,
2284 }
2285 }
2286
2287 /// `true` when the record type declares `name` in its own `field_list`,
2288 /// i.e. the record stores the field itself and owns whatever behaviour
2289 /// hangs off it.
2290 ///
2291 /// This separates the two meanings a link field carries. `common.inp` /
2292 /// `common.out` is the link *text* — always the value the `.db` file
2293 /// wrote, for every record type, because C device support reads
2294 /// `prec->inp` / `prec->out` at `init_record` no matter which layer owns
2295 /// the field ([`crate::server::db_loader::apply_fields`] keeps it
2296 /// populated). `parsed_inp` / `parsed_out` is the *framework's* dispatch
2297 /// of that link, and is armed only for a record type that does NOT
2298 /// declare the field: a record that declares it drives the link itself
2299 /// (`multi_output_links` for `acalcout`/`scalcout`, device support for
2300 /// `motorRecord`/`scalerRecord`, or its own `process`). Arming the
2301 /// framework path for those too would write the link twice per cycle.
2302 fn record_declares_field(&self, name: &str) -> bool {
2303 self.record.implements_field(name)
2304 }
2305
2306 /// Set a common field value from a runtime `dbPut` (CA/PVA/`dbpf`/link).
2307 /// Returns what scan index changes are needed.
2308 ///
2309 /// A `DBF_MENU` common field's string is converted by C's runtime
2310 /// converter, `dbConvert.c::putStringMenu` — see [`MenuBound::DbPut`].
2311 pub fn put_common_field(
2312 &mut self,
2313 name: &str,
2314 value: EpicsValue,
2315 ) -> CaResult<CommonFieldPutResult> {
2316 self.put_common_field_bounded(name, value, MenuBound::DbPut)
2317 }
2318
2319 /// **The single owner of a record's SCAN transition** — C `dbPutField` on
2320 /// SCAN, which is `scanDelete(precord)` … `scanAdd(precord)`
2321 /// (`dbAccess.c::dbPutSpecial` SPC_SCAN, dbScan.c:236-248).
2322 ///
2323 /// Two callers reach it, and they are the two C sites that move a record
2324 /// between scan lists: a `SCAN` put ([`Self::put_common_field`]) and the
2325 /// simulation-mode scan swap (`recGblCheckSimm`, recGbl.c:427-437, which
2326 /// calls exactly the same `scanDelete`/`scanAdd` pair). Returns the delta
2327 /// for the scan-index owner (`PvDatabase::update_scan_index`) to apply once
2328 /// the record lock is down; [`CommonFieldPutResult::NoChange`] when the scan
2329 /// did not move.
2330 pub fn set_scan(&mut self, new_scan: ScanType) -> CommonFieldPutResult {
2331 let old_scan = self.common.scan;
2332 self.common.scan = new_scan;
2333 if old_scan == new_scan {
2334 return CommonFieldPutResult::NoChange;
2335 }
2336 // C `scanDelete`/`scanAdd` call the record's device support
2337 // `get_ioint_info(1)` / `get_ioint_info(0)`. Only a change of I/O Intr
2338 // *membership* reaches those; a Passive→"1 second" move calls neither.
2339 let was_io_intr = old_scan == ScanType::IoIntr;
2340 let is_io_intr = new_scan == ScanType::IoIntr;
2341 if was_io_intr != is_io_intr {
2342 self.record.set_io_intr_scan(is_io_intr);
2343 }
2344 CommonFieldPutResult::ScanChanged {
2345 old_scan,
2346 new_scan,
2347 phas: self.common.phas,
2348 }
2349 }
2350
2351 /// C `recGblSaveSimm` (`recGbl.c:421-425`) — latch the CURRENT simulation
2352 /// mode into OLDSIMM:
2353 ///
2354 /// ```c
2355 /// void recGblSaveSimm(const epicsEnum16 sscn,
2356 /// epicsEnum16 *poldsimm, const epicsEnum16 simm) {
2357 /// if (sscn == USHRT_MAX) return;
2358 /// *poldsimm = simm;
2359 /// }
2360 /// ```
2361 ///
2362 /// **The only writer of `CommonFields::oldsimm`.** Must run BEFORE the SIMM
2363 /// value moves — C calls it from `special(SPC_MOD)` pass 0 (before the put)
2364 /// and from `recGblGetSimm`/`recGblInitSimm` before the SIML read. The
2365 /// `sscn == 65535` guard is C's: with SSCN unset there is no scan to swap
2366 /// to, so the latch is not even taken (and [`Self::rec_gbl_check_simm`]
2367 /// bails on the same test, so the stale OLDSIMM is never read).
2368 ///
2369 /// A record type with no SSCN/OLDSIMM in its C dbd (`busy`, `swait`) passes
2370 /// neither pointer to any recGbl helper: no-op here.
2371 pub fn rec_gbl_save_simm(&mut self) {
2372 if !self.record.uses_recgbl_simm_helpers() {
2373 return;
2374 }
2375 // C `recGblSaveSimm`: `if (*psscn == USHRT_MAX) return;` — the literal
2376 // sentinel, not "any index outside the menu".
2377 if self.common.sscn.is_unset() {
2378 return;
2379 }
2380 if let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") {
2381 self.common.oldsimm = simm;
2382 }
2383 }
2384
2385 /// C `recGblCheckSimm` (`recGbl.c:427-437`) — on a SIMM transition, swap the
2386 /// record's SCAN with SSCN:
2387 ///
2388 /// ```c
2389 /// void recGblCheckSimm(struct dbCommon *pcommon, epicsEnum16 *psscn,
2390 /// const epicsEnum16 oldsimm, const epicsEnum16 simm) {
2391 /// if (*psscn == USHRT_MAX) return;
2392 /// if (simm != oldsimm) {
2393 /// epicsUInt16 scan = pcommon->scan;
2394 /// scanDelete(pcommon);
2395 /// pcommon->scan = *psscn;
2396 /// scanAdd(pcommon);
2397 /// *psscn = scan;
2398 /// }
2399 /// }
2400 /// ```
2401 ///
2402 /// This is what makes SSCN mean anything at all: a record configured
2403 /// `field(SCAN,"1 second") field(SSCN,"Passive")` stops periodic scanning
2404 /// the moment SIMM leaves NO, and resumes it when SIMM goes back — with the
2405 /// two fields having traded places each time. Both are a genuine swap, not
2406 /// an assignment: SSCN ends up holding the scan the record just left.
2407 ///
2408 /// **The only writer of the SIMM-driven SCAN/SSCN swap.** The scan-list
2409 /// move itself goes through the single SCAN owner [`Self::set_scan`], whose
2410 /// [`CommonFieldPutResult`] the caller hands to
2411 /// `PvDatabase::update_scan_index` once the record lock is down. Runs AFTER
2412 /// the SIMM value moved — C `special(SPC_MOD)` pass 1, and the tail of
2413 /// `recGblGetSimm`/`recGblInitSimm`.
2414 pub fn rec_gbl_check_simm(&mut self) -> CommonFieldPutResult {
2415 if !self.record.uses_recgbl_simm_helpers() {
2416 return CommonFieldPutResult::NoChange;
2417 }
2418 let Some(sim_scan) = self.common.sscn.scan() else {
2419 // `*psscn == USHRT_MAX` — SSCN unset, no swap. An SSCN that is
2420 // merely ILLEGAL still swaps: C assigns it into SCAN and `scanAdd`
2421 // then declines to scan the record.
2422 return CommonFieldPutResult::NoChange;
2423 };
2424 let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") else {
2425 return CommonFieldPutResult::NoChange;
2426 };
2427 if simm == self.common.oldsimm {
2428 return CommonFieldPutResult::NoChange;
2429 }
2430 let previous_scan = self.common.scan;
2431 let result = self.set_scan(sim_scan);
2432 self.common.sscn = SimModeScan::from_scan(previous_scan);
2433 result
2434 }
2435
2436 /// C `dbAccess.c::putAckt` (`:1285-1300`) — the **only** writer of ACKT.
2437 ///
2438 /// Reached from `dbPut` for a `DBR_PUT_ACKT` request *type*
2439 /// (`dbAccess.c:1331-1332`), ABOVE the `SPC_NOMOD` gate that refuses every
2440 /// ordinary put to the field. Posts exactly what C posts: the ACKT change,
2441 /// the ACKS it may lower, and the record-wide `DBE_ALARM` — and only when
2442 /// `ackt` actually changed (C returns 0 early otherwise).
2443 pub fn put_ackt(&mut self, value: u16) {
2444 let new_ackt = value != 0;
2445 if new_ackt == self.common.ackt {
2446 return;
2447 }
2448 use crate::server::recgbl::EventMask;
2449 let ack_mask = EventMask::VALUE | EventMask::ALARM;
2450 self.common.ackt = new_ackt;
2451 self.cleanup_subscribers();
2452 self.notify_field("ACKT", ack_mask);
2453 // C `:1294-1297`: turning transient acknowledgement off lowers a
2454 // sticky ACKS down to the current SEVR — an alarm that has already
2455 // cleared must not keep a higher unacknowledged severity.
2456 if !new_ackt && self.common.acks > self.common.sevr {
2457 self.common.acks = self.common.sevr;
2458 self.notify_field("ACKS", ack_mask);
2459 }
2460 self.notify_record_alarm();
2461 }
2462
2463 /// C `dbAccess.c::putAcks` (`:1302-1315`) — the **only** runtime writer of
2464 /// ACKS. Reached from `dbPut` for a `DBR_PUT_ACKS` request type, ABOVE the
2465 /// `SPC_NOMOD` gate.
2466 ///
2467 /// The acknowledged severity is compared against the STORED unacknowledged
2468 /// severity `acks`, not the current `sevr`: an operator acknowledging at
2469 /// the severity that was latched into ACKS clears it even after `sevr` has
2470 /// since dropped. A too-low acknowledgement changes nothing and posts
2471 /// nothing; an acknowledgement of an already-clear ACKS still posts, which
2472 /// is C's literal `if (*psev >= precord->acks)` (0 >= 0 holds).
2473 pub fn put_acks(&mut self, value: u16) {
2474 let sev = AlarmSeverity::from_u16(value);
2475 if sev < self.common.acks {
2476 return;
2477 }
2478 use crate::server::recgbl::EventMask;
2479 self.common.acks = AlarmSeverity::NoAlarm;
2480 self.cleanup_subscribers();
2481 self.notify_field("ACKS", EventMask::VALUE | EventMask::ALARM);
2482 self.notify_record_alarm();
2483 }
2484
2485 /// Set a common field value from the `.db` loader, which in C is a
2486 /// different converter with a different out-of-menu bound
2487 /// (`dbStaticRun.c::dbPutStringNum`; see [`MenuBound::DbLoad`]). It is what
2488 /// lets `field(SSCN,"65535")` — the menuScan "use SCAN" sentinel, out of
2489 /// the menu's 0-9 range — load, while `caput REC.SSCN 65535` is refused at
2490 /// runtime exactly as C refuses it.
2491 pub fn put_common_field_db_load(
2492 &mut self,
2493 name: &str,
2494 value: EpicsValue,
2495 ) -> CaResult<CommonFieldPutResult> {
2496 self.put_common_field_bounded(name, value, MenuBound::DbLoad)
2497 }
2498
2499 fn put_common_field_bounded(
2500 &mut self,
2501 name: &str,
2502 value: EpicsValue,
2503 bound: MenuBound,
2504 ) -> CaResult<CommonFieldPutResult> {
2505 let name = name.to_ascii_uppercase();
2506 self.record.validate_put(&name, &value)?;
2507 self.record.special(&name, false)?;
2508 // The db loader hands every common field to this path as a raw
2509 // `EpicsValue::String` (no per-field `FieldDesc` to parse against).
2510 // Coerce it to the field's canonical numeric/menu type up front so the
2511 // typed arms below apply a `field(PHAS, "1")` / `field(PRIO, "HIGH")`
2512 // directive instead of silently dropping it at IOC load. String-typed
2513 // and already-typed values pass through unchanged.
2514 let value = coerce_common_field(&name, value, bound)?;
2515 match name.as_str() {
2516 // `special(SPC_NOMOD)` — C `dbPutSpecial` refuses the put with
2517 // `S_db_noMod` (dbAccess.c:123-127). OLDSIMM is written only by the
2518 // simulation-mode owner (`rec_gbl_save_simm`).
2519 "OLDSIMM" => return Err(CaError::ReadOnlyField(name)),
2520 "SEVR" => {
2521 if let EpicsValue::Short(v) = value {
2522 self.common.sevr = AlarmSeverity::from_u16(v as u16);
2523 }
2524 }
2525 "STAT" => {
2526 if let EpicsValue::Short(v) = value {
2527 self.common.stat = v as u16;
2528 }
2529 }
2530 "NSEV" => {
2531 if let EpicsValue::Short(v) = value {
2532 self.common.nsev = AlarmSeverity::from_u16(v as u16);
2533 }
2534 }
2535 "NSTA" => {
2536 if let EpicsValue::Short(v) = value {
2537 self.common.nsta = v as u16;
2538 }
2539 }
2540 "AMSG" => {
2541 if let EpicsValue::String(s) = value {
2542 self.common.amsg = s.as_str_lossy().into_owned();
2543 }
2544 }
2545 "NAMSG" => {
2546 if let EpicsValue::String(s) = value {
2547 self.common.namsg = s.as_str_lossy().into_owned();
2548 }
2549 }
2550 // ACKS/ACKT carry NO acknowledgement semantics here. They are
2551 // `special(SPC_NOMOD)` in `dbCommon.dbd:150-159`, so no runtime put
2552 // reaches this arm — the gate refuses it. C's acknowledgement is
2553 // driven by the DBR *request type* (`DBR_PUT_ACKS`/`ACKT`), which
2554 // `dbPut` intercepts ABOVE the SPC_NOMOD gate and hands to
2555 // [`Self::put_acks`] / [`Self::put_ackt`]. What is left here is the
2556 // `dbLoadRecords` / `dbStaticLib` load path (`field(ACKT,"YES")`),
2557 // which stores the value verbatim — C `dbPutString` never crosses
2558 // `dbPut`.
2559 "ACKS" => {
2560 if let EpicsValue::Short(v) = value {
2561 self.common.acks = AlarmSeverity::from_u16(v as u16);
2562 }
2563 }
2564 "ACKT" => match value {
2565 EpicsValue::Char(v) => self.common.ackt = v != 0,
2566 EpicsValue::Short(v) => self.common.ackt = v != 0,
2567 _ => return Ok(CommonFieldPutResult::NoChange),
2568 },
2569 "UDF" => {
2570 // Store the raw put byte (C keeps the epicsUInt8 verbatim); a
2571 // record that re-derives UDF on process overwrites it, one that
2572 // sources nothing this cycle keeps it (put-defect cluster #3).
2573 // The calc family reads UDF straight from `common` too
2574 // (`clears_udf() == false`), so the stored byte stands.
2575 if let EpicsValue::Char(v) = value {
2576 self.common.udf = v;
2577 }
2578 }
2579 "UDFS" => {
2580 self.common.udfs = menu_ordinal_raw(&value);
2581 }
2582 // The `String` form never reaches these three menu arms:
2583 // `coerce_common_field` has already run it through the one
2584 // menu converter, which either produced an `Enum` index or failed
2585 // the put with `S_db_badChoice`.
2586 "SCAN" => {
2587 let new_scan = match &value {
2588 EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
2589 EpicsValue::Enum(v) => ScanType::from_u16(*v),
2590 _ => return Ok(CommonFieldPutResult::NoChange),
2591 };
2592 let result = self.set_scan(new_scan);
2593 if !matches!(result, CommonFieldPutResult::NoChange) {
2594 self.record.on_put(&name);
2595 self.record.special(&name, true)?;
2596 return Ok(result);
2597 }
2598 }
2599 "SSCN" => {
2600 let new_sscn = match &value {
2601 EpicsValue::Short(v) => SimModeScan::from_u16(*v as u16),
2602 EpicsValue::Enum(v) => SimModeScan::from_u16(*v),
2603 _ => return Ok(CommonFieldPutResult::NoChange),
2604 };
2605 self.common.sscn = new_sscn;
2606 }
2607 // `PINI` is `menu(menuPini)` — the six choices NO/YES/RUN/RUNNING/
2608 // PAUSE/PAUSED (`menuPini.dbd.pod:59-65`). Resolved exactly like
2609 // `SCAN`: a menu label or a bare index, never a truthiness test.
2610 // The pre-fix `bool` arm collapsed `RUN` (index 2) to `false`, so
2611 // `caput REC.PINI RUN` *disabled* PINI instead of selecting the
2612 // iocRun pass.
2613 "PINI" => {
2614 // Store the RAW ordinal (see [`CommonFields::pini`]): C's numeric
2615 // menu put keeps `(epicsEnum16)`, so an out-of-range `caput
2616 // REC.PINI 6` / `-1` round-trips and simply matches no lifecycle
2617 // pass in `doRecordPini`. A `String` label is already resolved to
2618 // `Enum` by `coerce_common_field` (menuPini via `putStringMenu`).
2619 self.common.pini = match &value {
2620 EpicsValue::Short(v) => *v,
2621 EpicsValue::Char(v) => *v as i16,
2622 EpicsValue::Enum(v) => *v as i16,
2623 _ => return Ok(CommonFieldPutResult::NoChange),
2624 };
2625 }
2626 "TPRO" => {
2627 if let EpicsValue::Char(v) = value {
2628 self.common.tpro = v;
2629 }
2630 }
2631 "BKPT" => {
2632 if let EpicsValue::Char(v) = value {
2633 self.common.bkpt = v;
2634 }
2635 }
2636 "FLNK" => {
2637 if let EpicsValue::String(s) = value {
2638 self.common.flnk = s.as_str_lossy().into_owned();
2639 self.parsed_flnk = parse_forward_link_v2(&self.common.flnk);
2640 }
2641 }
2642 "INP" => {
2643 // A record type whose C `.dbd` has no INP must refuse it, the
2644 // way C's dbd does ("field not found" at load, record inert) —
2645 // `histogram`'s input link is SVL, not INP
2646 // (histogramRecord.dbd.pod:212). Without this the port accepts a
2647 // `field(INP,...)` no C IOC can load.
2648 if !self.record.declares_inp_link() {
2649 return Err(CaError::FieldNotFound("INP".to_string()));
2650 }
2651 if let EpicsValue::String(s) = value {
2652 self.check_link_assignment("INP", &s.as_str_lossy(), bound)?;
2653 self.common.inp = s.as_str_lossy().into_owned();
2654 if !self.record_declares_field("INP") {
2655 self.parsed_inp = parse_link_v2(&self.common.inp);
2656 }
2657 }
2658 }
2659 "OUT" => {
2660 if let EpicsValue::String(s) = value {
2661 let s = s.as_str_lossy();
2662 self.check_link_assignment("OUT", &s, bound)?;
2663 // C `dbParseLink` (dbStaticLib.c:2382-2386) discards a
2664 // CP/CPP modifier on a DBF_OUTLINK and warns once, naming
2665 // the holder record, its field and the target. The discard
2666 // itself is owned by `parse_output_link_v2` below; only the
2667 // diagnostic lives here, where the record name exists and
2668 // the link text is being (re)loaded rather than re-parsed
2669 // per process cycle.
2670 if out_link_discards_cp(&s) {
2671 tracing::warn!(
2672 target: "epics_base_rs::record",
2673 record = %self.name,
2674 field = "OUT",
2675 link = %s,
2676 "Discarding CP/CPP modifier in CA output link"
2677 );
2678 }
2679 self.common.out = s.into_owned();
2680 // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
2681 // link processes its target only on an explicit
2682 // ` PP` token (or a `.PROC` destination). A bare
2683 // OUT link is NPP — `parse_output_link_v2`
2684 // downgrades the modifier-less `ProcessPassive`
2685 // default that `parse_link_v2` would otherwise
2686 // apply.
2687 if !self.record_declares_field("OUT") {
2688 self.parsed_out = parse_output_link_v2(&self.common.out);
2689 }
2690 // C `longoutRecord.c::special` (PR #6c573b4 part 2)
2691 // and similar OOCH-style hooks need `after=true`
2692 // to fire after the link has actually moved. The
2693 // earlier `validate_put` + `special(name, false)`
2694 // pair only covered the before-side.
2695 self.record.special(&name, true)?;
2696 }
2697 }
2698 // Two shapes reach DTYP and both name a device support:
2699 //
2700 // * the `.db` loader hands over the NAME verbatim, and it may be a
2701 // name registered at runtime by a downstream crate ("asynInt32")
2702 // that no vendored `.dbd` declares — C would reject that at load,
2703 // the port's registry accepts it (Tier 3);
2704 // * a `dbPut` arrives as the menu INDEX, because `DBF_DEVICE` is
2705 // served as `DBR_ENUM` and `coerce_put_value` already resolved an
2706 // incoming label through the device menu (C `putStringMenu`,
2707 // which fails `S_db_badChoice` on a name the menu does not have).
2708 //
2709 // The index is meaningful against the MERGED device menu (static
2710 // `device()` declarations + runtime-contributed device support) —
2711 // the exact list `coerce_put_value` bounded it by. Resolving it
2712 // against the static-only menu here would drop every contributed
2713 // name (asyn's "asynInt32", scaler-rs's "Asyn Scaler") back to
2714 // NoChange, leaving DTYP unset after a valid put.
2715 "DTYP" => match value {
2716 EpicsValue::String(s) => self.common.dtyp = s.as_str_lossy().into_owned(),
2717 EpicsValue::Enum(i) => {
2718 let merged = super::merged_device_menu(self.record.record_type());
2719 match merged.get(i as usize) {
2720 Some(name) => self.common.dtyp = (*name).to_string(),
2721 None => return Ok(CommonFieldPutResult::NoChange),
2722 }
2723 }
2724 _ => return Ok(CommonFieldPutResult::NoChange),
2725 },
2726 "TSE" => {
2727 if let EpicsValue::Short(v) = value {
2728 self.common.tse = v;
2729 }
2730 }
2731 "TSEL" => {
2732 if let EpicsValue::String(s) = value {
2733 self.common.tsel = s.as_str_lossy().into_owned();
2734 self.parsed_tsel = parse_link_v2(&self.common.tsel);
2735 }
2736 }
2737 "UTAG" => {
2738 // C UTAG is DBF_UINT64 — accept any integer-shaped value and
2739 // store the unsigned 64-bit tag. The db loader feeds every
2740 // common field as EpicsValue::String, so parse field(UTAG, "N")
2741 // rather than dropping it silently at IOC load; a CA write to
2742 // this u64 field crosses as DBR_DOUBLE (CA has no uint64 wire
2743 // type), so accept Double too.
2744 match value {
2745 EpicsValue::UInt64(v) => self.common.utag = v,
2746 EpicsValue::Int64(v) => self.common.utag = v as u64,
2747 EpicsValue::Long(v) => self.common.utag = v as u64,
2748 EpicsValue::Short(v) => self.common.utag = v as u64,
2749 EpicsValue::Enum(v) => self.common.utag = v as u64,
2750 EpicsValue::Char(v) => self.common.utag = v as u64,
2751 EpicsValue::Double(v) => self.common.utag = v as u64,
2752 EpicsValue::String(s) => {
2753 if let Ok(EpicsValue::UInt64(v)) =
2754 EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
2755 {
2756 self.common.utag = v;
2757 }
2758 }
2759 _ => {}
2760 }
2761 }
2762 "ASG" => {
2763 if let EpicsValue::String(s) = value {
2764 self.common.asg = s.as_str_lossy().into_owned();
2765 }
2766 }
2767 "ASL" => {
2768 // C dbCommon.ASL is `epicsUInt32` in the .dbd but
2769 // only ever 0 or 1; accept Char / Short / Long for
2770 // the common put paths and clamp to {0, 1}.
2771 // db_loader feeds every common field as
2772 // `EpicsValue::String`; also accept that so a
2773 // `.db` `field(ASL, "1")` directive isn't silently
2774 // ignored at IOC load.
2775 let n: i64 = match value {
2776 EpicsValue::Char(v) => v as i64,
2777 EpicsValue::Short(v) => v as i64,
2778 EpicsValue::Long(v) => v as i64,
2779 EpicsValue::Int64(v) => v,
2780 EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
2781 _ => return Ok(CommonFieldPutResult::NoChange),
2782 };
2783 self.common.asl = if n != 0 { 1 } else { 0 };
2784 }
2785 "DESC" => {
2786 if let EpicsValue::String(s) = value {
2787 // DBF_STRING data field — store the bytes verbatim so a
2788 // non-UTF-8 DESC round-trips unchanged.
2789 self.common.desc = s;
2790 }
2791 }
2792 "PHAS" => {
2793 if let EpicsValue::Short(v) = value {
2794 let old_phas = self.common.phas;
2795 self.common.phas = v;
2796 // Only a record that IS in a scan list can be re-sorted
2797 // within one; the same gate the index owner applies.
2798 if old_phas != v && self.common.scan.scan_list().is_some() {
2799 let scan = self.common.scan;
2800 self.record.on_put(&name);
2801 self.record.special(&name, true)?;
2802 return Ok(CommonFieldPutResult::PhasChanged {
2803 scan,
2804 old_phas,
2805 new_phas: v,
2806 });
2807 }
2808 }
2809 }
2810 "EVNT" => {
2811 // C `EVNT` is DBF_STRING (event name). Accept a
2812 // string directly; accept a numeric value too for
2813 // backward compatibility (numeric events / a calc
2814 // record driving EVNT) by formatting it as a string.
2815 match value {
2816 EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
2817 EpicsValue::Short(v) => self.common.evnt = v.to_string(),
2818 EpicsValue::Long(v) => self.common.evnt = v.to_string(),
2819 EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
2820 EpicsValue::Double(v) => {
2821 // Match C `eventNameToHandle`: a double with
2822 // an integer part is treated as that integer.
2823 self.common.evnt = (v as i64).to_string();
2824 }
2825 _ => {}
2826 }
2827 }
2828 "PRIO" => {
2829 if let EpicsValue::Short(v) = value {
2830 self.common.prio = v;
2831 }
2832 }
2833 "DISV" => {
2834 if let EpicsValue::Short(v) = value {
2835 self.common.disv = v;
2836 }
2837 }
2838 "DISA" => {
2839 if let EpicsValue::Short(v) = value {
2840 self.common.disa = v;
2841 }
2842 }
2843 "SDIS" => {
2844 if let EpicsValue::String(s) = value {
2845 self.common.sdis = s.as_str_lossy().into_owned();
2846 self.parsed_sdis = parse_link_v2(&self.common.sdis);
2847 }
2848 }
2849 "DISS" => {
2850 self.common.diss = menu_ordinal_raw(&value);
2851 }
2852 "HYST" => {
2853 if let Some(v) = value.to_f64() {
2854 self.common.hyst = v;
2855 }
2856 }
2857 "LCNT" => {
2858 if let EpicsValue::Short(v) = value {
2859 self.common.lcnt = v;
2860 }
2861 }
2862 "DISP" => {
2863 if let EpicsValue::Char(v) = value {
2864 self.common.disp = v;
2865 }
2866 }
2867 "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
2868 "RPRO" => {
2869 if let EpicsValue::Char(v) = value {
2870 self.common.rpro = v;
2871 }
2872 }
2873 "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
2874 // C `dbPut` stores the raw byte in `prec->proc` (retained across
2875 // processing — C never resets it); `coerce_common_field` has
2876 // already projected the put onto `DBF_UCHAR` (→ `Char`). The
2877 // `pp(TRUE)` reprocess is driven separately by the put-path
2878 // force-process intercept, so this arm ONLY records the byte.
2879 "PROC" => {
2880 if let EpicsValue::Char(v) = value {
2881 self.common.proc_field = v;
2882 }
2883 }
2884 // Analog alarm limits. The DB-load String was already coerced to
2885 // `Double` by `coerce_common_field` — the one owner of
2886 // "what type does this common field hold" — so every writer
2887 // (`.db` load, `caput`, a link) lands here with a numeric value.
2888 "HIHI" => {
2889 if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
2890 a.hihi = v;
2891 }
2892 }
2893 "HIGH" => {
2894 if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
2895 a.high = v;
2896 }
2897 }
2898 "LOW" => {
2899 if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
2900 a.low = v;
2901 }
2902 }
2903 "LOLO" => {
2904 if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
2905 a.lolo = v;
2906 }
2907 }
2908 "HHSV" => {
2909 if let Some(a) = &mut self.common.analog_alarm {
2910 a.hhsv = menu_ordinal_raw(&value);
2911 }
2912 }
2913 "HSV" => {
2914 if let Some(a) = &mut self.common.analog_alarm {
2915 a.hsv = menu_ordinal_raw(&value);
2916 }
2917 }
2918 "LSV" => {
2919 if let Some(a) = &mut self.common.analog_alarm {
2920 a.lsv = menu_ordinal_raw(&value);
2921 }
2922 }
2923 "LLSV" => {
2924 if let Some(a) = &mut self.common.analog_alarm {
2925 a.llsv = menu_ordinal_raw(&value);
2926 }
2927 }
2928 // swait-specific: OUTN is the output link name for swait records.
2929 // Mirrors to common.out so the processing framework dispatches it.
2930 "OUTN" => {
2931 if self.record.record_type() != "swait" {
2932 // No OUTN field on any other record type — the same
2933 // `S_dbLib_fieldNotFound` the catch-all below reports.
2934 return Err(self.unknown_field_error(name));
2935 }
2936 if let EpicsValue::String(s) = value {
2937 self.common.out = s.as_str_lossy().into_owned();
2938 // Bare OUT link is NPP — see the "OUT" arm.
2939 self.parsed_out = parse_output_link_v2(&self.common.out);
2940 }
2941 }
2942 // C `dbNameToAddr` (dbAccess.c:660-676) resolves the field part
2943 // with `dbFindFieldPart`, then falls back to `dbGetAttributePart`.
2944 // A name that is neither a record field, nor a dbCommon field, nor
2945 // an attribute resolves to nothing (`S_dbLib_fieldNotFound`), so
2946 // `dbPutField` is never reached and the caller reports the error —
2947 // `dbpf` prints "PV '%s' not found" and returns -1 (dbTest.c:787-795).
2948 // Returning success here made a put to a misspelled field a silent
2949 // no-op.
2950 //
2951 // But a field the record's `.dbd` DECLARES and no arm above stored
2952 // is NOT unknown: C `dbPut` writes it into record memory even when
2953 // no record code reads it back (`caput dfanout.HOPR 10`). Land it in
2954 // the per-instance declared-override store — the write analog of
2955 // `declared_default` — so the put is accepted and a later read
2956 // reflects it. `put_declared_override` still returns
2957 // `unknown_field_error` for a name with no `dbFldDes`, so a
2958 // misspelled field is refused exactly as before.
2959 _ => return self.put_declared_override(&name, value),
2960 }
2961 self.record.on_put(&name);
2962 // C `dbPut` (dbAccess.c:1399-1405) returns the after-put
2963 // `dbPutSpecial(paddr, 1)` status to the caller — the stored value
2964 // stays, but the monitor post and the process are skipped and the
2965 // client sees the failure. Never drop it.
2966 self.record.special(&name, true)?;
2967 Ok(CommonFieldPutResult::NoChange)
2968 }
2969
2970 /// The error C reports for a write to a field name that
2971 /// [`Self::put_common_field`] does not own.
2972 ///
2973 /// Two C outcomes, split by whether the name resolves at all:
2974 ///
2975 /// - A record *attribute* (`NAME`, `RTYP`) resolves — `dbGetAttributePart`
2976 /// succeeds — but the write is refused: `NAME` is `special(SPC_NOMOD)`
2977 /// (dbCommon.dbd:13-17) so `dbPutSpecial` pass 0 returns `S_db_noMod`
2978 /// (dbAccess.c:123-124), and an attribute address carries
2979 /// `special == SPC_ATTRIBUTE`, which `dbPutField` rejects with the same
2980 /// `S_db_noMod` (dbAccess.c:1252-1253).
2981 /// - Anything else does not resolve: `S_dbLib_fieldNotFound`.
2982 fn unknown_field_error(&self, name: String) -> CaError {
2983 if self.get_virtual_field(&name).is_some() {
2984 CaError::ReadOnlyField(name)
2985 } else {
2986 CaError::FieldNotFound(name)
2987 }
2988 }
2989
2990 /// Store a put to a field the record's `.dbd` DECLARES but the record
2991 /// models no storage for — the WRITE owner of [`Self::declared_overrides`]
2992 /// and the write analog of [`Self::declared_default`].
2993 ///
2994 /// Reached only from [`Self::put_common_field_bounded`]'s catch-all, i.e.
2995 /// after both `Record::put_field` (returned `FieldNotFound`) and every
2996 /// `dbCommon` arm above have declined the field. Three gates, mirroring
2997 /// C `dbNameToAddr`/`dbPut`:
2998 ///
2999 /// * NO `dbFldDes` (`field_desc` is `None`) — the name is not a field of
3000 /// this record type at all. C resolves nothing and `dbPutField` reports
3001 /// `S_dbLib_fieldNotFound`; return [`Self::unknown_field_error`] (which
3002 /// also renders `NAME`/`RTYP` as the read-only attributes they are).
3003 /// * `special(SPC_NOMOD)` — a declared field that is immutable
3004 /// ([`Self::is_no_mod`]: the `.dbd` `read_only`/attribute bit or the
3005 /// record's runtime `field_no_mod`). C refuses the put with `S_db_noMod`;
3006 /// the runtime dispatch already gates this via `field_io::check_no_mod`,
3007 /// but the db-load path does not, so enforce it here too — never store an
3008 /// SPC_NOMOD field in the override map.
3009 /// * [`FieldDesc::runtime_typed`] — a field whose served type C's
3010 /// `cvt_dbaddr` re-derives from record state (`waveform.VAL` from `FTVL`,
3011 /// `aSub.A` from `FTA`). Such a field is record-owned by definition, so
3012 /// its `put_field` should have taken the put; if it somehow reached here
3013 /// the override store must not shadow it (`declared_default` skips it for
3014 /// the same reason). Treat as not-found rather than store a value under
3015 /// the wrong type.
3016 /// * PARTIALLY modeled — `Record::get_field` serves the field but no
3017 /// `put_field` arm accepts it (`calcout.PVAL` → `self.pval`). The record
3018 /// owns the read path, so the write belongs in its own `put_field`, not a
3019 /// shadow cell; refuse here rather than store a value `resolve_field`
3020 /// would never reach. See the inline note on the `get_field` guard.
3021 ///
3022 /// Otherwise coerce the incoming value to the field's C-declared DBF type
3023 /// through the one write-side value-coercion owner
3024 /// ([`coerce_put_value`](crate::server::record::coerce_put_value)) — so a
3025 /// `.db`/`caput` string parses with C's range rules (`caput REC.PREC 99999`
3026 /// into a `DBF_SHORT` is refused, not wrapped) and a menu label resolves
3027 /// against the field's own choices — and store it. Returns
3028 /// [`CommonFieldPutResult::NoChange`]: there is no scan/phas/alarm side
3029 /// effect for a metadata field with no record behaviour, and the caller's
3030 /// value-field monitor post reads the stored value back through
3031 /// [`Self::resolve_field`].
3032 fn put_declared_override(
3033 &mut self,
3034 name: &str,
3035 value: EpicsValue,
3036 ) -> CaResult<CommonFieldPutResult> {
3037 let Some(desc) = self.field_desc(name) else {
3038 return Err(self.unknown_field_error(name.to_string()));
3039 };
3040 if desc.runtime_typed {
3041 return Err(self.unknown_field_error(name.to_string()));
3042 }
3043 if self.is_no_mod(name) {
3044 // C `dbPutSpecial` pass 0 refuses SPC_NOMOD with `S_db_noMod`.
3045 return Err(CaError::ReadOnlyField(name.to_string()));
3046 }
3047 // The override is the WRITABLE TWIN of `declared_default`, and
3048 // `declared_default` is `resolve_field`'s fallback ONLY when the record
3049 // itself serves nothing (`Record::get_field` is `None`). If the record
3050 // DOES serve this field (`get_field` is `Some`), it is not unmodeled —
3051 // it is PARTIALLY modeled: a getter into record memory (e.g.
3052 // `calcout.PVAL` → `self.pval`, which `process()` also writes) but no
3053 // matching `put_field` arm. Storing here would place the value in a
3054 // second cell that `resolve_field` never reaches (`get_field` shadows
3055 // the override) and that no `process()` keeps in step — a silent write
3056 // loss. Such a field's put belongs in the record's OWN `put_field`
3057 // (a per-record setter, a distinct change); refuse it here rather than
3058 // half-accept it, so `resolve_field` stays single-valued. A field the
3059 // record does not serve at all falls through to be stored.
3060 if self.record.get_field(name).is_some() {
3061 return Err(self.unknown_field_error(name.to_string()));
3062 }
3063 let target = desc.dbf_type;
3064 let coerced =
3065 crate::server::record::coerce_put_value(self.record.as_ref(), name, target, value)?;
3066 self.declared_overrides
3067 .insert(name.to_ascii_uppercase(), coerced);
3068 Ok(CommonFieldPutResult::NoChange)
3069 }
3070
3071 /// Get virtual fields (NAME, RTYP).
3072 pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
3073 match name {
3074 "NAME" => Some(EpicsValue::String(self.name.clone().into())),
3075 "RTYP" => Some(EpicsValue::String(
3076 self.record.record_type().to_string().into(),
3077 )),
3078 _ => None,
3079 }
3080 }
3081
3082 /// Evaluate alarms based on record type and current value.
3083 /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
3084 ///
3085 /// CALC_ALARM is NOT raised here. C raises it inside the record's own
3086 /// `process()` (`calcRecord.c:121-123`, `calcoutRecord.c:238-241`,
3087 /// `sCalcoutRecord.c:357-363`, `aCalcoutRecord.c:304-305`,
3088 /// `swaitRecord.c:409-410`), and in the port [`Record::check_alarms`] — which
3089 /// runs immediately before this — is that owner. It used to be raised here
3090 /// instead, keyed on a hardcoded `rtype` list plus a `CALC_ALARM` pseudo-field
3091 /// no DBD declares; swait is what that construction cost: it carried the flag
3092 /// but was not on the list, so a failed `calcPerform` alarmed nowhere.
3093 pub fn evaluate_alarms(&mut self) {
3094 use crate::server::recgbl;
3095
3096 // Check UDF first — but only for record types whose C support carries
3097 // the `if (prec->udf) recGblSetSevr(..., UDF_ALARM, ...)` guard. C has
3098 // no central UDF alarm; see `Record::raises_udf_alarm`.
3099 if self.record.raises_udf_alarm() {
3100 recgbl::rec_gbl_check_udf(
3101 &mut self.common,
3102 self.record.udf_alarm_on_exact_one(),
3103 self.record.udf_alarm_message(),
3104 );
3105 }
3106
3107 // The analog-alarm SLOT is the enumeration — a record has the ladder iff
3108 // `new_boxed` gave it a config, which is the one place the C `.dbd`
3109 // survey lives. A second `match rtype` here was the same list written
3110 // twice, and the two could disagree: scalcout was in neither, so its ten
3111 // C alarm fields could not even be put.
3112 //
3113 // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT) alarm evaluation
3114 // lives in each record's `Record::check_alarms` hook (C `checkAlarms`);
3115 // those records carry no analog config, so they never reach here and
3116 // cannot double-raise.
3117 if let Some(ref alarm_cfg) = self.common.analog_alarm.clone() {
3118 let val = match self.record.val() {
3119 Some(EpicsValue::Double(v)) => v,
3120 Some(EpicsValue::Long(v)) => v as f64,
3121 Some(EpicsValue::Int64(v)) => v as f64,
3122 _ => return,
3123 };
3124 self.evaluate_analog_alarm(val, alarm_cfg);
3125 }
3126 }
3127
3128 fn evaluate_analog_alarm(&mut self, val: f64, cfg: &AnalogAlarmConfig) {
3129 use crate::server::recgbl::{self, alarm_status};
3130
3131 // C `checkAlarms` returns immediately on a UDF cycle: it raises
3132 // `UDF_ALARM`/`UDFS` (already done by `rec_gbl_check_udf` in
3133 // `evaluate_alarms`), zeroes `AFVL` on the AFTC-capable records, and
3134 // returns BEFORE the range check — so `LALM` is left untouched and
3135 // `AFVL` is not filtered this cycle. The identical guard appears in
3136 // every record that shares this arm (`aiRecord.c:319-323`,
3137 // `aoRecord.c:383-386`, `longinRecord.c:274-278`,
3138 // `longoutRecord.c:317-320`, `int64inRecord.c:267-271`,
3139 // `int64outRecord.c:298-301`, `calcRecord.c:300-304`,
3140 // `calcoutRecord.c:563-566`). AFTC-capable records (ai/longin/
3141 // int64in/calc) carry `AFVL` and zero it (`prec->afvl = 0`); the
3142 // out records (ao/longout/int64out/calcout) have no `AFVL` and just
3143 // return. Running the range check here would drift `LALM` to `val`
3144 // (NaN on an undefined cycle) and filter `AFVL` — both observable.
3145 if self.common.udf != 0 {
3146 if matches!(
3147 self.record.record_type(),
3148 "calc" | "ai" | "longin" | "int64in"
3149 ) && self.record.get_field("AFVL").and_then(|v| v.to_f64()) != Some(0.0)
3150 {
3151 let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
3152 }
3153 return;
3154 }
3155
3156 let hyst = self.common.hyst;
3157 let lalm = self
3158 .record
3159 .get_field("LALM")
3160 .and_then(|v| v.to_f64())
3161 .unwrap_or(val);
3162
3163 // C-style per-level hysteresis: alarm fires if val passes the level,
3164 // OR if we were already at that alarm level (lalm == alev) and val
3165 // hasn't retreated past the hysteresis margin.
3166 //
3167 // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
3168 // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
3169 // filter (`calcRecord.c::checkAlarms:339-381`) which filters
3170 // on the range level (not on severity) and re-maps back.
3171 // C's `checkAlarms` enables each level with a NONZERO test on the raw
3172 // severity ordinal (`if (prec->hhsv && …)`) and passes that raw ordinal
3173 // to `recGblSetSevr`; `recGblResetAlarms` then clamps the resulting
3174 // *severity* to `INVALID_ALARM` while the *status* keeps the level. So an
3175 // out-of-range selector (`HHSV = 4`) still fires HIHI and lands
3176 // SEVR=INVALID/STAT=HIHI — reproduced by testing `!= 0` and mapping the
3177 // ordinal through [`AlarmSeverity::from_u16`] (which clamps `>= 3` to
3178 // `Invalid`).
3179 let (mut new_sevr, mut new_stat, mut alev, mut alarm_range) = if cfg.hhsv != 0
3180 && (val >= cfg.hihi || (lalm == cfg.hihi && val >= cfg.hihi - hyst))
3181 {
3182 (
3183 AlarmSeverity::from_u16(cfg.hhsv as u16),
3184 alarm_status::HIHI_ALARM,
3185 cfg.hihi,
3186 5u16,
3187 )
3188 } else if cfg.llsv != 0 && (val <= cfg.lolo || (lalm == cfg.lolo && val <= cfg.lolo + hyst))
3189 {
3190 (
3191 AlarmSeverity::from_u16(cfg.llsv as u16),
3192 alarm_status::LOLO_ALARM,
3193 cfg.lolo,
3194 1u16,
3195 )
3196 } else if cfg.hsv != 0 && (val >= cfg.high || (lalm == cfg.high && val >= cfg.high - hyst))
3197 {
3198 (
3199 AlarmSeverity::from_u16(cfg.hsv as u16),
3200 alarm_status::HIGH_ALARM,
3201 cfg.high,
3202 4u16,
3203 )
3204 } else if cfg.lsv != 0 && (val <= cfg.low || (lalm == cfg.low && val <= cfg.low + hyst)) {
3205 (
3206 AlarmSeverity::from_u16(cfg.lsv as u16),
3207 alarm_status::LOW_ALARM,
3208 cfg.low,
3209 2u16,
3210 )
3211 } else {
3212 (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val, 3u16)
3213 };
3214
3215 // C parity: the alarm-range AFTC low-pass filter
3216 // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
3217 // integer `alarmRange` and re-maps. Only records that carry the
3218 // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
3219 // have no AFTC field (confirmed via the respective `.dbd.pod`),
3220 // so they are excluded.
3221 let aftc_capable = matches!(
3222 self.record.record_type(),
3223 "calc" | "ai" | "longin" | "int64in"
3224 );
3225 if aftc_capable {
3226 let aftc = self
3227 .record
3228 .get_field("AFTC")
3229 .and_then(|v| v.to_f64())
3230 .unwrap_or(0.0);
3231 let afvl = self
3232 .record
3233 .get_field("AFVL")
3234 .and_then(|v| v.to_f64())
3235 .unwrap_or(0.0);
3236 if aftc > 0.0 {
3237 let now = crate::runtime::general_time::get_current();
3238 let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
3239 alarm_range,
3240 aftc,
3241 afvl,
3242 self.common.time,
3243 now,
3244 );
3245 let _ = self.record.put_field("AFVL", EpicsValue::Double(new_afvl));
3246 if filtered_range != alarm_range {
3247 // Re-map filtered range back to (sevr, stat, alev).
3248 let (mapped_sevr, mapped_stat, mapped_alev) = match filtered_range {
3249 5 => (
3250 AlarmSeverity::from_u16(cfg.hhsv as u16),
3251 alarm_status::HIHI_ALARM,
3252 cfg.hihi,
3253 ),
3254 4 => (
3255 AlarmSeverity::from_u16(cfg.hsv as u16),
3256 alarm_status::HIGH_ALARM,
3257 cfg.high,
3258 ),
3259 2 => (
3260 AlarmSeverity::from_u16(cfg.lsv as u16),
3261 alarm_status::LOW_ALARM,
3262 cfg.low,
3263 ),
3264 1 => (
3265 AlarmSeverity::from_u16(cfg.llsv as u16),
3266 alarm_status::LOLO_ALARM,
3267 cfg.lolo,
3268 ),
3269 _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val),
3270 };
3271 new_sevr = mapped_sevr;
3272 new_stat = mapped_stat;
3273 alev = mapped_alev;
3274 alarm_range = filtered_range;
3275 }
3276 } else {
3277 // aftc <= 0 disables the filter. C `checkAlarms`
3278 // (e.g. aiRecord.c:356,402) initialises the local
3279 // `afvl = 0` and unconditionally stores `prec->afvl =
3280 // afvl` at the end, so a disabled filter drives AFVL to
3281 // 0. Mirror that here so a stale accumulator from a prior
3282 // `aftc > 0` run cannot mis-seed the filter if AFTC is
3283 // re-enabled later.
3284 if afvl != 0.0 {
3285 let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
3286 }
3287 }
3288 }
3289 let _ = alarm_range; // suppress unused-var on non-calc paths
3290
3291 if new_sevr != AlarmSeverity::NoAlarm {
3292 recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr);
3293 // C sets LALM to the alarm threshold level, not the current value
3294 let _ = self.record.put_field("LALM", EpicsValue::Double(alev));
3295 } else {
3296 // No alarm condition: reset LALM to current value (like C)
3297 let _ = self.record.put_field("LALM", EpicsValue::Double(val));
3298 }
3299 }
3300
3301 /// Invoke the registered subroutine (`sub`/`aSub` `SNAM`) if one is
3302 /// bound, before the record's `process()` body runs.
3303 ///
3304 /// C `subRecord.c::do_sub` / `aSubRecord.c::do_sub` call the named
3305 /// subroutine on EVERY `process()`. The function registry lives on the
3306 /// framework (`RecordInstance::subroutine`), not on the record, so the
3307 /// record's own `process()` is a no-op for these two types and the
3308 /// framework must drive the call. This is the SINGLE owner of that call
3309 /// for every dispatch path: the main engine
3310 /// (`process_record_with_links_inner`, the SCAN / event / CA-put-to-PP /
3311 /// FLNK path) and the by-name `process_local` (`db.process_record`,
3312 /// QSRV group / foreign-call path) both route through here, so a
3313 /// `sub`/`aSub` runs identically regardless of how it is processed.
3314 /// Previously only `process_local` invoked the subroutine, so on the
3315 /// main engine path `VAL`/`VALA..VALU`/`OUTA..OUTU` never updated.
3316 /// The cycle's status is delivered to the record on EVERY exit path — see
3317 /// [`Record::set_subroutine_status`], which aSub's OUT-link gate reads. The
3318 /// delivery is factored out of the body below so a future early return
3319 /// cannot skip it: the body returns the status, this wrapper publishes it.
3320 pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
3321 let outcome = self.run_subroutine_body();
3322 // A subroutine that errored out has no C counterpart (a C subroutine
3323 // returns a `long`); it is a failed cycle, so it takes the non-zero
3324 // arm — no outputs.
3325 let status = *outcome.as_ref().unwrap_or(&SUBROUTINE_STATUS_ERROR);
3326 self.record.set_subroutine_status(status);
3327 outcome.map(|_| ())
3328 }
3329
3330 /// Returns C `process`'s `status` for this cycle: 0 only when `do_sub` ran
3331 /// and returned 0.
3332 fn run_subroutine_body(&mut self) -> CaResult<i64> {
3333 use crate::server::recgbl::{self, alarm_status};
3334
3335 // aSub `LFLG=READ`: a `SUBL` re-resolution that found a bad/unregistered
3336 // name (C `fetch_values` -> `S_db_BadSub`) or failed to read the link
3337 // signals "skip do_sub this cycle" — C `process` runs `do_sub` only on
3338 // `!status`. The framework's failed input-link fetch arms the same flag.
3339 // One-shot: taken (cleared) whether or not a subroutine is set, so it
3340 // never leaks into the next cycle. The single consumer of the flag,
3341 // shared by every process path.
3342 if std::mem::take(&mut self.suppress_subroutine_run) {
3343 return Ok(SUBROUTINE_STATUS_SKIPPED);
3344 }
3345
3346 // Clone the Arc so the borrow on `self.subroutine` is released
3347 // before we mutate `self.record` / `self.common` below.
3348 let Some(sub_fn) = self.subroutine.clone() else {
3349 // C `do_sub` (aSubRecord.c:459-465, subRecord.c:118-123)
3350 // short-circuits an EMPTY SNAM to `return 0` BEFORE the
3351 // `pfunc == NULL` -> `S_db_BadSub` check: a subroutine record with
3352 // no SNAM is not a bad-sub, it is a no-op that completes with
3353 // status 0. Only a NON-empty, unregistered SNAM yields
3354 // `S_db_BadSub`.
3355 //
3356 // aSub depends on this: C `process` (aSubRecord.c:224) runs
3357 // `prec->val = status` (= 0) every cycle, forcing VAL back to 0.
3358 // C `monitor()` (aSubRecord.c:414) posts VAL only on
3359 // `val != oval`, so with val==oval==0 a periodic (SCAN) cycle
3360 // posts nothing — the driven `dbPut`s are the only VAL events.
3361 // Without the reset the port leaves VAL holding the last client
3362 // put, and the deadband gate re-posts it on every scan (the aSub
3363 // scanned monitor over-posts: 7 updates where C posts 4). `sub`
3364 // never reaches here with an empty SNAM — it parks PACT at init
3365 // (`Record::init_record_parks_pact`, subRecord.c:119-123) and does
3366 // not process — so the VAL write is scoped to aSub, whose VAL is
3367 // the do_sub status.
3368 let snam_empty = matches!(
3369 self.record.get_field("SNAM"),
3370 Some(EpicsValue::String(s)) if s.is_empty()
3371 );
3372 if snam_empty {
3373 if self.record.record_type() == "aSub" {
3374 // C `prec->val = do_sub() = 0`.
3375 let _ = self.record.put_field("VAL", EpicsValue::Long(0));
3376 }
3377 return Ok(0);
3378 }
3379 // A non-empty but unregistered SNAM: C `do_sub` returns
3380 // `S_db_BadSub`, so the cycle's status is non-zero.
3381 return Ok(SUBROUTINE_STATUS_NO_SUB);
3382 };
3383 // C `do_sub` returns the subroutine's `long` status.
3384 let status = sub_fn(&mut *self.record)?;
3385
3386 // aSub publishes the status as VAL (C `aSubRecord.c:223`
3387 // `prec->val = status`). The subroutine's computed outputs live in
3388 // VALA..VALU, so VAL is the return code and overwrites whatever the
3389 // closure may have written to VAL. `sub` does NOT do this — its VAL
3390 // is the value the subroutine computed.
3391 if self.record.record_type() == "aSub" {
3392 // aSub VAL is DBF_LONG (epicsInt32); the do_sub status is a C `long`
3393 // truncated into it (`prec->val = status`).
3394 let _ = self
3395 .record
3396 .put_field("VAL", EpicsValue::Long(status as i32));
3397 }
3398
3399 // A negative status raises SOFT_ALARM at the record's BRSV severity
3400 // (C `do_sub`: `if (status < 0) recGblSetSevr(SOFT_ALARM,
3401 // prec->brsv)`). It accumulates into nsta/nsev for this cycle's
3402 // recGblResetAlarms commit and runs before checkAlarms, so a higher
3403 // analog severity (e.g. the shared analog-alarm owner) still wins via
3404 // the raise-only rule. BRSV defaults to NO_ALARM, under which
3405 // recGblSetSevr is a no-op.
3406 if status < 0 {
3407 let brsv = self
3408 .record
3409 .get_field("BRSV")
3410 .and_then(|v| v.to_f64())
3411 .map(|f| AlarmSeverity::from_u16(f as u16))
3412 .unwrap_or(AlarmSeverity::NoAlarm);
3413 recgbl::rec_gbl_set_sevr(&mut self.common, alarm_status::SOFT_ALARM, brsv);
3414 } else if self.record.record_type() == "aSub" {
3415 // C `aSubRecord.c::do_sub` (469-470): a subroutine that ran and
3416 // returned `>= 0` DEFINES the record — `else prec->udf = FALSE`.
3417 // aSub opts out of the framework's per-cycle blanket UDF re-derive
3418 // (`Record::clears_udf` == false), so THIS is aSub's UDF clear,
3419 // reached only on the path where the subroutine actually executed
3420 // (past the suppress / no-sub early returns above). `sub` keeps the
3421 // blanket re-derive (`clears_udf` true, C `do_sub` `udf =
3422 // isnan(val)`), so it is deliberately not cleared here.
3423 self.common.udf = 0;
3424 }
3425 Ok(status)
3426 }
3427
3428 /// The single owner of a process cycle's SUBSCRIBER posts — C `monitor()`'s
3429 /// "post every subscribed field this cycle touched" loop.
3430 ///
3431 /// Every processing path (`process_record_with_links_inner`, the deferred
3432 /// async-completion path, the simulation path, and [`Self::process_local`])
3433 /// calls this; none of them may reimplement the rules, because a rule that
3434 /// holds on one path and not another is a monitor that fires on a scan cycle
3435 /// but not on an async completion. The per-field mask resolvers
3436 /// ([`AuxPostMask`], [`crate::server::record::value_gate`]) were already
3437 /// single-owned for the same reason — this is the loop around them.
3438 ///
3439 /// It also UPDATES `last_posted` for everything it emits, and it TAKES the
3440 /// record's per-cycle post mask ([`Record::take_cycle_posted_fields`]), so
3441 /// it must run exactly once per cycle.
3442 ///
3443 /// The rules, in order:
3444 ///
3445 /// * The deadband field (default VAL), the
3446 /// [`recgbl::RECGBL_POSTED_ALARM_FIELDS`](crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS)
3447 /// (SEVR/STAT/AMSG/ACKS) and UDF are emitted by the caller with their own
3448 /// C masks and are skipped here.
3449 /// * [`Record::event_posted_fields`] post from their own event path
3450 /// (waveform HASH) — never from change detection.
3451 /// * [`Record::process_posted_fields`], when declared, is the closed set of
3452 /// fields a process cycle may post at all.
3453 /// * A secondary value field ([`Record::fields_posted_with_value_mask`])
3454 /// carries VAL's monitor mask, gated per its [`ValuePostGate`].
3455 /// * A CHANGED field carries [`AuxPostMask::mask_for`] — unless it is a
3456 /// [`Record::fields_posted_only_when_marked`] field, which C never
3457 /// change-detects (aCalcout AA..LL) and which therefore posts from its
3458 /// mark alone.
3459 /// * An UNCHANGED field posts only if the record marked it this cycle:
3460 /// statically ([`Record::force_posted_fields`]), per-cycle
3461 /// ([`Record::take_cycle_posted_fields`]), on the alarm transition
3462 /// ([`Record::alarm_cycle_monitored_fields`]), or in the DBE_LOG sweep
3463 /// ([`Record::log_swept_fields`]).
3464 pub(crate) fn collect_subscriber_posts(
3465 &mut self,
3466 deadband_field: &str,
3467 deadband_mask: EventMask,
3468 alarm_bits: EventMask,
3469 aux_post: AuxPostMask,
3470 include_val: bool,
3471 ) -> Vec<(String, EpicsValue, EventMask)> {
3472 use crate::server::record::{CyclePostMask, ValuePostGate, value_gate};
3473
3474 // C's default for a change-detected auxiliary post:
3475 // `monitor_mask | DBE_VALUE | DBE_LOG` (calcRecord.c:420, subRecord.c:400;
3476 // motor `DBE_VAL_LOG` for marked fields, motorRecord.cc:3522-3645).
3477 let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
3478 let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
3479 &[]
3480 } else {
3481 self.record.alarm_cycle_monitored_fields()
3482 };
3483 let force_fields = self.record.force_posted_fields();
3484 // TAKE — this also clears the state it answers from (C's
3485 // `pcalc->newm = 0`), which is why this loop may run only once per cycle.
3486 let cycle_posted = self.record.take_cycle_posted_fields();
3487 let log_swept = self.record.log_swept_fields();
3488 // C change-detects nothing about these fields; only the record's own
3489 // per-cycle mark may post them (aCalcout AA..LL — no PAA..PLL previous
3490 // copy exists to compare against).
3491 let marked_only = self.record.fields_posted_only_when_marked();
3492 let value_masked = self.record.fields_posted_with_value_mask();
3493 let event_posted = self.record.event_posted_fields();
3494 let process_posted = self.record.process_posted_fields();
3495
3496 let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
3497 for (field, subs) in &self.subscribers {
3498 if subs.is_empty()
3499 || field == deadband_field
3500 // SEVR/STAT/AMSG/ACKS are posted by `recGblResetAlarms` itself,
3501 // each with its own C mask (recGbl.c:201-217) — the caller emits
3502 // them from `alarm_field_posts`. A second, change-detected copy
3503 // here would double-post with a mask C never uses for them
3504 // (`alarm_bits | DBE_VALUE | DBE_LOG` instead of C's DBE_VALUE
3505 // on ACKS). UDF is excluded for the opposite reason: NO C
3506 // `monitor()` posts it at all, so a processing cycle that
3507 // redefines VAL must emit no `.UDF` event (a caput to `.UDF`
3508 // still posts, through the generic put path).
3509 || crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS.contains(&field.as_str())
3510 || field == "UDF"
3511 || event_posted.contains(&field.as_str())
3512 || !process_posted.is_none_or(|allowed| allowed.contains(&field.as_str()))
3513 {
3514 continue;
3515 }
3516 let Some(val) = self.resolve_field(field) else {
3517 continue;
3518 };
3519 let changed = match self.posted_value(field) {
3520 Some(prev) => prev != &val,
3521 None => true,
3522 };
3523 if let Some(gate) = value_gate(value_masked, field) {
3524 // C posts this secondary value field with VAL's own monitor_mask,
3525 // from inside the guard that decides whether VAL posts at all —
3526 // never a forced DBE_VALUE|DBE_LOG. `ValuePostGate` says whether C
3527 // also re-tests the field's own value inside that guard (ai RVAL,
3528 // aiRecord.c:462) or posts it whenever the guard fires (timestamp
3529 // RVAL, timestampRecord.c:160).
3530 let post = match gate {
3531 ValuePostGate::OnChange => changed && !deadband_mask.is_empty(),
3532 ValuePostGate::WithValue => include_val,
3533 };
3534 if post {
3535 sub_updates.push((field.clone(), val.clone(), deadband_mask));
3536 }
3537 } else if changed && !marked_only.contains(&field.as_str()) {
3538 sub_updates.push((
3539 field.clone(),
3540 val.clone(),
3541 aux_post.mask_for(field, alarm_bits, deadband_mask),
3542 ));
3543 } else if force_fields.contains(&field.as_str()) {
3544 // C `monitor()` posts a statically re-marked field with
3545 // `monitor_mask | DBE_VAL_LOG` even when unchanged.
3546 sub_updates.push((field.clone(), val.clone(), aux_mask));
3547 } else if cycle_posted.iter().any(|(name, _)| *name == field) {
3548 // One event per MARK, each with the mask of the C call site that
3549 // made it (`CyclePostMask`) — a field marked twice (aCalcout's
3550 // AMASK `afterCalc` post AND its NEWM `monitor()` post) is posted
3551 // twice, exactly as C posts it from both loops.
3552 for (_, cycle_mask) in cycle_posted.iter().filter(|(name, _)| *name == field) {
3553 let mask = match cycle_mask {
3554 CyclePostMask::Value => EventMask::VALUE,
3555 CyclePostMask::ValueLog => EventMask::VALUE | EventMask::LOG,
3556 CyclePostMask::MonitorValueLog => aux_mask,
3557 };
3558 sub_updates.push((field.clone(), val.clone(), mask));
3559 }
3560 } else if alarm_fanout.contains(&field.as_str()) {
3561 // C motor `monitor()` (motorRecord.cc:3513-3645) posts every listed
3562 // field once `monitor_mask != 0`, so a DBE_ALARM-only subscriber
3563 // observes the alarm moment on any of them.
3564 sub_updates.push((field.clone(), val.clone(), alarm_bits));
3565 }
3566 // C `scalerRecord.c::monitor():757-773` posts EVERY S1..Snch with a
3567 // literal DBE_LOG on every cycle it runs (it runs when `ss == IDLE`,
3568 // scalerRecord.c:510). That sweep is INDEPENDENT of the change post,
3569 // not an alternative to it: on the count-completion cycle `ss` is
3570 // IDLE and `updateCounts()` has ALREADY posted each changed Sn with
3571 // DBE_VALUE (:582), so C emits two events for that field in that one
3572 // cycle — DBE_VALUE, then DBE_LOG. Making this an `else if` on
3573 // `changed` dropped the DBE_LOG half exactly when it matters: a
3574 // DBE_LOG-only archiver would never receive the final counts.
3575 //
3576 // The sweep carries the ALARM-transition bits too. DEVIATION from C,
3577 // deliberate — CBUG-B19. C's `monitor()` opens with
3578 // `monitor_mask = recGblResetAlarms(pscal); monitor_mask |=
3579 // (DBE_VALUE|DBE_LOG);` and then posts with a LITERAL `DBE_LOG`
3580 // (scalerRecord.c:764-771) — `monitor_mask` is assigned, OR-ed, and
3581 // never read. Those two lines are dead, and their only plausible use
3582 // was as the third `db_post_events` argument.
3583 // `recGblResetAlarms` returns the alarm-transition mask that every
3584 // other record ORs into its value posts, so discarding it drops the
3585 // alarm bit: a client subscribed to `Sn` with DBE_ALARM receives
3586 // NOTHING on an alarm-severity transition of the record.
3587 //
3588 // The DBE_VALUE half of C's dead `|=` is deliberately NOT
3589 // resurrected: this sweep is unconditional, so adding VALUE would
3590 // fire a value event at every VALUE subscriber on every idle scan,
3591 // changed or not — that would be a new defect, not a fix. The value
3592 // path is separately served by the change post (C's `updateCounts()`
3593 // DBE_VALUE at `:582`).
3594 if log_swept.contains(&field.as_str()) {
3595 sub_updates.push((field.clone(), val, EventMask::LOG | alarm_bits));
3596 }
3597 }
3598 for (field, val, _) in &sub_updates {
3599 self.record_value_post(field, val.clone());
3600 }
3601 sub_updates
3602 }
3603
3604 /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
3605 /// This does NOT handle links — see process_with_context in database.rs.
3606 ///
3607 /// Returns the value/log snapshot plus a list of alarm-field posts
3608 /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
3609 /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
3610 /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
3611 /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
3612 /// subscriber is not missed on an alarm-only change and a
3613 /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
3614 /// with `recGblResetAlarms` (recGbl.c:201-220), matching the
3615 /// `processing.rs` link path.
3616 pub fn process_local(
3617 &mut self,
3618 ) -> CaResult<(
3619 ProcessSnapshot,
3620 Vec<(&'static str, crate::server::recgbl::EventMask)>,
3621 )> {
3622 use crate::server::recgbl::{self, EventMask};
3623 const LCNT_ALARM_THRESHOLD: i16 = 10;
3624
3625 if self.pact.swap(true, std::sync::atomic::Ordering::AcqRel) {
3626 // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
3627 //
3628 // if ((precord->stat == SCAN_ALARM) ||
3629 // (precord->lcnt++ < MAX_LOCK) ||
3630 // (precord->sevr >= INVALID_ALARM)) goto all_done;
3631 // recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
3632 // "Async in progress");
3633 //
3634 // The alarm fires EXACTLY ONCE — on the attempt whose
3635 // pre-increment lcnt equals MAX_LOCK — and is then blocked
3636 // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
3637 // same shape as the link path
3638 // (`process_record_with_links_inner`). The pre-fix guard
3639 // here used post-increment `lcnt >= threshold` with no
3640 // already-raised bail, so every reentrant attempt past the
3641 // threshold re-posted the unchanged SEVR/STAT/VAL (and the
3642 // first fire came one attempt early); it also wrote
3643 // sevr/stat directly, skipping `recGblSetSevrMsg` +
3644 // `recGblResetAlarms` — losing the "Async in progress"
3645 // AMSG and the acks bookkeeping the reset performs.
3646 let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
3647 let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
3648 let lcnt_before = self.common.lcnt;
3649 self.common.lcnt = lcnt_before.saturating_add(1);
3650 if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
3651 return Ok((
3652 ProcessSnapshot {
3653 changed_fields: Vec::new(),
3654 },
3655 Vec::new(),
3656 ));
3657 }
3658 recgbl::rec_gbl_set_sevr_msg(
3659 &mut self.common,
3660 recgbl::alarm_status::SCAN_ALARM,
3661 AlarmSeverity::Invalid,
3662 "Async in progress",
3663 );
3664 let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
3665 // Per-field C masks (recGbl.c:201-220): this guard only
3666 // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
3667 // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
3668 // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
3669 // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
3670 let stat_mask = EventMask::ALARM | EventMask::VALUE;
3671 let mut changed_fields = Vec::new();
3672 if let Some(val) = self.record.val() {
3673 changed_fields.push((
3674 "VAL".to_string(),
3675 val,
3676 EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
3677 ));
3678 }
3679 changed_fields.push((
3680 "SEVR".to_string(),
3681 EpicsValue::Short(self.common.sevr as i16),
3682 EventMask::VALUE,
3683 ));
3684 changed_fields.push((
3685 "STAT".to_string(),
3686 EpicsValue::Short(self.common.stat as i16),
3687 stat_mask,
3688 ));
3689 // AMSG carries "Async in progress" alongside the STAT
3690 // transition (C recGbl.c posts STAT and AMSG together
3691 // when any alarm field moved).
3692 changed_fields.push((
3693 "AMSG".to_string(),
3694 EpicsValue::String(self.common.amsg.clone().into()),
3695 stat_mask,
3696 ));
3697 return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
3698 }
3699 self.common.lcnt = 0;
3700 // RAII guard that resets `self.pact` to false on drop — both for the
3701 // normal exit path and for any `?` early return. The guard holds a raw
3702 // pointer rather than a reference because we still need `self` mutably
3703 // while the guard is alive (the record body below mutates other `self`
3704 // fields).
3705 //
3706 // This is the one PACT release that does not go through `leave_pact`,
3707 // and it provably frees nothing: `process_local` holds `&mut self` for
3708 // the whole PACT window, and a put-notify is parked only through
3709 // `park_notify_put`, which needs that same `&mut`. So no deferral can
3710 // be created inside the window, and the `swap(true)` above proved none
3711 // existed on entry (`deferred_notify_put.is_some()` ⟹ PACT).
3712 debug_assert!(
3713 self.deferred_notify_put.is_none(),
3714 "PactExit invariant: a parked put-notify implies PACT, which the \
3715 swap above proved was clear"
3716 );
3717 struct ProcessGuard(*const AtomicBool);
3718 // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
3719 // Send. We hand-roll Send because the ptr targets a field of
3720 // `self`, which the caller already proves can be borrowed
3721 // through this code path. The pointer is only ever read for an
3722 // atomic store, never written, dereferenced for raw access, or
3723 // escaped from this scope.
3724 unsafe impl Send for ProcessGuard {}
3725 impl Drop for ProcessGuard {
3726 fn drop(&mut self) {
3727 // SAFETY: `self.0` was constructed from
3728 // `&self.pact as *const AtomicBool` below, where
3729 // `self` is the live RecordInstance whose lifetime
3730 // strictly outlives `_guard`. RecordInstance is
3731 // !Unpin-equivalent in practice (we never move it
3732 // while held in the database's `Arc<RwLock<_>>`), so
3733 // the pointer remains valid until Drop runs.
3734 unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
3735 }
3736 }
3737 let _guard = ProcessGuard(&self.pact as *const AtomicBool);
3738
3739 // Call subroutine if registered (for sub/aSub records). Single owner
3740 // shared with the main engine path — see `run_registered_subroutine`.
3741 self.run_registered_subroutine()?;
3742 // Soft-Channel input records must skip the RVAL->VAL convert
3743 // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
3744 // every Soft-Channel input record, incl. one with a constant /
3745 // unset INP). Without this, `process_local` on a soft input
3746 // with a preset VAL — e.g. NaN — would run `convert()` and
3747 // clobber it, after which the UDF check below would see a
3748 // defined value and wrongly clear UDF. The
3749 // `processing.rs` link path already does this; `process_local`
3750 // is the separate foreign-call path (`db.process_record`) and
3751 // needs the same skip. "Raw Soft Channel" has a distinct DTYP
3752 // so it is excluded by `is_soft` and still runs convert.
3753 //
3754 // Gated on `soft_channel_skips_convert()` — identical to the
3755 // `processing.rs` link path — so this only suppresses the
3756 // `RVAL → VAL` convert step. `set_device_did_compute` is an
3757 // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
3758 // "skip convert" (override true), but `epid` reads it as
3759 // "skip the whole built-in PID compute" (keeps default false).
3760 // Without this gate, a Soft-Channel `epid` driven through
3761 // `process_local` (`db.process_record`, e.g. QSRV group proc
3762 // members) would skip `do_pid()` entirely — the regression
3763 // d1032fe5 fixed on the `processing.rs` path only.
3764 {
3765 let is_soft = self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel";
3766 let is_output = self.record.can_device_write();
3767 if is_soft && !is_output && self.record.soft_channel_skips_convert() {
3768 self.record.set_device_did_compute(true);
3769 }
3770 }
3771 // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
3772 // record's process() can see it — same as the processing.rs link
3773 // path. `process_local` is the foreign-call path
3774 // (`db.process_record`); without this a record driven through it
3775 // (e.g. QSRV group-process members) would not see UDF/TSE.
3776 {
3777 let ctx = self.common.process_context();
3778 self.record.set_process_context(&ctx);
3779 }
3780 let outcome = self.record.process()?;
3781 let process_result = outcome.result;
3782 // Note: process_local() does not execute ProcessActions — those are
3783 // handled by the full process_record_with_links() path in processing.rs.
3784
3785 // If the record reports it modified a metadata-class field during
3786 // process(), invalidate the metadata cache so the next snapshot
3787 // rebuilds from the new values. Default impl returns false, so
3788 // most records pay zero cost here.
3789 if self.record.took_metadata_change() {
3790 self.invalidate_metadata_cache();
3791 // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
3792 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
3793 for f in fields {
3794 self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
3795 }
3796 }
3797
3798 if process_result == RecordProcessResult::AsyncPending {
3799 // Async: PACT stays set, no further processing this cycle
3800 // Don't clear processing flag (guard won't run — we leak it intentionally)
3801 std::mem::forget(_guard);
3802 return Ok((
3803 ProcessSnapshot {
3804 changed_fields: Vec::new(),
3805 },
3806 Vec::new(),
3807 ));
3808 }
3809 if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
3810 // Intermediate notification (e.g. DMOV=0 at move start).
3811 // Unlike AsyncPending, we DO release the processing flag so
3812 // subsequent I/O Intr cycles can continue processing normally.
3813 self.common.time = crate::runtime::general_time::get_current();
3814 // Filter out fields that haven't actually changed, and update
3815 // MLST/last_posted for those that have. Each intermediate
3816 // post carries DBE_VALUE|DBE_LOG — C motor's mid-move
3817 // `db_post_events` calls use `DBE_VAL_LOG`
3818 // (motorRecord.cc:2606 DMOV, and every other do_work post);
3819 // no alarm transition ran on this pending pass.
3820 let mut changed_fields = Vec::new();
3821 for (name, val) in fields {
3822 let changed = match self.posted_value(&name) {
3823 Some(prev) => prev != &val,
3824 None => true,
3825 };
3826 if changed {
3827 if name == "VAL" {
3828 if let Some(f) = val.to_f64() {
3829 self.put_coerced("MLST", f);
3830 self.common.mlst = Some(f);
3831 }
3832 }
3833 self.record_value_post(&name, val.clone());
3834 changed_fields.push((name, val, EventMask::VALUE | EventMask::LOG));
3835 }
3836 }
3837 // _guard drops here, clearing the processing flag
3838 return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
3839 }
3840 if process_result == RecordProcessResult::CompleteNoEmit {
3841 // The record accumulated this cycle without emitting (compress
3842 // `status == 1`). C `compressRecord.c:365` runs the completion
3843 // epilogue (udf clear, timestamp, monitor, FLNK) only on an emit
3844 // cycle (`if (status != 1)`), so a non-emitting cycle must publish
3845 // nothing — skip the epilogue and return an empty snapshot, exactly
3846 // as the production engine path does in `processing.rs`. This keeps
3847 // the emit-gate uniform across both process-dispatch paths so the
3848 // invariant holds by construction, not by "process_local never
3849 // produces it". CompleteNoEmit is synchronous (PACT already
3850 // cleared); the `_guard` drops here, clearing the processing flag.
3851 return Ok((
3852 ProcessSnapshot {
3853 changed_fields: Vec::new(),
3854 },
3855 Vec::new(),
3856 ));
3857 }
3858
3859 // `CompleteDeferOutput` (swait ODLY delay-start) is NOT special-cased
3860 // here: it deliberately shares the Complete value-side snapshot builder
3861 // below. C `swaitRecord.c::process` posts the value side (`monitor()`,
3862 // line 475) on the delaying cycle, so building the snapshot now is the
3863 // correct, parity-matching behavior — unlike `CompleteNoEmit` above,
3864 // whose fall-through would wrongly emit. The variant's *other* halves —
3865 // holding PACT across the delay and deferring OUT/OEVT/FLNK to the
3866 // `ReprocessAfter` continuation — are the engine path's responsibility
3867 // (`processing.rs::process_record_with_links_inner`); `process_local` is
3868 // a body-only test helper that dispatches no FLNK/output and no
3869 // `ProcessAction`, and no test drives a swait ODLY record through it. So
3870 // the invariant still holds by construction across both dispatch paths:
3871 // both publish the value side here, both leave the output side to the
3872 // engine.
3873
3874 // UDF update before alarm evaluation — C parity (see
3875 // `processing.rs`). A NaN / undefined value keeps UDF true so
3876 // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
3877 // record reporting a stale/garbage value with no alarm.
3878 if self.record.clears_udf() {
3879 self.common.udf = self.record.value_is_undefined() as u8;
3880 }
3881 // Per-record alarm hook (C `checkAlarms()`).
3882 self.record.check_alarms(&mut self.common);
3883
3884 // Evaluate alarms (accumulates into nsta/nsev)
3885 self.evaluate_alarms();
3886
3887 // Transfer nsta/nsev → sevr/stat, detect alarm change
3888 let alarm_result = recgbl::rec_gbl_reset_alarms(&mut self.common);
3889
3890 self.common.time = crate::runtime::general_time::get_current();
3891 // UDF already updated above — do not clear unconditionally.
3892
3893 // Deadband check for VAL monitor filtering
3894 let (include_val, include_archive) = self.check_deadband_ext();
3895 // C `recGblResetAlarms` `val_mask = DBE_ALARM`
3896 // (recGbl.c:194/203/212): every monitored-value post this cycle
3897 // carries DBE_ALARM when the severity/status OR the alarm
3898 // message moved — same parity rule as the `processing.rs`
3899 // paths.
3900 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
3901 EventMask::ALARM
3902 } else {
3903 EventMask::NONE
3904 };
3905
3906 // Build snapshot
3907 let mut changed_fields = Vec::new();
3908 // Same deadband-field routing and per-field mask as the
3909 // `processing.rs` paths: the tracked field posts the classes
3910 // that actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
3911 // movement → DBE_ALARM); a non-primary deadband field (motor
3912 // RBV — C motor `monitor()`, motorRecord.cc:3468-3507) leaves
3913 // VAL to the generic change-detection loop below.
3914 let deadband_field = self.record.monitor_deadband_field();
3915 // The mask every change-detected aux field posts with — owned by
3916 // `AuxPostMask`, the same resolver the `processing.rs` paths use, so
3917 // this builder cannot drift from them on what mask a field carries.
3918 let aux_post = AuxPostMask::of(self.record.as_ref());
3919 // The deadband field's post — mask owned by `deadband_post`, the single
3920 // assembler C's `db_post_events(&prec->val, monitor_mask)` maps to.
3921 let deadband = self.deadband_post(alarm_bits, include_val, include_archive);
3922 let deadband_mask = deadband.mask;
3923 if let Some((field, value)) = deadband.field {
3924 changed_fields.push((field, value, deadband_mask));
3925 }
3926 // C `recGblResetAlarms` (recGbl.c:201-220) posts each alarm
3927 // field with its OWN per-field mask, not one record-wide mask:
3928 // * SEVR — DBE_VALUE, ONLY on a sevr change.
3929 // * STAT — DBE_ALARM (sevr change) | DBE_VALUE (stat change).
3930 // * ACKS — DBE_VALUE, only when an alarm field moved.
3931 // Pushing SEVR/STAT into `changed_fields` collapses them onto
3932 // the single record-wide `event_mask` (which carries ALARM on
3933 // `alarm_changed`): a DBE_VALUE-only `.SEVR` subscriber would
3934 // miss a stat-only-driven sevr change, and a DBE_ALARM-only
3935 // `.SEVR` subscriber would be wrongly notified. Post them via
3936 // `notify_field` with their individual masks instead — exactly
3937 // as the `processing.rs` link path does.
3938 let sevr_changed = self.common.sevr != alarm_result.prev_sevr;
3939 let stat_changed = self.common.stat != alarm_result.prev_stat;
3940 let stat_mask = {
3941 let mut m = EventMask::NONE;
3942 // C `recGblResetAlarms` carries DBE_ALARM on the STAT/AMSG
3943 // posts whenever the severity OR the alarm message moved —
3944 // not on a severity change alone. Aligning with the
3945 // `processing.rs` link path (and `complete_async_record`).
3946 if sevr_changed || alarm_result.amsg_changed {
3947 m |= EventMask::ALARM;
3948 }
3949 if stat_changed {
3950 m |= EventMask::VALUE;
3951 }
3952 m
3953 };
3954 let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
3955 if sevr_changed {
3956 alarm_posts.push(("SEVR", EventMask::VALUE));
3957 }
3958 if !stat_mask.is_empty() {
3959 alarm_posts.push(("STAT", stat_mask));
3960 // AMSG shares STAT's mask — C posts it alongside STAT when
3961 // any alarm field moved.
3962 alarm_posts.push(("AMSG", stat_mask));
3963 }
3964 // C parity (recGbl.c:214-217): ACKS is posted (DBE_VALUE) whenever the
3965 // alarm-acknowledge rule fires — `acks_posted` already folds in C's
3966 // `if (stat_mask)` guard, and the post carries no value-change test.
3967 if alarm_result.acks_posted {
3968 alarm_posts.push(("ACKS", EventMask::VALUE));
3969 }
3970
3971 // The cycle's subscriber posts — assembled by the single owner
3972 // `collect_subscriber_posts`, shared with every `processing.rs` path.
3973 changed_fields.extend(self.collect_subscriber_posts(
3974 deadband_field,
3975 deadband_mask,
3976 alarm_bits,
3977 aux_post,
3978 include_val,
3979 ));
3980 // C waveform/aai/aao `monitor()` posts HASH with a literal
3981 // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
3982 // 317-319), independent of the VAL post mask. `array_hash_changed`
3983 // was set by `check_deadband_ext` this cycle.
3984 if self.array_hash_changed {
3985 if let Some(h) = self.resolve_field("HASH") {
3986 changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
3987 }
3988 }
3989
3990 // No `.UDF` post — C `monitor()` posts UDF nowhere, and
3991 // `recGblResetAlarms` (recGbl.c:204-216) posts only SEVR/STAT/AMSG/
3992 // ACKS. A `.UDF` event exists only where C's generic `dbPut` posts
3993 // the field it wrote (dbAccess.c:1420-1430) — i.e. a client caput to
3994 // `.UDF` itself.
3995
3996 Ok((ProcessSnapshot { changed_fields }, alarm_posts))
3997 }
3998
3999 /// Put a f64 value into a record field, coercing to the field's native type.
4000 pub(crate) fn put_coerced(&mut self, field: &str, val: f64) {
4001 use crate::types::EpicsValue;
4002 let target_type = self
4003 .record
4004 .get_field(field)
4005 .map(|v| v.db_field_type())
4006 .unwrap_or(crate::types::DbFieldType::Double);
4007 let coerced = EpicsValue::Double(val).convert_to(target_type);
4008 let _ = self.record.put_field(field, coerced);
4009 }
4010
4011 /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
4012 /// Returns `(monitor_trigger, archive_trigger)`.
4013 ///
4014 /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
4015 /// `mlst/alst` shadow when a trigger fires. Records without
4016 /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
4017 /// change triggers).
4018 ///
4019 /// Delegates per-axis deadband comparison to the free function
4020 /// [`check_deadband`] below — see that function's docstring for
4021 /// the four-quadrant NaN/infinity rule mirroring C
4022 /// `recGblCheckDeadband` (recGbl.c:345-370).
4023 ///
4024 /// **C-parity design note**: the Rust port uses `NaN` as the
4025 /// "never posted" sentinel for `MLST`/`ALST`. C achieves the
4026 /// same first-publish guarantee by allocating MLST/ALST in
4027 /// BSS-zeroed storage with a value of 0.0 that the C code is
4028 /// allowed to match against — but the first observed value is
4029 /// not necessarily 0.0, and the C rule "MLST==0 means never
4030 /// posted" relies on the deadband comparison `abs(val - 0.0)`
4031 /// firing on any non-zero first value. NaN is strictly more
4032 /// correct for the Rust port because a legitimate first
4033 /// `val=0.0` still fires on `NaN.is_nan() → true`. This
4034 /// sentinel-as-design is intentional, documented inside
4035 /// [`check_deadband`] (the `oldval.is_nan() → return true` short
4036 /// circuit). It is NOT a deviation inherited from an earlier
4037 /// silent compromise — `record_tests.rs::deadband_*` pins both
4038 /// the NaN-sentinel behaviour and the C four-quadrant transitions.
4039 /// The single owner of the deadband field's monitor post — C `monitor()`'s
4040 /// `db_post_events(&prec->val, monitor_mask)`, the one post every record
4041 /// makes for the value it deadbands.
4042 ///
4043 /// [`Self::check_deadband_ext`] decides WHETHER the MDEL/ADEL classes fired;
4044 /// this decides what the resulting post looks like, and it is the only place
4045 /// that assembles that mask. The three `processing.rs` snapshot builders and
4046 /// the `notify_monitors` path all route through here, so a record's mask rule
4047 /// cannot hold on one processing path and not another.
4048 ///
4049 /// Two record hooks strip C's `DBE_LOG` from the post:
4050 ///
4051 /// * [`Record::value_only_change_fields`] — C posts a literal `DBE_VALUE`
4052 /// (scaler VAL, scalerRecord.c:478).
4053 /// * [`Record::fields_posted_with_monitor_mask`] — C posts
4054 /// `monitor_mask | DBE_VALUE` (event VAL, eventRecord.c:163). `monitor_mask`
4055 /// there is `recGblResetAlarms`'s return, i.e. the alarm bits alone, so the
4056 /// post carries `DBE_VALUE` (+ `DBE_ALARM` when the alarm moved) and never
4057 /// the archive `DBE_LOG` — an event's VAL reaches a `DBE_LOG` archiver on
4058 /// no cycle at all.
4059 ///
4060 /// [`DeadbandPost::field`] is `None` when no class fired, i.e. when C's
4061 /// `if (monitor_mask)` guard would skip the post.
4062 /// C `monitor()`'s VALUE / LOG gate for the primary-value post —
4063 /// `(include_val, include_archive)`, the single owner every processing path
4064 /// feeds into [`Self::deadband_post`] and [`Self::collect_subscriber_posts`].
4065 /// Keeping it in one place is what stops the rule from holding on the
4066 /// synchronous path but not the async-continuation / put-notify paths.
4067 pub(crate) fn value_include_classes(&mut self) -> (bool, bool) {
4068 // fanout/seq "trigger" records post VAL only with the alarm events
4069 // `recGblResetAlarms` returns, never DBE_VALUE/DBE_LOG — see
4070 // `Record::process_posts_value_monitor`. The alarm bits still reach VAL
4071 // via `deadband_post`'s `alarm_bits`, so an alarm transition still posts
4072 // it; only the value/archive classes are suppressed.
4073 if !self.record.process_posts_value_monitor() {
4074 return (false, false);
4075 }
4076 match self.record.monitor_value_changed() {
4077 // lsi/lso post VALUE|LOG only when the string actually changed (C
4078 // `lsiRecord.c`/`lsoRecord.c` monitor: `len != olen || memcmp(oval,
4079 // val, len)`); they have no MDEL/ADEL deadband to express that, so
4080 // the gate is explicit. The MPST/APST `menuPost` "Always" override
4081 // OR-adds DBE_VALUE / DBE_LOG even on an unchanged cycle (C monitor:
4082 // `if (mpst == menuPost_Always) events |= DBE_VALUE; if (apst ==
4083 // menuPost_Always) events |= DBE_LOG;`).
4084 Some(changed) => {
4085 let (val_always, archive_always) = self.record.monitor_always_post();
4086 (changed || val_always, changed || archive_always)
4087 }
4088 None => {
4089 if self.record.uses_monitor_deadband() {
4090 self.check_deadband_ext()
4091 } else {
4092 // Binary records (bi/bo/busy/mbbi/mbbo): always post monitors
4093 (true, true)
4094 }
4095 }
4096 }
4097 }
4098
4099 pub(crate) fn deadband_post(
4100 &self,
4101 alarm_bits: EventMask,
4102 include_val: bool,
4103 include_archive: bool,
4104 ) -> DeadbandPost {
4105 let field = self.record.monitor_deadband_field();
4106 let log_suppressed = self.record.value_only_change_fields().contains(&field)
4107 || self
4108 .record
4109 .fields_posted_with_monitor_mask()
4110 .contains(&field);
4111
4112 let mut mask = alarm_bits;
4113 if include_val {
4114 mask |= EventMask::VALUE;
4115 }
4116 if include_archive && !log_suppressed {
4117 mask |= EventMask::LOG;
4118 }
4119
4120 // The closed set applies to THIS post too. `process_posted_fields` is
4121 // "the CLOSED set of fields a process cycle of this record may post" —
4122 // and the deadband post is a post. A record whose C `monitor()` never
4123 // names the deadband field must not have one invented for it: transform
4124 // `monitor()` (transformRecord.c:786-808) walks A..P and posts no VAL
4125 // at all — VAL is an inert dummy (`:422`) — so an alarm cycle, whose
4126 // `alarm_bits` alone make `mask` non-empty, was firing a `.VAL` monitor
4127 // C never sends. Gating here rather than at each builder keeps the
4128 // single owner of the deadband post the single enforcer of the set.
4129 let in_closed_set = self
4130 .record
4131 .process_posted_fields()
4132 .is_none_or(|allowed| allowed.contains(&field));
4133
4134 let value = if mask.is_empty() || !in_closed_set {
4135 None
4136 } else if field == "VAL" {
4137 self.record.val()
4138 } else {
4139 self.resolve_field(field)
4140 };
4141 DeadbandPost {
4142 mask,
4143 field: value.map(|v| (field.to_string(), v)),
4144 }
4145 }
4146
4147 pub fn check_deadband_ext(&mut self) -> (bool, bool) {
4148 // C waveform/aai/aao `monitor()` (waveformRecord.c:291-326) replaces
4149 // the analog MDEL/ADEL deadband with the MPST/APST "Always vs On
4150 // Change" mechanism: the record hashes its array content and posts
4151 // `DBE_VALUE`/`DBE_LOG` either always or only when the hash changed,
4152 // and posts `HASH` (`DBE_VALUE`) on a hash change. The record owns
4153 // the hash compute + `HASH` update; `array_hash_changed` carries the
4154 // event to the snapshot builders, which post `HASH` (the field is
4155 // excluded from the generic change-detection loop via
4156 // `event_posted_fields`).
4157 if let Some(post) = self.record.array_monitor_post() {
4158 self.array_hash_changed = post.hash_changed;
4159 return (post.post_value, post.post_archive);
4160 }
4161 self.array_hash_changed = false;
4162
4163 // The deadband is evaluated against `monitor_deadband_value()`,
4164 // not `val()` directly: a record whose monitored quantity is
4165 // not its primary value (e.g. the motor record, VAL=setpoint /
4166 // RBV=readback — C `monitor()` deadbands RBV) overrides that
4167 // hook. Default is `val()`, so other records are unaffected.
4168 let val = match self
4169 .record
4170 .monitor_deadband_value()
4171 .and_then(|v| v.to_f64())
4172 {
4173 Some(v) => v,
4174 None => return (true, true),
4175 };
4176
4177 let mdel = self
4178 .record
4179 .get_field("MDEL")
4180 .and_then(|v| v.to_f64())
4181 .unwrap_or(0.0);
4182 let adel = self
4183 .record
4184 .get_field("ADEL")
4185 .and_then(|v| v.to_f64())
4186 .unwrap_or(0.0);
4187
4188 // Use record's MLST/ALST fields if available, otherwise fall back to CommonFields
4189 let mlst = self
4190 .record
4191 .get_field("MLST")
4192 .and_then(|v| v.to_f64())
4193 .or(self.common.mlst)
4194 .unwrap_or(f64::NAN);
4195 let alst = self
4196 .record
4197 .get_field("ALST")
4198 .and_then(|v| v.to_f64())
4199 .or(self.common.alst)
4200 .unwrap_or(f64::NAN);
4201
4202 let monitor_trigger = check_deadband(val, mlst, mdel);
4203 let archive_trigger = check_deadband(val, alst, adel);
4204
4205 if archive_trigger {
4206 self.put_coerced("ALST", val);
4207 self.common.alst = Some(val);
4208 }
4209 if monitor_trigger {
4210 self.put_coerced("MLST", val);
4211 self.common.mlst = Some(val);
4212 }
4213
4214 (monitor_trigger, archive_trigger)
4215 }
4216
4217 /// Build a Snapshot for a given value, populated with the record's display metadata.
4218 /// Uses the metadata cache so the populate cost is paid at most once
4219 /// per metadata-stable interval (cf. `cached_metadata`).
4220 pub fn make_monitor_snapshot(
4221 &self,
4222 field: &str,
4223 value: EpicsValue,
4224 ) -> super::super::snapshot::Snapshot {
4225 // A monitor update is posted from the record's own change-detection
4226 // loop, which hands over the STORED variant. Project it onto the
4227 // field's declared type here, at the same owner the GET path and the
4228 // CA create-channel path use, or a client that was told `DBR_ENUM` at
4229 // create time would be posted a `DBR_SHORT` update.
4230 let value = self.project_to_declared_type(field, value);
4231 let mut snap = super::super::snapshot::Snapshot::new(
4232 value,
4233 self.common.stat,
4234 self.common.sevr as u16,
4235 self.common.time,
4236 );
4237 // Carry the record's `utag` into the monitor update's
4238 // `timeStamp.userTag`, same as the GET path
4239 // (`snapshot_for_field`) and pvxs `iocsource.cpp:245`. Narrows
4240 // the 64-bit `epicsUTag` to the int32 wire field by low-32-bit
4241 // truncation.
4242 snap.user_tag = self.common.utag as i32;
4243 // Same amsg carry as the GET path (`snapshot_for_field`): a monitor
4244 // update serves the record's own `common.amsg`, so PVA
4245 // `alarm.message` matches a read of the same channel.
4246 snap.alarm.amsg = self.common.amsg.clone();
4247 let meta = self.cached_metadata();
4248 snap.display = meta.display;
4249 snap.control = meta.control;
4250 snap.enums = meta.enums;
4251 // Same per-field routing owner as the GET path — a monitor update must
4252 // carry the same metadata a read of that field would.
4253 self.route_field_metadata(field, &mut snap);
4254 // Per-field RSET metadata, same as the GET path
4255 // (`snapshot_for_field`) — a monitor update for VELO must carry
4256 // VELO's limits, not the record-level VAL limits.
4257 self.apply_field_metadata_override(field, &mut snap);
4258 // A monitored DBF_MENU field carries the same DBR_ENUM value and
4259 // choice labels as the GET path, so a `camonitor`/`pvmonitor`
4260 // update shows the menu label, not a bare index.
4261 self.attach_menu_enum(field, &mut snap);
4262 // Same owner, same settled value, same mask as the GET path.
4263 self.assign_property_support(field, &mut snap);
4264 snap
4265 }
4266
4267 /// Apply a record's per-field metadata override (C RSET
4268 /// `get_units`/`get_precision`/`get_graphic_double`/
4269 /// `get_control_double`/`get_alarm_double`, all keyed by field)
4270 /// over the cached record-level metadata. Shared by the GET and
4271 /// monitor snapshot builders. Computed live on every call — never
4272 /// cached — so overrides derived from fields outside the
4273 /// `is_metadata_field` set cannot go stale.
4274 ///
4275 /// This is also where the record-level `Q:form` info tag is narrowed to
4276 /// the served field: QSRV assigns `display.form.index` only when the
4277 /// channel addresses the VAL field (`IOCSource::initialize` gates it on
4278 /// `dbIsValueField(dbChannelFldDes(chan))`, `iocsource.cpp:53`; the form
4279 /// *menu*, `form.choices`, is published for every field). The metadata
4280 /// cache is per-record, so a channel on `REC.RVAL` of a record carrying
4281 /// `info(Q:form, "Hex")` used to report Hex where pvxs reports Default.
4282 /// Both `Snapshot` producers (`snapshot_for_field` for GET,
4283 /// `make_monitor_snapshot` for updates) run this one owner, so
4284 /// `DisplayInfo::form` means exactly one thing on every path: the form
4285 /// index that applies to THIS field.
4286 fn apply_field_metadata_override(
4287 &self,
4288 field: &str,
4289 snap: &mut super::super::snapshot::Snapshot,
4290 ) {
4291 if let Some(display) = snap.display.as_mut()
4292 && !crate::server::database::is_value_field(field)
4293 {
4294 display.form = 0;
4295 }
4296 let Some(ov) = self.record.field_metadata_override(field) else {
4297 return;
4298 };
4299 if ov.units.is_some()
4300 || ov.precision.is_some()
4301 || ov.disp_limits.is_some()
4302 || ov.alarm_limits.is_some()
4303 {
4304 let d = snap.display.get_or_insert_with(Default::default);
4305 if let Some(units) = ov.units {
4306 d.units = units;
4307 }
4308 if let Some(precision) = ov.precision {
4309 d.precision = precision;
4310 }
4311 if let Some((upper, lower)) = ov.disp_limits {
4312 d.upper_disp_limit = upper;
4313 d.lower_disp_limit = lower;
4314 }
4315 if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
4316 d.upper_alarm_limit = hihi;
4317 d.upper_warning_limit = high;
4318 d.lower_warning_limit = low;
4319 d.lower_alarm_limit = lolo;
4320 }
4321 }
4322 if let Some((upper, lower)) = ov.ctrl_limits {
4323 let c = snap.control.get_or_insert_with(Default::default);
4324 c.upper_ctrl_limit = upper;
4325 c.lower_ctrl_limit = lower;
4326 }
4327 }
4328
4329 /// C's rset metadata slots route **per field**, on `dbGetFieldIndex`. The
4330 /// port's metadata cache is the record's VAL metadata, and serving it to
4331 /// every field is what made a non-VAL field report VAL's limits.
4332 ///
4333 /// Every base record's `get_control_double` / `get_alarm_double` has the
4334 /// same two-arm shape: a listed set of field indices that take the
4335 /// record's own limits, and a `default:` arm that hands the field to
4336 /// `recGblGetControlDouble` / `recGblGetAlarmDouble` — the field TYPE's
4337 /// numeric range, and four NaN. This routes the `default:` arm; a listed
4338 /// field keeps the cache, which already holds exactly the record's own
4339 /// limits (and already distinguishes `ao`'s DRVH/DRVL from `ai`'s
4340 /// HOPR/LOPR).
4341 ///
4342 /// The three slots' listed sets are **different**, and each has its own
4343 /// owner here: [`Self::control_explicit_field`],
4344 /// [`Self::graphic_explicit_field`] and [`Self::alarm_explicit_field`].
4345 /// They are separate switches over separate field lists in C, so the
4346 /// membership question is asked once per slot, never once for both — and
4347 /// each list varies by record TYPE, so it is asked once per type too.
4348 ///
4349 /// Measured on a real `softIocPVX` against `record(calc,"X"){}`:
4350 /// `.PHAS` (DBF_SHORT, unlisted) serves control ±32767 — the SHRT range —
4351 /// while `.VAL` and `.HIHI` (both listed) serve 0/0 from HOPR/LOPR.
4352 ///
4353 /// Deliberately NOT routed here, and why:
4354 ///
4355 /// * **display (graphic) limits.** Unlike control, the `default:` arm of
4356 /// `get_graphic_double` tries a LINK first
4357 /// (`calcRecord.c` `get_linkNumber` → `dbGetGraphicLimits`) and only
4358 /// falls to `recGbl` for a field that backs no link. A constant (unset)
4359 /// link has no metadata getters, so the `dbAccess.c:216` 0/0 seed stands
4360 /// — measured: `CALC.A` serves display 0/0 but control ±1e300. Which
4361 /// fields back links is per-record C knowledge the port models nowhere
4362 /// (no `FieldDesc` relation, no trait hook), so defaulting display to the
4363 /// type range would CREATE defects on every link-backed field.
4364 /// * **units / precision.** Same link-first shape
4365 /// (`calcRecord.c` `get_units`, `get_precision`).
4366 ///
4367 /// Both remain a measured residue rather than a guess.
4368 ///
4369 /// The last arm is not the same for every record type — a slot can also
4370 /// fall through WITHOUT delegating, keeping the seed. That fact is one bit
4371 /// per record type, read from its C source: [`control_default_arm`].
4372 fn route_field_metadata(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
4373 // The rset slots this record type actually supplies. A NULL slot makes
4374 // `dbAccess.c` clear the option bit, so the leaf is never served and
4375 // there is nothing to route — minting a value here would put a
4376 // fabricated number on the CA wire, which has no marking layer to
4377 // suppress it (`codec.rs` `get_limits` reads these structs ungated).
4378 let slots = self.record.property_support();
4379 let rtype = self.record.record_type();
4380
4381 // C `get_control_double`'s last arm. No base record routes control
4382 // through a link: `dbGetControlLimits` has zero callers in all of
4383 // base, so unlike display this arm needs no link branch.
4384 if slots.control_double && !Self::control_explicit_field(rtype, field) {
4385 let (upper, lower) =
4386 match super::record_trait::control_default_arm(self.record.record_type()) {
4387 // `recGblGetControlDouble` → `getMaxRangeValues(field_type)`.
4388 // A type with no case in C's switch (STRING/MENU/DEVICE/links)
4389 // is written by nothing, leaving the `dbAccess.c:256` seed —
4390 // which is 0/0, exactly what `unwrap_or` supplies.
4391 super::record_trait::RsetDefaultArm::RecGblRange => {
4392 self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
4393 }
4394 // The slot exists but writes nothing here, so the same
4395 // `dbAccess.c:256` seed stands. Modelled as a value rather
4396 // than as `None`: C's option bit is ON (the slot is supplied
4397 // and returned 0), so the leaf IS served — carrying the seed.
4398 super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
4399 };
4400 snap.control = Some(super::super::snapshot::ControlInfo {
4401 upper_ctrl_limit: upper,
4402 lower_ctrl_limit: lower,
4403 });
4404 }
4405
4406 // C `get_graphic_double`'s last arm. Unlike control this one has a LINK
4407 // branch ahead of the recGbl call, so the three answers are: keep the
4408 // cache (listed on HOPR/LOPR), the link's limits, or the default arm.
4409 if slots.graphic_double && !Self::graphic_explicit_field(rtype, field) {
4410 let (upper, lower) = if Self::graphic_link_backed_field(rtype, field) {
4411 // `dbGetGraphicLimits` on a CONSTANT link writes nothing — a
4412 // constant has no metadata getters — so the `dbAccess.c:216`
4413 // seed stands. The port has no link metadata to consult, and a
4414 // link that IS connected would be answered by the upstream
4415 // record, which this routing does not model either; both land
4416 // here as the seed.
4417 (0.0, 0.0)
4418 } else {
4419 match super::record_trait::graphic_default_arm(rtype) {
4420 super::record_trait::RsetDefaultArm::RecGblRange => {
4421 self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
4422 }
4423 super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
4424 }
4425 };
4426 let d = snap.display.get_or_insert_with(Default::default);
4427 d.upper_disp_limit = upper;
4428 d.lower_disp_limit = lower;
4429 }
4430
4431 // C `get_alarm_double`, BOTH arms. This branch owns the four limits
4432 // outright: `slots.alarm_double` is exactly the condition under which
4433 // `getProperties` assigns the four `valueAlarm.*Limit` leaves, so
4434 // whenever the leaves are served this assigns them.
4435 //
4436 // The explicit arm used to be left to the record-level metadata cache
4437 // (`populate_display_info`), whose `match rtype` covered only some of
4438 // the types that supply the slot. A type it missed reached the wire
4439 // with `snap.display == None` and the four leaves kept the NT's
4440 // structural 0 — measured: DFANOUT.VAL, SEL.VAL and SUB.VAL served 0
4441 // where C serves NaN. That made "which limits does VAL carry" depend on
4442 // a match arm existing somewhere else, which is the dual meaning this
4443 // single owner removes.
4444 if slots.alarm_double {
4445 let (hihi, high, low, lolo) = if Self::alarm_explicit_field(rtype, field) {
4446 self.explicit_alarm_limits(rtype)
4447 } else {
4448 // The link-backed sibling arm lands on the same answer:
4449 // `dbAccess.c:294` seeds four NaN, and a constant link supplies
4450 // no alarm limits to overwrite them.
4451 crate::server::recgbl::rec_gbl_get_alarm_double()
4452 };
4453 // The four alarm limits live on DisplayInfo because that mirrors
4454 // C's `dbr_gr_double` packing, which the CA encoder depends on.
4455 // Minting it here is safe: every other DisplayInfo field defaults
4456 // to the same value the `None` path already served.
4457 let d = snap.display.get_or_insert_with(Default::default);
4458 d.upper_alarm_limit = hihi;
4459 d.upper_warning_limit = high;
4460 d.lower_warning_limit = low;
4461 d.lower_alarm_limit = lolo;
4462 }
4463 }
4464
4465 /// The four limits C's `get_alarm_double` serves for the fields its rset
4466 /// lists — [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields).
4467 ///
4468 /// Read through [`Self::resolve_field`], the same unified accessor C's
4469 /// `prec->hihi` is. The port stores the eight alarm fields in one of two
4470 /// disjoint homes — `common.analog_alarm` for the types with the analog
4471 /// ladder (`ai`/`ao`/`calc`/…), the record's own struct for the types
4472 /// without it (`dfanout`/`sel`) — and `resolve_field` spans both. Reading
4473 /// the ladder slot directly instead would answer NaN for every `dfanout`
4474 /// and `sel` no matter how its HIHI/HHSV were set, because those two types
4475 /// have no slot at all.
4476 fn explicit_alarm_limits(&self, rtype: &str) -> (f64, f64, f64, f64) {
4477 let limit = |name: &str| {
4478 self.resolve_field(name)
4479 .and_then(|v| v.to_f64())
4480 .unwrap_or(0.0)
4481 };
4482 // The raw stored ordinal, NOT clamped to 0..=3: C tests `prec->hhsv`
4483 // for NONZERO, so an out-of-range severity still enables its limit.
4484 let severity = |name: &str| {
4485 self.resolve_field(name)
4486 .and_then(|v| v.to_f64())
4487 .unwrap_or(0.0) as i16
4488 };
4489 match super::record_trait::alarm_val_arm(rtype) {
4490 super::record_trait::AlarmValArm::Unconditional => {
4491 (limit("HIHI"), limit("HIGH"), limit("LOW"), limit("LOLO"))
4492 }
4493 super::record_trait::AlarmValArm::Gated => (
4494 gated(severity("HHSV"), limit("HIHI")),
4495 gated(severity("HSV"), limit("HIGH")),
4496 gated(severity("LSV"), limit("LOW")),
4497 gated(severity("LLSV"), limit("LOLO")),
4498 ),
4499 }
4500 }
4501
4502 /// The fields C's **`get_control_double`** answers with the record's own
4503 /// cached limits, rather than letting them fall to the `default:` arm.
4504 ///
4505 /// "VAL plus the seven alarm bands" is one type's list, not the shared
4506 /// one: it holds for `ai` (`aiRecord.c:267-288`), `ao`, `calc`, `calcout`,
4507 /// `longin`, `longout`, `int64in`, `int64out` and `sub`
4508 /// (`subRecord.c:272-292`) — the `_` arm — and for no other type. Every
4509 /// list below is transcribed from that type's own rset:
4510 ///
4511 /// * `aSub` (`aSubRecord.c:372-376`) is a bare `recGblGetControlDouble`:
4512 /// it lists NOTHING, VAL included.
4513 /// * `seq` (`seqRecord.c:342-353`) lists only DLYn and `bo`
4514 /// (`boRecord.c:310-318`) only HIGH — and both answer a LITERAL rather
4515 /// than the cache, so they come from
4516 /// [`Record::field_metadata_override`] (which runs after this routing
4517 /// and wins over the `default:` arm). Nothing of these two types keeps
4518 /// the cache, VAL included.
4519 /// * `dfanout` (`dfanoutRecord.c:197-213`) lists VAL and the three
4520 /// latches but NOT the four bands.
4521 /// * `sel` (`selRecord.c:203-235`) lists the eight plus `A`..`L` /
4522 /// `LA`..`LL`; `acalcout`/`scalcout` (`aCalcoutRecord.c:793-822`,
4523 /// `sCalcoutRecord.c:653-680`) list VAL and the four bands but NOT the
4524 /// latches, plus `A`..`L` / `PA`..`PL`.
4525 /// * `epid` (`epidRecord.c:157-180`) lists VAL, the four bands and CVAL on
4526 /// HOPR/LOPR; `motor` (`motorRecord.cc:3269-3305`) lists VAL and RBV on
4527 /// HLM/LLM.
4528 /// * the array types (`waveformRecord.c:268-289`, `aaiRecord.c:293-310`,
4529 /// `aaoRecord.c:296-313`, `compressRecord.c:487-502`,
4530 /// `histogramRecord.c:458-475`, `subArrayRecord.c:262-291`) list VAL
4531 /// alone on the cache — their other listed fields answer computed spans,
4532 /// so those too come from [`Record::field_metadata_override`].
4533 ///
4534 /// Fields whose listed case answers something OTHER than the record's
4535 /// cached limits are deliberately absent — `motor`'s DVAL/DRBV (DHLM/DLLM)
4536 /// and `epid`'s OVAL/P/I/D (DRVH/DRVL) have no override yet and so still
4537 /// take the `default:` arm.
4538 ///
4539 /// **Not** the other two slots' lists — see [`Self::alarm_explicit_field`]
4540 /// (smaller) and [`Self::graphic_explicit_field`] (larger, and cut short
4541 /// for different types). C's three rset arms are separate switches over
4542 /// separate field lists, so one shared predicate could only ever be right
4543 /// for one of them.
4544 fn control_explicit_field(rtype: &str, field: &str) -> bool {
4545 // The two types that list nothing the cache can answer, VAL included.
4546 if matches!(rtype, "aSub" | "seq" | "bo") {
4547 return false;
4548 }
4549 if crate::server::database::is_value_field(field) {
4550 return true;
4551 }
4552 let f = field.to_ascii_uppercase();
4553 let bands: &[&str] = match rtype {
4554 "dfanout" => &["LALM", "ALST", "MLST"],
4555 "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
4556 "waveform" | "aai" | "aao" | "compress" | "histogram" | "subArray" | "motor" => &[],
4557 _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
4558 };
4559 if bands.contains(&f.as_str()) {
4560 return true;
4561 }
4562 match rtype {
4563 // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
4564 "sel" => Self::calc_arg_field(&f, 12),
4565 "acalcout" | "scalcout" => {
4566 Self::calc_arg_field(&f, 12)
4567 || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
4568 }
4569 "epid" => f == "CVAL",
4570 "motor" => f == "RBV",
4571 _ => false,
4572 }
4573 }
4574
4575 /// The fields C's **`get_alarm_double`** lists explicitly — **VAL alone**,
4576 /// not the eight [`Self::control_explicit_field`] lists.
4577 ///
4578 /// Transcribed from every rset in base that supplies the slot; each is a
4579 /// bare `if (dbGetFieldIndex(paddr) == indexof(VAL))` with every other
4580 /// field falling to `recGblGetAlarmDouble` (`recGbl.c:155-162`, four NaN):
4581 /// `aiRecord.c:293`, `aoRecord.c:365`, `calcRecord.c:258`,
4582 /// `calcoutRecord.c`, `dfanoutRecord.c:216`, `int64inRecord.c:235`,
4583 /// `int64outRecord.c`, `longinRecord.c`, `longoutRecord.c`,
4584 /// `selRecord.c:222`, `subRecord.c:236`.
4585 ///
4586 /// So `.HIHI` serves VAL's *control* limits but NOT VAL's *alarm* limits —
4587 /// the band fields' four alarm limits are the recGbl NaN. Routing both
4588 /// slots off one VAL-class predicate is what put the record's own
4589 /// valueAlarm limits on all eight.
4590 ///
4591 /// Which fields each type lists — and the fact that some list none, and
4592 /// that `motor` lists two — is one per-type table,
4593 /// [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields);
4594 /// what that listed arm ANSWERS is its twin,
4595 /// [`alarm_val_arm`](super::record_trait::alarm_val_arm). Keeping the two
4596 /// questions in one place is what lets this predicate stay a pure
4597 /// membership test.
4598 fn alarm_explicit_field(rtype: &str, field: &str) -> bool {
4599 super::record_trait::alarm_explicit_fields(rtype)
4600 .iter()
4601 .any(|f| field.eq_ignore_ascii_case(f))
4602 }
4603
4604 /// `A`..`A+n-1` (a single letter) or `LA`..`LA+n-1` — C's calc-family
4605 /// argument fields, addressed by index range rather than by name.
4606 ///
4607 /// `calcRecord.c:161-167` / `calcoutRecord.c:417-423` test
4608 /// `idx >= indexof(A) && idx < indexof(A) + CALCPERFORM_NARGS`, and the dbd
4609 /// declares those `CALCPERFORM_NARGS` fields contiguously as the single
4610 /// letters `A`..`U` (`postfix.h:29` = 21, `calcRecord.dbd.pod:801-985`), so
4611 /// the index range and the letter range are the same set.
4612 fn calc_arg_field(field: &str, nargs: u8) -> bool {
4613 let last = b'A' + nargs - 1;
4614 match field.as_bytes() {
4615 [c] => c.is_ascii_uppercase() && *c <= last,
4616 [b'L', c] => c.is_ascii_uppercase() && *c <= last,
4617 _ => false,
4618 }
4619 }
4620
4621 /// The fields C's **`get_graphic_double`** answers with the record's own
4622 /// `HOPR`/`LOPR` — which is exactly what the VAL metadata cache already
4623 /// holds, so routing must leave them on it.
4624 ///
4625 /// The third membership question, and a third distinct set: the alarm arm
4626 /// lists VAL alone and the control arm lists the eight, but graphic lists
4627 /// the eight PLUS a per-type tail, and two types cut it short.
4628 ///
4629 /// * base analog (`aiRecord.c:244-266`, `aoRecord.c:316-339`,
4630 /// `calcRecord.c:187-212`, `calcoutRecord.c:452-484`,
4631 /// `subRecord.c:222-247`, `selRecord.c:181-201`,
4632 /// `dfanoutRecord.c:181-195`, `longinRecord.c:190-204`,
4633 /// `longoutRecord.c`, `int64inRecord.c:196-210`, `int64outRecord.c`):
4634 /// the eight.
4635 /// * `acalcout`/`scalcout` (`aCalcoutRecord.c:1046`, `sCalcoutRecord.c:906`)
4636 /// list only VAL/HIHI/HIGH/LOW/LOLO — NOT LALM/ALST/MLST — plus the
4637 /// `A`..`L` and `PA`..`PL` ranges.
4638 /// * `sel` (`selRecord.c:193-196`) also lists `A`..`L` / `LA`..`LL`, via a
4639 /// GCC case range. It has no link arm at all, so its args are HOPR/LOPR
4640 /// where calc's identically-named ones are link-backed.
4641 /// * the SVAL family (`aiRecord.c:253`, `longinRecord.c`,
4642 /// `int64inRecord.c:205`), `ao`'s `OVAL`/`PVAL`/`IVOV`
4643 /// (`aoRecord.c:322-338`), and `compress`'s `IHIL`/`ILIL`
4644 /// (`compressRecord.c:474-476`).
4645 ///
4646 /// Fields whose graphic case answers something OTHER than HOPR/LOPR are
4647 /// NOT here — they cannot keep the cache and are supplied by
4648 /// [`Record::field_metadata_override`] instead (`histogram` WDTH,
4649 /// `subArray`/`waveform`/`aai`/`aao` index fields, `seq` DLYn,
4650 /// `calcout` ODLY).
4651 fn graphic_explicit_field(rtype: &str, field: &str) -> bool {
4652 // The two types that do not list VAL. Neither switch is keyed on
4653 // VAL at all: `seqRecord.c:282-297` keys on `index - indexof(DLY0)`,
4654 // so every field BELOW DLY0 — VAL included — reaches
4655 // `recGblGetGraphicDouble`; `aSubRecord.c:350-368` keys on the link
4656 // number, and VAL is neither an inlink nor an outlink, so it falls out
4657 // having written nothing (the `graphic_default_arm` Seed).
4658 //
4659 // Measured: `SEQ.VAL` served display 0/0 — the empty VAL cache — where
4660 // C serves the DBF_LONG range.
4661 if matches!(rtype, "seq" | "aSub") {
4662 return false;
4663 }
4664 if crate::server::database::is_value_field(field) {
4665 return true;
4666 }
4667 let f = field.to_ascii_uppercase();
4668 let bands: &[&str] = match rtype {
4669 "acalcout" | "scalcout" => &["HIHI", "HIGH", "LOW", "LOLO"],
4670 _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
4671 };
4672 if bands.contains(&f.as_str()) {
4673 return true;
4674 }
4675 match rtype {
4676 "ai" | "longin" | "int64in" => f == "SVAL",
4677 "ao" => matches!(f.as_str(), "OVAL" | "PVAL" | "IVOV"),
4678 "compress" => matches!(f.as_str(), "IHIL" | "ILIL"),
4679 // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
4680 "sel" => Self::calc_arg_field(&f, 12),
4681 // A..L and PA..PL, both to HOPR/LOPR.
4682 "acalcout" | "scalcout" => {
4683 Self::calc_arg_field(&f, 12)
4684 || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
4685 }
4686 _ => false,
4687 }
4688 }
4689
4690 /// The fields whose C `get_graphic_double` routes through a LINK —
4691 /// `dbGetGraphicLimits` on the link that backs the field, not the field's
4692 /// own type range.
4693 ///
4694 /// This is the one thing display needs that control never did:
4695 /// `dbGetControlLimits` has zero callers in all of base, so the control
4696 /// arm had no link branch to model. Graphic does, and it is what makes the
4697 /// display default arm NOT a straight flip to the type range.
4698 ///
4699 /// A link left unset is a CONSTANT link, which supplies no metadata
4700 /// getters, so `dbGetGraphicLimits` writes nothing and the
4701 /// `dbAccess.c:216` `(0.0, 0.0)` seed stands — measured: `CALC.A` serves
4702 /// display 0/0 where its DBF_DOUBLE type range would be ±1e300, while
4703 /// `CALC.PHAS` (no link) serves the DBF_SHORT range ±32767.
4704 ///
4705 /// Four types, both by mechanical index test:
4706 /// * `calc`/`calcout`/`sub` — `get_linkNumber` (`calcRecord.c:161-167`,
4707 /// `calcoutRecord.c:417-423`, `subRecord.c:198-204`): `A`..`A+NARGS` and
4708 /// `LA`..`LA+NARGS`, both onto `&prec->inpa + n`. calc/calcout use
4709 /// `CALCPERFORM_NARGS` (21); `sub` uses `INP_ARG_MAX`
4710 /// (`subRecord.c:89`), also 21.
4711 /// * `seq` — `seqRecord.c:322-338`: field offset from `DLY0` with
4712 /// `offset & 3 == 2` is `DOn`, routed through `get_dol(prec, offset)`.
4713 ///
4714 /// `sel` names its args the same way and is deliberately NOT here: its
4715 /// rset lists them explicitly on HOPR/LOPR and calls `dbGetGraphicLimits`
4716 /// nowhere.
4717 fn graphic_link_backed_field(rtype: &str, field: &str) -> bool {
4718 let f = field.to_ascii_uppercase();
4719 match rtype {
4720 "calc" | "calcout" | "sub" => Self::calc_arg_field(&f, 21),
4721 // DLY0/DOL0/DO0/LNK0, DLY1/... — DOn is offset 2 of each group of
4722 // four, i.e. the `DO` prefix over the same 0-F suffix set.
4723 "seq" => {
4724 matches!(f.as_bytes(), [b'D', b'O', c] if c.is_ascii_digit() || (b'A'..=b'F').contains(c))
4725 }
4726 _ => false,
4727 }
4728 }
4729
4730 /// The field's type as the **dbd declares it**, which is the only type
4731 /// `recGblGetPrec` / `getMaxRangeValues` ever see.
4732 ///
4733 /// C reads `pdbFldDes->field_type` (`recGbl.c:127`, `:151`, `:169`) — the
4734 /// STATIC descriptor — so a `cvt_dbaddr` retype (the port's
4735 /// `runtime_typed`, DBF_NOACCESS in the dbd) never reaches the switch and
4736 /// the switch has no case for it. `None` reproduces that: no case, no
4737 /// write.
4738 fn static_field_type(&self, field: &str) -> Option<crate::types::DbFieldType> {
4739 let desc = self.field_desc(field)?;
4740 (!desc.runtime_typed).then_some(desc.dbf_type)
4741 }
4742
4743 /// `recGblGetGraphicDouble` / `recGblGetControlDouble` for `field` — the
4744 /// same `getMaxRangeValues` table both C entry points share
4745 /// (`recGbl.c:146-171`). `None` where C's switch has no case (STRING,
4746 /// MENU, DEVICE, NOACCESS, links), which writes nothing.
4747 fn rec_gbl_range_for(&self, field: &str) -> Option<(f64, f64)> {
4748 let desc = self.field_desc(field)?;
4749 crate::server::recgbl::rec_gbl_get_graphic_double(
4750 self.static_field_type(field),
4751 desc.menu.is_some(),
4752 )
4753 }
4754
4755 /// Notify subscribers from a snapshot (call outside lock).
4756 /// Each entry carries its own posting mask: only subscribers whose
4757 /// mask intersects that field's mask are notified, and the
4758 /// delivered [`MonitorEvent`] reports exactly that field's classes
4759 /// (C `db_post_events(prec, &field, mask)` per-field granularity).
4760 pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot) {
4761 use crate::server::database::filters::FilteredMonitorEvent;
4762 use crate::server::recgbl::EventMask;
4763
4764 for (field, value, posting_mask) in &snapshot.changed_fields {
4765 let posting_mask = *posting_mask;
4766 if let Some(subs) = self.subscribers.get(field) {
4767 // Build a full snapshot once per field (with display metadata)
4768 let mon_snap = self.make_monitor_snapshot(field, value.clone());
4769 for sub in subs {
4770 // Paused subscriber (`db_event_disable`): suppress at
4771 // the source — no delivery, no coalesce.
4772 if !sub.active {
4773 continue;
4774 }
4775 let sub_mask = EventMask::from_bits(sub.mask);
4776 // Only send when posting mask intersects subscriber mask.
4777 // Empty posting mask means nothing changed — skip.
4778 if !posting_mask.is_empty() && sub_mask.intersects(posting_mask) {
4779 let event = MonitorEvent {
4780 snapshot: mon_snap.clone(),
4781 origin: 0,
4782 mask: posting_mask,
4783 };
4784 // Server-side filter chain (3.15.7). Empty chain
4785 // is identity, so no behaviour change for the
4786 // common no-filter case.
4787 let filtered = if sub.filters.is_empty() {
4788 Some(event)
4789 } else {
4790 sub.filters
4791 .apply(FilteredMonitorEvent::new(event))
4792 .map(|fe| fe.event)
4793 };
4794 let Some(event) = filtered else {
4795 continue;
4796 };
4797 // C `db_queue_event_log`: append, or replace this
4798 // monitor's last queued entry in place when the queue
4799 // is in flow control or nearly full. The queue owns
4800 // that decision and counts the displaced value.
4801 sub.post(event);
4802 }
4803 }
4804 }
4805 }
4806 }
4807
4808 /// Notify subscribers of a specific field, filtering by event mask.
4809 pub fn notify_field(&mut self, field: &str, mask: crate::server::recgbl::EventMask) {
4810 self.notify_field_with_origin(field, mask, 0);
4811 }
4812
4813 /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
4814 /// alarm event. Delivers to every subscriber on any field whose mask
4815 /// includes DBE_ALARM, each carrying its own monitored field's current
4816 /// value (the per-field `notify_field` already filters by mask
4817 /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
4818 /// an alarm-mask monitor on any field observes the acknowledgement.
4819 pub fn notify_record_alarm(&mut self) {
4820 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
4821 for field in fields {
4822 self.notify_field(&field, crate::server::recgbl::EventMask::ALARM);
4823 }
4824 }
4825
4826 /// Notify subscribers with an origin tag for self-write filtering.
4827 ///
4828 /// This is C `db_post_events(precord, pfield, mask)` for one field, and —
4829 /// per the `last_posted` contract — the poster that advances the
4830 /// already-published value when `mask` carries a value class. Taking
4831 /// `&mut self` is what makes that unbypassable: there is no way to publish
4832 /// a field's value through the framework without the change detector
4833 /// learning that it was published.
4834 pub fn notify_field_with_origin(
4835 &mut self,
4836 field: &str,
4837 mask: crate::server::recgbl::EventMask,
4838 origin: u64,
4839 ) {
4840 use crate::server::database::filters::FilteredMonitorEvent;
4841 // A value-class post publishes the field to its DBE_VALUE/DBE_LOG
4842 // subscribers, exactly as C's `dbPut` does for the put field
4843 // (dbAccess.c:1414) — record it so the next process cycle's
4844 // change-detection loop does not publish the same value a second
4845 // time. An alarm-only / property-only post publishes no value, so it
4846 // leaves the map alone.
4847 let publishes_value = mask.intersects(
4848 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
4849 );
4850 let mut posted: Option<EpicsValue> = None;
4851 if let Some(subs) = self.subscribers.get(field) {
4852 if let Some(value) = self.resolve_field(field) {
4853 if publishes_value {
4854 posted = Some(value.clone());
4855 }
4856 let mon_snap = self.make_monitor_snapshot(field, value);
4857 for sub in subs {
4858 // Paused subscriber (`db_event_disable`): suppress at
4859 // the source — no delivery, no coalesce.
4860 if !sub.active {
4861 continue;
4862 }
4863 let sub_mask = crate::server::recgbl::EventMask::from_bits(sub.mask);
4864 if mask.is_empty() || sub_mask.intersects(mask) {
4865 let event = MonitorEvent {
4866 snapshot: mon_snap.clone(),
4867 origin,
4868 mask,
4869 };
4870 // Server-side filter chain (3.15.7). Empty
4871 // chain (the default for every subscriber
4872 // until a `.{filter:opts}` PV-name suffix
4873 // parser wires one in) is the identity, so
4874 // existing subscribers see no behaviour
4875 // change. A filter returning `None` silences
4876 // this event for this subscriber only.
4877 let filtered = if sub.filters.is_empty() {
4878 Some(event)
4879 } else {
4880 sub.filters
4881 .apply(FilteredMonitorEvent::new(event))
4882 .map(|fe| fe.event)
4883 };
4884 let Some(event) = filtered else {
4885 continue;
4886 };
4887 // Same single post owner as the snapshot path.
4888 sub.post(event);
4889 }
4890 }
4891 }
4892 }
4893 // The value is now published to this field's value-class subscribers:
4894 // hand it to the `last_posted` owner so the change detector does not
4895 // publish it again. Delivery to any individual subscriber may have
4896 // been filtered out, exactly as C's `db_post_events` may find an empty
4897 // `mlis` — C still leaves `monitor()`'s `*_lst` state advanced by the
4898 // cycle that ran, so the post, not the delivery, is what counts.
4899 if let Some(value) = posted {
4900 self.record_value_post(field, value);
4901 }
4902 }
4903
4904 /// Add a subscriber for a specific field. Returns `None` when the
4905 /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
4906 /// is reached. the parallel cap on `ProcessVariable`
4907 /// defends against a misbehaving client opening many
4908 /// MONITOR ops against one shared PV; the same defence is needed
4909 /// for record fields, which the CA server's
4910 /// `ChannelTarget::RecordField` path lands on.
4911 pub fn add_subscriber(
4912 &mut self,
4913 field: &str,
4914 sid: u32,
4915 data_type: DbFieldType,
4916 mask: u16,
4917 ) -> Option<EventReader> {
4918 self.add_subscriber_on(&EventUser::new(), field, sid, data_type, mask)
4919 }
4920
4921 /// Add a field subscriber whose events queue on `user`'s event queue —
4922 /// C `db_add_event` with the circuit's `event_user` as context. Every
4923 /// subscription on one CA circuit shares that queue and therefore its
4924 /// `nDuplicates`, so a duplicate queued for one of them releases the
4925 /// EVENTS_OFF drain for all of them (`dbEvent.c:947`). In-process consumers
4926 /// use [`Self::add_subscriber`], which gives each its own `event_user`.
4927 pub fn add_subscriber_on(
4928 &mut self,
4929 user: &EventUser,
4930 field: &str,
4931 sid: u32,
4932 data_type: DbFieldType,
4933 mask: u16,
4934 ) -> Option<EventReader> {
4935 let cap = crate::server::pv::max_subscribers_per_pv();
4936 let field_str = field.to_string();
4937 let bucket = self.subscribers.entry(field_str.clone()).or_default();
4938 // Reap rows whose consumer is gone before
4939 // counting against the cap. A record field whose value
4940 // never changes (e.g. a quasi-static catalog field) never
4941 // triggers `notify_field_with_origin`'s retain-filter, so
4942 // a long-lived subscribe-disconnect storm could pin the
4943 // bucket at `cap` worth of dead rows and lock out
4944 // genuine new subscribers.
4945 bucket.retain(|s| !s.is_closed());
4946 if bucket.len() >= cap {
4947 tracing::warn!(
4948 record = %self.name,
4949 field = %field_str,
4950 live = bucket.len(),
4951 cap,
4952 "record field subscriber cap reached, refusing add_subscriber"
4953 );
4954 return None;
4955 }
4956 let (sink, reader) = crate::server::event_queue::attach(user, sid);
4957 bucket.push(Subscriber {
4958 sid,
4959 data_type,
4960 mask,
4961 sink,
4962 filters: crate::server::database::filters::FilterChain::new(),
4963 active: true,
4964 });
4965 // Initialize last_posted with current value so the first process cycle
4966 // doesn't treat it as "changed" (the initial value is already sent
4967 // to the client as part of EVENT_ADD response).
4968 if !self.last_posted.contains_key(&field_str) {
4969 if let Some(val) = self.resolve_field(&field_str) {
4970 self.last_posted.insert(field_str, val);
4971 }
4972 }
4973 Some(reader)
4974 }
4975
4976 /// Attach a filter to the most recently added subscriber for
4977 /// `field`. Returns `false` when no subscriber exists yet on that
4978 /// field (call `add_subscriber` first). The CA / PVA channel-name
4979 /// parsers will use this once `.{filter:opts}` syntax is wired.
4980 /// Tests can also use it directly to compose filter chains.
4981 pub fn attach_filter_to_last_subscriber(
4982 &mut self,
4983 field: &str,
4984 filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
4985 ) -> bool {
4986 if let Some(bucket) = self.subscribers.get_mut(field) {
4987 if let Some(sub) = bucket.last_mut() {
4988 sub.filters.push(filter);
4989 return true;
4990 }
4991 }
4992 false
4993 }
4994
4995 /// Remove a subscriber by subscription ID from all fields.
4996 pub fn remove_subscriber(&mut self, sid: u32) {
4997 for subs in self.subscribers.values_mut() {
4998 subs.retain(|s| s.sid != sid);
4999 }
5000 }
5001
5002 /// Pause / resume one subscriber's event flow at the source
5003 /// (`db_event_disable` / `db_event_enable`). `active == false`
5004 /// suppresses every subsequent post to this subscriber, so the record stops
5005 /// doing per-event work for it. Entries already queued stay queued and are
5006 /// still delivered, exactly as in C: `db_event_disable` only unlinks the
5007 /// subscription from the record's monitor list (`dbEvent.c:521-533`) and
5008 /// never reaches into the event queue. No-op if no subscriber has this
5009 /// `sid`. The caller holds the record write lock, so this is exclusive with
5010 /// the read-locked post paths that consult `Subscriber::active`.
5011 pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
5012 for subs in self.subscribers.values_mut() {
5013 for sub in subs.iter_mut() {
5014 if sub.sid == sid {
5015 sub.active = active;
5016 }
5017 }
5018 }
5019 }
5020
5021 /// Clean up subscriber rows whose consumer is gone.
5022 pub fn cleanup_subscribers(&mut self) {
5023 for subs in self.subscribers.values_mut() {
5024 subs.retain(|s| !s.is_closed());
5025 }
5026 }
5027}
5028
5029/// C `recGblCheckDeadband` parity (recGbl.c:345-370). The four branches
5030/// the C path enumerates:
5031///
5032/// 1. Both `newval` and `oldval` finite: `delta = |old - new|`, fire when
5033/// `delta > deadband`.
5034/// 2. Exactly one of {newval, oldval} is NaN, the other not — OR exactly
5035/// one is +/-inf, the other not: `delta = +inf`, always fires.
5036/// 3. Both infinite with opposite signs: `delta = +inf`, always fires.
5037/// 4. Otherwise (e.g. both NaN, both same-signed infinity): no fire.
5038///
5039/// `oldval = NaN` is treated as "never posted" and fires (matches the
5040/// `mlst.is_nan() → trigger` short-circuit the Rust port already had).
5041/// `deadband < 0` fires unconditionally (matches `delta > deadband`
5042/// with a negative deadband — same effect on every numeric value).
5043pub(crate) fn check_deadband(newval: f64, oldval: f64, deadband: f64) -> bool {
5044 // Fire unconditionally when no prior posting has happened. C achieves
5045 // the same effect through the field being default-initialised to a
5046 // sentinel; Rust uses NaN-as-sentinel.
5047 if oldval.is_nan() {
5048 return true;
5049 }
5050 // Negative deadband short-circuits — any value passes.
5051 if deadband < 0.0 {
5052 return true;
5053 }
5054 let new_finite = newval.is_finite();
5055 let old_finite = oldval.is_finite();
5056 if new_finite && old_finite {
5057 return (newval - oldval).abs() > deadband;
5058 }
5059 // From here on, at least one of the two is not finite. We've already
5060 // ruled out oldval=NaN above, so any newval=NaN here is the "newval
5061 // went NaN while oldval was finite/inf" case — must fire (C case 2).
5062 if newval.is_nan() {
5063 return true;
5064 }
5065 // Exactly one infinite, the other finite: C case 2 → fire.
5066 if new_finite != old_finite {
5067 return true;
5068 }
5069 // Both infinite. Opposite signs → fire (C case 3); same sign → no
5070 // fire (C path leaves delta=0 and the `delta > deadband` check fails
5071 // for any non-negative deadband).
5072 newval != oldval
5073}
5074
5075#[cfg(test)]
5076mod device_menu_marking_tests {
5077 use super::*;
5078 use crate::server::records::ai::AiRecord;
5079 use crate::server::records::calc::CalcRecord;
5080 use crate::server::records::mbbo::MbboRecord;
5081
5082 /// C `dbAccess.c:176-179`: a `DBF_DEVICE` field whose record type declares
5083 /// no device support has `pfldDes->ftPvt == NULL` and takes `goto nostrs`,
5084 /// which clears `DBR_ENUM_STRS` — the client is sent NO choice list.
5085 ///
5086 /// `calc` declares no `device()` line, so QSRV2 omits `value.choices` on
5087 /// `CALC.DTYP`. The port used to default the missing menu to `[]` and mark
5088 /// an empty list instead.
5089 #[test]
5090 fn dtyp_of_a_record_type_with_no_device_support_supplies_no_choices() {
5091 let inst = RecordInstance::new("X".into(), CalcRecord::default());
5092 assert!(
5093 super::super::dbd_generated::device_menu("calc").is_none(),
5094 "precondition: calc declares no device() line (C ftPvt == NULL)"
5095 );
5096 assert!(
5097 inst.device_choices().is_none(),
5098 "a record type with no device menu must report None, not an empty list"
5099 );
5100 assert!(
5101 inst.enum_string_form_for("DTYP").is_none(),
5102 "DTYP must supply no enum-string form, so no `value.choices` is marked"
5103 );
5104 }
5105
5106 /// The other side of C's `dbAccess.c:205` comment — *"indicate option data
5107 /// not available. distinct from no_str==0"*. `ai` DOES declare device
5108 /// support, so its menu exists and its choices are served.
5109 #[test]
5110 fn dtyp_of_a_record_type_with_device_support_supplies_its_choices() {
5111 let inst = RecordInstance::new("X".into(), AiRecord::default());
5112 let choices = inst
5113 .device_choices()
5114 .expect("ai declares device() lines, so its menu exists");
5115 assert!(
5116 choices.iter().any(|c| c.as_str_lossy() == "Soft Channel"),
5117 "ai's device menu must carry its declared choices, got {choices:?}"
5118 );
5119 assert!(inst.enum_string_form_for("DTYP").is_some());
5120 }
5121
5122 /// An unset `DTYP` is index 0 on both sides of the distinction — a record
5123 /// type with no device menu has no slot for any DTYP, so the index stays 0
5124 /// rather than panicking or shifting.
5125 #[test]
5126 fn dtyp_index_is_zero_when_the_record_type_has_no_device_menu() {
5127 let inst = RecordInstance::new("X".into(), CalcRecord::default());
5128 assert_eq!(inst.dtyp_index(), 0);
5129 }
5130
5131 /// A downstream crate's registered device menu (asyn's) is merged AFTER the
5132 /// base-declared choices, matching a C fat softIoc that loaded `asyn.dbd`:
5133 /// `mbbo.DTYP` = the three base soft entries then `asynInt32`,
5134 /// `asynUInt32Digital`, in that order. `dtyp_index` reads the merged list,
5135 /// so an `mbbo` bound to `asynInt32` reports index 3 — the wire value C
5136 /// serves — instead of the appended-as-own-slot index the port gave before
5137 /// the menu was known.
5138 #[test]
5139 fn a_registered_device_menu_merges_after_the_base_declared_choices() {
5140 // The list asyn's generated `dbd_generated::DEVICE_MENU_MBBO` carries.
5141 static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
5142 super::super::register_device_menu("mbbo", ASYN_MBBO);
5143
5144 let mut inst = RecordInstance::new("X".into(), MbboRecord::default());
5145 let merged: Vec<String> = inst
5146 .device_choices()
5147 .expect("mbbo declares device() lines")
5148 .iter()
5149 .map(|c| c.as_str_lossy().into_owned())
5150 .collect();
5151 assert_eq!(
5152 merged,
5153 vec![
5154 "Soft Channel",
5155 "Raw Soft Channel",
5156 "Async Soft Channel",
5157 "asynInt32",
5158 "asynUInt32Digital",
5159 ],
5160 "base-declared choices first, asyn-contributed appended in asyn.dbd order"
5161 );
5162
5163 inst.common.dtyp = "asynInt32".into();
5164 assert_eq!(
5165 inst.dtyp_index(),
5166 3,
5167 "an asyn DTYP indexes into the merged menu, not an appended own slot"
5168 );
5169 }
5170
5171 /// The None-vs-empty contract survives the merge: a record type neither
5172 /// base nor any downstream crate contributes a `device()` for (calc) stays
5173 /// `None`, never `Some([])`, even after asyn menus are registered in this
5174 /// process.
5175 #[test]
5176 fn calc_stays_none_after_asyn_menus_are_registered() {
5177 static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
5178 super::super::register_device_menu("mbbo", ASYN_MBBO);
5179
5180 let inst = RecordInstance::new("X".into(), CalcRecord::default());
5181 assert!(
5182 inst.device_choices().is_none(),
5183 "calc declares no device() and gets no contribution — still None"
5184 );
5185 }
5186}
5187
5188#[cfg(test)]
5189mod property_support_owner_tests {
5190 use crate::server::record::record_trait::default_property_support;
5191 use crate::server::snapshot::PropertySupport as P;
5192
5193 /// `sseqRecord.c:124-144` — the rset table NULLs every property slot
5194 /// except `get_precision`:
5195 ///
5196 /// ```c
5197 /// NULL, /* get_units */
5198 /// get_precision, /* get_precision */
5199 /// NULL, /* get_enum_str */
5200 /// NULL, /* get_enum_strs */
5201 /// NULL, /* put_enum_str */
5202 /// NULL, /* get_graphic_double */
5203 /// NULL, /* get_control_double */
5204 /// NULL /* get_alarm_double */
5205 /// ```
5206 ///
5207 /// `sseq` was previously grouped with the full-numeric synApps types, so
5208 /// the port marked six leaves per field that QSRV2 omits entirely.
5209 #[test]
5210 fn sseq_supplies_only_precision() {
5211 assert_eq!(
5212 default_property_support("sseq"),
5213 P {
5214 precision: true,
5215 ..P::NONE
5216 }
5217 );
5218 }
5219
5220 /// A record type the table does not name keeps the permissive
5221 /// `NUMERIC` default rather than silently losing metadata. This is the
5222 /// arm `asyn` used to land on — and why marking had to become a trait
5223 /// method: asyn-rs cannot add a row here.
5224 #[test]
5225 fn an_untranscribed_record_type_keeps_the_permissive_default() {
5226 assert_eq!(default_property_support("no-such-record-type"), P::NUMERIC);
5227 }
5228}
5229
5230#[cfg(test)]
5231mod metadata_cache_tests {
5232 use super::*;
5233 use crate::server::records::ai::AiRecord;
5234
5235 /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
5236 fn ai_instance() -> RecordInstance {
5237 let mut rec = AiRecord::default();
5238 let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
5239 let _ = rec.put_field("PREC", EpicsValue::Short(2));
5240 let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
5241 let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
5242 let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
5243 RecordInstance::new("TEMP".to_string(), rec)
5244 }
5245
5246 /// a record-field monitor whose event queue has run short of room
5247 /// replaces its last queued entry in place (C `db_queue_event_log`,
5248 /// `dbEvent.c:812-820`), and the displaced value — which the consumer never
5249 /// observed — must be counted in the shared `dropped_monitor_events()`
5250 /// counter (C `nreplace`), the same accounting a `ProcessVariable` post
5251 /// uses. Before the fix the record-field path overwrote its coalesce slot
5252 /// without counting, hiding slow-consumer loss on the path most CA/PVA
5253 /// database monitors use. The counter is process-global, so the assertion is
5254 /// a strict monotonic increase (robust under parallel tests); the
5255 /// revert-verify runs this test in isolation.
5256 #[test]
5257 fn bfr10_record_field_overflow_counts_dropped_event() {
5258 use crate::server::event_queue::{event_que_size, events_per_que};
5259 use crate::server::pv::dropped_monitor_events;
5260 use crate::server::recgbl::EventMask;
5261 let mut inst = ai_instance();
5262 // Keep the reader alive and do NOT drain, so the ring fills to the
5263 // replace threshold and later posts displace the tail entry.
5264 let _reader = inst
5265 .add_subscriber(
5266 "VAL",
5267 1,
5268 crate::types::DbFieldType::Double,
5269 EventMask::VALUE.bits(),
5270 )
5271 .expect("subscriber added");
5272 let before = dropped_monitor_events();
5273 let posts = event_que_size() - events_per_que() + 10;
5274 for _ in 0..posts {
5275 inst.notify_field_with_origin("VAL", EventMask::VALUE, 0);
5276 }
5277 let after = dropped_monitor_events();
5278 assert!(
5279 after > before,
5280 "a post that replaces an unobserved queued entry must record a \
5281 dropped monitor event (before={before}, after={after})"
5282 );
5283 }
5284
5285 #[test]
5286 fn metadata_field_set_check() {
5287 // Sanity check that the metadata field set is recognized.
5288 assert!(is_metadata_field("EGU"));
5289 assert!(is_metadata_field("PREC"));
5290 assert!(is_metadata_field("HOPR"));
5291 assert!(is_metadata_field("LOPR"));
5292 assert!(is_metadata_field("HIHI"));
5293 assert!(is_metadata_field("DRVH"));
5294 assert!(is_metadata_field("ZNAM"));
5295 assert!(is_metadata_field("ZRST"));
5296 assert!(is_metadata_field("FFST"));
5297
5298 // Non-metadata fields should NOT invalidate the cache
5299 assert!(!is_metadata_field("VAL"));
5300 assert!(!is_metadata_field("DESC"));
5301 assert!(!is_metadata_field("SCAN"));
5302 assert!(!is_metadata_field("PHAS"));
5303 }
5304
5305 #[test]
5306 fn cache_starts_empty_then_populates_on_first_snapshot() {
5307 let inst = ai_instance();
5308
5309 // Cache starts empty
5310 assert!(inst.metadata_cache.lock().unwrap().is_none());
5311
5312 // First snapshot triggers populate + cache store
5313 let snap = inst.snapshot_for_field("VAL").unwrap();
5314 let display = snap.display.expect("ai snapshot must have display");
5315 assert_eq!(display.units, "degC");
5316 assert_eq!(display.precision, 2);
5317 assert_eq!(display.upper_disp_limit, 100.0);
5318 assert_eq!(display.lower_disp_limit, 0.0);
5319
5320 // Cache is now populated
5321 assert!(inst.metadata_cache.lock().unwrap().is_some());
5322 }
5323
5324 #[test]
5325 fn q_form_info_tag_sets_display_form_index() {
5326 // pvxs maps the `Q:form` info tag to `display.form.index` for the
5327 // VAL field (iocsource.cpp:42-62). "Hex" is slot 4 of the
5328 // seven-entry menu (Default/String/Binary/Decimal/Hex/...).
5329 let mut inst = ai_instance();
5330 inst.set_info("Q:form", "Hex");
5331 let snap = inst.snapshot_for_field("VAL").unwrap();
5332 let display = snap.display.expect("ai snapshot must have display");
5333 assert_eq!(display.form, 4, "Q:form=Hex -> display.form index 4");
5334 }
5335
5336 /// R16-31: `Q:form` is a record-level info tag, but QSRV assigns
5337 /// `display.form.index` only when the channel addresses the VAL field
5338 /// (`if(dbIsValueField(dbChannelFldDes(chan)))`, `iocsource.cpp:53`). A
5339 /// snapshot of any other field of the same record reports the default
5340 /// form, on both the GET and the monitor producer.
5341 #[test]
5342 fn q_form_applies_to_the_val_field_only() {
5343 let mut inst = ai_instance();
5344 inst.set_info("Q:form", "Hex");
5345
5346 let val = inst.snapshot_for_field("VAL").unwrap();
5347 assert_eq!(val.display.expect("ai display").form, 4);
5348
5349 for non_val in ["RVAL", "SEVR", "HOPR"] {
5350 let Some(snap) = inst.snapshot_for_field(non_val) else {
5351 panic!("ai.{non_val} must resolve");
5352 };
5353 assert_eq!(
5354 snap.display.expect("ai display").form,
5355 0,
5356 "Q:form must not reach ai.{non_val} — pvxs applies it to VAL only"
5357 );
5358 }
5359
5360 // The monitor producer shares the same per-field owner.
5361 let update = inst.make_monitor_snapshot("RVAL", EpicsValue::Long(7));
5362 assert_eq!(
5363 update.display.expect("ai display").form,
5364 0,
5365 "a monitor update on a non-VAL field carries the default form too"
5366 );
5367 let update = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
5368 assert_eq!(update.display.expect("ai display").form, 4);
5369 }
5370
5371 #[test]
5372 fn q_form_absent_or_unknown_leaves_form_default() {
5373 // No `Q:form` tag -> form stays 0 (Default).
5374 let inst = ai_instance();
5375 let snap = inst.snapshot_for_field("VAL").unwrap();
5376 assert_eq!(snap.display.expect("ai display").form, 0);
5377
5378 // Unrecognised tag -> pvxs leaves the index untouched (0).
5379 let mut inst2 = ai_instance();
5380 inst2.set_info("Q:form", "Nonsense");
5381 let snap2 = inst2.snapshot_for_field("VAL").unwrap();
5382 assert_eq!(snap2.display.expect("ai display").form, 0);
5383 }
5384
5385 /// `info(Q:time:tag)` resolves to pvxs's `nsecMask`
5386 /// (`ioc/typeutils.cpp:79-88`). The prefix test there is a byte-exact
5387 /// `strncmp("nsec:lsb:", 9)` and the digit count is fed straight to
5388 /// `(uint64_t(1u)<<dig)-1u` — no case folding, no whitespace tolerance
5389 /// around the prefix, and no bounds clamp. Each boundary gets a case.
5390 #[test]
5391 fn qtime_nsec_mask_matches_pvxs_updatensecmask() {
5392 let cases: &[(&str, u64)] = &[
5393 // parses: `epicsParseInt32` skips whitespace around the digits
5394 // and accepts a sign.
5395 ("nsec:lsb:20", (1 << 20) - 1),
5396 ("nsec:lsb:1", 1),
5397 ("nsec:lsb: 4 ", 0xF),
5398 ("nsec:lsb:+4", 0xF),
5399 // no clamp: 31 is the mask pvxs actually serves (the old Rust
5400 // `(1..=30)` guard dropped it), and 0 is pvxs's "off" mask.
5401 ("nsec:lsb:31", 0x7FFF_FFFF),
5402 ("nsec:lsb:0", 0),
5403 // `strncmp` is byte-exact: case-folded or whitespace-split
5404 // prefixes do not match, so pvxs leaves `nsecMask` at 0.
5405 ("NSEC:LSB:4", 0),
5406 ("Nsec:Lsb:4", 0),
5407 ("nsec: lsb: 4", 0),
5408 (" nsec:lsb:4", 0),
5409 // `epicsParseInt32` failures: no conversion, extraneous trailing
5410 // bytes, overflow past epicsInt32.
5411 ("nsec:lsb:", 0),
5412 ("nsec:lsb:abc", 0),
5413 ("nsec:lsb:4x", 0),
5414 ("nsec:lsb:4 5", 0),
5415 ("nsec:lsb:99999999999999999999", 0),
5416 ("nsec:lsb:2147483648", 0),
5417 ];
5418 for (tag, want) in cases {
5419 let mut inst = ai_instance();
5420 inst.set_info("Q:time:tag", *tag);
5421 assert_eq!(
5422 inst.qtime_nsec_mask(),
5423 *want,
5424 "info(Q:time:tag, {tag:?}) must resolve to nsecMask {want:#x}"
5425 );
5426 }
5427 // Tag absent entirely → pvxs never enters the `if(auto val = ...)`
5428 // body and `nsecMask` stays 0.
5429 assert_eq!(ai_instance().qtime_nsec_mask(), 0);
5430 }
5431
5432 /// End-to-end on the snapshot: `nsec:lsb:31` publishes
5433 /// `nanoseconds & ~mask` (0, since nanoseconds < 1e9 < 2^31) and
5434 /// `userTag = nanoseconds & mask` (pvxs `iocsource.cpp:239-248`). The
5435 /// old `(1..=30)` clamp served the raw nanoseconds and the record's
5436 /// utag instead.
5437 #[test]
5438 fn qtime_nsec_lsb_31_is_served_not_ignored() {
5439 use std::time::{Duration, SystemTime};
5440 let mut inst = ai_instance();
5441 // 123_456_700, not …789: Windows `SystemTime` is a FILETIME with 100 ns
5442 // resolution, so a sub-100 ns literal is truncated on readback and the
5443 // assertion below would see …700. Any value < 2^31 exercises the
5444 // nsec:lsb:31 mask identically, so pin one that survives the round trip.
5445 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
5446 inst.common.utag = 5;
5447 inst.set_info("Q:time:tag", "nsec:lsb:31");
5448
5449 let snap = inst.snapshot_for_field("VAL").unwrap();
5450 assert_eq!(snap.user_tag, 123_456_700);
5451 assert_eq!(snap.timestamp.subsec_nanos(), 0);
5452 assert_eq!(snap.timestamp.unix_secs(), 42);
5453 }
5454
5455 /// The mirror boundary: a tag pvxs's `strncmp` rejects must leave the
5456 /// timestamp and the record's own utag alone. The old case-insensitive
5457 /// split matched `NSEC:LSB:4` and masked the wire timestamp pvxs serves
5458 /// unmasked.
5459 #[test]
5460 fn qtime_uppercase_tag_leaves_timestamp_untouched() {
5461 use std::time::{Duration, SystemTime};
5462 let mut inst = ai_instance();
5463 // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
5464 // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
5465 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
5466 inst.common.utag = 5;
5467 inst.set_info("Q:time:tag", "NSEC:LSB:4");
5468
5469 let snap = inst.snapshot_for_field("VAL").unwrap();
5470 assert_eq!(
5471 snap.user_tag, 5,
5472 "record utag must survive a non-matching tag"
5473 );
5474 assert_eq!(snap.timestamp.subsec_nanos(), 123_456_700);
5475 }
5476
5477 /// the served `timeStamp.userTag` defaults to the record's `utag`
5478 /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
5479 /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
5480 /// it to 0, dropping the record's tag. A bit-31 utag also pins the
5481 /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
5482 /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
5483 /// wire field.
5484 #[test]
5485 fn snapshot_serves_record_utag_as_timestamp_usertag() {
5486 let mut inst = ai_instance();
5487 // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
5488 // override never fires and the utag default is what is served.
5489 inst.common.utag = 0x9000_0000;
5490 let want = 0x9000_0000u32 as i32;
5491
5492 let get = inst.snapshot_for_field("VAL").unwrap();
5493 assert_eq!(
5494 get.user_tag, want,
5495 "GET path must serve the record's utag as timeStamp.userTag"
5496 );
5497
5498 let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
5499 assert_eq!(
5500 mon.user_tag, want,
5501 "MONITOR path must carry the record's utag too"
5502 );
5503 }
5504
5505 #[test]
5506 fn cache_hit_returns_same_metadata() {
5507 let inst = ai_instance();
5508
5509 // Prime the cache
5510 let snap1 = inst.snapshot_for_field("VAL").unwrap();
5511 let display1 = snap1.display.unwrap();
5512
5513 // Subsequent snapshots return the same cached metadata
5514 let snap2 = inst.snapshot_for_field("VAL").unwrap();
5515 let display2 = snap2.display.unwrap();
5516
5517 assert_eq!(display1.units, display2.units);
5518 assert_eq!(display1.precision, display2.precision);
5519 assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
5520 assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
5521 }
5522
5523 #[test]
5524 fn invalidate_clears_cache() {
5525 let inst = ai_instance();
5526 let _ = inst.snapshot_for_field("VAL");
5527 assert!(inst.metadata_cache.lock().unwrap().is_some());
5528
5529 inst.invalidate_metadata_cache();
5530 assert!(inst.metadata_cache.lock().unwrap().is_none());
5531 }
5532
5533 #[test]
5534 fn notify_field_written_invalidates_for_metadata_field() {
5535 let inst = ai_instance();
5536 let _ = inst.snapshot_for_field("VAL");
5537 assert!(inst.metadata_cache.lock().unwrap().is_some());
5538
5539 // Writing a metadata field should invalidate
5540 inst.notify_field_written("EGU");
5541 assert!(inst.metadata_cache.lock().unwrap().is_none());
5542 }
5543
5544 #[test]
5545 fn notify_field_written_skips_non_metadata_field() {
5546 let inst = ai_instance();
5547 let _ = inst.snapshot_for_field("VAL");
5548 assert!(inst.metadata_cache.lock().unwrap().is_some());
5549
5550 // Writing a value field should NOT invalidate the cache
5551 inst.notify_field_written("VAL");
5552 assert!(inst.metadata_cache.lock().unwrap().is_some());
5553
5554 // Same for DESC
5555 inst.notify_field_written("DESC");
5556 assert!(inst.metadata_cache.lock().unwrap().is_some());
5557 }
5558
5559 #[test]
5560 fn notify_field_written_is_case_insensitive() {
5561 let inst = ai_instance();
5562 let _ = inst.snapshot_for_field("VAL");
5563 assert!(inst.metadata_cache.lock().unwrap().is_some());
5564
5565 // Lowercase metadata field name should still trigger invalidation
5566 inst.notify_field_written("egu");
5567 assert!(inst.metadata_cache.lock().unwrap().is_none());
5568 }
5569
5570 /// epics-base faac1df1 — `notify_field_written_if_changed` must
5571 /// SKIP the cache invalidation when the metadata field's value
5572 /// didn't actually change. Otherwise a stream of idempotent puts
5573 /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
5574 /// "property changed" events on every cycle.
5575 #[test]
5576 fn notify_field_written_if_changed_skips_when_unchanged() {
5577 let mut inst = ai_instance();
5578 let _ = inst.snapshot_for_field("VAL");
5579 assert!(inst.metadata_cache.lock().unwrap().is_some());
5580
5581 // Capture prev, do a no-op put, then notify — cache must remain.
5582 let prev = inst.record.get_field("EGU");
5583 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
5584 inst.notify_field_written_if_changed("EGU", prev.as_ref());
5585 assert!(
5586 inst.metadata_cache.lock().unwrap().is_some(),
5587 "no-op put must not invalidate the metadata cache"
5588 );
5589 }
5590
5591 /// And when the value DID change, the cache must invalidate.
5592 #[test]
5593 fn notify_field_written_if_changed_invalidates_on_real_change() {
5594 let mut inst = ai_instance();
5595 let _ = inst.snapshot_for_field("VAL");
5596 assert!(inst.metadata_cache.lock().unwrap().is_some());
5597
5598 let prev = inst.record.get_field("EGU");
5599 let _ = inst
5600 .record
5601 .put_field("EGU", EpicsValue::String("kPa".into()));
5602 inst.notify_field_written_if_changed("EGU", prev.as_ref());
5603 assert!(
5604 inst.metadata_cache.lock().unwrap().is_none(),
5605 "real metadata change must invalidate cache"
5606 );
5607 }
5608
5609 /// Non-metadata fields don't carry property semantics — the
5610 /// `if_changed` variant must never invalidate for them, matching
5611 /// the existing `notify_field_written` short-circuit.
5612 #[test]
5613 fn notify_field_written_if_changed_skips_non_metadata_field() {
5614 let mut inst = ai_instance();
5615 let _ = inst.snapshot_for_field("VAL");
5616 assert!(inst.metadata_cache.lock().unwrap().is_some());
5617 // VAL is not in is_metadata_field set — must be skipped even
5618 // with a changed value.
5619 inst.notify_field_written_if_changed("VAL", None);
5620 assert!(inst.metadata_cache.lock().unwrap().is_some());
5621 }
5622
5623 #[test]
5624 fn cache_picks_up_new_value_after_invalidation() {
5625 let mut inst = ai_instance();
5626
5627 // First snapshot: degC
5628 let snap1 = inst.snapshot_for_field("VAL").unwrap();
5629 assert_eq!(snap1.display.unwrap().units, "degC");
5630
5631 // Mutate EGU and invalidate
5632 let _ = inst
5633 .record
5634 .put_field("EGU", EpicsValue::String("mV".into()));
5635 inst.notify_field_written("EGU");
5636
5637 // Second snapshot: mV (rebuilt)
5638 let snap2 = inst.snapshot_for_field("VAL").unwrap();
5639 assert_eq!(snap2.display.unwrap().units, "mV");
5640 }
5641
5642 /// R19-41: every snapshot carries the mask of which properties the
5643 /// channel SUPPLIES — C's `rset` slots (`dbAccess.c:336-430` clears the
5644 /// option bit of each NULL slot) narrowed to the addressed field. One
5645 /// case per gate boundary; the three record types are the ones measured
5646 /// against pvxs, which marks none of these leaves.
5647 #[test]
5648 fn property_support_masks_what_the_record_type_does_not_supply() {
5649 use crate::server::records::longout::LongoutRecord;
5650 use crate::server::records::stringout::StringoutRecord;
5651 use crate::server::records::waveform::WaveformRecord;
5652
5653 // ai VAL (DBF_DOUBLE): every numeric slot, no enum strings.
5654 let ai = ai_instance();
5655 let p = ai.snapshot_for_field("VAL").unwrap().properties;
5656 assert_eq!(p, PropertySupport::NUMERIC);
5657 assert_eq!(
5658 ai.snapshot_for_field("VAL").unwrap().precision(),
5659 Some(2),
5660 "an ai supplies get_precision and VAL is DBF_DOUBLE"
5661 );
5662
5663 // ai RVAL (DBF_LONG): the SAME rset, but C keeps DBR_PRECISION only
5664 // for DBF_FLOAT/DBF_DOUBLE (`dbAccess.c:386-395`).
5665 let rval = ai.snapshot_for_field("RVAL").unwrap();
5666 assert!(
5667 !rval.properties.precision && rval.precision().is_none(),
5668 "a non-float field supplies no precision even when the rset does"
5669 );
5670 assert!(
5671 rval.properties.units,
5672 "the other slots are unaffected by the field's type"
5673 );
5674
5675 // longout: `#define get_precision NULL`.
5676 let lo = RecordInstance::new("LO".to_string(), LongoutRecord::default());
5677 let lo = lo.snapshot_for_field("VAL").unwrap();
5678 assert!(!lo.properties.precision && lo.precision().is_none());
5679 assert!(lo.properties.units && lo.properties.graphic_double);
5680
5681 // stringout: no property slot at all.
5682 let so = RecordInstance::new("SO".to_string(), StringoutRecord::default());
5683 let so = so.snapshot_for_field("VAL").unwrap();
5684 assert_eq!(so.properties, PropertySupport::NONE);
5685 assert!(so.units().is_none(), "a stringout supplies no EGU");
5686
5687 // waveform: `#define get_alarm_double NULL`.
5688 let wf = RecordInstance::new("WF".to_string(), WaveformRecord::default());
5689 let wf = wf.snapshot_for_field("VAL").unwrap();
5690 assert!(
5691 !wf.properties.alarm_double && wf.alarm_limits().is_none(),
5692 "a waveform supplies no alarm limits — a GUI must not draw bands at zero"
5693 );
5694 assert!(wf.properties.units && wf.properties.graphic_double);
5695 }
5696
5697 #[test]
5698 fn make_monitor_snapshot_uses_cache() {
5699 let inst = ai_instance();
5700 assert!(inst.metadata_cache.lock().unwrap().is_none());
5701
5702 // make_monitor_snapshot should also populate the cache
5703 let snap = inst.make_monitor_snapshot("VAL", EpicsValue::Double(42.0));
5704 assert!(snap.display.is_some());
5705 assert!(inst.metadata_cache.lock().unwrap().is_some());
5706
5707 // Subsequent call hits cache
5708 let snap2 = inst.make_monitor_snapshot("VAL", EpicsValue::Double(43.0));
5709 let d1 = snap.display.unwrap();
5710 let d2 = snap2.display.unwrap();
5711 assert_eq!(d1.units, d2.units);
5712 assert_eq!(d1.precision, d2.precision);
5713 }
5714
5715 /// Stub record with a per-field metadata override on SPD only —
5716 /// models a C RSET whose get_units/get_graphic_double key on
5717 /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
5718 struct PerFieldMetaRecord;
5719
5720 impl Record for PerFieldMetaRecord {
5721 fn record_type(&self) -> &'static str {
5722 "ai" // record-level metadata populates from EGU/PREC/HOPR/LOPR
5723 }
5724 fn get_field(&self, name: &str) -> Option<EpicsValue> {
5725 match name {
5726 "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
5727 "EGU" => Some(EpicsValue::String("mm".into())),
5728 "PREC" => Some(EpicsValue::Short(3)),
5729 "HOPR" => Some(EpicsValue::Double(100.0)),
5730 "LOPR" => Some(EpicsValue::Double(-100.0)),
5731 _ => None,
5732 }
5733 }
5734 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
5735 Err(CaError::FieldNotFound(name.to_string()))
5736 }
5737 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
5738 &[]
5739 }
5740 fn field_metadata_override(
5741 &self,
5742 field: &str,
5743 ) -> Option<crate::server::record::FieldMetadataOverride> {
5744 if field != "SPD" {
5745 return None;
5746 }
5747 Some(crate::server::record::FieldMetadataOverride {
5748 units: Some("mm/sec".into()),
5749 precision: Some(1),
5750 disp_limits: Some((5.0, 0.5)),
5751 ctrl_limits: Some((4.0, 1.0)),
5752 alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
5753 })
5754 }
5755 }
5756
5757 #[test]
5758 fn field_metadata_override_applies_on_get_and_monitor_paths() {
5759 let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
5760
5761 // VAL: no override — record-level metadata serves it.
5762 let snap = inst.snapshot_for_field("VAL").unwrap();
5763 let d = snap.display.unwrap();
5764 assert_eq!(d.units, "mm");
5765 assert_eq!(d.precision, 3);
5766 assert_eq!(d.upper_disp_limit, 100.0);
5767
5768 // SPD via the GET path: every member patched over the cache.
5769 let snap = inst.snapshot_for_field("SPD").unwrap();
5770 let d = snap.display.unwrap();
5771 assert_eq!(d.units, "mm/sec");
5772 assert_eq!(d.precision, 1);
5773 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
5774 assert_eq!(
5775 (
5776 d.upper_alarm_limit,
5777 d.upper_warning_limit,
5778 d.lower_warning_limit,
5779 d.lower_alarm_limit
5780 ),
5781 (9.0, 8.0, -8.0, -9.0)
5782 );
5783 let c = snap.control.unwrap();
5784 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
5785
5786 // SPD via the monitor path: identical override.
5787 let snap = inst.make_monitor_snapshot("SPD", EpicsValue::Double(2.0));
5788 let d = snap.display.unwrap();
5789 assert_eq!(d.units, "mm/sec");
5790 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
5791 let c = snap.control.unwrap();
5792 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
5793 }
5794
5795 /// Stub modelling the motor monitor() shape (C motorRecord.cc:
5796 /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
5797 /// the RBV readback, which advances on every process.
5798 struct ReadbackDeadbandRecord {
5799 val: f64,
5800 rbv: f64,
5801 deadband: f64,
5802 }
5803
5804 impl Record for ReadbackDeadbandRecord {
5805 fn record_type(&self) -> &'static str {
5806 "ai"
5807 }
5808 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
5809 self.rbv += 30.0;
5810 Ok(crate::server::record::ProcessOutcome::complete())
5811 }
5812 fn get_field(&self, name: &str) -> Option<EpicsValue> {
5813 match name {
5814 "VAL" => Some(EpicsValue::Double(self.val)),
5815 "RBV" => Some(EpicsValue::Double(self.rbv)),
5816 "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
5817 _ => None,
5818 }
5819 }
5820 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
5821 match (name, value) {
5822 ("VAL", EpicsValue::Double(v)) => {
5823 self.val = v;
5824 Ok(())
5825 }
5826 ("MDEL", EpicsValue::Double(v)) => {
5827 self.deadband = v;
5828 Ok(())
5829 }
5830 _ => Err(CaError::FieldNotFound(name.to_string())),
5831 }
5832 }
5833 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
5834 &[]
5835 }
5836 fn monitor_deadband_value(&self) -> Option<EpicsValue> {
5837 Some(EpicsValue::Double(self.rbv))
5838 }
5839 fn monitor_deadband_field(&self) -> &'static str {
5840 "RBV"
5841 }
5842 }
5843
5844 /// C motor monitor() parity: MDEL/ADEL throttle the deadband
5845 /// field's (RBV) delivery; VAL posts only when the setpoint
5846 /// actually changed — not on every readback poll.
5847 #[test]
5848 fn deadband_field_routes_readback_and_val_posts_only_on_change() {
5849 use crate::server::recgbl::EventMask;
5850 let mut inst = RecordInstance::new(
5851 "RDB".to_string(),
5852 ReadbackDeadbandRecord {
5853 val: 5.0,
5854 rbv: 0.0,
5855 deadband: 10.0,
5856 },
5857 );
5858 let _val_rx = inst
5859 .add_subscriber(
5860 "VAL",
5861 1,
5862 crate::types::DbFieldType::Double,
5863 EventMask::VALUE.bits(),
5864 )
5865 .expect("VAL subscriber");
5866 let _rbv_rx = inst
5867 .add_subscriber(
5868 "RBV",
5869 2,
5870 crate::types::DbFieldType::Double,
5871 EventMask::VALUE.bits(),
5872 )
5873 .expect("RBV subscriber");
5874 let names = |snap: &ProcessSnapshot| {
5875 snap.changed_fields
5876 .iter()
5877 .map(|(n, _, _)| n.clone())
5878 .collect::<Vec<_>>()
5879 };
5880
5881 // Cycle 1 (first publish): RBV fires via the deadband trigger
5882 // (MLST starts at the NaN never-posted sentinel). VAL must NOT
5883 // post: `add_subscriber` seeded `last_posted` with the current
5884 // value (the initial value already went out with EVENT_ADD), and
5885 // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
5886 // it.
5887 let (snap, _) = inst.process_local().unwrap();
5888 let n = names(&snap);
5889 assert!(n.contains(&"RBV".to_string()), "{n:?}");
5890 assert!(
5891 !n.contains(&"VAL".to_string()),
5892 "VAL unchanged since subscribe must not post: {n:?}"
5893 );
5894
5895 // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
5896 // VAL not re-posted.
5897 let (snap, _) = inst.process_local().unwrap();
5898 let n = names(&snap);
5899 assert!(n.contains(&"RBV".to_string()), "RBV crossed MDEL: {n:?}");
5900 assert!(
5901 !n.contains(&"VAL".to_string()),
5902 "unchanged VAL must not post: {n:?}"
5903 );
5904
5905 // Cycle 3: widen the deadband — RBV moves within it → throttled.
5906 let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
5907 let (snap, _) = inst.process_local().unwrap();
5908 let n = names(&snap);
5909 assert!(
5910 !n.contains(&"RBV".to_string()),
5911 "MDEL must throttle RBV: {n:?}"
5912 );
5913
5914 // Cycle 4: setpoint moves while RBV stays inside the deadband →
5915 // VAL posts via change detection, RBV stays throttled.
5916 let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
5917 let (snap, _) = inst.process_local().unwrap();
5918 let n = names(&snap);
5919 assert!(
5920 n.contains(&"VAL".to_string()),
5921 "changed VAL must post: {n:?}"
5922 );
5923 assert!(
5924 !n.contains(&"RBV".to_string()),
5925 "MDEL must throttle RBV: {n:?}"
5926 );
5927 }
5928
5929 /// A subroutine-less aSub (empty SNAM — the record the PVA monitor
5930 /// oracle drives as `ORACLE:MONSCAN:ASUB`) mirrors C `do_sub`
5931 /// (aSubRecord.c:459-465): an empty SNAM returns 0 BEFORE the bad-sub
5932 /// check, and C `process` (`:224`) runs `prec->val = status = 0` every
5933 /// cycle. So a periodic scan forces VAL back to 0, and C `monitor()`
5934 /// (`:414`, `val != oval`) posts nothing — the driven `dbPut`s are the
5935 /// only VAL events.
5936 ///
5937 /// Before the fix the port's "no bound subroutine" branch returned
5938 /// `S_db_BadSub` and never wrote VAL, so a scanned aSub kept VAL at the
5939 /// last client put and the deadband gate re-posted it on every scan (the
5940 /// oracle's 7 updates where C posts 4). This pins both halves: `process`
5941 /// resets VAL to 0, and a scan of the reset value posts nothing.
5942 #[test]
5943 fn subroutineless_asub_process_resets_val_and_stops_scan_overposting() {
5944 use crate::server::recgbl::EventMask;
5945 use crate::server::records::asub_record::ASubRecord;
5946
5947 let mut inst = RecordInstance::new("ASUB".to_string(), ASubRecord::default());
5948 // The default record: no subroutine bound, SNAM empty.
5949 assert!(inst.subroutine.is_none());
5950 let _val_rx = inst
5951 .add_subscriber(
5952 "VAL",
5953 1,
5954 crate::types::DbFieldType::Long,
5955 EventMask::VALUE.bits(),
5956 )
5957 .expect("VAL subscriber");
5958 let posts_val =
5959 |snap: &ProcessSnapshot| snap.changed_fields.iter().any(|(n, _, _)| n == "VAL");
5960
5961 // A settling scan of the unchanged record: C `do_sub` returns 0 and
5962 // `process` leaves VAL at 0 (already 0), settling the monitor gate.
5963 let _ = inst.process_local().unwrap();
5964 assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
5965 // status 0 -> C `if (!status)` drives every OUT link (aSub's
5966 // `multi_output_links` gate reads the cycle status); a bad-sub status
5967 // would suppress all 21.
5968 assert_eq!(
5969 inst.record.multi_output_links().len(),
5970 21,
5971 "empty-SNAM do_sub status must be 0, not S_db_BadSub"
5972 );
5973
5974 // A client caput lands on VAL (DBF_LONG, not process-passive: it posts
5975 // but does not itself process, leaving VAL non-zero — exactly how the
5976 // oracle drives the scanned reproducer between scans).
5977 inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
5978
5979 // The periodic scan processes. C forces VAL back to 0 and posts
5980 // nothing (val == oval == 0). Before the fix VAL stayed 7 and the scan
5981 // re-posted it.
5982 let (snap, _) = inst.process_local().unwrap();
5983 assert_eq!(
5984 inst.record.get_field("VAL"),
5985 Some(EpicsValue::Long(0)),
5986 "a scan must reset VAL to the do_sub status (0)"
5987 );
5988 assert!(
5989 !posts_val(&snap),
5990 "a scan that resets VAL to 0 must not re-post it"
5991 );
5992
5993 // A second driven put + scan: the monitor marker stays at 0, so no
5994 // scan ever re-posts the reset value.
5995 inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
5996 let (snap, _) = inst.process_local().unwrap();
5997 assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
5998 assert!(
5999 !posts_val(&snap),
6000 "repeated scans must not re-post the reset VAL"
6001 );
6002 }
6003
6004 /// Record that names DIFF in `force_posted_fields` (the motor's C
6005 /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
6006 /// every value constant — a settled axis parked at a fixed non-zero
6007 /// following error. VAL is a control: not force-listed, so it must
6008 /// fall back to change-detection.
6009 struct ForcePostRecord {
6010 diff: f64,
6011 val: f64,
6012 }
6013
6014 impl Record for ForcePostRecord {
6015 fn record_type(&self) -> &'static str {
6016 "ai"
6017 }
6018 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6019 // Values never change — the readback already matches; only the
6020 // unconditional MARK should keep DIFF flowing.
6021 Ok(crate::server::record::ProcessOutcome::complete())
6022 }
6023 fn get_field(&self, name: &str) -> Option<EpicsValue> {
6024 match name {
6025 "DIFF" => Some(EpicsValue::Double(self.diff)),
6026 "VAL" => Some(EpicsValue::Double(self.val)),
6027 _ => None,
6028 }
6029 }
6030 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
6031 Err(CaError::FieldNotFound(name.to_string()))
6032 }
6033 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6034 &[]
6035 }
6036 fn force_posted_fields(&self) -> &'static [&'static str] {
6037 &["DIFF"]
6038 }
6039 }
6040
6041 /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
6042 /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
6043 /// regardless of change, so a force-posted field re-posts on an
6044 /// otherwise-idle cycle while an unchanged non-force field does not.
6045 #[test]
6046 fn force_posted_field_reposts_unchanged_value_each_cycle() {
6047 use crate::server::recgbl::EventMask;
6048 let mut inst = RecordInstance::new(
6049 "FP".to_string(),
6050 ForcePostRecord {
6051 diff: 2.5,
6052 val: 1.0,
6053 },
6054 );
6055 let _diff_rx = inst
6056 .add_subscriber(
6057 "DIFF",
6058 1,
6059 crate::types::DbFieldType::Double,
6060 EventMask::VALUE.bits(),
6061 )
6062 .expect("DIFF subscriber");
6063 let _val_rx = inst
6064 .add_subscriber(
6065 "VAL",
6066 2,
6067 crate::types::DbFieldType::Double,
6068 EventMask::VALUE.bits(),
6069 )
6070 .expect("VAL subscriber");
6071 let names = |snap: &ProcessSnapshot| {
6072 snap.changed_fields
6073 .iter()
6074 .map(|(n, _, _)| n.clone())
6075 .collect::<Vec<_>>()
6076 };
6077
6078 // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
6079 // empty so change-detection treats every subscribed field as new.
6080 let (snap1, _) = inst.process_local().unwrap();
6081 assert!(
6082 names(&snap1).contains(&"DIFF".to_string()),
6083 "DIFF posts on first publish: {:?}",
6084 names(&snap1)
6085 );
6086
6087 // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
6088 // DIFF (force-listed) MUST re-post — the C unconditional MARK +
6089 // DBE_VAL_LOG. This is the divergence MOT-1 closes.
6090 let (snap2, _) = inst.process_local().unwrap();
6091 assert!(
6092 names(&snap2).contains(&"DIFF".to_string()),
6093 "force-posted DIFF must re-post when unchanged: {:?}",
6094 names(&snap2)
6095 );
6096 assert!(
6097 !names(&snap2).contains(&"VAL".to_string()),
6098 "an unchanged non-force field must not re-post: {:?}",
6099 names(&snap2)
6100 );
6101 // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
6102 // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
6103 let diff_mask = snap2
6104 .changed_fields
6105 .iter()
6106 .find(|(n, _, _)| n == "DIFF")
6107 .map(|(_, _, m)| *m)
6108 .expect("DIFF post present");
6109 assert_eq!(
6110 diff_mask.bits(),
6111 (EventMask::VALUE | EventMask::LOG).bits(),
6112 "forced re-post mask is DBE_VAL_LOG"
6113 );
6114 }
6115
6116 /// Record that names S1 in `log_swept_fields` (the scaler's idle
6117 /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
6118 /// is a control: subscribed but NOT swept, so an unchanged S2 must
6119 /// not re-post. Neither field is the primary `VAL`, so the default
6120 /// deadband field resolves to nothing and does not confound the test.
6121 struct LogSweepRecord {
6122 s1: i32,
6123 s2: i32,
6124 }
6125
6126 impl Record for LogSweepRecord {
6127 fn record_type(&self) -> &'static str {
6128 "scaler"
6129 }
6130 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6131 // Counts never change — only the unconditional idle LOG sweep
6132 // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
6133 Ok(crate::server::record::ProcessOutcome::complete())
6134 }
6135 fn get_field(&self, name: &str) -> Option<EpicsValue> {
6136 match name {
6137 "S1" => Some(EpicsValue::Long(self.s1)),
6138 "S2" => Some(EpicsValue::Long(self.s2)),
6139 _ => None,
6140 }
6141 }
6142 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
6143 match (name, value) {
6144 ("S1", EpicsValue::Long(v)) => {
6145 self.s1 = v;
6146 Ok(())
6147 }
6148 ("S2", EpicsValue::Long(v)) => {
6149 self.s2 = v;
6150 Ok(())
6151 }
6152 _ => Err(CaError::FieldNotFound(name.to_string())),
6153 }
6154 }
6155 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6156 &[]
6157 }
6158 fn log_swept_fields(&self) -> &'static [&'static str] {
6159 &["S1"]
6160 }
6161 }
6162
6163 /// C `scalerRecord.c::monitor():757-773` sweeps each active channel with a
6164 /// literal `DBE_LOG` on every cycle it runs, unconditionally — the sweep is
6165 /// INDEPENDENT of the change post, not an alternative to it (R12-62). So an
6166 /// UNCHANGED swept field posts `DBE_LOG` only, and a CHANGED swept field
6167 /// posts TWICE on that one cycle: once by change-detection, and once by the
6168 /// sweep with `DBE_LOG`. (In C's scaler those two are `updateCounts()`'s
6169 /// `DBE_VALUE` at `:582` and `monitor()`'s `DBE_LOG` at `:771`.) A
6170 /// non-swept field never re-posts when unchanged. `add_subscriber` seeds
6171 /// `last_posted` with the current value (the initial value goes out via
6172 /// EVENT_ADD), so a freshly subscribed unchanged field already takes the
6173 /// sweep path on cycle 1.
6174 #[test]
6175 fn log_swept_field_reposts_unchanged_with_log_mask_only() {
6176 use crate::server::recgbl::EventMask;
6177 let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
6178 let _s1_rx = inst
6179 .add_subscriber(
6180 "S1",
6181 1,
6182 crate::types::DbFieldType::Long,
6183 EventMask::LOG.bits(),
6184 )
6185 .expect("S1 subscriber");
6186 let _s2_rx = inst
6187 .add_subscriber(
6188 "S2",
6189 2,
6190 crate::types::DbFieldType::Long,
6191 EventMask::VALUE.bits(),
6192 )
6193 .expect("S2 subscriber");
6194 let names = |snap: &ProcessSnapshot| {
6195 snap.changed_fields
6196 .iter()
6197 .map(|(n, _, _)| n.clone())
6198 .collect::<Vec<_>>()
6199 };
6200 let count_of = |snap: &ProcessSnapshot, f: &str| {
6201 snap.changed_fields
6202 .iter()
6203 .filter(|(n, _, _)| n == f)
6204 .count()
6205 };
6206 let mask_of = |snap: &ProcessSnapshot, f: &str| {
6207 snap.changed_fields
6208 .iter()
6209 .find(|(n, _, _)| n == f)
6210 .map(|(_, _, m)| *m)
6211 };
6212
6213 // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
6214 // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
6215 let (snap1, _) = inst.process_local().unwrap();
6216 assert!(
6217 names(&snap1).contains(&"S1".to_string()),
6218 "log-swept S1 must re-post when unchanged: {:?}",
6219 names(&snap1)
6220 );
6221 assert!(
6222 !names(&snap1).contains(&"S2".to_string()),
6223 "unchanged non-swept S2 must not re-post: {:?}",
6224 names(&snap1)
6225 );
6226 // DBE_LOG, plus the DBE_ALARM of this cycle's transition: a record
6227 // starts UDF/INVALID and its first process clears that, so cycle 1 IS an
6228 // alarm transition (CBUG-B19 — C's sweep drops the alarm bit; this
6229 // assertion used to require a bare DBE_LOG). No DBE_VALUE either way:
6230 // the counts have not moved.
6231 assert_eq!(
6232 mask_of(&snap1, "S1").unwrap().bits(),
6233 (EventMask::LOG | EventMask::ALARM).bits(),
6234 "idle sweep posts DBE_LOG + the alarm transition, never DBE_VALUE"
6235 );
6236
6237 // Cycle 2: S1's count changed. Change-detection delivers it, and the
6238 // sweep delivers it AGAIN with DBE_LOG — the two C `db_post_events`
6239 // calls of the count-completion cycle.
6240 inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
6241 let (snap2, _) = inst.process_local().unwrap();
6242 assert_eq!(
6243 count_of(&snap2, "S1"),
6244 2,
6245 "a changed swept field posts twice — change post + independent \
6246 DBE_LOG sweep: {:?}",
6247 snap2.changed_fields
6248 );
6249 let s1_masks: Vec<u16> = snap2
6250 .changed_fields
6251 .iter()
6252 .filter(|(n, _, _)| n == "S1")
6253 .map(|(_, _, m)| m.bits())
6254 .collect();
6255 assert_eq!(
6256 s1_masks,
6257 vec![
6258 (EventMask::VALUE | EventMask::LOG).bits(),
6259 EventMask::LOG.bits()
6260 ],
6261 "change post first (VALUE|LOG here — this stub is not a \
6262 value_only_change_fields record), then the sweep's literal DBE_LOG"
6263 );
6264
6265 // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
6266 let (snap3, _) = inst.process_local().unwrap();
6267 assert_eq!(
6268 mask_of(&snap3, "S1").unwrap().bits(),
6269 EventMask::LOG.bits(),
6270 "unchanged-again S1 returns to the DBE_LOG-only sweep"
6271 );
6272 }
6273
6274 /// A log-swept record that can raise an alarm on demand — the scaler's
6275 /// `do_alarm()` (scalerRecord.c:745-755) in miniature.
6276 struct AlarmingLogSweepRecord {
6277 s1: i32,
6278 alarm: bool,
6279 }
6280
6281 impl Record for AlarmingLogSweepRecord {
6282 fn record_type(&self) -> &'static str {
6283 "scaler"
6284 }
6285 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6286 Ok(crate::server::record::ProcessOutcome::complete())
6287 }
6288 /// This fixture drives its alarm purely through `check_alarms`
6289 /// (`self.alarm`), so it must NOT also raise the central UDF alarm —
6290 /// otherwise the born `udf = 1` pins severity at INVALID every cycle
6291 /// and there is never a real NO_ALARM → INVALID transition to test.
6292 /// (Before `rec_gbl_check_udf` stopped fabricating a UDF message, the
6293 /// ALARM bit this test asserts came from that fabricated amsg
6294 /// flipping to "" — an artifact, not the severity transition the
6295 /// test name and comments describe.) With no UDF alarm, cycle 1
6296 /// genuinely clears the born UDF/INVALID to NO_ALARM, and the
6297 /// `self.alarm` cycle is a true severity transition.
6298 fn raises_udf_alarm(&self) -> bool {
6299 false
6300 }
6301 fn check_alarms(&mut self, common: &mut crate::server::record::CommonFields) {
6302 if self.alarm {
6303 crate::server::recgbl::rec_gbl_set_sevr(
6304 common,
6305 crate::server::recgbl::alarm_status::UDF_ALARM,
6306 crate::server::record::AlarmSeverity::Invalid,
6307 );
6308 }
6309 }
6310 fn get_field(&self, name: &str) -> Option<EpicsValue> {
6311 match name {
6312 "S1" => Some(EpicsValue::Long(self.s1)),
6313 _ => None,
6314 }
6315 }
6316 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
6317 match (name, value) {
6318 ("S1", EpicsValue::Long(v)) => {
6319 self.s1 = v;
6320 Ok(())
6321 }
6322 _ => Err(CaError::FieldNotFound(name.to_string())),
6323 }
6324 }
6325 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6326 &[]
6327 }
6328 fn log_swept_fields(&self) -> &'static [&'static str] {
6329 &["S1"]
6330 }
6331 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
6332 Some(self)
6333 }
6334 }
6335
6336 /// CBUG-B19 — the sweep post carries the alarm-transition bits.
6337 ///
6338 /// DEVIATION from C, deliberate. C's scaler `monitor()` computes
6339 /// `monitor_mask = recGblResetAlarms(pscal)` (scalerRecord.c:764), ORs
6340 /// `DBE_VALUE|DBE_LOG` into it (`:766`), and then posts every `Sn` with a
6341 /// LITERAL `DBE_LOG` (`:771`) — `monitor_mask` is assigned, OR-ed, and never
6342 /// read. The alarm bit that `recGblResetAlarms` returns is exactly what every
6343 /// other record ORs into its value posts, so C drops it: a client subscribed
6344 /// to `Sn` with DBE_ALARM receives NOTHING on a severity transition.
6345 ///
6346 /// The DBE_VALUE half of C's dead `|=` is deliberately not resurrected — the
6347 /// sweep is unconditional, so a VALUE bit here would fire a value event on
6348 /// every idle scan whether or not the counts moved. The first assertion pins
6349 /// that.
6350 #[test]
6351 fn b19_log_swept_field_carries_the_alarm_transition_bits() {
6352 use crate::server::recgbl::EventMask;
6353 let mut inst = RecordInstance::new(
6354 "SW".to_string(),
6355 AlarmingLogSweepRecord {
6356 s1: 7,
6357 alarm: false,
6358 },
6359 );
6360 let _s1_rx = inst
6361 .add_subscriber(
6362 "S1",
6363 1,
6364 crate::types::DbFieldType::Long,
6365 (EventMask::LOG | EventMask::ALARM).bits(),
6366 )
6367 .expect("S1 subscriber");
6368 let mask_of = |snap: &ProcessSnapshot, f: &str| {
6369 snap.changed_fields
6370 .iter()
6371 .find(|(n, _, _)| n == f)
6372 .map(|(_, _, m)| *m)
6373 };
6374
6375 // Cycle 1 clears the record's initial UDF/INVALID alarm, which is itself
6376 // a transition; cycle 2 is the quiet baseline. The sweep is then DBE_LOG
6377 // alone — in particular NOT DBE_VALUE, since the counts have not moved.
6378 let _ = inst.process_local().unwrap();
6379 let (snap1, _) = inst.process_local().unwrap();
6380 assert_eq!(
6381 mask_of(&snap1, "S1").unwrap().bits(),
6382 EventMask::LOG.bits(),
6383 "no alarm transition → the sweep is DBE_LOG only"
6384 );
6385
6386 // The alarm fires: severity moves NO_ALARM → INVALID, so this cycle's
6387 // posts carry DBE_ALARM. C posts DBE_LOG here and the alarm subscriber
6388 // learns nothing.
6389 if let Some(r) = inst
6390 .record
6391 .as_any_mut()
6392 .and_then(|a| a.downcast_mut::<AlarmingLogSweepRecord>())
6393 {
6394 r.alarm = true;
6395 }
6396 let (snap2, _) = inst.process_local().unwrap();
6397 assert_eq!(
6398 mask_of(&snap2, "S1").unwrap().bits(),
6399 (EventMask::LOG | EventMask::ALARM).bits(),
6400 "the severity transition must reach the swept field (C drops it)"
6401 );
6402
6403 // Severity stays INVALID: no transition, so no alarm bit — the sweep is
6404 // DBE_LOG again.
6405 let (snap3, _) = inst.process_local().unwrap();
6406 assert_eq!(
6407 mask_of(&snap3, "S1").unwrap().bits(),
6408 EventMask::LOG.bits(),
6409 "a steady severity is not a transition"
6410 );
6411 }
6412
6413 /// Stub record that simulates a record whose process() mutates an
6414 /// internal metadata field. Used to verify that the
6415 /// `Record::took_metadata_change()` hook actually triggers cache
6416 /// invalidation in `process_local()`.
6417 struct MutatingMetaRecord {
6418 val: f64,
6419 egu: String,
6420 took_change: bool,
6421 }
6422
6423 impl Record for MutatingMetaRecord {
6424 fn record_type(&self) -> &'static str {
6425 "ai" // pretend to be ai so populate_display_info populates EGU
6426 }
6427 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6428 // Simulate dynamic metadata change inside processing
6429 self.egu = "kV".into();
6430 self.took_change = true;
6431 Ok(crate::server::record::ProcessOutcome::complete())
6432 }
6433 fn get_field(&self, name: &str) -> Option<EpicsValue> {
6434 match name {
6435 "VAL" => Some(EpicsValue::Double(self.val)),
6436 "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
6437 "PREC" => Some(EpicsValue::Short(0)),
6438 "HOPR" => Some(EpicsValue::Double(0.0)),
6439 "LOPR" => Some(EpicsValue::Double(0.0)),
6440 _ => None,
6441 }
6442 }
6443 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
6444 match (name, value) {
6445 ("VAL", EpicsValue::Double(v)) => {
6446 self.val = v;
6447 Ok(())
6448 }
6449 ("EGU", EpicsValue::String(s)) => {
6450 self.egu = s.as_str_lossy().into_owned();
6451 Ok(())
6452 }
6453 _ => Err(CaError::FieldNotFound(name.to_string())),
6454 }
6455 }
6456 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6457 &[]
6458 }
6459 fn took_metadata_change(&mut self) -> bool {
6460 let was = self.took_change;
6461 self.took_change = false; // reset after reporting
6462 was
6463 }
6464 }
6465
6466 #[test]
6467 fn process_local_invalidates_cache_on_took_metadata_change() {
6468 let mut inst = RecordInstance::new(
6469 "MUT".to_string(),
6470 MutatingMetaRecord {
6471 val: 1.0,
6472 egu: "V".to_string(),
6473 took_change: false,
6474 },
6475 );
6476
6477 // Build the cache once with the original EGU
6478 let snap1 = inst.snapshot_for_field("VAL").unwrap();
6479 assert_eq!(snap1.display.unwrap().units, "V");
6480 assert!(inst.metadata_cache.lock().unwrap().is_some());
6481
6482 // Run process_local — the stub record sets took_change inside process()
6483 let _ = inst.process_local();
6484
6485 // Cache should now be invalidated (took_metadata_change returned true)
6486 assert!(
6487 inst.metadata_cache.lock().unwrap().is_none(),
6488 "process_local should invalidate cache when took_metadata_change is true"
6489 );
6490
6491 // Next snapshot picks up the new EGU
6492 let snap2 = inst.snapshot_for_field("VAL").unwrap();
6493 assert_eq!(snap2.display.unwrap().units, "kV");
6494 }
6495
6496 /// Stub record that does NOT mutate metadata fields. Verifies the
6497 /// default `took_metadata_change` returns false and the cache stays.
6498 struct StableMetaRecord {
6499 val: f64,
6500 }
6501 impl Record for StableMetaRecord {
6502 fn record_type(&self) -> &'static str {
6503 "ai"
6504 }
6505 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6506 self.val += 1.0;
6507 Ok(crate::server::record::ProcessOutcome::complete())
6508 }
6509 fn get_field(&self, name: &str) -> Option<EpicsValue> {
6510 match name {
6511 "VAL" => Some(EpicsValue::Double(self.val)),
6512 "EGU" => Some(EpicsValue::String("V".into())),
6513 "PREC" => Some(EpicsValue::Short(0)),
6514 "HOPR" => Some(EpicsValue::Double(0.0)),
6515 "LOPR" => Some(EpicsValue::Double(0.0)),
6516 _ => None,
6517 }
6518 }
6519 fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
6520 Ok(())
6521 }
6522 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6523 &[]
6524 }
6525 // took_metadata_change uses default impl (returns false)
6526 }
6527
6528 #[test]
6529 fn process_local_keeps_cache_when_no_metadata_change() {
6530 let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
6531
6532 let _ = inst.snapshot_for_field("VAL");
6533 assert!(inst.metadata_cache.lock().unwrap().is_some());
6534
6535 // Run process_local several times — cache should remain intact
6536 let _ = inst.process_local();
6537 assert!(inst.metadata_cache.lock().unwrap().is_some());
6538 let _ = inst.process_local();
6539 assert!(inst.metadata_cache.lock().unwrap().is_some());
6540 let _ = inst.process_local();
6541 assert!(inst.metadata_cache.lock().unwrap().is_some());
6542 }
6543
6544 // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
6545
6546 /// motor `prop(YES)` fields (motorRecord.dbd 154/161/289/361/368)
6547 /// are property-class: a changed write must post DBE_PROPERTY
6548 /// (C dbAccess.c dbPut, `pfldDes->prop`). They feed the
6549 /// live-computed `field_metadata_override`, not the cache, but the
6550 /// posting gate is this same set.
6551 #[test]
6552 fn motor_prop_yes_fields_are_property_class() {
6553 for f in ["VBAS", "VMAX", "MRES", "DHLM", "DLLM"] {
6554 assert!(is_metadata_field(f), "{f} must be property-class");
6555 }
6556 }
6557
6558 /// Boundary 1: metadata field written with a CHANGED value, subscriber
6559 /// mask includes PROPERTY → subscriber receives an event.
6560 /// Mirrors C dbAccess.c:1396-1397 `db_post_events(precord,NULL,DBE_PROPERTY)`.
6561 #[test]
6562 fn r47_property_event_delivered_on_changed_metadata() {
6563 use crate::server::recgbl::EventMask;
6564 let mut inst = ai_instance();
6565 let mut rx = inst
6566 .add_subscriber(
6567 "VAL",
6568 1,
6569 crate::types::DbFieldType::Double,
6570 EventMask::PROPERTY.bits(),
6571 )
6572 .expect("subscriber added");
6573
6574 let prev = inst.record.get_field("EGU"); // "degC"
6575 let _ = inst
6576 .record
6577 .put_field("EGU", EpicsValue::String("kPa".into()));
6578 inst.notify_field_written_if_changed("EGU", prev.as_ref());
6579
6580 assert!(
6581 rx.try_recv().is_ok(),
6582 "PROPERTY subscriber must receive event when metadata field changes"
6583 );
6584 }
6585
6586 /// Boundary 2: same metadata field written with the SAME value → NO event.
6587 /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
6588 #[test]
6589 fn r47_no_event_on_unchanged_metadata() {
6590 use crate::server::recgbl::EventMask;
6591 let mut inst = ai_instance();
6592 let mut rx = inst
6593 .add_subscriber(
6594 "VAL",
6595 1,
6596 crate::types::DbFieldType::Double,
6597 EventMask::PROPERTY.bits(),
6598 )
6599 .expect("subscriber added");
6600
6601 let prev = inst.record.get_field("EGU"); // "degC"
6602 // Write the same value — no change
6603 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
6604 inst.notify_field_written_if_changed("EGU", prev.as_ref());
6605
6606 assert!(
6607 rx.try_recv().is_err(),
6608 "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
6609 );
6610 }
6611
6612 /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
6613 /// from a metadata write, even when the field value changed.
6614 #[test]
6615 fn r47_value_only_subscriber_no_event_on_metadata_write() {
6616 use crate::server::recgbl::EventMask;
6617 let mut inst = ai_instance();
6618 let mut rx = inst
6619 .add_subscriber(
6620 "VAL",
6621 1,
6622 crate::types::DbFieldType::Double,
6623 EventMask::VALUE.bits(),
6624 )
6625 .expect("subscriber added");
6626
6627 let prev = inst.record.get_field("EGU"); // "degC"
6628 let _ = inst
6629 .record
6630 .put_field("EGU", EpicsValue::String("kPa".into()));
6631 inst.notify_field_written_if_changed("EGU", prev.as_ref());
6632
6633 assert!(
6634 rx.try_recv().is_err(),
6635 "VALUE-only subscriber must NOT receive event from a metadata write"
6636 );
6637 }
6638
6639 /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
6640 /// event after process_local() when the record reports a metadata change.
6641 #[test]
6642 fn r47_process_local_property_event_on_took_metadata_change() {
6643 use crate::server::recgbl::EventMask;
6644 let mut inst = RecordInstance::new(
6645 "MUT2".to_string(),
6646 MutatingMetaRecord {
6647 val: 1.0,
6648 egu: "V".to_string(),
6649 took_change: false,
6650 },
6651 );
6652 let mut rx = inst
6653 .add_subscriber(
6654 "VAL",
6655 1,
6656 crate::types::DbFieldType::Double,
6657 EventMask::PROPERTY.bits(),
6658 )
6659 .expect("subscriber added");
6660
6661 // process() sets took_change = true and updates egu to "kV"
6662 let _ = inst.process_local();
6663
6664 assert!(
6665 rx.try_recv().is_ok(),
6666 "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
6667 );
6668 }
6669}
6670
6671#[cfg(test)]
6672mod aftc_filter_tests {
6673 //! Tests for the shared AFTC alarm-range filter
6674 //! (`records::alarm_filter::aftc_filter`) as driven by
6675 //! `evaluate_analog_alarm`. Pure-function tests: no record instance
6676 //! needed — the filter is a stateless transform of (raw_alarm, aftc,
6677 //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
6678 //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
6679
6680 use crate::server::records::alarm_filter::aftc_filter;
6681 use std::time::{Duration, SystemTime};
6682
6683 fn at(secs: f64) -> SystemTime {
6684 SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
6685 }
6686
6687 #[test]
6688 fn disabled_when_aftc_le_zero() {
6689 // aftc=0 means filter disabled — pass-through.
6690 let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
6691 assert_eq!(out, 2);
6692 assert_eq!(afvl, 0.0);
6693 }
6694
6695 #[test]
6696 fn initial_sample_seeds_state_unchanged_alarm() {
6697 // afvl=0 means first sample after enable — alarm passes through
6698 // and accumulator seeds with the raw severity.
6699 let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
6700 assert_eq!(out, 2);
6701 assert_eq!(afvl, 2.0);
6702 }
6703
6704 #[test]
6705 fn raises_alarm_only_after_full_time_constant() {
6706 // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
6707 // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
6708 // produce alarm=2 yet — the filter must hold off until the
6709 // accumulator crosses the threshold.
6710 // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
6711 // yet"); the filter must keep alarm at 0 after one short tick.
6712 let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
6713 assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
6714 assert!(afvl > 0.0 && afvl < 2.0);
6715 }
6716
6717 #[test]
6718 fn dt_zero_is_no_op() {
6719 // Two evaluations at the same instant produce no filter advance.
6720 let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
6721 assert_eq!(out, 1); // floor(|1.5|) = 1
6722 assert_eq!(afvl, 1.5);
6723 }
6724
6725 #[test]
6726 fn long_steady_state_converges_to_alarm() {
6727 // After many steps with raw_alarm=2 and dt much smaller than aftc,
6728 // the accumulator must converge towards 2.
6729 let aftc = 1.0;
6730 let mut afvl = 0.0;
6731 let mut last = at(0.0);
6732 let mut alarm = 0;
6733 for i in 1..=100 {
6734 let now = at(i as f64 * 0.05);
6735 let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
6736 alarm = out;
6737 afvl = new_afvl;
6738 last = now;
6739 }
6740 assert_eq!(
6741 alarm, 2,
6742 "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
6743 );
6744 assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
6745 }
6746}
6747
6748#[cfg(test)]
6749mod check_deadband_tests {
6750 use super::check_deadband;
6751
6752 /// Sentinel: `oldval=NaN` means "no prior posting", always fire.
6753 #[test]
6754 fn nan_old_value_fires() {
6755 assert!(check_deadband(0.0, f64::NAN, 1.0));
6756 assert!(check_deadband(f64::NAN, f64::NAN, 1.0));
6757 }
6758
6759 /// C path: `delta > deadband` with both finite. delta within deadband
6760 /// must NOT fire.
6761 #[test]
6762 fn within_finite_deadband_does_not_fire() {
6763 assert!(!check_deadband(10.0, 10.5, 1.0));
6764 assert!(!check_deadband(10.0, 9.5, 1.0));
6765 // Boundary: `delta == deadband` is NOT strictly greater.
6766 assert!(!check_deadband(10.0, 11.0, 1.0));
6767 }
6768
6769 /// `delta > deadband` with both finite, beyond → fire.
6770 #[test]
6771 fn beyond_finite_deadband_fires() {
6772 assert!(check_deadband(10.0, 12.0, 1.0));
6773 }
6774
6775 /// Negative deadband acts as "always fire" (C `delta > deadband` is
6776 /// trivially true for any non-negative delta).
6777 #[test]
6778 fn negative_deadband_fires() {
6779 assert!(check_deadband(10.0, 10.0, -1.0));
6780 }
6781
6782 /// C parity bug fix (recGbl.c:355-358): exactly one of {newval,
6783 /// oldval} is NaN — fire. Rust port previously short-circuited only
6784 /// on `oldval=NaN`; `newval=NaN` with `oldval=finite` produced
6785 /// `(NaN - finite).abs() = NaN`, `NaN > deadband = false` →
6786 /// silently dropped the NaN transition. End effect: a record that
6787 /// went UDF (e.g. divide-by-zero in calc) never posted the change
6788 /// to monitors, leaving every camonitor seeing the last valid value.
6789 #[test]
6790 fn newval_nan_with_finite_oldval_fires() {
6791 assert!(check_deadband(f64::NAN, 10.0, 1.0));
6792 }
6793
6794 /// C path case 2 (recGbl.c:355): exactly one infinite, the other
6795 /// finite — fire.
6796 #[test]
6797 fn one_finite_one_infinite_fires() {
6798 assert!(check_deadband(f64::INFINITY, 10.0, 1.0));
6799 assert!(check_deadband(10.0, f64::INFINITY, 1.0));
6800 assert!(check_deadband(f64::NEG_INFINITY, 10.0, 1.0));
6801 }
6802
6803 /// C path case 3 (recGbl.c:360-362): both infinite with opposite
6804 /// signs — fire.
6805 #[test]
6806 fn opposite_signed_infinities_fire() {
6807 assert!(check_deadband(f64::INFINITY, f64::NEG_INFINITY, 1.0));
6808 assert!(check_deadband(f64::NEG_INFINITY, f64::INFINITY, 1.0));
6809 }
6810
6811 /// Same-signed infinity → no fire (C path leaves `delta = 0`,
6812 /// `0 > deadband` is false for any non-negative deadband).
6813 #[test]
6814 fn same_signed_infinity_does_not_fire() {
6815 assert!(!check_deadband(f64::INFINITY, f64::INFINITY, 1.0));
6816 assert!(!check_deadband(f64::NEG_INFINITY, f64::NEG_INFINITY, 1.0));
6817 }
6818}
6819
6820#[cfg(test)]
6821mod common_field_dbload_tests {
6822 use super::*;
6823 use crate::server::records::ai::AiRecord;
6824
6825 /// The db loader feeds every common field to `put_common_field` as an
6826 /// `EpicsValue::String`. Each numeric/menu common field directive must
6827 /// take effect at load — both the integer form (`field(PHAS, "1")`) and
6828 /// the menu-label form (`field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`) —
6829 /// rather than being silently dropped because the arm matched only its
6830 /// typed variant. One assertion per affected common-field arm.
6831 #[test]
6832 fn db_loaded_string_common_fields_take_effect() {
6833 let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
6834 let put = |inst: &mut RecordInstance, f: &str, v: &str| {
6835 inst.put_common_field_db_load(f, EpicsValue::String(v.into()))
6836 .unwrap_or_else(|e| panic!("put_common_field_db_load({f}, {v:?}) failed: {e}"));
6837 };
6838
6839 // Integer-valued directives.
6840 put(&mut inst, "PHAS", "1");
6841 assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
6842 put(&mut inst, "TSE", "-2");
6843 assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
6844 put(&mut inst, "DISV", "1");
6845 assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
6846 put(&mut inst, "DISA", "1");
6847 assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
6848 put(&mut inst, "LCNT", "3");
6849 assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
6850 put(&mut inst, "DISP", "1");
6851 assert!(inst.common.disp != 0, "field(DISP, \"1\")");
6852 put(&mut inst, "UDF", "0");
6853 assert!(inst.common.udf == 0, "field(UDF, \"0\")");
6854
6855 // Menu-label directives (resolved via the one menu converter).
6856 put(&mut inst, "PRIO", "HIGH");
6857 assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
6858 put(&mut inst, "DISS", "MAJOR");
6859 assert_eq!(
6860 inst.common.diss,
6861 AlarmSeverity::Major as i16,
6862 "field(DISS, \"MAJOR\")"
6863 );
6864 put(&mut inst, "UDFS", "NO_ALARM");
6865 assert_eq!(
6866 inst.common.udfs,
6867 AlarmSeverity::NoAlarm as i16,
6868 "field(UDFS, \"NO_ALARM\")"
6869 );
6870 put(&mut inst, "ACKT", "NO");
6871 assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
6872
6873 // Numeric form of a menu field still works (field(PRIO, "0")).
6874 put(&mut inst, "PRIO", "0");
6875 assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
6876
6877 // A String-typed common field is untouched by the coercion.
6878 put(&mut inst, "DESC", "a description");
6879 assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
6880 }
6881}
6882
6883#[cfg(test)]
6884mod declared_override_tests {
6885 use super::*;
6886 use crate::server::records::dfanout::DfanoutRecord;
6887
6888 /// A field `dfanout`'s `.dbd` DECLARES (HOPR/LOPR/PREC/EGU) but the
6889 /// `DfanoutRecord` struct models no storage for: a put must be ACCEPTED and
6890 /// stored (C `dbPut` writes it into record memory), and a later
6891 /// `resolve_field` must serve the written value — not the `.dbd` initial.
6892 #[test]
6893 fn declared_but_unmodeled_field_put_is_stored_and_served() {
6894 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
6895
6896 // Untouched: reads its declared default (initial / type-zero), NOT an
6897 // error, and the override store is empty.
6898 assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(0.0)));
6899 assert!(inst.declared_overrides.is_empty());
6900
6901 // DBF_DOUBLE, DBF_SHORT and DBF_STRING declared metadata fields all
6902 // land, coerced to the declared type.
6903 inst.put_common_field("HOPR", EpicsValue::String("10".into()))
6904 .expect("caput dfanout.HOPR 10 must be accepted");
6905 inst.put_common_field("PREC", EpicsValue::String("3".into()))
6906 .expect("caput dfanout.PREC 3 must be accepted");
6907 inst.put_common_field("EGU", EpicsValue::String("volts".into()))
6908 .expect("caput dfanout.EGU volts must be accepted");
6909
6910 assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(10.0)));
6911 assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
6912 assert_eq!(
6913 inst.resolve_field("EGU"),
6914 Some(EpicsValue::String("volts".into()))
6915 );
6916 // Case-insensitive key: the lower-case read reaches the same slot.
6917 assert_eq!(inst.resolve_field("hopr"), Some(EpicsValue::Double(10.0)));
6918 }
6919
6920 /// The declared type's C range rules apply through the write-side coercion
6921 /// owner: `caput dfanout.PREC 99999` into a `DBF_SHORT` is REFUSED (C
6922 /// `epicsParseInt16` overflow → `S_db_badField`), and the field keeps its
6923 /// prior value — never wraps to a garbage `Short`.
6924 #[test]
6925 fn declared_override_honors_declared_type_range() {
6926 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
6927 inst.put_common_field("PREC", EpicsValue::String("3".into()))
6928 .expect("in-range PREC accepted");
6929 assert!(
6930 inst.put_common_field("PREC", EpicsValue::String("99999".into()))
6931 .is_err(),
6932 "PREC 99999 overflows DBF_SHORT and must be refused"
6933 );
6934 assert!(
6935 inst.put_common_field("PREC", EpicsValue::String("abc".into()))
6936 .is_err(),
6937 "non-numeric PREC must be refused"
6938 );
6939 // The refused puts left the accepted value intact.
6940 assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
6941 }
6942
6943 /// An UNDECLARED field name is still `FieldNotFound` — the override store
6944 /// captures only fields with a real `dbFldDes`, so a misspelled field is
6945 /// refused exactly as C's `dbNameToAddr` refuses it.
6946 #[test]
6947 fn undeclared_field_is_still_not_found() {
6948 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
6949 assert!(matches!(
6950 inst.put_common_field("XYZZY", EpicsValue::String("1".into())),
6951 Err(CaError::FieldNotFound(_))
6952 ));
6953 assert!(inst.declared_overrides.is_empty());
6954 }
6955
6956 /// A PARTIALLY modeled field — one the record SERVES via `get_field` but
6957 /// has no `put_field` arm for (`calcout.PVAL` → `self.pval`) — must NOT
6958 /// land in the override map: doing so would place the value where
6959 /// `resolve_field` (which reads `get_field` first) never sees it, a silent
6960 /// write loss. The override is only for fields the record serves nothing
6961 /// for; a partially modeled field's put is the record's own concern.
6962 #[test]
6963 fn partially_modeled_field_is_not_captured_by_override() {
6964 use crate::server::records::calcout::CalcoutRecord;
6965 let mut inst = RecordInstance::new("CO".to_string(), CalcoutRecord::default());
6966 // PVAL is served by the record (its own storage), so it is not stored
6967 // in the override map; the map stays empty and no ghost cell shadows
6968 // the record's read.
6969 let _ = inst.put_common_field("PVAL", EpicsValue::String("1".into()));
6970 assert!(
6971 inst.declared_overrides.is_empty(),
6972 "a field the record serves via get_field must not enter the override map"
6973 );
6974 }
6975}