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