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::runtime::sync::mpsc;
7
8use crate::error::{CaError, CaResult};
9use crate::server::pv::{MonitorEvent, Subscriber};
10use crate::server::snapshot::{ControlInfo, DisplayInfo, EnumInfo};
11use crate::types::{DbFieldType, EpicsValue, PvString};
12
13use super::alarm::{AlarmSeverity, AnalogAlarmConfig};
14use super::common_fields::CommonFields;
15use super::link::{ParsedLink, parse_link_v2, parse_output_link_v2};
16use super::record_trait::{
17 CommonFieldPutResult, ProcessSnapshot, Record, RecordProcessResult, SubroutineFn,
18};
19use super::scan::{ScanType, SimModeScan};
20
21/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
22/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
23///
24/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
25/// originating (put-target) record AND every record reached through its
26/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
27/// has finished processing. A single wait-set owns the completion
28/// oneshot; only it fires, and only when the last chain member leaves.
29///
30/// Counting convention: [`Self::new`] arms `pending = 1` for the
31/// originating record (which always joins). Every additional PP target
32/// that will process under the active notify [`Self::enter`]s on join
33/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
34/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
35/// the `leave` that drops `pending` to zero.
36pub struct NotifyWaitSet {
37 pending: AtomicUsize,
38 tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
39}
40
41impl NotifyWaitSet {
42 /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
43 /// starts at 1 for the originating record — its completion `leave`s
44 /// that implicit slot, so a put with no chain targets fires
45 /// immediately on the originating record's own completion.
46 pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
47 Arc::new(Self {
48 pending: AtomicUsize::new(1),
49 tx: StdMutex::new(Some(tx)),
50 })
51 }
52
53 /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
54 /// one [`Self::leave`].
55 pub fn enter(&self) {
56 self.pending.fetch_add(1, Ordering::AcqRel);
57 }
58
59 /// A record finished its contribution (C `dbNotifyCompletion`). Fires
60 /// the completion oneshot on the `leave` that empties the set.
61 pub fn leave(&self) {
62 let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
63 debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
64 if prev == 1 {
65 if let Some(tx) = self.tx.lock().unwrap().take() {
66 let _ = tx.send(());
67 }
68 }
69 }
70
71 /// True once every chain member has left (the completion has fired).
72 /// Used by the put entry to decide synchronous (return `None`) vs
73 /// async-pending (return the receiver) completion.
74 pub fn completed(&self) -> bool {
75 self.pending.load(Ordering::Acquire) == 0
76 }
77}
78
79/// Cached metadata for a record.
80///
81/// Stores the result of `populate_display_info` / `populate_control_info` /
82/// `populate_enum_info` so subsequent `snapshot_for_field` /
83/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
84/// cache is invalidated whenever a metadata-class field is written
85/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
86///
87/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
88/// every snapshot needs full metadata for NTScalar serialization, the
89/// cache eliminates redundant per-event populate work.
90#[derive(Clone, Default)]
91pub(crate) struct MetadataSnapshot {
92 pub display: Option<DisplayInfo>,
93 pub control: Option<ControlInfo>,
94 pub enums: Option<EnumInfo>,
95}
96
97/// Returns true if this field is property-class — the C `prop(YES)`
98/// dbd attribute: writing a changed value posts `DBE_PROPERTY` to the
99/// record's subscribers AND invalidates the metadata cache. Field name
100/// is expected uppercase.
101///
102/// **Every field read by `populate_display_info`,
103/// `populate_control_info`, or `populate_enum_info` MUST be in this
104/// set** — otherwise the cache serves stale metadata until some other
105/// tracked field is written. The reverse does not hold: a field may be
106/// property-class without being a cache source (e.g. the motor fields
107/// below feed the live-computed `field_metadata_override`, never the
108/// cache — its invalidation on their write is harmless).
109///
110/// Currently uncovered (because it is not yet populated by any
111/// `populate_*` function): `DESC` (would map to `display.description`
112/// — populate hook missing). The `Q:form` info tag is now wired
113/// (`populate_display_info` -> `display.form`), but as an immutable
114/// load-time info tag — not a runtime field — it needs no cache
115/// invalidation and so is intentionally absent from this field set.
116fn is_metadata_field(name: &str) -> bool {
117 matches!(
118 name,
119 // Display info (analog + integer + motor) — `prop(YES)` in
120 // ai/ao/longin/longout DBDs.
121 "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
122 // Alarm limits (used by both display and the analog_alarm config) —
123 // ai/ao/longin/longout `prop(YES)`.
124 | "HIHI" | "HIGH" | "LOW" | "LOLO"
125 // Alarm severities for the four limit thresholds —
126 // ai/ao/longin/longout `prop(YES)` per upstream DBDs
127 // (`aiRecord.dbd.pod` lines 357-388).
128 | "HHSV" | "HSV" | "LSV" | "LLSV"
129 // Output ctrl limits — ao/longout `prop(YES)`.
130 | "DRVH" | "DRVL"
131 // motor `prop(YES)` (`motorRecord.dbd` 154/161/289/361/368):
132 // VBAS/VMAX bound VELO's range, MRES the RVAL/RRBV raw range,
133 // DHLM/DLLM the DVAL/DRBV range — all served per field by
134 // `Record::field_metadata_override` (C get_graphic_double /
135 // get_control_double). HLM/LLM/EGU/PREC and the alarm limits
136 // are motor `prop(YES)` too, already listed above.
137 | "VBAS" | "VMAX" | "MRES" | "DHLM" | "DLLM"
138 // bi/bo/busy enum strings — `prop(YES)`.
139 | "ZNAM" | "ONAM"
140 // bi/bo state severities — `biRecord.dbd.pod` / `boRecord.dbd.pod`
141 // `prop(YES)` for ZSV/OSV/COSV (zero / one / change-of-state).
142 | "ZSV" | "OSV" | "COSV"
143 // mbbi/mbbo state strings (16 levels) — `prop(YES)`.
144 | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
145 | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
146 )
147}
148
149/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
150/// severity threshold is enabled, `NaN` otherwise. Mirrors C
151/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN`.
152fn gated(severity: AlarmSeverity, limit: f64) -> f64 {
153 if severity != AlarmSeverity::NoAlarm {
154 limit
155 } else {
156 f64::NAN
157 }
158}
159
160fn parse_alarm_severity(value: &EpicsValue) -> AlarmSeverity {
161 match value {
162 EpicsValue::Short(v) => AlarmSeverity::from_u16(*v as u16),
163 EpicsValue::String(s) => AlarmSeverity::from_u16(match s.as_str_lossy().as_ref() {
164 "NO_ALARM" => 0,
165 "MINOR" => 1,
166 "MAJOR" => 2,
167 "INVALID" => 3,
168 other => other.parse::<u16>().unwrap_or(0),
169 }),
170 other => AlarmSeverity::from_u16(other.to_f64().unwrap_or(0.0) as u16),
171 }
172}
173
174/// Coerce a db-loaded `String` for a numeric/menu **common** field to that
175/// field's canonical DBF type before [`RecordInstance::put_common_field`]
176/// dispatches on it.
177///
178/// The db loader applies a record's own fields with the typed
179/// `EpicsValue::parse(desc.dbf_type, value_str)` (`db_loader::apply_fields`),
180/// but a field absent from `field_list` is pushed to the common-field path as
181/// a raw `EpicsValue::String` — it has no `FieldDesc` to parse against. The
182/// numeric common-field arms in `put_common_field` match only their typed
183/// variant, so without this step a `.db` `field(PHAS, "1")`,
184/// `field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`, `field(DISA, "1")`, … is
185/// silently dropped at IOC load. Routing the String through the same
186/// `EpicsValue::parse` the record-field path uses handles the numeric *and*
187/// menu-label forms uniformly, so the arm receives the value it expects.
188///
189/// Only fields whose canonical type is numeric/menu are listed; the
190/// String-typed common fields (DESC, ASG, OUT, TSEL, …) and already-typed
191/// non-String writes pass through untouched, and an unparseable String is
192/// returned as-is so the arm drops it exactly as before. The runtime
193/// alarm-output fields (SEVR/STAT/NSEV/NSTA/ACKS) and debug flags
194/// (RPRO/TPRO/BKPT) are deliberately omitted: they are recomputed every
195/// process, not `.db` init directives, so coercing a loaded value would be
196/// overwritten immediately.
197fn coerce_common_field_string(name: &str, value: EpicsValue) -> EpicsValue {
198 let s = match &value {
199 EpicsValue::String(s) => s,
200 _ => return value,
201 };
202 // Canonical DBF type per numeric/menu common field, chosen to match the
203 // variant its `put_common_field` arm binds (e.g. ACKT/UDFS resolve their
204 // menu labels through the `Short` branch's `resolve_menu_string`).
205 let dbf = match name {
206 "TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" => {
207 DbFieldType::Short
208 }
209 "DISP" | "UDF" => DbFieldType::Char,
210 _ => return value,
211 };
212 match EpicsValue::parse(dbf, s.as_str_lossy().trim()) {
213 Ok(parsed) => parsed,
214 Err(_) => value,
215 }
216}
217
218/// A type-erased record instance stored in the database.
219pub struct RecordInstance {
220 pub name: String,
221 pub record: Box<dyn Record>,
222 pub common: CommonFields,
223 pub subscribers: HashMap<String, Vec<Subscriber>>,
224 // Link parse cache
225 pub parsed_inp: ParsedLink,
226 pub parsed_out: ParsedLink,
227 pub parsed_flnk: ParsedLink,
228 pub parsed_sdis: ParsedLink,
229 pub parsed_tsel: ParsedLink,
230 // Device support
231 pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
232 // Subroutine (for sub records)
233 pub subroutine: Option<Arc<SubroutineFn>>,
234 // Re-entrancy guard
235 pub processing: AtomicBool,
236 // Put-notify wait-set this record currently belongs to (C
237 // `precord->ppn`). Set when the record joins an active put-notify
238 // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
239 // taken + `leave`d when the record's processing completes. `None`
240 // outside any put-notify. See [`NotifyWaitSet`].
241 pub notify: Option<Arc<NotifyWaitSet>>,
242 // Last posted values for subscribed fields (generic change detection)
243 pub last_posted: HashMap<String, EpicsValue>,
244 /// Set by `check_deadband_ext` for waveform/aai/aao when their
245 /// content hash changed this cycle (C `monitor()` On Change mode,
246 /// waveformRecord.c:310-319). The snapshot builders read it to post
247 /// `HASH` with a literal `DBE_VALUE` event, independent of the VAL
248 /// post mask. False for every record without the MPST/APST/HASH
249 /// mechanism.
250 pub(crate) array_hash_changed: bool,
251 /// One-shot "skip the registered subroutine this cycle" signal for aSub
252 /// `LFLG=READ`. The async processing path resolves the `SUBL` link before
253 /// taking this lock; when the resolved name is bad (C `fetch_values` ->
254 /// `S_db_BadSub`) or the link read failed, C `process` runs `do_sub` only
255 /// on `!status`, so the subroutine is skipped. Set by the resolution
256 /// apply, consumed (and cleared) by [`Self::run_registered_subroutine`];
257 /// `false` for every record without a pending bad re-resolution.
258 pub(crate) suppress_subroutine_run: bool,
259 /// Generation counter for ReprocessAfter timer cancellation.
260 /// Bumped each process cycle. Spawned timers check this to avoid
261 /// stale re-processes from accumulated timers.
262 pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
263 /// Per-record info tags from `info("key", "value")` directives in
264 /// the .db file (epics-base info(...) grammar). Consumers include
265 /// asyn (`asyn:READBACK`), record-as-PV bridge tags
266 /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
267 /// records loaded without info(...) clauses.
268 pub info: HashMap<String, String>,
269 /// Cached metadata (display/control/enums) — `None` means stale or
270 /// not yet built. Populated lazily by `snapshot_for_field` /
271 /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
272 /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
273 /// is written.
274 ///
275 /// Wrapped in `std::sync::Mutex` for interior mutability — the
276 /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
277 /// `PvDatabase`, and snapshot construction holds a read lock; the
278 /// inner Mutex lets us still mutate the cache from a `&self` method.
279 ///
280 /// # Cache invariant (CONTRACT)
281 ///
282 /// The cache is **only correct under the following contract**: every
283 /// code path that mutates a metadata-class field (the set defined in
284 /// the file-private `is_metadata_field` predicate) MUST call
285 /// [`RecordInstance::notify_field_written`] (or
286 /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
287 ///
288 /// All current write paths in `field_io.rs` already do this. If you
289 /// add a new code path that:
290 ///
291 /// - calls `instance.record.put_field(...)` directly, OR
292 /// - mutates record fields from inside `Record::process()`,
293 /// `Record::on_put`, or `Record::special` and that mutation could
294 /// touch a metadata-class field, OR
295 /// - lets a `Box<dyn Record>` implementation expose its own
296 /// mutation methods that change metadata fields,
297 ///
298 /// then call `instance.notify_field_written(field_name)` to keep the
299 /// cache consistent. Forgetting will produce a stale snapshot —
300 /// monitors will continue to see the old EGU/PREC/limits until the
301 /// next legitimate metadata-field write triggers invalidation.
302 ///
303 /// # Symmetric note for `populate_*` extensions
304 ///
305 /// If a future change adds a new field to `populate_display_info`,
306 /// `populate_control_info`, or `populate_enum_info` (e.g. populating
307 /// `display.description` from DESC), the new source field name MUST
308 /// also be added to `is_metadata_field` so writes to it invalidate
309 /// the cache. (The `Q:form` -> `display.form` mapping is exempt: it
310 /// reads an immutable load-time info tag, not a runtime field.)
311 pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
312}
313
314impl RecordInstance {
315 pub fn new(name: String, record: impl Record) -> Self {
316 Self::new_boxed(name, Box::new(record))
317 }
318
319 pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
320 let rtype = record.record_type();
321 let analog_alarm = match rtype {
322 // C parity: every record type whose dbd carries
323 // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
324 // config slot. Previously calc / calcout were missing —
325 // their put_field for those fields silently no-op'd
326 // because `self.common.analog_alarm` was None at the
327 // mutation site. Confirmed via
328 // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
329 // calcoutRecord.dbd.pod:1103+ (same). `sub` carries the same
330 // HIHI/HIGH/LOLO/LOW + HHSV/HSV/LSV/LLSV set
331 // (subRecord.dbd.pod:569-642) and runs the analog `checkAlarms`.
332 "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
333 | "sub" => Some(AnalogAlarmConfig::default()),
334 _ => None,
335 };
336 let mut common = CommonFields::default();
337 common.analog_alarm = analog_alarm;
338
339 Self {
340 name,
341 record,
342 common,
343 subscribers: HashMap::new(),
344 parsed_inp: ParsedLink::None,
345 parsed_out: ParsedLink::None,
346 parsed_flnk: ParsedLink::None,
347 parsed_sdis: ParsedLink::None,
348 parsed_tsel: ParsedLink::None,
349 device: None,
350 subroutine: None,
351 processing: AtomicBool::new(false),
352 notify: None,
353 last_posted: HashMap::new(),
354 array_hash_changed: false,
355 suppress_subroutine_run: false,
356 reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
357 info: HashMap::new(),
358 metadata_cache: StdMutex::new(None),
359 }
360 }
361
362 /// Set a single `info("key", "value")` tag on this record. Last
363 /// write wins. Used by the .db loader (`info(...)` directive) and
364 /// `dbpf`-style tools.
365 pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
366 self.info.insert(key.into(), value.into());
367 }
368
369 /// Look up a single info tag. Returns `None` when the record has
370 /// no tag with that key.
371 pub fn get_info(&self, key: &str) -> Option<&str> {
372 self.info.get(key).map(|s| s.as_str())
373 }
374
375 /// Invalidate the metadata cache. Called after writing any
376 /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
377 /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
378 /// cache from the new values.
379 pub fn invalidate_metadata_cache(&self) {
380 if let Ok(mut guard) = self.metadata_cache.lock() {
381 *guard = None;
382 }
383 }
384
385 /// Hook called by the database after a field is written. If the
386 /// field is in the metadata-class set, the cache is invalidated so
387 /// the next snapshot picks up the new value.
388 ///
389 /// Field name is automatically uppercased.
390 pub fn notify_field_written(&self, field: &str) {
391 let upper = field.to_ascii_uppercase();
392 if is_metadata_field(&upper) {
393 self.invalidate_metadata_cache();
394 }
395 }
396
397 /// Like [`notify_field_written`] but skips the invalidation when
398 /// the put did not actually change the field's value. Mirrors
399 /// epics-base `faac1df1` — `DBE_PROPERTY` events fire only on
400 /// real changes, not on idempotent writes (the C path compares
401 /// `paddr->pfield` against the converted payload before setting
402 /// the `propertyUpdate` flag).
403 ///
404 /// `prev` is the value captured BEFORE the put. Callers that
405 /// don't need the change-detection (e.g. internal writers that
406 /// know the field is non-metadata) can keep using
407 /// [`notify_field_written`].
408 // must post EventMask::PROPERTY to all field subscribers when metadata changes
409 pub fn notify_field_written_if_changed(&self, field: &str, prev: Option<&EpicsValue>) {
410 let upper = field.to_ascii_uppercase();
411 if !is_metadata_field(&upper) {
412 return;
413 }
414 let now = self.record.get_field(&upper);
415 if prev != now.as_ref() {
416 self.invalidate_metadata_cache();
417 // mirror C dbAccess.c:1396-1397 db_post_events(precord, NULL, DBE_PROPERTY).
418 // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
419 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
420 for f in fields {
421 self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
422 }
423 }
424 }
425
426 /// Returns the cached MetadataSnapshot, building and storing it on
427 /// the first call (or after invalidation). Used by both
428 /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
429 /// cost is paid at most once per metadata-stable interval.
430 fn cached_metadata(&self) -> MetadataSnapshot {
431 // Fast path: cache hit
432 if let Ok(guard) = self.metadata_cache.lock()
433 && let Some(cached) = guard.as_ref()
434 {
435 return cached.clone();
436 }
437
438 // Cache miss: build a fresh metadata snapshot
439 let mut tmp = super::super::snapshot::Snapshot::new(
440 EpicsValue::Double(0.0),
441 0,
442 0,
443 std::time::SystemTime::UNIX_EPOCH,
444 );
445 self.populate_display_info(&mut tmp);
446 self.populate_control_info(&mut tmp);
447 self.populate_enum_info(&mut tmp);
448
449 let meta = MetadataSnapshot {
450 display: tmp.display,
451 control: tmp.control,
452 enums: tmp.enums,
453 };
454
455 // Store back; ignore poisoning (cache is best-effort).
456 if let Ok(mut guard) = self.metadata_cache.lock() {
457 *guard = Some(meta.clone());
458 }
459 meta
460 }
461
462 /// Check if the record is currently processing (PACT equivalent).
463 pub fn is_processing(&self) -> bool {
464 self.processing.load(std::sync::atomic::Ordering::Acquire)
465 }
466
467 /// Unified field resolution: record fields → common fields → virtual fields.
468 pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
469 let name = name.to_ascii_uppercase();
470 self.record
471 .get_field(&name)
472 .or_else(|| self.get_common_field(&name))
473 .or_else(|| self.get_virtual_field(&name))
474 }
475
476 /// Resolve a field for EPICS `$` long-string (character-array) access.
477 ///
478 /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
479 /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
480 /// array of `field_size` elements, a link field a char array of
481 /// `PVLINK_STRINGSZ`, and every other field type is rejected with
482 /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
483 /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
484 /// bytes and NUL-terminates them back into a string
485 /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
486 ///
487 /// Both `DBF_STRING` fields and link fields resolve to an
488 /// [`EpicsValue::String`] in this database (a link resolves to its
489 /// textual form, see [`Self::get_common_field`]), so a field is
490 /// `$`-eligible exactly when it resolves to a string value. Returns
491 /// that string value for an eligible field, or `None` for a field the
492 /// `$` modifier cannot view as a char array (the
493 /// `S_dbLib_fieldNotFound` case) — the single owner of the
494 /// dbChannel `$`-eligibility rule for the channel-resolution layer.
495 pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
496 match self.resolve_field(name)? {
497 v @ EpicsValue::String(_) => Some(v),
498 _ => None,
499 }
500 }
501
502 /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
503 /// the record's own record-specific menu
504 /// ([`Record::menu_field_choices`](super::record_trait::Record::menu_field_choices)),
505 /// else a shared menu keyed by field name
506 /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
507 fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
508 self.record
509 .menu_field_choices(field)
510 .or_else(|| super::menu_choices::shared_menu_choices(field))
511 }
512
513 /// Promote a `DBF_MENU` field's value to its `DBR_ENUM` client form: a
514 /// menu index stored as a short becomes [`EpicsValue::Enum`], so the
515 /// wire type a client sees is `DBR_ENUM` (CA) / `NTEnum` (PVA),
516 /// matching C dbStaticLib serving `DBF_MENU` as `DBR_ENUM`. The
517 /// menu index is held internally as `DbFieldType::Short`, so only that
518 /// representation is promoted; a same-named field that is not a menu
519 /// index here (e.g. `scalcout.OSV`, a string) is returned unchanged.
520 /// Idempotent for a value already delivered as `Enum` (`.SCAN`/`SSCN`,
521 /// the record-specific `SELM`).
522 fn promote_menu_value(&self, field: &str, value: EpicsValue) -> EpicsValue {
523 if self.menu_choices_for(field).is_some() {
524 if let EpicsValue::Short(idx) = value {
525 return EpicsValue::Enum(idx as u16);
526 }
527 }
528 value
529 }
530
531 /// The client-facing value of `field`: the resolved value with a
532 /// `DBF_MENU` field promoted to its `DBR_ENUM` form (see
533 /// [`Self::promote_menu_value`]), so a wire type derived directly from
534 /// the value matches the GET/MONITOR data. Used by the CA create-
535 /// channel path, which reads the native type from the value rather
536 /// than from [`Self::snapshot_for_field`].
537 pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
538 let value = self.resolve_field(field)?;
539 Some(self.promote_menu_value(field, value))
540 }
541
542 /// Attach the `DBF_MENU` → `DBR_ENUM` representation to a built
543 /// snapshot: promote the value to [`EpicsValue::Enum`] and attach the
544 /// menu's `menu()` choice labels so the CA/PVA enum encoders present
545 /// them. The single owner of "menu field -> (enum value, choice
546 /// table)" for both the GET ([`Self::snapshot_for_field`]) and MONITOR
547 /// ([`Self::make_monitor_snapshot`]) snapshot builders, so the wire
548 /// form is identical on every delivery path. A same-named non-menu
549 /// field (whose value is not a menu index) keeps its plain value and
550 /// gets no choice table.
551 fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
552 let Some(choices) = self.menu_choices_for(field) else {
553 return;
554 };
555 snap.value = self.promote_menu_value(field, snap.value.clone());
556 if matches!(snap.value, EpicsValue::Enum(_)) {
557 snap.enums = Some(super::super::snapshot::EnumInfo {
558 strings: choices.iter().map(|s| PvString::from(*s)).collect(),
559 });
560 }
561 }
562
563 /// Build a Snapshot with full metadata for the given field.
564 pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
565 let value = self.resolve_field(field)?;
566 let mut snap = super::super::snapshot::Snapshot::new(
567 value,
568 self.common.stat,
569 self.common.sevr as u16,
570 self.common.time,
571 );
572 // Default the served `timeStamp.userTag` to the record's `utag`,
573 // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
574 // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
575 // truncating to the low 32 bits — pvxs assigns the same uint64
576 // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
577 // nsec-LSB split below overrides this when configured, matching
578 // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
579 // (:247).
580 snap.user_tag = self.common.utag as i32;
581
582 // Pull display/control/enums from the metadata cache (build on
583 // first call, hit thereafter until invalidated by a metadata-class
584 // field write).
585 let meta = self.cached_metadata();
586 snap.display = meta.display;
587 snap.control = meta.control;
588 snap.enums = meta.enums;
589
590 // Per-field RSET metadata (C get_units/get_precision/
591 // get_graphic_double/get_control_double/get_alarm_double key on
592 // dbGetFieldIndex) patches the record-level cache for this field.
593 self.apply_field_metadata_override(field, &mut snap);
594
595 // Common-field enum mapping (e.g. .SCAN choices) is field-specific
596 // and not part of the per-record cache.
597 self.populate_common_enum_info(field, &mut snap);
598
599 // DBF_MENU field (a shared menu such as `OMSL`/`HHSV`/`SIMM`/... or
600 // a record-specific menu such as `sel.SELM`): carry the menu index
601 // as DBR_ENUM and attach its `menu()` choice labels. See
602 // `attach_menu_enum`. This overrides any record VAL enum table
603 // copied from the metadata cache above, because a menu field
604 // carries its own menu's choices, not the record's VAL state
605 // strings.
606 self.attach_menu_enum(field, &mut snap);
607
608 // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
609 // typeutils.cpp:79 splits the low N bits of the timestamp's
610 // nanoseconds into `timeStamp.userTag` and clears those bits
611 // from `nanoseconds`. Standard pvxs convention is `nsec:lsb:N`
612 // with N in 1..=30; values outside that range are ignored to
613 // match pvxs's bounds clamp. The split is applied to both
614 // `snap.timestamp` and `snap.user_tag` so downstream encoders
615 // (NTScalar `timeStamp`, QSRV groups via `+nsecmask`) all see
616 // the same shape.
617 if let Some(n) = self.parse_qtime_tag_nsec_lsb() {
618 crate::server::snapshot::apply_nsec_lsb_split(&mut snap, n);
619 }
620
621 Some(snap)
622 }
623
624 /// Parse `info(Q:time:tag, "nsec:lsb:N")` and return `N`.
625 /// Returns `None` when the info tag is absent or malformed (pvxs
626 /// silently ignores bad values; we match that by returning None
627 /// so the timestamp is emitted unchanged).
628 fn parse_qtime_tag_nsec_lsb(&self) -> Option<u8> {
629 let raw = self.get_info("Q:time:tag")?;
630 // Accept `nsec:lsb:N` with arbitrary whitespace and case.
631 let mut parts = raw.split(':');
632 let key = parts.next()?.trim();
633 let suffix = parts.next()?.trim();
634 let n = parts.next()?.trim();
635 if !key.eq_ignore_ascii_case("nsec") || !suffix.eq_ignore_ascii_case("lsb") {
636 return None;
637 }
638 let n: u32 = n.parse().ok()?;
639 // pvxs clamps to [1, 30]; values outside leave the timestamp
640 // alone (the userTag is meaningful only with a non-trivial
641 // mask, and >30 would consume all nanoseconds).
642 if (1..=30).contains(&n) {
643 Some(n as u8)
644 } else {
645 None
646 }
647 }
648
649 /// Populate DisplayInfo from record fields if applicable.
650 /// Resolve the `Q:form` info-tag value to a `display.form` menu index.
651 ///
652 /// pvxs publishes the fixed seven-entry form menu
653 /// (Default/String/Binary/Decimal/Hex/Exponential/Engineering) for every
654 /// numeric value and, for the VAL field only, sets `display.form.index`
655 /// to the slot whose name equals the field's `Q:form` info tag
656 /// (`iocsource.cpp:42-62`, case-sensitive). Unset or unrecognised ->
657 /// `None` (form stays 0 = Default), exactly as pvxs leaves the index
658 /// untouched on no match.
659 fn q_form_index(&self) -> Option<i16> {
660 const FORM_NAMES: [&str; 7] = [
661 "Default",
662 "String",
663 "Binary",
664 "Decimal",
665 "Hex",
666 "Exponential",
667 "Engineering",
668 ];
669 let tag = self.info.get("Q:form")?;
670 FORM_NAMES
671 .iter()
672 .position(|name| name == tag)
673 .map(|i| i as i16)
674 }
675
676 fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
677 let rtype = self.record.record_type();
678 match rtype {
679 "ai" | "ao" | "calc" | "calcout" => {
680 let egu = self
681 .record
682 .get_field("EGU")
683 .and_then(|v| {
684 if let EpicsValue::String(s) = v {
685 Some(s)
686 } else {
687 None
688 }
689 })
690 .unwrap_or_default();
691 let prec = self
692 .record
693 .get_field("PREC")
694 .and_then(|v| v.to_f64())
695 .unwrap_or(0.0) as i16;
696 let hopr = self
697 .record
698 .get_field("HOPR")
699 .and_then(|v| v.to_f64())
700 .unwrap_or(0.0);
701 let lopr = self
702 .record
703 .get_field("LOPR")
704 .and_then(|v| v.to_f64())
705 .unwrap_or(0.0);
706 let (hihi, high, low, lolo) = self.alarm_limits();
707 snap.display = Some(super::super::snapshot::DisplayInfo {
708 units: egu,
709 precision: prec,
710 upper_disp_limit: hopr,
711 lower_disp_limit: lopr,
712 upper_alarm_limit: hihi,
713 upper_warning_limit: high,
714 lower_warning_limit: low,
715 lower_alarm_limit: lolo,
716 ..Default::default()
717 });
718 }
719 "longin" | "longout" | "int64in" | "int64out" => {
720 let egu = self
721 .record
722 .get_field("EGU")
723 .and_then(|v| {
724 if let EpicsValue::String(s) = v {
725 Some(s)
726 } else {
727 None
728 }
729 })
730 .unwrap_or_default();
731 let hopr = self
732 .record
733 .get_field("HOPR")
734 .and_then(|v| v.to_f64())
735 .unwrap_or(0.0);
736 let lopr = self
737 .record
738 .get_field("LOPR")
739 .and_then(|v| v.to_f64())
740 .unwrap_or(0.0);
741 // longin/longout severity-gate (get_alarm_double);
742 // int64in/int64out send the limits verbatim (C is
743 // unconditional for those two record types only).
744 let (hihi, high, low, lolo) = match rtype {
745 "int64in" | "int64out" => self.alarm_limits_unchecked(),
746 _ => self.alarm_limits(),
747 };
748 snap.display = Some(super::super::snapshot::DisplayInfo {
749 units: egu,
750 precision: 0,
751 upper_disp_limit: hopr,
752 lower_disp_limit: lopr,
753 upper_alarm_limit: hihi,
754 upper_warning_limit: high,
755 lower_warning_limit: low,
756 lower_alarm_limit: lolo,
757 ..Default::default()
758 });
759 }
760 // waveform/aai/aao — HOPR/LOPR/PREC/EGU for VAL display limits.
761 // (waveformRecord.c:251-252,239; aaiRecord.c:280-281,268; aaoRecord.c:283-284)
762 "waveform" | "aai" | "aao" => {
763 let egu = self
764 .record
765 .get_field("EGU")
766 .and_then(|v| {
767 if let EpicsValue::String(s) = v {
768 Some(s)
769 } else {
770 None
771 }
772 })
773 .unwrap_or_default();
774 let prec = self
775 .record
776 .get_field("PREC")
777 .and_then(|v| v.to_f64())
778 .unwrap_or(0.0) as i16;
779 let hopr = self
780 .record
781 .get_field("HOPR")
782 .and_then(|v| v.to_f64())
783 .unwrap_or(0.0);
784 let lopr = self
785 .record
786 .get_field("LOPR")
787 .and_then(|v| v.to_f64())
788 .unwrap_or(0.0);
789 snap.display = Some(super::super::snapshot::DisplayInfo {
790 units: egu,
791 precision: prec,
792 upper_disp_limit: hopr,
793 lower_disp_limit: lopr,
794 upper_alarm_limit: 0.0,
795 upper_warning_limit: 0.0,
796 lower_warning_limit: 0.0,
797 lower_alarm_limit: 0.0,
798 ..Default::default()
799 });
800 }
801 // compress — HOPR/LOPR/PREC/EGU for VAL display limits.
802 // (compressRecord.c:478-479,464,455)
803 "compress" => {
804 let egu = self
805 .record
806 .get_field("EGU")
807 .and_then(|v| {
808 if let EpicsValue::String(s) = v {
809 Some(s)
810 } else {
811 None
812 }
813 })
814 .unwrap_or_default();
815 let prec = self
816 .record
817 .get_field("PREC")
818 .and_then(|v| v.to_f64())
819 .unwrap_or(0.0) as i16;
820 let hopr = self
821 .record
822 .get_field("HOPR")
823 .and_then(|v| v.to_f64())
824 .unwrap_or(0.0);
825 let lopr = self
826 .record
827 .get_field("LOPR")
828 .and_then(|v| v.to_f64())
829 .unwrap_or(0.0);
830 snap.display = Some(super::super::snapshot::DisplayInfo {
831 units: egu,
832 precision: prec,
833 upper_disp_limit: hopr,
834 lower_disp_limit: lopr,
835 upper_alarm_limit: 0.0,
836 upper_warning_limit: 0.0,
837 lower_warning_limit: 0.0,
838 lower_alarm_limit: 0.0,
839 ..Default::default()
840 });
841 }
842 "motor" => {
843 let egu = self
844 .record
845 .get_field("EGU")
846 .and_then(|v| {
847 if let EpicsValue::String(s) = v {
848 Some(s)
849 } else {
850 None
851 }
852 })
853 .unwrap_or_default();
854 let prec = self
855 .record
856 .get_field("PREC")
857 .and_then(|v| v.to_f64())
858 .unwrap_or(0.0) as i16;
859 let hlm = self
860 .record
861 .get_field("HLM")
862 .and_then(|v| v.to_f64())
863 .unwrap_or(0.0);
864 let llm = self
865 .record
866 .get_field("LLM")
867 .and_then(|v| v.to_f64())
868 .unwrap_or(0.0);
869 snap.display = Some(super::super::snapshot::DisplayInfo {
870 units: egu,
871 precision: prec,
872 upper_disp_limit: hlm,
873 lower_disp_limit: llm,
874 upper_alarm_limit: 0.0,
875 upper_warning_limit: 0.0,
876 lower_warning_limit: 0.0,
877 lower_alarm_limit: 0.0,
878 ..Default::default()
879 });
880 }
881 _ => {}
882 }
883 // Apply the `Q:form` display-format hint. The match above builds
884 // `snap.display` only for numeric record types — the same set for
885 // which pvxs emits `display.form.choices` — so a present `Q:form`
886 // tag maps to `display.form.index` exactly when pvxs applies it
887 // (`iocsource.cpp:42-62`, VAL-only; the per-record DisplayInfo here
888 // *is* the VAL field's metadata).
889 if let Some(display) = snap.display.as_mut() {
890 if let Some(form) = self.q_form_index() {
891 display.form = form;
892 }
893 }
894 }
895
896 /// Populate ControlInfo from record fields if applicable.
897 fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
898 let rtype = self.record.record_type();
899 match rtype {
900 // ao unconditionally uses DRVH/DRVL (aoRecord.c:356-357).
901 "ao" => {
902 let upper = self
903 .record
904 .get_field("DRVH")
905 .and_then(|v| v.to_f64())
906 .unwrap_or(0.0);
907 let lower = self
908 .record
909 .get_field("DRVL")
910 .and_then(|v| v.to_f64())
911 .unwrap_or(0.0);
912 snap.control = Some(super::super::snapshot::ControlInfo {
913 upper_ctrl_limit: upper,
914 lower_ctrl_limit: lower,
915 });
916 }
917 // longout/int64out use DRVH/DRVL only when drvh > drvl, else HOPR/LOPR
918 // (longoutRecord.c:282-287, int64outRecord.c:265-270).
919 "longout" | "int64out" => {
920 let drvh = self
921 .record
922 .get_field("DRVH")
923 .and_then(|v| v.to_f64())
924 .unwrap_or(0.0);
925 let drvl = self
926 .record
927 .get_field("DRVL")
928 .and_then(|v| v.to_f64())
929 .unwrap_or(0.0);
930 let (upper, lower) = if drvh > drvl {
931 (drvh, drvl)
932 } else {
933 let hopr = self
934 .record
935 .get_field("HOPR")
936 .and_then(|v| v.to_f64())
937 .unwrap_or(0.0);
938 let lopr = self
939 .record
940 .get_field("LOPR")
941 .and_then(|v| v.to_f64())
942 .unwrap_or(0.0);
943 (hopr, lopr)
944 };
945 snap.control = Some(super::super::snapshot::ControlInfo {
946 upper_ctrl_limit: upper,
947 lower_ctrl_limit: lower,
948 });
949 }
950 "motor" => {
951 // Motor records use HLM/LLM as control limits
952 let hlm = self
953 .record
954 .get_field("HLM")
955 .and_then(|v| v.to_f64())
956 .unwrap_or(0.0);
957 let llm = self
958 .record
959 .get_field("LLM")
960 .and_then(|v| v.to_f64())
961 .unwrap_or(0.0);
962 snap.control = Some(super::super::snapshot::ControlInfo {
963 upper_ctrl_limit: hlm,
964 lower_ctrl_limit: llm,
965 });
966 }
967 // int64in uses HOPR/LOPR as control limits (int64inRecord.c:226-227)
968 "ai" | "int64in" | "longin" | "calc" | "calcout" => {
969 // Input records use HOPR/LOPR as control limits
970 let hopr = self
971 .record
972 .get_field("HOPR")
973 .and_then(|v| v.to_f64())
974 .unwrap_or(0.0);
975 let lopr = self
976 .record
977 .get_field("LOPR")
978 .and_then(|v| v.to_f64())
979 .unwrap_or(0.0);
980 snap.control = Some(super::super::snapshot::ControlInfo {
981 upper_ctrl_limit: hopr,
982 lower_ctrl_limit: lopr,
983 });
984 }
985 // Array records map their VAL control limits to HOPR/LOPR, exactly
986 // like the display limits above (waveformRecord.c get_control_double
987 // VAL case; aaiRecord.c:293-303; aaoRecord.c; compressRecord.c:487-501).
988 // Without this arm an array DBR_CTRL collapses the control range to
989 // 0/0 while the scalar records expose it.
990 "waveform" | "aai" | "aao" | "compress" => {
991 let hopr = self
992 .record
993 .get_field("HOPR")
994 .and_then(|v| v.to_f64())
995 .unwrap_or(0.0);
996 let lopr = self
997 .record
998 .get_field("LOPR")
999 .and_then(|v| v.to_f64())
1000 .unwrap_or(0.0);
1001 snap.control = Some(super::super::snapshot::ControlInfo {
1002 upper_ctrl_limit: hopr,
1003 lower_ctrl_limit: lopr,
1004 });
1005 }
1006 _ => {}
1007 }
1008 }
1009
1010 /// Populate EnumInfo from record fields if applicable.
1011 fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
1012 let rtype = self.record.record_type();
1013 match rtype {
1014 // bi/bo/busy — C trims no_str to 1 when ZNAM set and ONAM empty (boRecord.c:342-352).
1015 "bi" | "bo" | "busy" => {
1016 let znam = self
1017 .record
1018 .get_field("ZNAM")
1019 .and_then(|v| {
1020 if let EpicsValue::String(s) = v {
1021 Some(s)
1022 } else {
1023 None
1024 }
1025 })
1026 .unwrap_or_default();
1027 let onam = self
1028 .record
1029 .get_field("ONAM")
1030 .and_then(|v| {
1031 if let EpicsValue::String(s) = v {
1032 Some(s)
1033 } else {
1034 None
1035 }
1036 })
1037 .unwrap_or_default();
1038 let no_str_1 = !znam.is_empty() && onam.is_empty();
1039 let mut strings = vec![znam, onam];
1040 if no_str_1 {
1041 strings.truncate(1);
1042 }
1043 snap.enums = Some(super::super::snapshot::EnumInfo { strings });
1044 }
1045 // mbbi/mbbo — C uses highwater mark: last non-empty index + 1 (mbbiRecord.c:262-269).
1046 "mbbi" | "mbbo" => {
1047 let state_fields = [
1048 "ZRST", "ONST", "TWST", "THST", "FRST", "FVST", "SXST", "SVST", "EIST", "NIST",
1049 "TEST", "ELST", "TVST", "TTST", "FTST", "FFST",
1050 ];
1051 let mut strings: Vec<PvString> = state_fields
1052 .iter()
1053 .map(|f| {
1054 self.record
1055 .get_field(f)
1056 .and_then(|v| {
1057 if let EpicsValue::String(s) = v {
1058 Some(s)
1059 } else {
1060 None
1061 }
1062 })
1063 .unwrap_or_default()
1064 })
1065 .collect();
1066 let no_str = strings
1067 .iter()
1068 .rposition(|s| !s.is_empty())
1069 .map(|i| i + 1)
1070 .unwrap_or(0);
1071 strings.truncate(no_str);
1072 snap.enums = Some(super::super::snapshot::EnumInfo { strings });
1073 }
1074 _ => {}
1075 }
1076 }
1077
1078 /// Populate enum strings for common fields accessed via CA (e.g. .SCAN).
1079 fn populate_common_enum_info(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
1080 match field {
1081 "SCAN" => {
1082 snap.enums = Some(super::super::snapshot::EnumInfo {
1083 strings: vec![
1084 "Passive".into(),
1085 "Event".into(),
1086 "I/O Intr".into(),
1087 "10 second".into(),
1088 "5 second".into(),
1089 "2 second".into(),
1090 "1 second".into(),
1091 ".5 second".into(),
1092 ".2 second".into(),
1093 ".1 second".into(),
1094 ],
1095 });
1096 }
1097 _ => {}
1098 }
1099 }
1100
1101 /// Extract analog alarm limits from CommonFields.
1102 // DBR_GR_*/DBR_CTRL_* alarm limits MUST be severity-gated — C
1103 // get_alarm_double returns `prec->hhsv ? prec->hihi : epicsNAN`
1104 // (aiRecord.c:295-298 and ao/longin/longout/calc/calcout). int64in/
1105 // int64out are the sole exception (unconditional, int64inRecord.c:239-243)
1106 // and use `alarm_limits_unchecked()`. NaN encodes byte-exact for every
1107 // DBR variant: f64/f32 keep NaN, integer casts make `NaN as iN == 0`,
1108 // matching dbAccess.c:300-326 (`finite(ald)?cast:0`).
1109 fn alarm_limits(&self) -> (f64, f64, f64, f64) {
1110 match self.common.analog_alarm {
1111 // Each limit is reported only when its severity is enabled,
1112 // exactly as C `get_alarm_double` (`x ? limit : epicsNAN`).
1113 Some(ref aa) => (
1114 gated(aa.hhsv, aa.hihi),
1115 gated(aa.hsv, aa.high),
1116 gated(aa.lsv, aa.low),
1117 gated(aa.llsv, aa.lolo),
1118 ),
1119 // No analog-alarm config ⇒ all severities are NO_ALARM in C,
1120 // so every limit is NaN (not 0).
1121 None => (f64::NAN, f64::NAN, f64::NAN, f64::NAN),
1122 }
1123 }
1124
1125 // int64in/int64out are the one analog family whose C `get_alarm_double`
1126 // is UNCONDITIONAL (int64inRecord.c:239-243, int64outRecord.c:283-287):
1127 // the limits are sent verbatim regardless of HHSV/HSV/LSV/LLSV. Keep a
1128 // separate accessor so the gated `alarm_limits()` cannot leak into this
1129 // path.
1130 fn alarm_limits_unchecked(&self) -> (f64, f64, f64, f64) {
1131 if let Some(ref aa) = self.common.analog_alarm {
1132 (aa.hihi, aa.high, aa.low, aa.lolo)
1133 } else {
1134 (0.0, 0.0, 0.0, 0.0)
1135 }
1136 }
1137
1138 /// Get a common field value.
1139 pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
1140 match name {
1141 "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
1142 "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
1143 "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
1144 "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
1145 // epics-base PR #568 / #566 — alarm message string.
1146 "AMSG" => Some(EpicsValue::String(self.common.amsg.clone().into())),
1147 "NAMSG" => Some(EpicsValue::String(self.common.namsg.clone().into())),
1148 "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
1149 "ACKT" => Some(EpicsValue::Char(if self.common.ackt { 1 } else { 0 })),
1150 "UDF" => Some(EpicsValue::Char(if self.common.udf { 1 } else { 0 })),
1151 "UDFS" => Some(EpicsValue::Short(self.common.udfs as i16)),
1152 "SCAN" => Some(EpicsValue::Enum(self.common.scan as u16)),
1153 "SSCN" => Some(EpicsValue::Enum(self.common.sscn.to_u16())),
1154 "PINI" => Some(EpicsValue::Char(if self.common.pini { 1 } else { 0 })),
1155 "TPRO" => Some(EpicsValue::Char(if self.common.tpro { 1 } else { 0 })),
1156 "BKPT" => Some(EpicsValue::Char(self.common.bkpt)),
1157 "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
1158 "INP" => Some(EpicsValue::String(self.common.inp.clone().into())),
1159 "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
1160 "DTYP" => Some(EpicsValue::String(self.common.dtyp.clone().into())),
1161 "TSE" => Some(EpicsValue::Short(self.common.tse)),
1162 "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
1163 // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
1164 // 64-bit value variant so values above i64::MAX round-trip.
1165 "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
1166 "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
1167 "ASL" => Some(EpicsValue::Char(self.common.asl)),
1168 "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
1169 "PHAS" => Some(EpicsValue::Short(self.common.phas)),
1170 "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
1171 "PRIO" => Some(EpicsValue::Short(self.common.prio)),
1172 "DISV" => Some(EpicsValue::Short(self.common.disv)),
1173 "DISA" => Some(EpicsValue::Short(self.common.disa)),
1174 "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
1175 "DISS" => Some(EpicsValue::Short(self.common.diss as i16)),
1176 "HYST" => Some(EpicsValue::Double(self.common.hyst)),
1177 "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
1178 "DISP" => Some(EpicsValue::Char(if self.common.disp { 1 } else { 0 })),
1179 "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
1180 "RPRO" => Some(EpicsValue::Char(if self.common.rpro { 1 } else { 0 })),
1181 "PACT" => Some(EpicsValue::Char(
1182 if self.processing.load(std::sync::atomic::Ordering::Acquire) {
1183 1
1184 } else {
1185 0
1186 },
1187 )),
1188 "PROC" => Some(EpicsValue::Char(0)), // Always 0 (trigger-only)
1189 // Analog alarm fields
1190 "HIHI" => self
1191 .common
1192 .analog_alarm
1193 .as_ref()
1194 .map(|a| EpicsValue::Double(a.hihi)),
1195 "HIGH" => self
1196 .common
1197 .analog_alarm
1198 .as_ref()
1199 .map(|a| EpicsValue::Double(a.high)),
1200 "LOW" => self
1201 .common
1202 .analog_alarm
1203 .as_ref()
1204 .map(|a| EpicsValue::Double(a.low)),
1205 "LOLO" => self
1206 .common
1207 .analog_alarm
1208 .as_ref()
1209 .map(|a| EpicsValue::Double(a.lolo)),
1210 "HHSV" => self
1211 .common
1212 .analog_alarm
1213 .as_ref()
1214 .map(|a| EpicsValue::Short(a.hhsv as i16)),
1215 "HSV" => self
1216 .common
1217 .analog_alarm
1218 .as_ref()
1219 .map(|a| EpicsValue::Short(a.hsv as i16)),
1220 "LSV" => self
1221 .common
1222 .analog_alarm
1223 .as_ref()
1224 .map(|a| EpicsValue::Short(a.lsv as i16)),
1225 "LLSV" => self
1226 .common
1227 .analog_alarm
1228 .as_ref()
1229 .map(|a| EpicsValue::Short(a.llsv as i16)),
1230 // swait OUTN is aliased to common.out
1231 "OUTN" => {
1232 if self.record.record_type() == "swait" {
1233 Some(EpicsValue::String(self.common.out.clone().into()))
1234 } else {
1235 None
1236 }
1237 }
1238 _ => None,
1239 }
1240 }
1241
1242 /// Set a common field value. Returns what scan index changes are needed.
1243 pub fn put_common_field(
1244 &mut self,
1245 name: &str,
1246 value: EpicsValue,
1247 ) -> CaResult<CommonFieldPutResult> {
1248 let name = name.to_ascii_uppercase();
1249 self.record.validate_put(&name, &value)?;
1250 self.record.special(&name, false)?;
1251 // The db loader hands every common field to this path as a raw
1252 // `EpicsValue::String` (no per-field `FieldDesc` to parse against).
1253 // Coerce it to the field's canonical numeric/menu type up front so the
1254 // typed arms below apply a `field(PHAS, "1")` / `field(PRIO, "HIGH")`
1255 // directive instead of silently dropping it at IOC load. String-typed
1256 // and already-typed values pass through unchanged.
1257 let value = coerce_common_field_string(&name, value);
1258 match name.as_str() {
1259 "SEVR" => {
1260 if let EpicsValue::Short(v) = value {
1261 self.common.sevr = AlarmSeverity::from_u16(v as u16);
1262 }
1263 }
1264 "STAT" => {
1265 if let EpicsValue::Short(v) = value {
1266 self.common.stat = v as u16;
1267 }
1268 }
1269 "NSEV" => {
1270 if let EpicsValue::Short(v) = value {
1271 self.common.nsev = AlarmSeverity::from_u16(v as u16);
1272 }
1273 }
1274 "NSTA" => {
1275 if let EpicsValue::Short(v) = value {
1276 self.common.nsta = v as u16;
1277 }
1278 }
1279 "AMSG" => {
1280 if let EpicsValue::String(s) = value {
1281 self.common.amsg = s.as_str_lossy().into_owned();
1282 }
1283 }
1284 "NAMSG" => {
1285 if let EpicsValue::String(s) = value {
1286 self.common.namsg = s.as_str_lossy().into_owned();
1287 }
1288 }
1289 "ACKS" => {
1290 if let EpicsValue::Short(v) = value {
1291 let sev = AlarmSeverity::from_u16(v as u16);
1292 // C `dbAccess.c:1309` putAcks:
1293 // `if (*psev >= precord->acks) precord->acks = 0;`
1294 // The written severity is compared against the
1295 // STORED unacknowledged severity `acks` — NOT the
1296 // current `sevr`. An operator acknowledging an
1297 // alarm at the severity that was latched into ACKS
1298 // must clear it even after `sevr` has since
1299 // dropped; comparing against `sevr` instead would
1300 // leave a stale unacknowledged alarm stuck.
1301 if sev >= self.common.acks {
1302 self.common.acks = AlarmSeverity::NoAlarm;
1303 }
1304 }
1305 }
1306 "ACKT" => {
1307 let new_ackt = match value {
1308 EpicsValue::Char(v) => v != 0,
1309 EpicsValue::Short(v) => v != 0,
1310 _ => return Ok(CommonFieldPutResult::NoChange),
1311 };
1312 self.common.ackt = new_ackt;
1313 // C `dbAccess.c:1294-1297` putAckt: when ACKT is set
1314 // false (transient acknowledgement disabled) and the
1315 // stored unacknowledged severity is higher than the
1316 // current `sevr`, lower `acks` down to `sevr` — a
1317 // transient alarm that has already cleared should not
1318 // keep a sticky higher-severity ACKS once transient
1319 // acknowledgement is turned off.
1320 if !new_ackt && self.common.acks > self.common.sevr {
1321 self.common.acks = self.common.sevr;
1322 }
1323 }
1324 "UDF" => {
1325 if let EpicsValue::Char(v) = value {
1326 self.common.udf = v != 0;
1327 }
1328 }
1329 "UDFS" => {
1330 if let EpicsValue::Short(v) = value {
1331 self.common.udfs = AlarmSeverity::from_u16(v as u16);
1332 }
1333 }
1334 "SCAN" => {
1335 let old_scan = self.common.scan;
1336 let new_scan = match &value {
1337 EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
1338 EpicsValue::Enum(v) => ScanType::from_u16(*v),
1339 EpicsValue::String(s) => ScanType::from_str(s.as_str_lossy().as_ref())?,
1340 _ => return Ok(CommonFieldPutResult::NoChange),
1341 };
1342 self.common.scan = new_scan;
1343 if old_scan != new_scan {
1344 let phas = self.common.phas;
1345 self.record.on_put(&name);
1346 let _ = self.record.special(&name, true);
1347 return Ok(CommonFieldPutResult::ScanChanged {
1348 old_scan,
1349 new_scan,
1350 phas,
1351 });
1352 }
1353 }
1354 "SSCN" => {
1355 let new_sscn = match &value {
1356 EpicsValue::Short(v) => SimModeScan::from_u16(*v as u16),
1357 EpicsValue::Enum(v) => SimModeScan::from_u16(*v),
1358 EpicsValue::String(s) => SimModeScan::from_str(s.as_str_lossy().as_ref())?,
1359 _ => return Ok(CommonFieldPutResult::NoChange),
1360 };
1361 self.common.sscn = new_sscn;
1362 }
1363 "PINI" => {
1364 if let EpicsValue::Char(v) = value {
1365 self.common.pini = v != 0;
1366 } else if let EpicsValue::String(s) = &value {
1367 self.common.pini = s == "YES" || s == "1" || s == "true";
1368 }
1369 }
1370 "TPRO" => {
1371 if let EpicsValue::Char(v) = value {
1372 self.common.tpro = v != 0;
1373 }
1374 }
1375 "BKPT" => {
1376 if let EpicsValue::Char(v) = value {
1377 self.common.bkpt = v;
1378 }
1379 }
1380 "FLNK" => {
1381 if let EpicsValue::String(s) = value {
1382 self.common.flnk = s.as_str_lossy().into_owned();
1383 self.parsed_flnk = parse_link_v2(&self.common.flnk);
1384 }
1385 }
1386 "INP" => {
1387 if let EpicsValue::String(s) = value {
1388 self.common.inp = s.as_str_lossy().into_owned();
1389 self.parsed_inp = parse_link_v2(&self.common.inp);
1390 }
1391 }
1392 "OUT" => {
1393 if let EpicsValue::String(s) = value {
1394 let s = s.as_str_lossy();
1395 // C parity (acd1aef): CP/CPP modifiers on output links are
1396 // meaningless (they request "process on change" semantics
1397 // that only apply to input links). dbParseLink in C strips
1398 // them and emits an errlogPrintf warning naming the source
1399 // record and field. Mirror the diagnostic here.
1400 let trimmed = s.trim_end();
1401 if trimmed.ends_with(" CP") || trimmed.ends_with(" CPP") {
1402 tracing::warn!(
1403 target: "epics_base_rs::record",
1404 record = %name,
1405 field = "OUT",
1406 "CP/CPP modifier ignored on output link"
1407 );
1408 }
1409 self.common.out = s.into_owned();
1410 // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
1411 // link processes its target only on an explicit
1412 // ` PP` token (or a `.PROC` destination). A bare
1413 // OUT link is NPP — `parse_output_link_v2`
1414 // downgrades the modifier-less `ProcessPassive`
1415 // default that `parse_link_v2` would otherwise
1416 // apply.
1417 self.parsed_out = parse_output_link_v2(&self.common.out);
1418 // C `longoutRecord.c::special` (PR #6c573b4 part 2)
1419 // and similar OOCH-style hooks need `after=true`
1420 // to fire after the link has actually moved. The
1421 // earlier `validate_put` + `special(name, false)`
1422 // pair only covered the before-side.
1423 let _ = self.record.special(&name, true);
1424 }
1425 }
1426 "DTYP" => {
1427 if let EpicsValue::String(s) = value {
1428 self.common.dtyp = s.as_str_lossy().into_owned();
1429 }
1430 }
1431 "TSE" => {
1432 if let EpicsValue::Short(v) = value {
1433 self.common.tse = v;
1434 }
1435 }
1436 "TSEL" => {
1437 if let EpicsValue::String(s) = value {
1438 self.common.tsel = s.as_str_lossy().into_owned();
1439 self.parsed_tsel = parse_link_v2(&self.common.tsel);
1440 }
1441 }
1442 "UTAG" => {
1443 // C UTAG is DBF_UINT64 — accept any integer-shaped value and
1444 // store the unsigned 64-bit tag. The db loader feeds every
1445 // common field as EpicsValue::String, so parse field(UTAG, "N")
1446 // rather than dropping it silently at IOC load; a CA write to
1447 // this u64 field crosses as DBR_DOUBLE (CA has no uint64 wire
1448 // type), so accept Double too.
1449 match value {
1450 EpicsValue::UInt64(v) => self.common.utag = v,
1451 EpicsValue::Int64(v) => self.common.utag = v as u64,
1452 EpicsValue::Long(v) => self.common.utag = v as u64,
1453 EpicsValue::Short(v) => self.common.utag = v as u64,
1454 EpicsValue::Enum(v) => self.common.utag = v as u64,
1455 EpicsValue::Char(v) => self.common.utag = v as u64,
1456 EpicsValue::Double(v) => self.common.utag = v as u64,
1457 EpicsValue::String(s) => {
1458 if let Ok(EpicsValue::UInt64(v)) =
1459 EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
1460 {
1461 self.common.utag = v;
1462 }
1463 }
1464 _ => {}
1465 }
1466 }
1467 "ASG" => {
1468 if let EpicsValue::String(s) = value {
1469 self.common.asg = s.as_str_lossy().into_owned();
1470 }
1471 }
1472 "ASL" => {
1473 // C dbCommon.ASL is `epicsUInt32` in the .dbd but
1474 // only ever 0 or 1; accept Char / Short / Long for
1475 // the common put paths and clamp to {0, 1}.
1476 // db_loader feeds every common field as
1477 // `EpicsValue::String`; also accept that so a
1478 // `.db` `field(ASL, "1")` directive isn't silently
1479 // ignored at IOC load.
1480 let n: i64 = match value {
1481 EpicsValue::Char(v) => v as i64,
1482 EpicsValue::Short(v) => v as i64,
1483 EpicsValue::Long(v) => v as i64,
1484 EpicsValue::Int64(v) => v,
1485 EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
1486 _ => return Ok(CommonFieldPutResult::NoChange),
1487 };
1488 self.common.asl = if n != 0 { 1 } else { 0 };
1489 }
1490 "DESC" => {
1491 if let EpicsValue::String(s) = value {
1492 // DBF_STRING data field — store the bytes verbatim so a
1493 // non-UTF-8 DESC round-trips unchanged.
1494 self.common.desc = s;
1495 }
1496 }
1497 "PHAS" => {
1498 if let EpicsValue::Short(v) = value {
1499 let old_phas = self.common.phas;
1500 self.common.phas = v;
1501 if old_phas != v && self.common.scan != ScanType::Passive {
1502 let scan = self.common.scan;
1503 self.record.on_put(&name);
1504 let _ = self.record.special(&name, true);
1505 return Ok(CommonFieldPutResult::PhasChanged {
1506 scan,
1507 old_phas,
1508 new_phas: v,
1509 });
1510 }
1511 }
1512 }
1513 "EVNT" => {
1514 // C `EVNT` is DBF_STRING (event name). Accept a
1515 // string directly; accept a numeric value too for
1516 // backward compatibility (numeric events / a calc
1517 // record driving EVNT) by formatting it as a string.
1518 match value {
1519 EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
1520 EpicsValue::Short(v) => self.common.evnt = v.to_string(),
1521 EpicsValue::Long(v) => self.common.evnt = v.to_string(),
1522 EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
1523 EpicsValue::Double(v) => {
1524 // Match C `eventNameToHandle`: a double with
1525 // an integer part is treated as that integer.
1526 self.common.evnt = (v as i64).to_string();
1527 }
1528 _ => {}
1529 }
1530 }
1531 "PRIO" => {
1532 if let EpicsValue::Short(v) = value {
1533 self.common.prio = v;
1534 }
1535 }
1536 "DISV" => {
1537 if let EpicsValue::Short(v) = value {
1538 self.common.disv = v;
1539 }
1540 }
1541 "DISA" => {
1542 if let EpicsValue::Short(v) = value {
1543 self.common.disa = v;
1544 }
1545 }
1546 "SDIS" => {
1547 if let EpicsValue::String(s) = value {
1548 self.common.sdis = s.as_str_lossy().into_owned();
1549 self.parsed_sdis = parse_link_v2(&self.common.sdis);
1550 }
1551 }
1552 "DISS" => {
1553 if let EpicsValue::Short(v) = value {
1554 self.common.diss = AlarmSeverity::from_u16(v as u16);
1555 }
1556 }
1557 "HYST" => {
1558 if let EpicsValue::Double(v) = value {
1559 self.common.hyst = v;
1560 }
1561 }
1562 "LCNT" => {
1563 if let EpicsValue::Short(v) = value {
1564 self.common.lcnt = v;
1565 }
1566 }
1567 "DISP" => match value {
1568 EpicsValue::Char(v) => self.common.disp = v != 0,
1569 EpicsValue::Short(v) => self.common.disp = v != 0,
1570 _ => {}
1571 },
1572 "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
1573 "RPRO" => {
1574 if let EpicsValue::Char(v) = value {
1575 self.common.rpro = v != 0;
1576 }
1577 }
1578 "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
1579 "PROC" => { /* Trigger handled by put_record_field_from_ca; no-op here */ }
1580 // Analog alarm fields — accept Double, Long, or String (DB-load path sends String)
1581 "HIHI" => {
1582 if let Some(a) = &mut self.common.analog_alarm {
1583 if let Some(v) = value.to_f64().or_else(|| {
1584 if let EpicsValue::String(s) = &value {
1585 s.as_str_lossy().parse::<f64>().ok()
1586 } else {
1587 None
1588 }
1589 }) {
1590 a.hihi = v;
1591 }
1592 }
1593 }
1594 "HIGH" => {
1595 if let Some(a) = &mut self.common.analog_alarm {
1596 if let Some(v) = value.to_f64().or_else(|| {
1597 if let EpicsValue::String(s) = &value {
1598 s.as_str_lossy().parse::<f64>().ok()
1599 } else {
1600 None
1601 }
1602 }) {
1603 a.high = v;
1604 }
1605 }
1606 }
1607 "LOW" => {
1608 if let Some(a) = &mut self.common.analog_alarm {
1609 if let Some(v) = value.to_f64().or_else(|| {
1610 if let EpicsValue::String(s) = &value {
1611 s.as_str_lossy().parse::<f64>().ok()
1612 } else {
1613 None
1614 }
1615 }) {
1616 a.low = v;
1617 }
1618 }
1619 }
1620 "LOLO" => {
1621 if let Some(a) = &mut self.common.analog_alarm {
1622 if let Some(v) = value.to_f64().or_else(|| {
1623 if let EpicsValue::String(s) = &value {
1624 s.as_str_lossy().parse::<f64>().ok()
1625 } else {
1626 None
1627 }
1628 }) {
1629 a.lolo = v;
1630 }
1631 }
1632 }
1633 "HHSV" => {
1634 if let Some(a) = &mut self.common.analog_alarm {
1635 a.hhsv = parse_alarm_severity(&value);
1636 }
1637 }
1638 "HSV" => {
1639 if let Some(a) = &mut self.common.analog_alarm {
1640 a.hsv = parse_alarm_severity(&value);
1641 }
1642 }
1643 "LSV" => {
1644 if let Some(a) = &mut self.common.analog_alarm {
1645 a.lsv = parse_alarm_severity(&value);
1646 }
1647 }
1648 "LLSV" => {
1649 if let Some(a) = &mut self.common.analog_alarm {
1650 a.llsv = parse_alarm_severity(&value);
1651 }
1652 }
1653 // swait-specific: OUTN is the output link name for swait records.
1654 // Mirrors to common.out so the processing framework dispatches it.
1655 "OUTN" => {
1656 if self.record.record_type() == "swait" {
1657 if let EpicsValue::String(s) = value {
1658 self.common.out = s.as_str_lossy().into_owned();
1659 // Bare OUT link is NPP — see the "OUT" arm.
1660 self.parsed_out = parse_output_link_v2(&self.common.out);
1661 }
1662 }
1663 }
1664 _ => {}
1665 }
1666 self.record.on_put(&name);
1667 let _ = self.record.special(&name, true);
1668 Ok(CommonFieldPutResult::NoChange)
1669 }
1670
1671 /// Get virtual fields (NAME, RTYP).
1672 pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
1673 match name {
1674 "NAME" => Some(EpicsValue::String(self.name.clone().into())),
1675 "RTYP" => Some(EpicsValue::String(
1676 self.record.record_type().to_string().into(),
1677 )),
1678 _ => None,
1679 }
1680 }
1681
1682 /// Evaluate alarms based on record type and current value.
1683 /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
1684 pub fn evaluate_alarms(&mut self) {
1685 use crate::server::recgbl::{self, alarm_status};
1686
1687 // Check UDF first
1688 recgbl::rec_gbl_check_udf(&mut self.common);
1689
1690 // Check CALC_ALARM for calc/calcout records
1691 let rtype = self.record.record_type();
1692 if rtype == "calc" || rtype == "calcout" || rtype == "scalcout" {
1693 // calc_alarm is exposed as a boolean field - check it
1694 if let Some(EpicsValue::Char(1)) = self.record.get_field("CALC_ALARM") {
1695 recgbl::rec_gbl_set_sevr_msg(
1696 &mut self.common,
1697 alarm_status::CALC_ALARM,
1698 crate::server::record::AlarmSeverity::Invalid,
1699 "CALC expression evaluation failed",
1700 );
1701 }
1702 }
1703
1704 match rtype {
1705 "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
1706 | "sub" => {
1707 if let Some(ref alarm_cfg) = self.common.analog_alarm.clone() {
1708 let val = match self.record.val() {
1709 Some(EpicsValue::Double(v)) => v,
1710 Some(EpicsValue::Long(v)) => v as f64,
1711 Some(EpicsValue::Int64(v)) => v as f64,
1712 _ => return,
1713 };
1714 self.evaluate_analog_alarm(val, alarm_cfg);
1715 }
1716 }
1717 // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT)
1718 // alarm evaluation now lives in each record's
1719 // `Record::check_alarms` hook (C `checkAlarms`). Keeping an
1720 // arm here would double-raise.
1721 _ => {} // no-op for other types
1722 }
1723 }
1724
1725 fn evaluate_analog_alarm(&mut self, val: f64, cfg: &AnalogAlarmConfig) {
1726 use crate::server::recgbl::{self, alarm_status};
1727
1728 // C `checkAlarms` returns immediately on a UDF cycle: it raises
1729 // `UDF_ALARM`/`UDFS` (already done by `rec_gbl_check_udf` in
1730 // `evaluate_alarms`), zeroes `AFVL` on the AFTC-capable records, and
1731 // returns BEFORE the range check — so `LALM` is left untouched and
1732 // `AFVL` is not filtered this cycle. The identical guard appears in
1733 // every record that shares this arm (`aiRecord.c:319-323`,
1734 // `aoRecord.c:383-386`, `longinRecord.c:274-278`,
1735 // `longoutRecord.c:317-320`, `int64inRecord.c:267-271`,
1736 // `int64outRecord.c:298-301`, `calcRecord.c:300-304`,
1737 // `calcoutRecord.c:563-566`). AFTC-capable records (ai/longin/
1738 // int64in/calc) carry `AFVL` and zero it (`prec->afvl = 0`); the
1739 // out records (ao/longout/int64out/calcout) have no `AFVL` and just
1740 // return. Running the range check here would drift `LALM` to `val`
1741 // (NaN on an undefined cycle) and filter `AFVL` — both observable.
1742 if self.common.udf {
1743 if matches!(
1744 self.record.record_type(),
1745 "calc" | "ai" | "longin" | "int64in"
1746 ) && self.record.get_field("AFVL").and_then(|v| v.to_f64()) != Some(0.0)
1747 {
1748 let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
1749 }
1750 return;
1751 }
1752
1753 let hyst = self.common.hyst;
1754 let lalm = self
1755 .record
1756 .get_field("LALM")
1757 .and_then(|v| v.to_f64())
1758 .unwrap_or(val);
1759
1760 // C-style per-level hysteresis: alarm fires if val passes the level,
1761 // OR if we were already at that alarm level (lalm == alev) and val
1762 // hasn't retreated past the hysteresis margin.
1763 //
1764 // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
1765 // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
1766 // filter (`calcRecord.c::checkAlarms:339-381`) which filters
1767 // on the range level (not on severity) and re-maps back.
1768 let (mut new_sevr, mut new_stat, mut alev, mut alarm_range) = if cfg.hhsv
1769 != AlarmSeverity::NoAlarm
1770 && (val >= cfg.hihi || (lalm == cfg.hihi && val >= cfg.hihi - hyst))
1771 {
1772 (cfg.hhsv, alarm_status::HIHI_ALARM, cfg.hihi, 5u16)
1773 } else if cfg.llsv != AlarmSeverity::NoAlarm
1774 && (val <= cfg.lolo || (lalm == cfg.lolo && val <= cfg.lolo + hyst))
1775 {
1776 (cfg.llsv, alarm_status::LOLO_ALARM, cfg.lolo, 1u16)
1777 } else if cfg.hsv != AlarmSeverity::NoAlarm
1778 && (val >= cfg.high || (lalm == cfg.high && val >= cfg.high - hyst))
1779 {
1780 (cfg.hsv, alarm_status::HIGH_ALARM, cfg.high, 4u16)
1781 } else if cfg.lsv != AlarmSeverity::NoAlarm
1782 && (val <= cfg.low || (lalm == cfg.low && val <= cfg.low + hyst))
1783 {
1784 (cfg.lsv, alarm_status::LOW_ALARM, cfg.low, 2u16)
1785 } else {
1786 (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val, 3u16)
1787 };
1788
1789 // C parity: the alarm-range AFTC low-pass filter
1790 // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
1791 // integer `alarmRange` and re-maps. Only records that carry the
1792 // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
1793 // have no AFTC field (confirmed via the respective `.dbd.pod`),
1794 // so they are excluded.
1795 let aftc_capable = matches!(
1796 self.record.record_type(),
1797 "calc" | "ai" | "longin" | "int64in"
1798 );
1799 if aftc_capable {
1800 let aftc = self
1801 .record
1802 .get_field("AFTC")
1803 .and_then(|v| v.to_f64())
1804 .unwrap_or(0.0);
1805 let afvl = self
1806 .record
1807 .get_field("AFVL")
1808 .and_then(|v| v.to_f64())
1809 .unwrap_or(0.0);
1810 if aftc > 0.0 {
1811 let now = crate::runtime::general_time::get_current();
1812 let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
1813 alarm_range,
1814 aftc,
1815 afvl,
1816 self.common.time,
1817 now,
1818 );
1819 let _ = self.record.put_field("AFVL", EpicsValue::Double(new_afvl));
1820 if filtered_range != alarm_range {
1821 // Re-map filtered range back to (sevr, stat, alev).
1822 let (mapped_sevr, mapped_stat, mapped_alev) = match filtered_range {
1823 5 => (cfg.hhsv, alarm_status::HIHI_ALARM, cfg.hihi),
1824 4 => (cfg.hsv, alarm_status::HIGH_ALARM, cfg.high),
1825 2 => (cfg.lsv, alarm_status::LOW_ALARM, cfg.low),
1826 1 => (cfg.llsv, alarm_status::LOLO_ALARM, cfg.lolo),
1827 _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val),
1828 };
1829 new_sevr = mapped_sevr;
1830 new_stat = mapped_stat;
1831 alev = mapped_alev;
1832 alarm_range = filtered_range;
1833 }
1834 } else {
1835 // aftc <= 0 disables the filter. C `checkAlarms`
1836 // (e.g. aiRecord.c:356,402) initialises the local
1837 // `afvl = 0` and unconditionally stores `prec->afvl =
1838 // afvl` at the end, so a disabled filter drives AFVL to
1839 // 0. Mirror that here so a stale accumulator from a prior
1840 // `aftc > 0` run cannot mis-seed the filter if AFTC is
1841 // re-enabled later.
1842 if afvl != 0.0 {
1843 let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
1844 }
1845 }
1846 }
1847 let _ = alarm_range; // suppress unused-var on non-calc paths
1848
1849 if new_sevr != AlarmSeverity::NoAlarm {
1850 recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr);
1851 // C sets LALM to the alarm threshold level, not the current value
1852 let _ = self.record.put_field("LALM", EpicsValue::Double(alev));
1853 } else {
1854 // No alarm condition: reset LALM to current value (like C)
1855 let _ = self.record.put_field("LALM", EpicsValue::Double(val));
1856 }
1857 }
1858
1859 /// Invoke the registered subroutine (`sub`/`aSub` `SNAM`) if one is
1860 /// bound, before the record's `process()` body runs.
1861 ///
1862 /// C `subRecord.c::do_sub` / `aSubRecord.c::do_sub` call the named
1863 /// subroutine on EVERY `process()`. The function registry lives on the
1864 /// framework (`RecordInstance::subroutine`), not on the record, so the
1865 /// record's own `process()` is a no-op for these two types and the
1866 /// framework must drive the call. This is the SINGLE owner of that call
1867 /// for every dispatch path: the main engine
1868 /// (`process_record_with_links_inner`, the SCAN / event / CA-put-to-PP /
1869 /// FLNK path) and the by-name `process_local` (`db.process_record`,
1870 /// QSRV group / foreign-call path) both route through here, so a
1871 /// `sub`/`aSub` runs identically regardless of how it is processed.
1872 /// Previously only `process_local` invoked the subroutine, so on the
1873 /// main engine path `VAL`/`VALA..VALU`/`OUTA..OUTU` never updated.
1874 pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
1875 use crate::server::recgbl::{self, alarm_status};
1876
1877 // aSub `LFLG=READ`: a `SUBL` re-resolution that found a bad/unregistered
1878 // name (C `fetch_values` -> `S_db_BadSub`) or failed to read the link
1879 // signals "skip do_sub this cycle" — C `process` runs `do_sub` only on
1880 // `!status`. One-shot: taken (cleared) whether or not a subroutine is
1881 // set, so it never leaks into the next cycle. The single consumer of
1882 // the flag, shared by every process path.
1883 if std::mem::take(&mut self.suppress_subroutine_run) {
1884 return Ok(());
1885 }
1886
1887 // Clone the Arc so the borrow on `self.subroutine` is released
1888 // before we mutate `self.record` / `self.common` below.
1889 let Some(sub_fn) = self.subroutine.clone() else {
1890 return Ok(());
1891 };
1892 // C `do_sub` returns the subroutine's `long` status.
1893 let status = sub_fn(&mut *self.record)?;
1894
1895 // aSub publishes the status as VAL (C `aSubRecord.c:223`
1896 // `prec->val = status`). The subroutine's computed outputs live in
1897 // VALA..VALU, so VAL is the return code and overwrites whatever the
1898 // closure may have written to VAL. `sub` does NOT do this — its VAL
1899 // is the value the subroutine computed.
1900 if self.record.record_type() == "aSub" {
1901 let _ = self
1902 .record
1903 .put_field("VAL", EpicsValue::Double(status as f64));
1904 }
1905
1906 // A negative status raises SOFT_ALARM at the record's BRSV severity
1907 // (C `do_sub`: `if (status < 0) recGblSetSevr(SOFT_ALARM,
1908 // prec->brsv)`). It accumulates into nsta/nsev for this cycle's
1909 // recGblResetAlarms commit and runs before checkAlarms, so a higher
1910 // analog severity (e.g. the shared analog-alarm owner) still wins via
1911 // the raise-only rule. BRSV defaults to NO_ALARM, under which
1912 // recGblSetSevr is a no-op.
1913 if status < 0 {
1914 let brsv = self
1915 .record
1916 .get_field("BRSV")
1917 .and_then(|v| v.to_f64())
1918 .map(|f| AlarmSeverity::from_u16(f as u16))
1919 .unwrap_or(AlarmSeverity::NoAlarm);
1920 recgbl::rec_gbl_set_sevr(&mut self.common, alarm_status::SOFT_ALARM, brsv);
1921 }
1922 Ok(())
1923 }
1924
1925 /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
1926 /// This does NOT handle links — see process_with_context in database.rs.
1927 ///
1928 /// Returns the value/log snapshot plus a list of alarm-field posts
1929 /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
1930 /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
1931 /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
1932 /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
1933 /// subscriber is not missed on an alarm-only change and a
1934 /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
1935 /// with `recGblResetAlarms` (recGbl.c:201-220), matching the
1936 /// `processing.rs` link path.
1937 pub fn process_local(
1938 &mut self,
1939 ) -> CaResult<(
1940 ProcessSnapshot,
1941 Vec<(&'static str, crate::server::recgbl::EventMask)>,
1942 )> {
1943 use crate::server::recgbl::{self, EventMask};
1944 const LCNT_ALARM_THRESHOLD: i16 = 10;
1945
1946 if self
1947 .processing
1948 .swap(true, std::sync::atomic::Ordering::AcqRel)
1949 {
1950 // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
1951 //
1952 // if ((precord->stat == SCAN_ALARM) ||
1953 // (precord->lcnt++ < MAX_LOCK) ||
1954 // (precord->sevr >= INVALID_ALARM)) goto all_done;
1955 // recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
1956 // "Async in progress");
1957 //
1958 // The alarm fires EXACTLY ONCE — on the attempt whose
1959 // pre-increment lcnt equals MAX_LOCK — and is then blocked
1960 // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
1961 // same shape as the link path
1962 // (`process_record_with_links_inner`). The pre-fix guard
1963 // here used post-increment `lcnt >= threshold` with no
1964 // already-raised bail, so every reentrant attempt past the
1965 // threshold re-posted the unchanged SEVR/STAT/VAL (and the
1966 // first fire came one attempt early); it also wrote
1967 // sevr/stat directly, skipping `recGblSetSevrMsg` +
1968 // `recGblResetAlarms` — losing the "Async in progress"
1969 // AMSG and the acks bookkeeping the reset performs.
1970 let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
1971 let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
1972 let lcnt_before = self.common.lcnt;
1973 self.common.lcnt = lcnt_before.saturating_add(1);
1974 if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
1975 return Ok((
1976 ProcessSnapshot {
1977 changed_fields: Vec::new(),
1978 },
1979 Vec::new(),
1980 ));
1981 }
1982 recgbl::rec_gbl_set_sevr_msg(
1983 &mut self.common,
1984 recgbl::alarm_status::SCAN_ALARM,
1985 AlarmSeverity::Invalid,
1986 "Async in progress",
1987 );
1988 let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
1989 // Per-field C masks (recGbl.c:201-220): this guard only
1990 // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
1991 // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
1992 // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
1993 // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
1994 let stat_mask = EventMask::ALARM | EventMask::VALUE;
1995 let mut changed_fields = Vec::new();
1996 if let Some(val) = self.record.val() {
1997 changed_fields.push((
1998 "VAL".to_string(),
1999 val,
2000 EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
2001 ));
2002 }
2003 changed_fields.push((
2004 "SEVR".to_string(),
2005 EpicsValue::Short(self.common.sevr as i16),
2006 EventMask::VALUE,
2007 ));
2008 changed_fields.push((
2009 "STAT".to_string(),
2010 EpicsValue::Short(self.common.stat as i16),
2011 stat_mask,
2012 ));
2013 // AMSG carries "Async in progress" alongside the STAT
2014 // transition (C recGbl.c posts STAT and AMSG together
2015 // when any alarm field moved).
2016 changed_fields.push((
2017 "AMSG".to_string(),
2018 EpicsValue::String(self.common.amsg.clone().into()),
2019 stat_mask,
2020 ));
2021 return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
2022 }
2023 self.common.lcnt = 0;
2024 // RAII guard that resets `self.processing` to false on drop —
2025 // both for the normal exit path and for any `?` early return.
2026 // The guard holds a raw pointer rather than a reference because
2027 // we still need `self` mutably while the guard is alive (the
2028 // record body below mutates other `self` fields).
2029 struct ProcessGuard(*const AtomicBool);
2030 // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
2031 // Send. We hand-roll Send because the ptr targets a field of
2032 // `self`, which the caller already proves can be borrowed
2033 // through this code path. The pointer is only ever read for an
2034 // atomic store, never written, dereferenced for raw access, or
2035 // escaped from this scope.
2036 unsafe impl Send for ProcessGuard {}
2037 impl Drop for ProcessGuard {
2038 fn drop(&mut self) {
2039 // SAFETY: `self.0` was constructed from
2040 // `&self.processing as *const AtomicBool` below, where
2041 // `self` is the live RecordInstance whose lifetime
2042 // strictly outlives `_guard`. RecordInstance is
2043 // !Unpin-equivalent in practice (we never move it
2044 // while held in the database's `Arc<RwLock<_>>`), so
2045 // the pointer remains valid until Drop runs.
2046 unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
2047 }
2048 }
2049 let _guard = ProcessGuard(&self.processing as *const AtomicBool);
2050
2051 // Call subroutine if registered (for sub/aSub records). Single owner
2052 // shared with the main engine path — see `run_registered_subroutine`.
2053 self.run_registered_subroutine()?;
2054 // Soft-Channel input records must skip the RVAL->VAL convert
2055 // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
2056 // every Soft-Channel input record, incl. one with a constant /
2057 // unset INP). Without this, `process_local` on a soft input
2058 // with a preset VAL — e.g. NaN — would run `convert()` and
2059 // clobber it, after which the UDF check below would see a
2060 // defined value and wrongly clear UDF. The
2061 // `processing.rs` link path already does this; `process_local`
2062 // is the separate foreign-call path (`db.process_record`) and
2063 // needs the same skip. "Raw Soft Channel" has a distinct DTYP
2064 // so it is excluded by `is_soft` and still runs convert.
2065 //
2066 // Gated on `soft_channel_skips_convert()` — identical to the
2067 // `processing.rs` link path — so this only suppresses the
2068 // `RVAL → VAL` convert step. `set_device_did_compute` is an
2069 // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
2070 // "skip convert" (override true), but `epid` reads it as
2071 // "skip the whole built-in PID compute" (keeps default false).
2072 // Without this gate, a Soft-Channel `epid` driven through
2073 // `process_local` (`db.process_record`, e.g. QSRV group proc
2074 // members) would skip `do_pid()` entirely — the regression
2075 // d1032fe5 fixed on the `processing.rs` path only.
2076 {
2077 let is_soft = self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel";
2078 let is_output = self.record.can_device_write();
2079 if is_soft && !is_output && self.record.soft_channel_skips_convert() {
2080 self.record.set_device_did_compute(true);
2081 }
2082 }
2083 // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
2084 // record's process() can see it — same as the processing.rs link
2085 // path. `process_local` is the foreign-call path
2086 // (`db.process_record`); without this a record driven through it
2087 // (e.g. QSRV group-process members) would not see UDF/TSE.
2088 {
2089 let ctx = self.common.process_context();
2090 self.record.set_process_context(&ctx);
2091 }
2092 let outcome = self.record.process()?;
2093 let process_result = outcome.result;
2094 // Note: process_local() does not execute ProcessActions — those are
2095 // handled by the full process_record_with_links() path in processing.rs.
2096
2097 // If the record reports it modified a metadata-class field during
2098 // process(), invalidate the metadata cache so the next snapshot
2099 // rebuilds from the new values. Default impl returns false, so
2100 // most records pay zero cost here.
2101 if self.record.took_metadata_change() {
2102 self.invalidate_metadata_cache();
2103 // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
2104 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
2105 for f in fields {
2106 self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
2107 }
2108 }
2109
2110 if process_result == RecordProcessResult::AsyncPending {
2111 // Async: PACT stays set, no further processing this cycle
2112 // Don't clear processing flag (guard won't run — we leak it intentionally)
2113 std::mem::forget(_guard);
2114 return Ok((
2115 ProcessSnapshot {
2116 changed_fields: Vec::new(),
2117 },
2118 Vec::new(),
2119 ));
2120 }
2121 if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
2122 // Intermediate notification (e.g. DMOV=0 at move start).
2123 // Unlike AsyncPending, we DO release the processing flag so
2124 // subsequent I/O Intr cycles can continue processing normally.
2125 self.common.time = crate::runtime::general_time::get_current();
2126 // Filter out fields that haven't actually changed, and update
2127 // MLST/last_posted for those that have. Each intermediate
2128 // post carries DBE_VALUE|DBE_LOG — C motor's mid-move
2129 // `db_post_events` calls use `DBE_VAL_LOG`
2130 // (motorRecord.cc:2606 DMOV, and every other do_work post);
2131 // no alarm transition ran on this pending pass.
2132 let mut changed_fields = Vec::new();
2133 for (name, val) in fields {
2134 let changed = match self.last_posted.get(&name) {
2135 Some(prev) => prev != &val,
2136 None => true,
2137 };
2138 if changed {
2139 if name == "VAL" {
2140 if let Some(f) = val.to_f64() {
2141 self.put_coerced("MLST", f);
2142 self.common.mlst = Some(f);
2143 }
2144 }
2145 self.last_posted.insert(name.clone(), val.clone());
2146 changed_fields.push((name, val, EventMask::VALUE | EventMask::LOG));
2147 }
2148 }
2149 // _guard drops here, clearing the processing flag
2150 return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
2151 }
2152 if process_result == RecordProcessResult::CompleteNoEmit {
2153 // The record accumulated this cycle without emitting (compress
2154 // `status == 1`). C `compressRecord.c:365` runs the completion
2155 // epilogue (udf clear, timestamp, monitor, FLNK) only on an emit
2156 // cycle (`if (status != 1)`), so a non-emitting cycle must publish
2157 // nothing — skip the epilogue and return an empty snapshot, exactly
2158 // as the production engine path does in `processing.rs`. This keeps
2159 // the emit-gate uniform across both process-dispatch paths so the
2160 // invariant holds by construction, not by "process_local never
2161 // produces it". CompleteNoEmit is synchronous (PACT already
2162 // cleared); the `_guard` drops here, clearing the processing flag.
2163 return Ok((
2164 ProcessSnapshot {
2165 changed_fields: Vec::new(),
2166 },
2167 Vec::new(),
2168 ));
2169 }
2170
2171 // `CompleteDeferOutput` (swait ODLY delay-start) is NOT special-cased
2172 // here: it deliberately shares the Complete value-side snapshot builder
2173 // below. C `swaitRecord.c::process` posts the value side (`monitor()`,
2174 // line 475) on the delaying cycle, so building the snapshot now is the
2175 // correct, parity-matching behavior — unlike `CompleteNoEmit` above,
2176 // whose fall-through would wrongly emit. The variant's *other* halves —
2177 // holding PACT across the delay and deferring OUT/OEVT/FLNK to the
2178 // `ReprocessAfter` continuation — are the engine path's responsibility
2179 // (`processing.rs::process_record_with_links_inner`); `process_local` is
2180 // a body-only test helper that dispatches no FLNK/output and no
2181 // `ProcessAction`, and no test drives a swait ODLY record through it. So
2182 // the invariant still holds by construction across both dispatch paths:
2183 // both publish the value side here, both leave the output side to the
2184 // engine.
2185
2186 // UDF update before alarm evaluation — C parity (see
2187 // `processing.rs`). A NaN / undefined value keeps UDF true so
2188 // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
2189 // record reporting a stale/garbage value with no alarm.
2190 if self.record.clears_udf() {
2191 self.common.udf = self.record.value_is_undefined();
2192 }
2193 // Per-record alarm hook (C `checkAlarms()`).
2194 self.record.check_alarms(&mut self.common);
2195
2196 // Evaluate alarms (accumulates into nsta/nsev)
2197 self.evaluate_alarms();
2198
2199 // Transfer nsta/nsev → sevr/stat, detect alarm change
2200 let alarm_result = recgbl::rec_gbl_reset_alarms(&mut self.common);
2201
2202 self.common.time = crate::runtime::general_time::get_current();
2203 // UDF already updated above — do not clear unconditionally.
2204
2205 // Deadband check for VAL monitor filtering
2206 let (include_val, include_archive) = self.check_deadband_ext();
2207 // C `recGblResetAlarms` `val_mask = DBE_ALARM`
2208 // (recGbl.c:194/203/212): every monitored-value post this cycle
2209 // carries DBE_ALARM when the severity/status OR the alarm
2210 // message moved — same parity rule as the `processing.rs`
2211 // paths.
2212 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
2213 EventMask::ALARM
2214 } else {
2215 EventMask::NONE
2216 };
2217
2218 // Build snapshot
2219 let mut changed_fields = Vec::new();
2220 // Same deadband-field routing and per-field mask as the
2221 // `processing.rs` paths: the tracked field posts the classes
2222 // that actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
2223 // movement → DBE_ALARM); a non-primary deadband field (motor
2224 // RBV — C motor `monitor()`, motorRecord.cc:3468-3507) leaves
2225 // VAL to the generic change-detection loop below.
2226 let deadband_field = self.record.monitor_deadband_field();
2227 // Fields whose change post carries DBE_VALUE only (LOG stripped) —
2228 // C `db_post_events(field, DBE_VALUE)` literal (e.g. scaler VAL,
2229 // scalerRecord.c:478). Consulted in the deadband post here and the
2230 // generic change loop below.
2231 let value_only = self.record.value_only_change_fields();
2232 let deadband_mask = {
2233 let mut m = alarm_bits;
2234 if include_val {
2235 m |= EventMask::VALUE;
2236 }
2237 // A value-only field's archive (ADEL) LOG bit is dropped — C
2238 // posts it with a literal DBE_VALUE on a value change, never
2239 // DBE_LOG (the LOG sweep is the idle `monitor()` path).
2240 if include_archive && !value_only.contains(&deadband_field) {
2241 m |= EventMask::LOG;
2242 }
2243 m
2244 };
2245 if !deadband_mask.is_empty() {
2246 let dval = if deadband_field == "VAL" {
2247 self.record.val()
2248 } else {
2249 self.resolve_field(deadband_field)
2250 };
2251 if let Some(val) = dval {
2252 changed_fields.push((deadband_field.to_string(), val, deadband_mask));
2253 }
2254 }
2255 // C `recGblResetAlarms` (recGbl.c:201-220) posts each alarm
2256 // field with its OWN per-field mask, not one record-wide mask:
2257 // * SEVR — DBE_VALUE, ONLY on a sevr change.
2258 // * STAT — DBE_ALARM (sevr change) | DBE_VALUE (stat change).
2259 // * ACKS — DBE_VALUE, only when an alarm field moved.
2260 // Pushing SEVR/STAT into `changed_fields` collapses them onto
2261 // the single record-wide `event_mask` (which carries ALARM on
2262 // `alarm_changed`): a DBE_VALUE-only `.SEVR` subscriber would
2263 // miss a stat-only-driven sevr change, and a DBE_ALARM-only
2264 // `.SEVR` subscriber would be wrongly notified. Post them via
2265 // `notify_field` with their individual masks instead — exactly
2266 // as the `processing.rs` link path does.
2267 let sevr_changed = self.common.sevr != alarm_result.prev_sevr;
2268 let stat_changed = self.common.stat != alarm_result.prev_stat;
2269 let stat_mask = {
2270 let mut m = EventMask::NONE;
2271 // C `recGblResetAlarms` carries DBE_ALARM on the STAT/AMSG
2272 // posts whenever the severity OR the alarm message moved —
2273 // not on a severity change alone. Aligning with the
2274 // `processing.rs` link path (and `complete_async_record`).
2275 if sevr_changed || alarm_result.amsg_changed {
2276 m |= EventMask::ALARM;
2277 }
2278 if stat_changed {
2279 m |= EventMask::VALUE;
2280 }
2281 m
2282 };
2283 let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
2284 if sevr_changed {
2285 alarm_posts.push(("SEVR", EventMask::VALUE));
2286 }
2287 if !stat_mask.is_empty() {
2288 alarm_posts.push(("STAT", stat_mask));
2289 // AMSG shares STAT's mask — C posts it alongside STAT when
2290 // any alarm field moved.
2291 alarm_posts.push(("AMSG", stat_mask));
2292 }
2293 // C parity (recGbl.c:216): ACKS is posted (DBE_VALUE) only when
2294 // an alarm field moved (`stat_mask != 0`) AND it was raised.
2295 if alarm_result.acks_changed && !stat_mask.is_empty() {
2296 alarm_posts.push(("ACKS", EventMask::VALUE));
2297 }
2298
2299 // Add subscribed fields that actually changed since last notification.
2300 // Exclude {deadband-field}/SEVR/STAT/AMSG/UDF — all five are already
2301 // emitted by this path (the deadband field, default VAL, in
2302 // `changed_fields`, SEVR/STAT/AMSG via `alarm_posts`, UDF via the
2303 // explicit UDF push below). Mirrors the two `processing.rs`
2304 // snapshot gates, which exclude the same five; excluding only the
2305 // first three would double-post AMSG and UDF. Each carries
2306 // DBE_VALUE|DBE_LOG plus the cycle's alarm bits — the C
2307 // convention for change-detected auxiliary posts
2308 // (`monitor_mask | DBE_VALUE | DBE_LOG`, calcRecord.c:420,
2309 // subRecord.c:400; motor `DBE_VAL_LOG` for marked fields,
2310 // motorRecord.cc:3522-3645).
2311 //
2312 // On a cycle whose alarm transition fired, fields named by
2313 // `alarm_cycle_monitored_fields` post even when unchanged, with
2314 // the alarm bits alone — C motor `monitor()`
2315 // (motorRecord.cc:3513-3645) posts every listed field once
2316 // `monitor_mask != 0`, so a `DBE_ALARM`-only subscriber
2317 // observes the alarm moment on any of them.
2318 let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
2319 let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
2320 &[]
2321 } else {
2322 self.record.alarm_cycle_monitored_fields()
2323 };
2324 // Fields the record force-posts every cycle it recomputed them
2325 // (C unconditional MARK + DBE_VAL_LOG), even when unchanged —
2326 // see `Record::force_posted_fields`. Empty for most records.
2327 let force_fields = self.record.force_posted_fields();
2328 // Fields the record re-posts with DBE_LOG only every cycle it
2329 // names them, regardless of change — see `Record::log_swept_fields`
2330 // (the scaler's idle S1..Snch sweep). Empty for most records.
2331 let log_swept = self.record.log_swept_fields();
2332 // Secondary value fields posted with VAL's monitor_mask, gated
2333 // inside C's `if (monitor_mask)` (ai RVAL, aiRecord.c:460-465) —
2334 // see `Record::fields_posted_with_value_mask`. Empty for most.
2335 let value_masked = self.record.fields_posted_with_value_mask();
2336 // Fields the record posts itself via an event-driven path (HASH on
2337 // a content-hash change, waveformRecord.c:317-319) — excluded from
2338 // generic change-detection so they are neither double-posted nor
2339 // spuriously posted. See `Record::event_posted_fields`.
2340 let event_posted = self.record.event_posted_fields();
2341 let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
2342 for (field, subs) in &self.subscribers {
2343 if !subs.is_empty()
2344 && field != deadband_field
2345 && field != "SEVR"
2346 && field != "STAT"
2347 && field != "AMSG"
2348 && field != "UDF"
2349 && !event_posted.contains(&field.as_str())
2350 {
2351 if let Some(val) = self.resolve_field(field) {
2352 let changed = match self.last_posted.get(field) {
2353 Some(prev) => prev != &val,
2354 None => true,
2355 };
2356 if value_masked.contains(&field.as_str()) {
2357 // C posts this secondary value field with VAL's own
2358 // monitor_mask, nested in `if (monitor_mask)` (ai
2359 // RVAL, aiRecord.c:460-465): only when VAL is posted
2360 // this cycle (deadband_mask non-empty) and the field
2361 // changed — never a forced DBE_VALUE|DBE_LOG.
2362 if changed && !deadband_mask.is_empty() {
2363 sub_updates.push((field.clone(), val, deadband_mask));
2364 }
2365 } else if changed {
2366 // A value-only field posts DBE_VALUE (+ this
2367 // cycle's alarm bits) without the LOG bit —
2368 // `aux_mask` minus LOG is exactly
2369 // `alarm_bits | DBE_VALUE`.
2370 let mask = if value_only.contains(&field.as_str()) {
2371 alarm_bits | EventMask::VALUE
2372 } else {
2373 aux_mask
2374 };
2375 sub_updates.push((field.clone(), val, mask));
2376 } else if force_fields.contains(&field.as_str()) {
2377 // C `monitor()` posts a re-marked field with
2378 // `monitor_mask | DBE_VAL_LOG` even when unchanged.
2379 sub_updates.push((field.clone(), val, aux_mask));
2380 } else if alarm_fanout.contains(&field.as_str()) {
2381 sub_updates.push((field.clone(), val, alarm_bits));
2382 } else if log_swept.contains(&field.as_str()) {
2383 // C scalerRecord.c:770-787 `monitor()`: every idle
2384 // process re-posts each S1..Snch with a literal
2385 // DBE_LOG regardless of change. A value-only field
2386 // (e.g. Sn) posts DBE_VALUE only on a counting
2387 // change, so the DBE_LOG subscriber is served here
2388 // by the idle sweep — and Sn does not change on an
2389 // idle cycle, so changed/unchanged stay disjoint
2390 // (no double post).
2391 sub_updates.push((field.clone(), val, EventMask::LOG));
2392 }
2393 }
2394 }
2395 }
2396 if !sub_updates.is_empty() {
2397 for (field, val, _) in &sub_updates {
2398 self.last_posted.insert(field.clone(), val.clone());
2399 }
2400 changed_fields.extend(sub_updates);
2401 }
2402 // C waveform/aai/aao `monitor()` posts HASH with a literal
2403 // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
2404 // 317-319), independent of the VAL post mask. `array_hash_changed`
2405 // was set by `check_deadband_ext` this cycle.
2406 if self.array_hash_changed {
2407 if let Some(h) = self.resolve_field("HASH") {
2408 changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
2409 }
2410 }
2411
2412 // Post UDF on the snapshot whenever any monitor event fires this
2413 // cycle, carrying the union of the cycle's posted classes —
2414 // mirrors the two `processing.rs` UDF pushes. C
2415 // `recGblResetAlarms` / `recGblCheckUDF` (recGbl.c) keep UDF
2416 // current every process cycle, and `db_post_events` delivers
2417 // `.UDF` alongside the record-wide post. `process_local` is the
2418 // foreign-process path (`db.process_record`, e.g. QSRV
2419 // group-process members); without this push a UDF change here is
2420 // never delivered to `.UDF` subscribers — the `sub_updates` loop
2421 // above deliberately excludes UDF to avoid a double-post, so the
2422 // push must be here.
2423 let cycle_mask = changed_fields
2424 .iter()
2425 .fold(EventMask::NONE, |m, (_, _, fm)| m | *fm);
2426 if !cycle_mask.is_empty() {
2427 changed_fields.push((
2428 "UDF".to_string(),
2429 EpicsValue::Char(if self.common.udf { 1 } else { 0 }),
2430 cycle_mask,
2431 ));
2432 }
2433
2434 Ok((ProcessSnapshot { changed_fields }, alarm_posts))
2435 }
2436
2437 /// Put a f64 value into a record field, coercing to the field's native type.
2438 pub(crate) fn put_coerced(&mut self, field: &str, val: f64) {
2439 use crate::types::EpicsValue;
2440 let target_type = self
2441 .record
2442 .get_field(field)
2443 .map(|v| v.db_field_type())
2444 .unwrap_or(crate::types::DbFieldType::Double);
2445 let coerced = EpicsValue::Double(val).convert_to(target_type);
2446 let _ = self.record.put_field(field, coerced);
2447 }
2448
2449 /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
2450 /// Returns `(monitor_trigger, archive_trigger)`.
2451 ///
2452 /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
2453 /// `mlst/alst` shadow when a trigger fires. Records without
2454 /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
2455 /// change triggers).
2456 ///
2457 /// Delegates per-axis deadband comparison to the free function
2458 /// [`check_deadband`] below — see that function's docstring for
2459 /// the four-quadrant NaN/infinity rule mirroring C
2460 /// `recGblCheckDeadband` (recGbl.c:345-370).
2461 ///
2462 /// **C-parity design note**: the Rust port uses `NaN` as the
2463 /// "never posted" sentinel for `MLST`/`ALST`. C achieves the
2464 /// same first-publish guarantee by allocating MLST/ALST in
2465 /// BSS-zeroed storage with a value of 0.0 that the C code is
2466 /// allowed to match against — but the first observed value is
2467 /// not necessarily 0.0, and the C rule "MLST==0 means never
2468 /// posted" relies on the deadband comparison `abs(val - 0.0)`
2469 /// firing on any non-zero first value. NaN is strictly more
2470 /// correct for the Rust port because a legitimate first
2471 /// `val=0.0` still fires on `NaN.is_nan() → true`. This
2472 /// sentinel-as-design is intentional, documented inside
2473 /// [`check_deadband`] (the `oldval.is_nan() → return true` short
2474 /// circuit). It is NOT a deviation inherited from an earlier
2475 /// silent compromise — `record_tests.rs::deadband_*` pins both
2476 /// the NaN-sentinel behaviour and the C four-quadrant transitions.
2477 pub fn check_deadband_ext(&mut self) -> (bool, bool) {
2478 // C waveform/aai/aao `monitor()` (waveformRecord.c:291-326) replaces
2479 // the analog MDEL/ADEL deadband with the MPST/APST "Always vs On
2480 // Change" mechanism: the record hashes its array content and posts
2481 // `DBE_VALUE`/`DBE_LOG` either always or only when the hash changed,
2482 // and posts `HASH` (`DBE_VALUE`) on a hash change. The record owns
2483 // the hash compute + `HASH` update; `array_hash_changed` carries the
2484 // event to the snapshot builders, which post `HASH` (the field is
2485 // excluded from the generic change-detection loop via
2486 // `event_posted_fields`).
2487 if let Some(post) = self.record.array_monitor_post() {
2488 self.array_hash_changed = post.hash_changed;
2489 return (post.post_value, post.post_archive);
2490 }
2491 self.array_hash_changed = false;
2492
2493 // The deadband is evaluated against `monitor_deadband_value()`,
2494 // not `val()` directly: a record whose monitored quantity is
2495 // not its primary value (e.g. the motor record, VAL=setpoint /
2496 // RBV=readback — C `monitor()` deadbands RBV) overrides that
2497 // hook. Default is `val()`, so other records are unaffected.
2498 let val = match self
2499 .record
2500 .monitor_deadband_value()
2501 .and_then(|v| v.to_f64())
2502 {
2503 Some(v) => v,
2504 None => return (true, true),
2505 };
2506
2507 let mdel = self
2508 .record
2509 .get_field("MDEL")
2510 .and_then(|v| v.to_f64())
2511 .unwrap_or(0.0);
2512 let adel = self
2513 .record
2514 .get_field("ADEL")
2515 .and_then(|v| v.to_f64())
2516 .unwrap_or(0.0);
2517
2518 // Use record's MLST/ALST fields if available, otherwise fall back to CommonFields
2519 let mlst = self
2520 .record
2521 .get_field("MLST")
2522 .and_then(|v| v.to_f64())
2523 .or(self.common.mlst)
2524 .unwrap_or(f64::NAN);
2525 let alst = self
2526 .record
2527 .get_field("ALST")
2528 .and_then(|v| v.to_f64())
2529 .or(self.common.alst)
2530 .unwrap_or(f64::NAN);
2531
2532 let monitor_trigger = check_deadband(val, mlst, mdel);
2533 let archive_trigger = check_deadband(val, alst, adel);
2534
2535 if archive_trigger {
2536 self.put_coerced("ALST", val);
2537 self.common.alst = Some(val);
2538 }
2539 if monitor_trigger {
2540 self.put_coerced("MLST", val);
2541 self.common.mlst = Some(val);
2542 }
2543
2544 (monitor_trigger, archive_trigger)
2545 }
2546
2547 /// Build a Snapshot for a given value, populated with the record's display metadata.
2548 /// Uses the metadata cache so the populate cost is paid at most once
2549 /// per metadata-stable interval (cf. `cached_metadata`).
2550 fn make_monitor_snapshot(
2551 &self,
2552 field: &str,
2553 value: EpicsValue,
2554 ) -> super::super::snapshot::Snapshot {
2555 let mut snap = super::super::snapshot::Snapshot::new(
2556 value,
2557 self.common.stat,
2558 self.common.sevr as u16,
2559 self.common.time,
2560 );
2561 // Carry the record's `utag` into the monitor update's
2562 // `timeStamp.userTag`, same as the GET path
2563 // (`snapshot_for_field`) and pvxs `iocsource.cpp:245`. Narrows
2564 // the 64-bit `epicsUTag` to the int32 wire field by low-32-bit
2565 // truncation.
2566 snap.user_tag = self.common.utag as i32;
2567 let meta = self.cached_metadata();
2568 snap.display = meta.display;
2569 snap.control = meta.control;
2570 snap.enums = meta.enums;
2571 // Per-field RSET metadata, same as the GET path
2572 // (`snapshot_for_field`) — a monitor update for VELO must carry
2573 // VELO's limits, not the record-level VAL limits.
2574 self.apply_field_metadata_override(field, &mut snap);
2575 // A monitored DBF_MENU field carries the same DBR_ENUM value and
2576 // choice labels as the GET path, so a `camonitor`/`pvmonitor`
2577 // update shows the menu label, not a bare index.
2578 self.attach_menu_enum(field, &mut snap);
2579 snap
2580 }
2581
2582 /// Apply a record's per-field metadata override (C RSET
2583 /// `get_units`/`get_precision`/`get_graphic_double`/
2584 /// `get_control_double`/`get_alarm_double`, all keyed by field)
2585 /// over the cached record-level metadata. Shared by the GET and
2586 /// monitor snapshot builders. Computed live on every call — never
2587 /// cached — so overrides derived from fields outside the
2588 /// `is_metadata_field` set cannot go stale.
2589 fn apply_field_metadata_override(
2590 &self,
2591 field: &str,
2592 snap: &mut super::super::snapshot::Snapshot,
2593 ) {
2594 let Some(ov) = self.record.field_metadata_override(field) else {
2595 return;
2596 };
2597 if ov.units.is_some()
2598 || ov.precision.is_some()
2599 || ov.disp_limits.is_some()
2600 || ov.alarm_limits.is_some()
2601 {
2602 let d = snap.display.get_or_insert_with(Default::default);
2603 if let Some(units) = ov.units {
2604 d.units = units;
2605 }
2606 if let Some(precision) = ov.precision {
2607 d.precision = precision;
2608 }
2609 if let Some((upper, lower)) = ov.disp_limits {
2610 d.upper_disp_limit = upper;
2611 d.lower_disp_limit = lower;
2612 }
2613 if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
2614 d.upper_alarm_limit = hihi;
2615 d.upper_warning_limit = high;
2616 d.lower_warning_limit = low;
2617 d.lower_alarm_limit = lolo;
2618 }
2619 }
2620 if let Some((upper, lower)) = ov.ctrl_limits {
2621 let c = snap.control.get_or_insert_with(Default::default);
2622 c.upper_ctrl_limit = upper;
2623 c.lower_ctrl_limit = lower;
2624 }
2625 }
2626
2627 /// Notify subscribers from a snapshot (call outside lock).
2628 /// Each entry carries its own posting mask: only subscribers whose
2629 /// mask intersects that field's mask are notified, and the
2630 /// delivered [`MonitorEvent`] reports exactly that field's classes
2631 /// (C `db_post_events(prec, &field, mask)` per-field granularity).
2632 pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot) {
2633 use crate::server::database::filters::FilteredMonitorEvent;
2634 use crate::server::recgbl::EventMask;
2635
2636 for (field, value, posting_mask) in &snapshot.changed_fields {
2637 let posting_mask = *posting_mask;
2638 if let Some(subs) = self.subscribers.get(field) {
2639 // Build a full snapshot once per field (with display metadata)
2640 let mon_snap = self.make_monitor_snapshot(field, value.clone());
2641 for sub in subs {
2642 // Paused subscriber (`db_event_disable`): suppress at
2643 // the source — no delivery, no coalesce.
2644 if !sub.active {
2645 continue;
2646 }
2647 let sub_mask = EventMask::from_bits(sub.mask);
2648 // Only send when posting mask intersects subscriber mask.
2649 // Empty posting mask means nothing changed — skip.
2650 if !posting_mask.is_empty() && sub_mask.intersects(posting_mask) {
2651 let event = MonitorEvent {
2652 snapshot: mon_snap.clone(),
2653 origin: 0,
2654 mask: posting_mask,
2655 };
2656 // Server-side filter chain (3.15.7). Empty chain
2657 // is identity, so no behaviour change for the
2658 // common no-filter case.
2659 let filtered = if sub.filters.is_empty() {
2660 Some(event)
2661 } else {
2662 sub.filters
2663 .apply(FilteredMonitorEvent::new(event))
2664 .map(|fe| fe.event)
2665 };
2666 let Some(event) = filtered else {
2667 continue;
2668 };
2669 if sub.tx.try_send(event.clone()).is_err() {
2670 // route the coalesce overwrite through
2671 // the single owner so a record-field monitor
2672 // value lost to a slow consumer is counted in
2673 // `dropped_monitor_events()`, exactly like a
2674 // `ProcessVariable` overflow.
2675 sub.coalesce_overflow(event);
2676 }
2677 }
2678 }
2679 }
2680 }
2681 }
2682
2683 /// Notify subscribers of a specific field, filtering by event mask.
2684 pub fn notify_field(&self, field: &str, mask: crate::server::recgbl::EventMask) {
2685 self.notify_field_with_origin(field, mask, 0);
2686 }
2687
2688 /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
2689 /// alarm event. Delivers to every subscriber on any field whose mask
2690 /// includes DBE_ALARM, each carrying its own monitored field's current
2691 /// value (the per-field `notify_field` already filters by mask
2692 /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
2693 /// an alarm-mask monitor on any field observes the acknowledgement.
2694 pub fn notify_record_alarm(&self) {
2695 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
2696 for field in fields {
2697 self.notify_field(&field, crate::server::recgbl::EventMask::ALARM);
2698 }
2699 }
2700
2701 /// Notify subscribers with an origin tag for self-write filtering.
2702 pub fn notify_field_with_origin(
2703 &self,
2704 field: &str,
2705 mask: crate::server::recgbl::EventMask,
2706 origin: u64,
2707 ) {
2708 use crate::server::database::filters::FilteredMonitorEvent;
2709 if let Some(subs) = self.subscribers.get(field) {
2710 if let Some(value) = self.resolve_field(field) {
2711 let mon_snap = self.make_monitor_snapshot(field, value);
2712 for sub in subs {
2713 // Paused subscriber (`db_event_disable`): suppress at
2714 // the source — no delivery, no coalesce.
2715 if !sub.active {
2716 continue;
2717 }
2718 let sub_mask = crate::server::recgbl::EventMask::from_bits(sub.mask);
2719 if mask.is_empty() || sub_mask.intersects(mask) {
2720 let event = MonitorEvent {
2721 snapshot: mon_snap.clone(),
2722 origin,
2723 mask,
2724 };
2725 // Server-side filter chain (3.15.7). Empty
2726 // chain (the default for every subscriber
2727 // until a `.{filter:opts}` PV-name suffix
2728 // parser wires one in) is the identity, so
2729 // existing subscribers see no behaviour
2730 // change. A filter returning `None` silences
2731 // this event for this subscriber only.
2732 let filtered = if sub.filters.is_empty() {
2733 Some(event)
2734 } else {
2735 sub.filters
2736 .apply(FilteredMonitorEvent::new(event))
2737 .map(|fe| fe.event)
2738 };
2739 let Some(event) = filtered else {
2740 continue;
2741 };
2742 if sub.tx.try_send(event.clone()).is_err() {
2743 // same single coalesce-overflow owner
2744 // as the snapshot path — record-field loss to
2745 // a slow consumer must be counted, not silently
2746 // overwritten.
2747 sub.coalesce_overflow(event);
2748 }
2749 }
2750 }
2751 }
2752 }
2753 }
2754
2755 /// Add a subscriber for a specific field. Returns `None` when the
2756 /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
2757 /// is reached. the parallel cap on `ProcessVariable`
2758 /// defends against a misbehaving client opening many
2759 /// MONITOR ops against one shared PV; the same defence is needed
2760 /// for record fields, which the CA server's
2761 /// `ChannelTarget::RecordField` path lands on.
2762 pub fn add_subscriber(
2763 &mut self,
2764 field: &str,
2765 sid: u32,
2766 data_type: DbFieldType,
2767 mask: u16,
2768 ) -> Option<mpsc::Receiver<MonitorEvent>> {
2769 let cap = crate::server::pv::max_subscribers_per_pv();
2770 let field_str = field.to_string();
2771 let bucket = self.subscribers.entry(field_str.clone()).or_default();
2772 // Reap dead Senders before
2773 // counting against the cap. A record field whose value
2774 // never changes (e.g. a quasi-static catalog field) never
2775 // triggers `notify_field_with_origin`'s retain-filter, so
2776 // a long-lived subscribe-disconnect storm could pin the
2777 // bucket at `cap` worth of closed Senders and lock out
2778 // genuine new subscribers.
2779 bucket.retain(|s| !s.tx.is_closed());
2780 if bucket.len() >= cap {
2781 tracing::warn!(
2782 record = %self.name,
2783 field = %field_str,
2784 live = bucket.len(),
2785 cap,
2786 "record field subscriber cap reached, refusing add_subscriber"
2787 );
2788 return None;
2789 }
2790 let (tx, rx) = mpsc::channel(64);
2791 bucket.push(Subscriber {
2792 sid,
2793 data_type,
2794 mask,
2795 tx,
2796 coalesced: std::sync::Arc::new(std::sync::Mutex::new(None)),
2797 filters: crate::server::database::filters::FilterChain::new(),
2798 active: true,
2799 });
2800 // Initialize last_posted with current value so the first process cycle
2801 // doesn't treat it as "changed" (the initial value is already sent
2802 // to the client as part of EVENT_ADD response).
2803 if !self.last_posted.contains_key(&field_str) {
2804 if let Some(val) = self.resolve_field(&field_str) {
2805 self.last_posted.insert(field_str, val);
2806 }
2807 }
2808 Some(rx)
2809 }
2810
2811 /// Attach a filter to the most recently added subscriber for
2812 /// `field`. Returns `false` when no subscriber exists yet on that
2813 /// field (call `add_subscriber` first). The CA / PVA channel-name
2814 /// parsers will use this once `.{filter:opts}` syntax is wired.
2815 /// Tests can also use it directly to compose filter chains.
2816 pub fn attach_filter_to_last_subscriber(
2817 &mut self,
2818 field: &str,
2819 filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
2820 ) -> bool {
2821 if let Some(bucket) = self.subscribers.get_mut(field) {
2822 if let Some(sub) = bucket.last_mut() {
2823 sub.filters.push(filter);
2824 return true;
2825 }
2826 }
2827 false
2828 }
2829
2830 /// Remove a subscriber by subscription ID from all fields.
2831 pub fn remove_subscriber(&mut self, sid: u32) {
2832 for subs in self.subscribers.values_mut() {
2833 subs.retain(|s| s.sid != sid);
2834 }
2835 }
2836
2837 /// Pause / resume one subscriber's event flow at the source
2838 /// (`db_event_disable` / `db_event_enable`). `active == false`
2839 /// suppresses every subsequent post to this subscriber AND drops any
2840 /// pending coalesced overflow, so a resumed monitor restarts from the
2841 /// source-side edge rather than replaying a value captured while it
2842 /// was paused. No-op if no subscriber has this `sid`. The caller holds
2843 /// the record write lock, so this is exclusive with the read-locked
2844 /// post paths that consult `Subscriber::active`.
2845 pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
2846 for subs in self.subscribers.values_mut() {
2847 for sub in subs.iter_mut() {
2848 if sub.sid == sid {
2849 sub.active = active;
2850 if !active && let Ok(mut slot) = sub.coalesced.lock() {
2851 *slot = None;
2852 }
2853 }
2854 }
2855 }
2856 }
2857
2858 /// Take any pending coalesced overflow event for `sid` across all
2859 /// fields. Drops-oldest semantics: if the per-subscriber mpsc filled
2860 /// while the consumer was slow, the newest event was stashed in the
2861 /// coalesce slot and is returned here.
2862 pub fn pop_coalesced(&self, sid: u32) -> Option<MonitorEvent> {
2863 for subs in self.subscribers.values() {
2864 for sub in subs {
2865 if sub.sid == sid {
2866 if let Ok(mut slot) = sub.coalesced.lock() {
2867 if let Some(ev) = slot.take() {
2868 return Some(ev);
2869 }
2870 }
2871 }
2872 }
2873 }
2874 None
2875 }
2876
2877 /// Clean up closed subscriber channels.
2878 pub fn cleanup_subscribers(&mut self) {
2879 for subs in self.subscribers.values_mut() {
2880 subs.retain(|s| !s.tx.is_closed());
2881 }
2882 }
2883}
2884
2885/// C `recGblCheckDeadband` parity (recGbl.c:345-370). The four branches
2886/// the C path enumerates:
2887///
2888/// 1. Both `newval` and `oldval` finite: `delta = |old - new|`, fire when
2889/// `delta > deadband`.
2890/// 2. Exactly one of {newval, oldval} is NaN, the other not — OR exactly
2891/// one is +/-inf, the other not: `delta = +inf`, always fires.
2892/// 3. Both infinite with opposite signs: `delta = +inf`, always fires.
2893/// 4. Otherwise (e.g. both NaN, both same-signed infinity): no fire.
2894///
2895/// `oldval = NaN` is treated as "never posted" and fires (matches the
2896/// `mlst.is_nan() → trigger` short-circuit the Rust port already had).
2897/// `deadband < 0` fires unconditionally (matches `delta > deadband`
2898/// with a negative deadband — same effect on every numeric value).
2899pub(crate) fn check_deadband(newval: f64, oldval: f64, deadband: f64) -> bool {
2900 // Fire unconditionally when no prior posting has happened. C achieves
2901 // the same effect through the field being default-initialised to a
2902 // sentinel; Rust uses NaN-as-sentinel.
2903 if oldval.is_nan() {
2904 return true;
2905 }
2906 // Negative deadband short-circuits — any value passes.
2907 if deadband < 0.0 {
2908 return true;
2909 }
2910 let new_finite = newval.is_finite();
2911 let old_finite = oldval.is_finite();
2912 if new_finite && old_finite {
2913 return (newval - oldval).abs() > deadband;
2914 }
2915 // From here on, at least one of the two is not finite. We've already
2916 // ruled out oldval=NaN above, so any newval=NaN here is the "newval
2917 // went NaN while oldval was finite/inf" case — must fire (C case 2).
2918 if newval.is_nan() {
2919 return true;
2920 }
2921 // Exactly one infinite, the other finite: C case 2 → fire.
2922 if new_finite != old_finite {
2923 return true;
2924 }
2925 // Both infinite. Opposite signs → fire (C case 3); same sign → no
2926 // fire (C path leaves delta=0 and the `delta > deadband` check fails
2927 // for any non-negative deadband).
2928 newval != oldval
2929}
2930
2931#[cfg(test)]
2932mod metadata_cache_tests {
2933 use super::*;
2934 use crate::server::records::ai::AiRecord;
2935
2936 /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
2937 fn ai_instance() -> RecordInstance {
2938 let mut rec = AiRecord::default();
2939 let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
2940 let _ = rec.put_field("PREC", EpicsValue::Short(2));
2941 let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
2942 let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
2943 let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
2944 RecordInstance::new("TEMP".to_string(), rec)
2945 }
2946
2947 /// a record-field monitor whose bounded queue is full and
2948 /// whose coalesce slot already holds an unobserved value must count
2949 /// the displaced value in the shared `dropped_monitor_events()`
2950 /// counter — the same accounting a `ProcessVariable` overflow uses.
2951 /// Before the fix the record-field path overwrote the slot without
2952 /// counting, hiding slow-consumer loss on the path most CA/PVA
2953 /// database monitors use. The counter is process-global, so the
2954 /// assertion is a strict monotonic increase (robust under parallel
2955 /// tests); the revert-verify runs this test in isolation.
2956 #[test]
2957 fn bfr10_record_field_overflow_counts_dropped_event() {
2958 use crate::server::pv::dropped_monitor_events;
2959 use crate::server::recgbl::EventMask;
2960 let mut inst = ai_instance();
2961 // Keep `rx` alive (do NOT drain) so the bounded 64-deep queue
2962 // fills, then the coalesce slot, before overflow replacement.
2963 let _rx = inst
2964 .add_subscriber(
2965 "VAL",
2966 1,
2967 crate::types::DbFieldType::Double,
2968 EventMask::VALUE.bits(),
2969 )
2970 .expect("subscriber added");
2971 let before = dropped_monitor_events();
2972 // 64 sends fill the queue; the 65th fills the (empty) coalesce
2973 // slot; each send after that overwrites an UNOBSERVED slot value
2974 // and must be counted as a dropped monitor event.
2975 for _ in 0..70 {
2976 inst.notify_field_with_origin("VAL", EventMask::VALUE, 0);
2977 }
2978 let after = dropped_monitor_events();
2979 assert!(
2980 after > before,
2981 "record-field overflow onto an occupied coalesce slot must \
2982 record a dropped monitor event (before={before}, after={after})"
2983 );
2984 }
2985
2986 #[test]
2987 fn metadata_field_set_check() {
2988 // Sanity check that the metadata field set is recognized.
2989 assert!(is_metadata_field("EGU"));
2990 assert!(is_metadata_field("PREC"));
2991 assert!(is_metadata_field("HOPR"));
2992 assert!(is_metadata_field("LOPR"));
2993 assert!(is_metadata_field("HIHI"));
2994 assert!(is_metadata_field("DRVH"));
2995 assert!(is_metadata_field("ZNAM"));
2996 assert!(is_metadata_field("ZRST"));
2997 assert!(is_metadata_field("FFST"));
2998
2999 // Non-metadata fields should NOT invalidate the cache
3000 assert!(!is_metadata_field("VAL"));
3001 assert!(!is_metadata_field("DESC"));
3002 assert!(!is_metadata_field("SCAN"));
3003 assert!(!is_metadata_field("PHAS"));
3004 }
3005
3006 #[test]
3007 fn cache_starts_empty_then_populates_on_first_snapshot() {
3008 let inst = ai_instance();
3009
3010 // Cache starts empty
3011 assert!(inst.metadata_cache.lock().unwrap().is_none());
3012
3013 // First snapshot triggers populate + cache store
3014 let snap = inst.snapshot_for_field("VAL").unwrap();
3015 let display = snap.display.expect("ai snapshot must have display");
3016 assert_eq!(display.units, "degC");
3017 assert_eq!(display.precision, 2);
3018 assert_eq!(display.upper_disp_limit, 100.0);
3019 assert_eq!(display.lower_disp_limit, 0.0);
3020
3021 // Cache is now populated
3022 assert!(inst.metadata_cache.lock().unwrap().is_some());
3023 }
3024
3025 #[test]
3026 fn q_form_info_tag_sets_display_form_index() {
3027 // pvxs maps the `Q:form` info tag to `display.form.index` for the
3028 // VAL field (iocsource.cpp:42-62). "Hex" is slot 4 of the
3029 // seven-entry menu (Default/String/Binary/Decimal/Hex/...).
3030 let mut inst = ai_instance();
3031 inst.set_info("Q:form", "Hex");
3032 let snap = inst.snapshot_for_field("VAL").unwrap();
3033 let display = snap.display.expect("ai snapshot must have display");
3034 assert_eq!(display.form, 4, "Q:form=Hex -> display.form index 4");
3035 }
3036
3037 #[test]
3038 fn q_form_absent_or_unknown_leaves_form_default() {
3039 // No `Q:form` tag -> form stays 0 (Default).
3040 let inst = ai_instance();
3041 let snap = inst.snapshot_for_field("VAL").unwrap();
3042 assert_eq!(snap.display.expect("ai display").form, 0);
3043
3044 // Unrecognised tag -> pvxs leaves the index untouched (0).
3045 let mut inst2 = ai_instance();
3046 inst2.set_info("Q:form", "Nonsense");
3047 let snap2 = inst2.snapshot_for_field("VAL").unwrap();
3048 assert_eq!(snap2.display.expect("ai display").form, 0);
3049 }
3050
3051 /// the served `timeStamp.userTag` defaults to the record's `utag`
3052 /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
3053 /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
3054 /// it to 0, dropping the record's tag. A bit-31 utag also pins the
3055 /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
3056 /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
3057 /// wire field.
3058 #[test]
3059 fn snapshot_serves_record_utag_as_timestamp_usertag() {
3060 let mut inst = ai_instance();
3061 // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
3062 // override never fires and the utag default is what is served.
3063 inst.common.utag = 0x9000_0000;
3064 let want = 0x9000_0000u32 as i32;
3065
3066 let get = inst.snapshot_for_field("VAL").unwrap();
3067 assert_eq!(
3068 get.user_tag, want,
3069 "GET path must serve the record's utag as timeStamp.userTag"
3070 );
3071
3072 let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
3073 assert_eq!(
3074 mon.user_tag, want,
3075 "MONITOR path must carry the record's utag too"
3076 );
3077 }
3078
3079 #[test]
3080 fn cache_hit_returns_same_metadata() {
3081 let inst = ai_instance();
3082
3083 // Prime the cache
3084 let snap1 = inst.snapshot_for_field("VAL").unwrap();
3085 let display1 = snap1.display.unwrap();
3086
3087 // Subsequent snapshots return the same cached metadata
3088 let snap2 = inst.snapshot_for_field("VAL").unwrap();
3089 let display2 = snap2.display.unwrap();
3090
3091 assert_eq!(display1.units, display2.units);
3092 assert_eq!(display1.precision, display2.precision);
3093 assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
3094 assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
3095 }
3096
3097 #[test]
3098 fn invalidate_clears_cache() {
3099 let inst = ai_instance();
3100 let _ = inst.snapshot_for_field("VAL");
3101 assert!(inst.metadata_cache.lock().unwrap().is_some());
3102
3103 inst.invalidate_metadata_cache();
3104 assert!(inst.metadata_cache.lock().unwrap().is_none());
3105 }
3106
3107 #[test]
3108 fn notify_field_written_invalidates_for_metadata_field() {
3109 let inst = ai_instance();
3110 let _ = inst.snapshot_for_field("VAL");
3111 assert!(inst.metadata_cache.lock().unwrap().is_some());
3112
3113 // Writing a metadata field should invalidate
3114 inst.notify_field_written("EGU");
3115 assert!(inst.metadata_cache.lock().unwrap().is_none());
3116 }
3117
3118 #[test]
3119 fn notify_field_written_skips_non_metadata_field() {
3120 let inst = ai_instance();
3121 let _ = inst.snapshot_for_field("VAL");
3122 assert!(inst.metadata_cache.lock().unwrap().is_some());
3123
3124 // Writing a value field should NOT invalidate the cache
3125 inst.notify_field_written("VAL");
3126 assert!(inst.metadata_cache.lock().unwrap().is_some());
3127
3128 // Same for DESC
3129 inst.notify_field_written("DESC");
3130 assert!(inst.metadata_cache.lock().unwrap().is_some());
3131 }
3132
3133 #[test]
3134 fn notify_field_written_is_case_insensitive() {
3135 let inst = ai_instance();
3136 let _ = inst.snapshot_for_field("VAL");
3137 assert!(inst.metadata_cache.lock().unwrap().is_some());
3138
3139 // Lowercase metadata field name should still trigger invalidation
3140 inst.notify_field_written("egu");
3141 assert!(inst.metadata_cache.lock().unwrap().is_none());
3142 }
3143
3144 /// epics-base faac1df1 — `notify_field_written_if_changed` must
3145 /// SKIP the cache invalidation when the metadata field's value
3146 /// didn't actually change. Otherwise a stream of idempotent puts
3147 /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
3148 /// "property changed" events on every cycle.
3149 #[test]
3150 fn notify_field_written_if_changed_skips_when_unchanged() {
3151 let mut inst = ai_instance();
3152 let _ = inst.snapshot_for_field("VAL");
3153 assert!(inst.metadata_cache.lock().unwrap().is_some());
3154
3155 // Capture prev, do a no-op put, then notify — cache must remain.
3156 let prev = inst.record.get_field("EGU");
3157 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
3158 inst.notify_field_written_if_changed("EGU", prev.as_ref());
3159 assert!(
3160 inst.metadata_cache.lock().unwrap().is_some(),
3161 "no-op put must not invalidate the metadata cache"
3162 );
3163 }
3164
3165 /// And when the value DID change, the cache must invalidate.
3166 #[test]
3167 fn notify_field_written_if_changed_invalidates_on_real_change() {
3168 let mut inst = ai_instance();
3169 let _ = inst.snapshot_for_field("VAL");
3170 assert!(inst.metadata_cache.lock().unwrap().is_some());
3171
3172 let prev = inst.record.get_field("EGU");
3173 let _ = inst
3174 .record
3175 .put_field("EGU", EpicsValue::String("kPa".into()));
3176 inst.notify_field_written_if_changed("EGU", prev.as_ref());
3177 assert!(
3178 inst.metadata_cache.lock().unwrap().is_none(),
3179 "real metadata change must invalidate cache"
3180 );
3181 }
3182
3183 /// Non-metadata fields don't carry property semantics — the
3184 /// `if_changed` variant must never invalidate for them, matching
3185 /// the existing `notify_field_written` short-circuit.
3186 #[test]
3187 fn notify_field_written_if_changed_skips_non_metadata_field() {
3188 let inst = ai_instance();
3189 let _ = inst.snapshot_for_field("VAL");
3190 assert!(inst.metadata_cache.lock().unwrap().is_some());
3191 // VAL is not in is_metadata_field set — must be skipped even
3192 // with a changed value.
3193 inst.notify_field_written_if_changed("VAL", None);
3194 assert!(inst.metadata_cache.lock().unwrap().is_some());
3195 }
3196
3197 #[test]
3198 fn cache_picks_up_new_value_after_invalidation() {
3199 let mut inst = ai_instance();
3200
3201 // First snapshot: degC
3202 let snap1 = inst.snapshot_for_field("VAL").unwrap();
3203 assert_eq!(snap1.display.unwrap().units, "degC");
3204
3205 // Mutate EGU and invalidate
3206 let _ = inst
3207 .record
3208 .put_field("EGU", EpicsValue::String("mV".into()));
3209 inst.notify_field_written("EGU");
3210
3211 // Second snapshot: mV (rebuilt)
3212 let snap2 = inst.snapshot_for_field("VAL").unwrap();
3213 assert_eq!(snap2.display.unwrap().units, "mV");
3214 }
3215
3216 #[test]
3217 fn make_monitor_snapshot_uses_cache() {
3218 let inst = ai_instance();
3219 assert!(inst.metadata_cache.lock().unwrap().is_none());
3220
3221 // make_monitor_snapshot should also populate the cache
3222 let snap = inst.make_monitor_snapshot("VAL", EpicsValue::Double(42.0));
3223 assert!(snap.display.is_some());
3224 assert!(inst.metadata_cache.lock().unwrap().is_some());
3225
3226 // Subsequent call hits cache
3227 let snap2 = inst.make_monitor_snapshot("VAL", EpicsValue::Double(43.0));
3228 let d1 = snap.display.unwrap();
3229 let d2 = snap2.display.unwrap();
3230 assert_eq!(d1.units, d2.units);
3231 assert_eq!(d1.precision, d2.precision);
3232 }
3233
3234 /// Stub record with a per-field metadata override on SPD only —
3235 /// models a C RSET whose get_units/get_graphic_double key on
3236 /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
3237 struct PerFieldMetaRecord;
3238
3239 impl Record for PerFieldMetaRecord {
3240 fn record_type(&self) -> &'static str {
3241 "ai" // record-level metadata populates from EGU/PREC/HOPR/LOPR
3242 }
3243 fn get_field(&self, name: &str) -> Option<EpicsValue> {
3244 match name {
3245 "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
3246 "EGU" => Some(EpicsValue::String("mm".into())),
3247 "PREC" => Some(EpicsValue::Short(3)),
3248 "HOPR" => Some(EpicsValue::Double(100.0)),
3249 "LOPR" => Some(EpicsValue::Double(-100.0)),
3250 _ => None,
3251 }
3252 }
3253 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
3254 Err(CaError::FieldNotFound(name.to_string()))
3255 }
3256 fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3257 &[]
3258 }
3259 fn field_metadata_override(
3260 &self,
3261 field: &str,
3262 ) -> Option<crate::server::record::FieldMetadataOverride> {
3263 if field != "SPD" {
3264 return None;
3265 }
3266 Some(crate::server::record::FieldMetadataOverride {
3267 units: Some("mm/sec".into()),
3268 precision: Some(1),
3269 disp_limits: Some((5.0, 0.5)),
3270 ctrl_limits: Some((4.0, 1.0)),
3271 alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
3272 })
3273 }
3274 }
3275
3276 #[test]
3277 fn field_metadata_override_applies_on_get_and_monitor_paths() {
3278 let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
3279
3280 // VAL: no override — record-level metadata serves it.
3281 let snap = inst.snapshot_for_field("VAL").unwrap();
3282 let d = snap.display.unwrap();
3283 assert_eq!(d.units, "mm");
3284 assert_eq!(d.precision, 3);
3285 assert_eq!(d.upper_disp_limit, 100.0);
3286
3287 // SPD via the GET path: every member patched over the cache.
3288 let snap = inst.snapshot_for_field("SPD").unwrap();
3289 let d = snap.display.unwrap();
3290 assert_eq!(d.units, "mm/sec");
3291 assert_eq!(d.precision, 1);
3292 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
3293 assert_eq!(
3294 (
3295 d.upper_alarm_limit,
3296 d.upper_warning_limit,
3297 d.lower_warning_limit,
3298 d.lower_alarm_limit
3299 ),
3300 (9.0, 8.0, -8.0, -9.0)
3301 );
3302 let c = snap.control.unwrap();
3303 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
3304
3305 // SPD via the monitor path: identical override.
3306 let snap = inst.make_monitor_snapshot("SPD", EpicsValue::Double(2.0));
3307 let d = snap.display.unwrap();
3308 assert_eq!(d.units, "mm/sec");
3309 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
3310 let c = snap.control.unwrap();
3311 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
3312 }
3313
3314 /// Stub modelling the motor monitor() shape (C motorRecord.cc:
3315 /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
3316 /// the RBV readback, which advances on every process.
3317 struct ReadbackDeadbandRecord {
3318 val: f64,
3319 rbv: f64,
3320 deadband: f64,
3321 }
3322
3323 impl Record for ReadbackDeadbandRecord {
3324 fn record_type(&self) -> &'static str {
3325 "ai"
3326 }
3327 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3328 self.rbv += 30.0;
3329 Ok(crate::server::record::ProcessOutcome::complete())
3330 }
3331 fn get_field(&self, name: &str) -> Option<EpicsValue> {
3332 match name {
3333 "VAL" => Some(EpicsValue::Double(self.val)),
3334 "RBV" => Some(EpicsValue::Double(self.rbv)),
3335 "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
3336 _ => None,
3337 }
3338 }
3339 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3340 match (name, value) {
3341 ("VAL", EpicsValue::Double(v)) => {
3342 self.val = v;
3343 Ok(())
3344 }
3345 ("MDEL", EpicsValue::Double(v)) => {
3346 self.deadband = v;
3347 Ok(())
3348 }
3349 _ => Err(CaError::FieldNotFound(name.to_string())),
3350 }
3351 }
3352 fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3353 &[]
3354 }
3355 fn monitor_deadband_value(&self) -> Option<EpicsValue> {
3356 Some(EpicsValue::Double(self.rbv))
3357 }
3358 fn monitor_deadband_field(&self) -> &'static str {
3359 "RBV"
3360 }
3361 }
3362
3363 /// C motor monitor() parity: MDEL/ADEL throttle the deadband
3364 /// field's (RBV) delivery; VAL posts only when the setpoint
3365 /// actually changed — not on every readback poll.
3366 #[test]
3367 fn deadband_field_routes_readback_and_val_posts_only_on_change() {
3368 use crate::server::recgbl::EventMask;
3369 let mut inst = RecordInstance::new(
3370 "RDB".to_string(),
3371 ReadbackDeadbandRecord {
3372 val: 5.0,
3373 rbv: 0.0,
3374 deadband: 10.0,
3375 },
3376 );
3377 let _val_rx = inst
3378 .add_subscriber(
3379 "VAL",
3380 1,
3381 crate::types::DbFieldType::Double,
3382 EventMask::VALUE.bits(),
3383 )
3384 .expect("VAL subscriber");
3385 let _rbv_rx = inst
3386 .add_subscriber(
3387 "RBV",
3388 2,
3389 crate::types::DbFieldType::Double,
3390 EventMask::VALUE.bits(),
3391 )
3392 .expect("RBV subscriber");
3393 let names = |snap: &ProcessSnapshot| {
3394 snap.changed_fields
3395 .iter()
3396 .map(|(n, _, _)| n.clone())
3397 .collect::<Vec<_>>()
3398 };
3399
3400 // Cycle 1 (first publish): RBV fires via the deadband trigger
3401 // (MLST starts at the NaN never-posted sentinel). VAL must NOT
3402 // post: `add_subscriber` seeded `last_posted` with the current
3403 // value (the initial value already went out with EVENT_ADD), and
3404 // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
3405 // it.
3406 let (snap, _) = inst.process_local().unwrap();
3407 let n = names(&snap);
3408 assert!(n.contains(&"RBV".to_string()), "{n:?}");
3409 assert!(
3410 !n.contains(&"VAL".to_string()),
3411 "VAL unchanged since subscribe must not post: {n:?}"
3412 );
3413
3414 // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
3415 // VAL not re-posted.
3416 let (snap, _) = inst.process_local().unwrap();
3417 let n = names(&snap);
3418 assert!(n.contains(&"RBV".to_string()), "RBV crossed MDEL: {n:?}");
3419 assert!(
3420 !n.contains(&"VAL".to_string()),
3421 "unchanged VAL must not post: {n:?}"
3422 );
3423
3424 // Cycle 3: widen the deadband — RBV moves within it → throttled.
3425 let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
3426 let (snap, _) = inst.process_local().unwrap();
3427 let n = names(&snap);
3428 assert!(
3429 !n.contains(&"RBV".to_string()),
3430 "MDEL must throttle RBV: {n:?}"
3431 );
3432
3433 // Cycle 4: setpoint moves while RBV stays inside the deadband →
3434 // VAL posts via change detection, RBV stays throttled.
3435 let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
3436 let (snap, _) = inst.process_local().unwrap();
3437 let n = names(&snap);
3438 assert!(
3439 n.contains(&"VAL".to_string()),
3440 "changed VAL must post: {n:?}"
3441 );
3442 assert!(
3443 !n.contains(&"RBV".to_string()),
3444 "MDEL must throttle RBV: {n:?}"
3445 );
3446 }
3447
3448 /// Record that names DIFF in `force_posted_fields` (the motor's C
3449 /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
3450 /// every value constant — a settled axis parked at a fixed non-zero
3451 /// following error. VAL is a control: not force-listed, so it must
3452 /// fall back to change-detection.
3453 struct ForcePostRecord {
3454 diff: f64,
3455 val: f64,
3456 }
3457
3458 impl Record for ForcePostRecord {
3459 fn record_type(&self) -> &'static str {
3460 "ai"
3461 }
3462 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3463 // Values never change — the readback already matches; only the
3464 // unconditional MARK should keep DIFF flowing.
3465 Ok(crate::server::record::ProcessOutcome::complete())
3466 }
3467 fn get_field(&self, name: &str) -> Option<EpicsValue> {
3468 match name {
3469 "DIFF" => Some(EpicsValue::Double(self.diff)),
3470 "VAL" => Some(EpicsValue::Double(self.val)),
3471 _ => None,
3472 }
3473 }
3474 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
3475 Err(CaError::FieldNotFound(name.to_string()))
3476 }
3477 fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3478 &[]
3479 }
3480 fn force_posted_fields(&self) -> &'static [&'static str] {
3481 &["DIFF"]
3482 }
3483 }
3484
3485 /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
3486 /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
3487 /// regardless of change, so a force-posted field re-posts on an
3488 /// otherwise-idle cycle while an unchanged non-force field does not.
3489 #[test]
3490 fn force_posted_field_reposts_unchanged_value_each_cycle() {
3491 use crate::server::recgbl::EventMask;
3492 let mut inst = RecordInstance::new(
3493 "FP".to_string(),
3494 ForcePostRecord {
3495 diff: 2.5,
3496 val: 1.0,
3497 },
3498 );
3499 let _diff_rx = inst
3500 .add_subscriber(
3501 "DIFF",
3502 1,
3503 crate::types::DbFieldType::Double,
3504 EventMask::VALUE.bits(),
3505 )
3506 .expect("DIFF subscriber");
3507 let _val_rx = inst
3508 .add_subscriber(
3509 "VAL",
3510 2,
3511 crate::types::DbFieldType::Double,
3512 EventMask::VALUE.bits(),
3513 )
3514 .expect("VAL subscriber");
3515 let names = |snap: &ProcessSnapshot| {
3516 snap.changed_fields
3517 .iter()
3518 .map(|(n, _, _)| n.clone())
3519 .collect::<Vec<_>>()
3520 };
3521
3522 // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
3523 // empty so change-detection treats every subscribed field as new.
3524 let (snap1, _) = inst.process_local().unwrap();
3525 assert!(
3526 names(&snap1).contains(&"DIFF".to_string()),
3527 "DIFF posts on first publish: {:?}",
3528 names(&snap1)
3529 );
3530
3531 // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
3532 // DIFF (force-listed) MUST re-post — the C unconditional MARK +
3533 // DBE_VAL_LOG. This is the divergence MOT-1 closes.
3534 let (snap2, _) = inst.process_local().unwrap();
3535 assert!(
3536 names(&snap2).contains(&"DIFF".to_string()),
3537 "force-posted DIFF must re-post when unchanged: {:?}",
3538 names(&snap2)
3539 );
3540 assert!(
3541 !names(&snap2).contains(&"VAL".to_string()),
3542 "an unchanged non-force field must not re-post: {:?}",
3543 names(&snap2)
3544 );
3545 // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
3546 // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
3547 let diff_mask = snap2
3548 .changed_fields
3549 .iter()
3550 .find(|(n, _, _)| n == "DIFF")
3551 .map(|(_, _, m)| *m)
3552 .expect("DIFF post present");
3553 assert_eq!(
3554 diff_mask.bits(),
3555 (EventMask::VALUE | EventMask::LOG).bits(),
3556 "forced re-post mask is DBE_VAL_LOG"
3557 );
3558 }
3559
3560 /// Record that names S1 in `log_swept_fields` (the scaler's idle
3561 /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
3562 /// is a control: subscribed but NOT swept, so an unchanged S2 must
3563 /// not re-post. Neither field is the primary `VAL`, so the default
3564 /// deadband field resolves to nothing and does not confound the test.
3565 struct LogSweepRecord {
3566 s1: i32,
3567 s2: i32,
3568 }
3569
3570 impl Record for LogSweepRecord {
3571 fn record_type(&self) -> &'static str {
3572 "scaler"
3573 }
3574 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3575 // Counts never change — only the unconditional idle LOG sweep
3576 // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
3577 Ok(crate::server::record::ProcessOutcome::complete())
3578 }
3579 fn get_field(&self, name: &str) -> Option<EpicsValue> {
3580 match name {
3581 "S1" => Some(EpicsValue::Long(self.s1)),
3582 "S2" => Some(EpicsValue::Long(self.s2)),
3583 _ => None,
3584 }
3585 }
3586 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3587 match (name, value) {
3588 ("S1", EpicsValue::Long(v)) => {
3589 self.s1 = v;
3590 Ok(())
3591 }
3592 ("S2", EpicsValue::Long(v)) => {
3593 self.s2 = v;
3594 Ok(())
3595 }
3596 _ => Err(CaError::FieldNotFound(name.to_string())),
3597 }
3598 }
3599 fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3600 &[]
3601 }
3602 fn log_swept_fields(&self) -> &'static [&'static str] {
3603 &["S1"]
3604 }
3605 }
3606
3607 /// C scalerRecord.c:770-787 `monitor()` sweeps each active channel
3608 /// with a literal `DBE_LOG` on every idle process: an UNCHANGED swept
3609 /// field re-posts with `DBE_LOG` ONLY, while a CHANGED swept field is
3610 /// delivered once by change-detection with `DBE_VALUE|DBE_LOG` (NOT
3611 /// double-posted by the sweep). A non-swept field never re-posts when
3612 /// unchanged. `add_subscriber` seeds `last_posted` with the current
3613 /// value (the initial value goes out via EVENT_ADD), so a freshly
3614 /// subscribed unchanged field already takes the sweep path on cycle 1.
3615 #[test]
3616 fn log_swept_field_reposts_unchanged_with_log_mask_only() {
3617 use crate::server::recgbl::EventMask;
3618 let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
3619 let _s1_rx = inst
3620 .add_subscriber(
3621 "S1",
3622 1,
3623 crate::types::DbFieldType::Long,
3624 EventMask::LOG.bits(),
3625 )
3626 .expect("S1 subscriber");
3627 let _s2_rx = inst
3628 .add_subscriber(
3629 "S2",
3630 2,
3631 crate::types::DbFieldType::Long,
3632 EventMask::VALUE.bits(),
3633 )
3634 .expect("S2 subscriber");
3635 let names = |snap: &ProcessSnapshot| {
3636 snap.changed_fields
3637 .iter()
3638 .map(|(n, _, _)| n.clone())
3639 .collect::<Vec<_>>()
3640 };
3641 let count_of = |snap: &ProcessSnapshot, f: &str| {
3642 snap.changed_fields
3643 .iter()
3644 .filter(|(n, _, _)| n == f)
3645 .count()
3646 };
3647 let mask_of = |snap: &ProcessSnapshot, f: &str| {
3648 snap.changed_fields
3649 .iter()
3650 .find(|(n, _, _)| n == f)
3651 .map(|(_, _, m)| *m)
3652 };
3653
3654 // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
3655 // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
3656 let (snap1, _) = inst.process_local().unwrap();
3657 assert!(
3658 names(&snap1).contains(&"S1".to_string()),
3659 "log-swept S1 must re-post when unchanged: {:?}",
3660 names(&snap1)
3661 );
3662 assert!(
3663 !names(&snap1).contains(&"S2".to_string()),
3664 "unchanged non-swept S2 must not re-post: {:?}",
3665 names(&snap1)
3666 );
3667 assert_eq!(
3668 mask_of(&snap1, "S1").unwrap().bits(),
3669 EventMask::LOG.bits(),
3670 "idle sweep posts DBE_LOG only (no DBE_VALUE)"
3671 );
3672
3673 // Cycle 2: S1's count changed. Change-detection delivers it ONCE
3674 // with DBE_VALUE|DBE_LOG; the sweep does NOT add a second post.
3675 inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
3676 let (snap2, _) = inst.process_local().unwrap();
3677 assert_eq!(
3678 count_of(&snap2, "S1"),
3679 1,
3680 "a changed swept field posts exactly once (no double-post): {:?}",
3681 snap2.changed_fields
3682 );
3683 assert_eq!(
3684 mask_of(&snap2, "S1").unwrap().bits(),
3685 (EventMask::VALUE | EventMask::LOG).bits(),
3686 "a changed swept field posts VALUE|LOG via change-detection"
3687 );
3688
3689 // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
3690 let (snap3, _) = inst.process_local().unwrap();
3691 assert_eq!(
3692 mask_of(&snap3, "S1").unwrap().bits(),
3693 EventMask::LOG.bits(),
3694 "unchanged-again S1 returns to the DBE_LOG-only sweep"
3695 );
3696 }
3697
3698 /// Stub record that simulates a record whose process() mutates an
3699 /// internal metadata field. Used to verify that the
3700 /// `Record::took_metadata_change()` hook actually triggers cache
3701 /// invalidation in `process_local()`.
3702 struct MutatingMetaRecord {
3703 val: f64,
3704 egu: String,
3705 took_change: bool,
3706 }
3707
3708 impl Record for MutatingMetaRecord {
3709 fn record_type(&self) -> &'static str {
3710 "ai" // pretend to be ai so populate_display_info populates EGU
3711 }
3712 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3713 // Simulate dynamic metadata change inside processing
3714 self.egu = "kV".into();
3715 self.took_change = true;
3716 Ok(crate::server::record::ProcessOutcome::complete())
3717 }
3718 fn get_field(&self, name: &str) -> Option<EpicsValue> {
3719 match name {
3720 "VAL" => Some(EpicsValue::Double(self.val)),
3721 "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
3722 "PREC" => Some(EpicsValue::Short(0)),
3723 "HOPR" => Some(EpicsValue::Double(0.0)),
3724 "LOPR" => Some(EpicsValue::Double(0.0)),
3725 _ => None,
3726 }
3727 }
3728 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3729 match (name, value) {
3730 ("VAL", EpicsValue::Double(v)) => {
3731 self.val = v;
3732 Ok(())
3733 }
3734 ("EGU", EpicsValue::String(s)) => {
3735 self.egu = s.as_str_lossy().into_owned();
3736 Ok(())
3737 }
3738 _ => Err(CaError::FieldNotFound(name.to_string())),
3739 }
3740 }
3741 fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3742 &[]
3743 }
3744 fn took_metadata_change(&mut self) -> bool {
3745 let was = self.took_change;
3746 self.took_change = false; // reset after reporting
3747 was
3748 }
3749 }
3750
3751 #[test]
3752 fn process_local_invalidates_cache_on_took_metadata_change() {
3753 let mut inst = RecordInstance::new(
3754 "MUT".to_string(),
3755 MutatingMetaRecord {
3756 val: 1.0,
3757 egu: "V".to_string(),
3758 took_change: false,
3759 },
3760 );
3761
3762 // Build the cache once with the original EGU
3763 let snap1 = inst.snapshot_for_field("VAL").unwrap();
3764 assert_eq!(snap1.display.unwrap().units, "V");
3765 assert!(inst.metadata_cache.lock().unwrap().is_some());
3766
3767 // Run process_local — the stub record sets took_change inside process()
3768 let _ = inst.process_local();
3769
3770 // Cache should now be invalidated (took_metadata_change returned true)
3771 assert!(
3772 inst.metadata_cache.lock().unwrap().is_none(),
3773 "process_local should invalidate cache when took_metadata_change is true"
3774 );
3775
3776 // Next snapshot picks up the new EGU
3777 let snap2 = inst.snapshot_for_field("VAL").unwrap();
3778 assert_eq!(snap2.display.unwrap().units, "kV");
3779 }
3780
3781 /// Stub record that does NOT mutate metadata fields. Verifies the
3782 /// default `took_metadata_change` returns false and the cache stays.
3783 struct StableMetaRecord {
3784 val: f64,
3785 }
3786 impl Record for StableMetaRecord {
3787 fn record_type(&self) -> &'static str {
3788 "ai"
3789 }
3790 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3791 self.val += 1.0;
3792 Ok(crate::server::record::ProcessOutcome::complete())
3793 }
3794 fn get_field(&self, name: &str) -> Option<EpicsValue> {
3795 match name {
3796 "VAL" => Some(EpicsValue::Double(self.val)),
3797 "EGU" => Some(EpicsValue::String("V".into())),
3798 "PREC" => Some(EpicsValue::Short(0)),
3799 "HOPR" => Some(EpicsValue::Double(0.0)),
3800 "LOPR" => Some(EpicsValue::Double(0.0)),
3801 _ => None,
3802 }
3803 }
3804 fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
3805 Ok(())
3806 }
3807 fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3808 &[]
3809 }
3810 // took_metadata_change uses default impl (returns false)
3811 }
3812
3813 #[test]
3814 fn process_local_keeps_cache_when_no_metadata_change() {
3815 let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
3816
3817 let _ = inst.snapshot_for_field("VAL");
3818 assert!(inst.metadata_cache.lock().unwrap().is_some());
3819
3820 // Run process_local several times — cache should remain intact
3821 let _ = inst.process_local();
3822 assert!(inst.metadata_cache.lock().unwrap().is_some());
3823 let _ = inst.process_local();
3824 assert!(inst.metadata_cache.lock().unwrap().is_some());
3825 let _ = inst.process_local();
3826 assert!(inst.metadata_cache.lock().unwrap().is_some());
3827 }
3828
3829 // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
3830
3831 /// motor `prop(YES)` fields (motorRecord.dbd 154/161/289/361/368)
3832 /// are property-class: a changed write must post DBE_PROPERTY
3833 /// (C dbAccess.c dbPut, `pfldDes->prop`). They feed the
3834 /// live-computed `field_metadata_override`, not the cache, but the
3835 /// posting gate is this same set.
3836 #[test]
3837 fn motor_prop_yes_fields_are_property_class() {
3838 for f in ["VBAS", "VMAX", "MRES", "DHLM", "DLLM"] {
3839 assert!(is_metadata_field(f), "{f} must be property-class");
3840 }
3841 }
3842
3843 /// Boundary 1: metadata field written with a CHANGED value, subscriber
3844 /// mask includes PROPERTY → subscriber receives an event.
3845 /// Mirrors C dbAccess.c:1396-1397 `db_post_events(precord,NULL,DBE_PROPERTY)`.
3846 #[test]
3847 fn r47_property_event_delivered_on_changed_metadata() {
3848 use crate::server::recgbl::EventMask;
3849 let mut inst = ai_instance();
3850 let mut rx = inst
3851 .add_subscriber(
3852 "VAL",
3853 1,
3854 crate::types::DbFieldType::Double,
3855 EventMask::PROPERTY.bits(),
3856 )
3857 .expect("subscriber added");
3858
3859 let prev = inst.record.get_field("EGU"); // "degC"
3860 let _ = inst
3861 .record
3862 .put_field("EGU", EpicsValue::String("kPa".into()));
3863 inst.notify_field_written_if_changed("EGU", prev.as_ref());
3864
3865 assert!(
3866 rx.try_recv().is_ok(),
3867 "PROPERTY subscriber must receive event when metadata field changes"
3868 );
3869 }
3870
3871 /// Boundary 2: same metadata field written with the SAME value → NO event.
3872 /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
3873 #[test]
3874 fn r47_no_event_on_unchanged_metadata() {
3875 use crate::server::recgbl::EventMask;
3876 let mut inst = ai_instance();
3877 let mut rx = inst
3878 .add_subscriber(
3879 "VAL",
3880 1,
3881 crate::types::DbFieldType::Double,
3882 EventMask::PROPERTY.bits(),
3883 )
3884 .expect("subscriber added");
3885
3886 let prev = inst.record.get_field("EGU"); // "degC"
3887 // Write the same value — no change
3888 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
3889 inst.notify_field_written_if_changed("EGU", prev.as_ref());
3890
3891 assert!(
3892 rx.try_recv().is_err(),
3893 "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
3894 );
3895 }
3896
3897 /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
3898 /// from a metadata write, even when the field value changed.
3899 #[test]
3900 fn r47_value_only_subscriber_no_event_on_metadata_write() {
3901 use crate::server::recgbl::EventMask;
3902 let mut inst = ai_instance();
3903 let mut rx = inst
3904 .add_subscriber(
3905 "VAL",
3906 1,
3907 crate::types::DbFieldType::Double,
3908 EventMask::VALUE.bits(),
3909 )
3910 .expect("subscriber added");
3911
3912 let prev = inst.record.get_field("EGU"); // "degC"
3913 let _ = inst
3914 .record
3915 .put_field("EGU", EpicsValue::String("kPa".into()));
3916 inst.notify_field_written_if_changed("EGU", prev.as_ref());
3917
3918 assert!(
3919 rx.try_recv().is_err(),
3920 "VALUE-only subscriber must NOT receive event from a metadata write"
3921 );
3922 }
3923
3924 /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
3925 /// event after process_local() when the record reports a metadata change.
3926 #[test]
3927 fn r47_process_local_property_event_on_took_metadata_change() {
3928 use crate::server::recgbl::EventMask;
3929 let mut inst = RecordInstance::new(
3930 "MUT2".to_string(),
3931 MutatingMetaRecord {
3932 val: 1.0,
3933 egu: "V".to_string(),
3934 took_change: false,
3935 },
3936 );
3937 let mut rx = inst
3938 .add_subscriber(
3939 "VAL",
3940 1,
3941 crate::types::DbFieldType::Double,
3942 EventMask::PROPERTY.bits(),
3943 )
3944 .expect("subscriber added");
3945
3946 // process() sets took_change = true and updates egu to "kV"
3947 let _ = inst.process_local();
3948
3949 assert!(
3950 rx.try_recv().is_ok(),
3951 "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
3952 );
3953 }
3954}
3955
3956#[cfg(test)]
3957mod aftc_filter_tests {
3958 //! Tests for the shared AFTC alarm-range filter
3959 //! (`records::alarm_filter::aftc_filter`) as driven by
3960 //! `evaluate_analog_alarm`. Pure-function tests: no record instance
3961 //! needed — the filter is a stateless transform of (raw_alarm, aftc,
3962 //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
3963 //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
3964
3965 use crate::server::records::alarm_filter::aftc_filter;
3966 use std::time::{Duration, SystemTime};
3967
3968 fn at(secs: f64) -> SystemTime {
3969 SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
3970 }
3971
3972 #[test]
3973 fn disabled_when_aftc_le_zero() {
3974 // aftc=0 means filter disabled — pass-through.
3975 let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
3976 assert_eq!(out, 2);
3977 assert_eq!(afvl, 0.0);
3978 }
3979
3980 #[test]
3981 fn initial_sample_seeds_state_unchanged_alarm() {
3982 // afvl=0 means first sample after enable — alarm passes through
3983 // and accumulator seeds with the raw severity.
3984 let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
3985 assert_eq!(out, 2);
3986 assert_eq!(afvl, 2.0);
3987 }
3988
3989 #[test]
3990 fn raises_alarm_only_after_full_time_constant() {
3991 // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
3992 // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
3993 // produce alarm=2 yet — the filter must hold off until the
3994 // accumulator crosses the threshold.
3995 // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
3996 // yet"); the filter must keep alarm at 0 after one short tick.
3997 let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
3998 assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
3999 assert!(afvl > 0.0 && afvl < 2.0);
4000 }
4001
4002 #[test]
4003 fn dt_zero_is_no_op() {
4004 // Two evaluations at the same instant produce no filter advance.
4005 let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
4006 assert_eq!(out, 1); // floor(|1.5|) = 1
4007 assert_eq!(afvl, 1.5);
4008 }
4009
4010 #[test]
4011 fn long_steady_state_converges_to_alarm() {
4012 // After many steps with raw_alarm=2 and dt much smaller than aftc,
4013 // the accumulator must converge towards 2.
4014 let aftc = 1.0;
4015 let mut afvl = 0.0;
4016 let mut last = at(0.0);
4017 let mut alarm = 0;
4018 for i in 1..=100 {
4019 let now = at(i as f64 * 0.05);
4020 let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
4021 alarm = out;
4022 afvl = new_afvl;
4023 last = now;
4024 }
4025 assert_eq!(
4026 alarm, 2,
4027 "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
4028 );
4029 assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
4030 }
4031}
4032
4033#[cfg(test)]
4034mod check_deadband_tests {
4035 use super::check_deadband;
4036
4037 /// Sentinel: `oldval=NaN` means "no prior posting", always fire.
4038 #[test]
4039 fn nan_old_value_fires() {
4040 assert!(check_deadband(0.0, f64::NAN, 1.0));
4041 assert!(check_deadband(f64::NAN, f64::NAN, 1.0));
4042 }
4043
4044 /// C path: `delta > deadband` with both finite. delta within deadband
4045 /// must NOT fire.
4046 #[test]
4047 fn within_finite_deadband_does_not_fire() {
4048 assert!(!check_deadband(10.0, 10.5, 1.0));
4049 assert!(!check_deadband(10.0, 9.5, 1.0));
4050 // Boundary: `delta == deadband` is NOT strictly greater.
4051 assert!(!check_deadband(10.0, 11.0, 1.0));
4052 }
4053
4054 /// `delta > deadband` with both finite, beyond → fire.
4055 #[test]
4056 fn beyond_finite_deadband_fires() {
4057 assert!(check_deadband(10.0, 12.0, 1.0));
4058 }
4059
4060 /// Negative deadband acts as "always fire" (C `delta > deadband` is
4061 /// trivially true for any non-negative delta).
4062 #[test]
4063 fn negative_deadband_fires() {
4064 assert!(check_deadband(10.0, 10.0, -1.0));
4065 }
4066
4067 /// C parity bug fix (recGbl.c:355-358): exactly one of {newval,
4068 /// oldval} is NaN — fire. Rust port previously short-circuited only
4069 /// on `oldval=NaN`; `newval=NaN` with `oldval=finite` produced
4070 /// `(NaN - finite).abs() = NaN`, `NaN > deadband = false` →
4071 /// silently dropped the NaN transition. End effect: a record that
4072 /// went UDF (e.g. divide-by-zero in calc) never posted the change
4073 /// to monitors, leaving every camonitor seeing the last valid value.
4074 #[test]
4075 fn newval_nan_with_finite_oldval_fires() {
4076 assert!(check_deadband(f64::NAN, 10.0, 1.0));
4077 }
4078
4079 /// C path case 2 (recGbl.c:355): exactly one infinite, the other
4080 /// finite — fire.
4081 #[test]
4082 fn one_finite_one_infinite_fires() {
4083 assert!(check_deadband(f64::INFINITY, 10.0, 1.0));
4084 assert!(check_deadband(10.0, f64::INFINITY, 1.0));
4085 assert!(check_deadband(f64::NEG_INFINITY, 10.0, 1.0));
4086 }
4087
4088 /// C path case 3 (recGbl.c:360-362): both infinite with opposite
4089 /// signs — fire.
4090 #[test]
4091 fn opposite_signed_infinities_fire() {
4092 assert!(check_deadband(f64::INFINITY, f64::NEG_INFINITY, 1.0));
4093 assert!(check_deadband(f64::NEG_INFINITY, f64::INFINITY, 1.0));
4094 }
4095
4096 /// Same-signed infinity → no fire (C path leaves `delta = 0`,
4097 /// `0 > deadband` is false for any non-negative deadband).
4098 #[test]
4099 fn same_signed_infinity_does_not_fire() {
4100 assert!(!check_deadband(f64::INFINITY, f64::INFINITY, 1.0));
4101 assert!(!check_deadband(f64::NEG_INFINITY, f64::NEG_INFINITY, 1.0));
4102 }
4103}
4104
4105#[cfg(test)]
4106mod common_field_dbload_tests {
4107 use super::*;
4108 use crate::server::records::ai::AiRecord;
4109
4110 /// The db loader feeds every common field to `put_common_field` as an
4111 /// `EpicsValue::String`. Each numeric/menu common field directive must
4112 /// take effect at load — both the integer form (`field(PHAS, "1")`) and
4113 /// the menu-label form (`field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`) —
4114 /// rather than being silently dropped because the arm matched only its
4115 /// typed variant. One assertion per affected common-field arm.
4116 #[test]
4117 fn db_loaded_string_common_fields_take_effect() {
4118 let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
4119 let put = |inst: &mut RecordInstance, f: &str, v: &str| {
4120 inst.put_common_field(f, EpicsValue::String(v.into()))
4121 .unwrap_or_else(|e| panic!("put_common_field({f}, {v:?}) failed: {e}"));
4122 };
4123
4124 // Integer-valued directives.
4125 put(&mut inst, "PHAS", "1");
4126 assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
4127 put(&mut inst, "TSE", "-2");
4128 assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
4129 put(&mut inst, "DISV", "1");
4130 assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
4131 put(&mut inst, "DISA", "1");
4132 assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
4133 put(&mut inst, "LCNT", "3");
4134 assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
4135 put(&mut inst, "DISP", "1");
4136 assert!(inst.common.disp, "field(DISP, \"1\")");
4137 put(&mut inst, "UDF", "0");
4138 assert!(!inst.common.udf, "field(UDF, \"0\")");
4139
4140 // Menu-label directives (resolved via resolve_menu_string).
4141 put(&mut inst, "PRIO", "HIGH");
4142 assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
4143 put(&mut inst, "DISS", "MAJOR");
4144 assert_eq!(
4145 inst.common.diss,
4146 AlarmSeverity::Major,
4147 "field(DISS, \"MAJOR\")"
4148 );
4149 put(&mut inst, "UDFS", "NO_ALARM");
4150 assert_eq!(
4151 inst.common.udfs,
4152 AlarmSeverity::NoAlarm,
4153 "field(UDFS, \"NO_ALARM\")"
4154 );
4155 put(&mut inst, "ACKT", "NO");
4156 assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
4157
4158 // Numeric form of a menu field still works (field(PRIO, "0")).
4159 put(&mut inst, "PRIO", "0");
4160 assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
4161
4162 // A String-typed common field is untouched by the coercion.
4163 put(&mut inst, "DESC", "a description");
4164 assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
4165 }
4166}