Skip to main content

epics_base_rs/server/record/
record_trait.rs

1use crate::error::CaResult;
2use crate::types::{DbFieldType, EpicsValue};
3
4use super::scan::ScanType;
5
6/// Metadata describing a single field in a record.
7#[derive(Debug, Clone)]
8pub struct FieldDesc {
9    pub name: &'static str,
10    pub dbf_type: DbFieldType,
11    pub read_only: bool,
12}
13
14/// Outcome of a record's array-style monitor decision, returned by
15/// [`Record::array_monitor_post`] (C waveform/aai/aao `monitor()`,
16/// waveformRecord.c:291-326).
17#[derive(Debug, Clone, Copy)]
18pub struct ArrayMonitorPost {
19    /// Include `DBE_VALUE` on the VAL post this cycle (MPST = Always, or
20    /// MPST = On Change with a changed hash).
21    pub post_value: bool,
22    /// Include `DBE_LOG` on the VAL post this cycle (APST = Always, or
23    /// APST = On Change with a changed hash).
24    pub post_archive: bool,
25    /// The content hash changed this cycle (On Change mode) — the owner
26    /// posts `HASH` with a literal `DBE_VALUE`.
27    pub hash_changed: bool,
28}
29
30/// Per-field metadata deltas returned by
31/// [`Record::field_metadata_override`].
32///
33/// Each `Some` member replaces the corresponding member of the
34/// snapshot's record-level display/control metadata; `None` members
35/// keep the record-level value.
36#[derive(Debug, Clone, Default)]
37pub struct FieldMetadataOverride {
38    /// `display.units` — C RSET `get_units`.
39    pub units: Option<crate::types::PvString>,
40    /// `display.precision` — C RSET `get_precision`.
41    pub precision: Option<i16>,
42    /// `(upper, lower)` display limits — C RSET `get_graphic_double`.
43    pub disp_limits: Option<(f64, f64)>,
44    /// `(upper, lower)` control limits — C RSET `get_control_double`.
45    pub ctrl_limits: Option<(f64, f64)>,
46    /// `(hihi, high, low, lolo)` — C RSET `get_alarm_double`.
47    pub alarm_limits: Option<(f64, f64, f64, f64)>,
48}
49
50/// Side-effect actions that a record requests from the processing framework.
51///
52/// Records return these from `process()` via `ProcessOutcome::actions`.
53/// The framework executes them at the appropriate point in the processing
54/// cycle, keeping records as pure state machines without direct DB access.
55#[derive(Clone, Debug, PartialEq)]
56pub enum ProcessAction {
57    /// Write a value to a DB link. The framework reads `link_field` from the
58    /// record to get the target PV name, then writes `value` to that PV.
59    ///
60    /// Executed after alarm/snapshot, before FLNK.
61    /// Example: scaler writes CNT to COUT/COUTP links.
62    WriteDbLink {
63        link_field: &'static str,
64        value: EpicsValue,
65    },
66
67    /// Read a value from a DB link into a record field. The framework reads
68    /// `link_field` from the record to get the source PV name, reads that PV,
69    /// and writes the result into `target_field` via an internal put that
70    /// bypasses read-only checks.
71    ///
72    /// The value delivered is the link target's **native** [`EpicsValue`] — it
73    /// is NOT coerced to a numeric type on the way in. The record coerces (or
74    /// preserves) it at its own `put_field`/`put_field_internal` boundary, so a
75    /// string-class source can reach a string field byte-exact (the `sseq`
76    /// `DOLn`→`STRn` path, C `sseqRecord.c:643-705`). Records whose
77    /// `target_field` is numeric simply convert there, exactly as before.
78    ///
79    /// **Pre-process action**: executed BEFORE the next process() cycle so
80    /// the value is immediately available. This matches C EPICS `dbGetLink()`
81    /// which is synchronous/immediate.
82    ///
83    /// Example: throttle reads SINP into VAL when SYNC is triggered.
84    ReadDbLink {
85        link_field: &'static str,
86        target_field: &'static str,
87    },
88
89    /// Schedule a re-process of this record after the given duration.
90    /// The framework spawns `tokio::spawn(sleep(d) + process_record(name))`.
91    /// The current cycle's OUT/FLNK/notify proceed normally.
92    ///
93    /// Equivalent to C EPICS `callbackRequestDelayed()` + `scanOnce()`.
94    ReprocessAfter(std::time::Duration),
95
96    /// Send a named command to the device support driver.
97    /// The framework calls `DeviceSupport::handle_command()` with this data.
98    /// Used by scaler to request reset/arm/write_preset operations
99    /// without the record holding a direct driver reference.
100    DeviceCommand {
101        command: &'static str,
102        args: Vec<EpicsValue>,
103    },
104
105    /// Write a value to a DB link as a put-*with-completion*, then re-enter
106    /// THIS record's `process()` when the downstream operation completes.
107    ///
108    /// The framework arms a put-notify wait-set (C `dbProcessNotify`),
109    /// writes `link_field`'s target through it, releases the initiator's
110    /// own count, and wires the completion to an async re-entry of this
111    /// record (`mint_async_token` + `reprocess_on_notify`). The record
112    /// returns [`RecordProcessResult::AsyncPending`] alongside this action
113    /// and is re-entered once the downstream record (and its FLNK/OUT
114    /// chain) finishes — the synApps `sseq` `WAITn` "wait for the put
115    /// callback" dependency (`sseqRecord.c::processNextLink`,
116    /// `dbCaPutLinkCallback`). Built on the same `new_put_notify` +
117    /// `reprocess_on_notify` primitive an out-of-band
118    /// [`crate::server::database::AsyncDbHandle`] caller uses.
119    ///
120    /// Executed before FLNK, like [`Self::WriteDbLink`].
121    WriteDbLinkNotify {
122        link_field: &'static str,
123        value: EpicsValue,
124    },
125
126    /// Cancel this record's outstanding async re-entry (C
127    /// `callbackCancelDelayed`): the framework advances the record's
128    /// re-entry generation so any pending `ReprocessAfter` timer or
129    /// `WriteDbLinkNotify` completion re-entry becomes a structural no-op
130    /// (the `AsyncToken` gate), with no runtime "is-aborted" check on the
131    /// re-entry path. Used by `sseq` `ABORT` to drop a pending `DLYn`
132    /// delay or `WAITn` wait; the record resets its own sequence state in
133    /// the same `process()` cycle that emits this.
134    CancelReprocess,
135}
136
137/// Result of a record's process() call.
138///
139/// Determines how the framework handles the current processing cycle.
140/// Side-effect actions (link writes, delayed reprocess, etc.) are expressed
141/// separately in `ProcessOutcome::actions`.
142#[derive(Clone, Debug, PartialEq)]
143pub enum RecordProcessResult {
144    /// Processing completed synchronously this cycle.
145    /// Framework proceeds with alarm/timestamp/snapshot/OUT/FLNK.
146    Complete,
147    /// Processing started but not yet complete (PACT stays set).
148    /// Current cycle skips alarm/timestamp/snapshot/OUT/FLNK.
149    /// ProcessActions (if any) are still executed.
150    AsyncPending,
151    /// Async pending, but notify these intermediate field changes immediately.
152    /// Used by motor records to flush DMOV=0 before the move completes.
153    AsyncPendingNotify(Vec<(String, EpicsValue)>),
154    /// Completed synchronously (PACT cleared, unlike `AsyncPending`), but the
155    /// record produced no new value to publish this cycle — the framework must
156    /// skip the value-publication epilogue (UDF clear / timestamp / monitor /
157    /// FLNK). C parity `compressRecord.c:365` `if (status != 1)`: a compress
158    /// record still accumulating toward its next compressed sample runs none of
159    /// `recGblGetTimeStamp` / `monitor` / `recGblFwdLink` on that cycle.
160    CompleteNoEmit,
161    /// Ran the value-publication epilogue NOW (UDF clear / timestamp / monitor —
162    /// VAL and the alarm fields are posted this cycle), but the OUTPUT side (OUT
163    /// link write / OEVT / forward link) is deferred to a scheduled
164    /// reprocess, with PACT held across the wait. C parity `swaitRecord.c::process`
165    /// (lines 425-481): when `schedOutput` arms the ODLY watchdog it sets
166    /// `async=TRUE`, so `process` still runs `monitor()` (line 475) — posting the
167    /// value side at the START of the delay — but skips the `if(!async)
168    /// {recGblFwdLink; pact=FALSE;}` tail; the deferred `execOutput` (watchdog,
169    /// at delay-END) does the OUT write + OEVT + forward link and posts no
170    /// monitors. Unlike the calcout/scalcout/acalcout family, whose C `process`
171    /// `return`s BEFORE `monitor()` (calcoutRecord.c:282, only `dlya` posted), so
172    /// they defer the value side too and use `AsyncPendingNotify`. The deferral
173    /// must carry a [`ProcessAction::ReprocessAfter`] — that scheduled reprocess
174    /// is the continuation that releases the held PACT (same by-construction
175    /// invariant as the `AsyncPendingNotify` ODLY defer).
176    CompleteDeferOutput,
177}
178
179/// Complete outcome of a record's process() call.
180///
181/// Contains the processing result (Complete, AsyncPending, etc.) and a list
182/// of side-effect actions for the framework to execute.
183#[derive(Clone, Debug)]
184pub struct ProcessOutcome {
185    pub result: RecordProcessResult,
186    pub actions: Vec<ProcessAction>,
187    /// Set by the framework when device support's read() returned
188    /// `did_compute: true`. The record's process() can check this to
189    /// skip its built-in computation (e.g., PID). Replaces the `pid_done`
190    /// flag pattern.
191    pub device_did_compute: bool,
192}
193
194impl ProcessOutcome {
195    /// Shorthand for a simple Complete with no actions.
196    pub fn complete() -> Self {
197        Self {
198            result: RecordProcessResult::Complete,
199            actions: Vec::new(),
200            device_did_compute: false,
201        }
202    }
203
204    /// Shorthand for Complete with actions.
205    pub fn complete_with(actions: Vec<ProcessAction>) -> Self {
206        Self {
207            result: RecordProcessResult::Complete,
208            actions,
209            device_did_compute: false,
210        }
211    }
212
213    /// Completed synchronously, but no new value was emitted this cycle, so
214    /// the framework skips the value-publication epilogue (UDF clear /
215    /// timestamp / monitor / FLNK). See `RecordProcessResult::CompleteNoEmit`.
216    pub fn complete_no_emit() -> Self {
217        Self {
218            result: RecordProcessResult::CompleteNoEmit,
219            actions: Vec::new(),
220            device_did_compute: false,
221        }
222    }
223
224    /// Shorthand for AsyncPending with no actions.
225    pub fn async_pending() -> Self {
226        Self {
227            result: RecordProcessResult::AsyncPending,
228            actions: Vec::new(),
229            device_did_compute: false,
230        }
231    }
232}
233
234impl Default for ProcessOutcome {
235    fn default() -> Self {
236        Self::complete()
237    }
238}
239
240/// Result of setting a common field, indicating what scan index updates are needed.
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub enum CommonFieldPutResult {
243    NoChange,
244    ScanChanged {
245        old_scan: ScanType,
246        new_scan: ScanType,
247        phas: i16,
248    },
249    PhasChanged {
250        scan: ScanType,
251        old_phas: i16,
252        new_phas: i16,
253    },
254}
255
256/// Read-only snapshot of framework-owned `CommonFields` state that a
257/// record's `process()` or device support's `read()` needs to see
258/// *during* the processing cycle.
259///
260/// The framework owns `RecordInstance.common`; a record `process()`
261/// receives only `&mut self` (the concrete record) and device support
262/// `read()` receives only `&mut dyn Record`. Neither can reach
263/// `CommonFields`. C records, by contrast, see `dbCommon` directly —
264/// e.g. `epidRecord.c:195` reads `pepid->udf`, `timestampRecord.c:90`
265/// reads `ptimestamp->tse`, `devTimeOfDay.c:122` reads `psi->phas`.
266///
267/// The framework builds a `ProcessContext` from `common` and pushes it
268/// onto the record (via [`Record::set_process_context`]) and onto the
269/// device support (via
270/// [`crate::server::device_support::DeviceSupport::set_process_context`])
271/// immediately before the respective call. This mirrors the existing
272/// `set_device_did_compute` framework-set-hook pattern: additive,
273/// no `process()` / `read()` signature change.
274#[derive(Clone, Debug, PartialEq)]
275pub struct ProcessContext {
276    /// `dbCommon.udf` — value is undefined. C records check this at the
277    /// top of `process()` (e.g. `epidRecord.c:195`).
278    pub udf: bool,
279    /// `dbCommon.udfs` — alarm severity raised for a UDF record.
280    pub udfs: crate::server::record::AlarmSeverity,
281    /// `dbCommon.phas` — phase. Used by device support for format
282    /// selection (`devTimeOfDay.c:122`).
283    pub phas: i16,
284    /// `dbCommon.tse` — time-stamp event. `timestampRecord.c:90`
285    /// branches on `tse == epicsTimeEventDeviceTime`.
286    pub tse: i16,
287    /// `dbCommon.time` — the record's current resolved time stamp at the
288    /// start of this cycle (the previous cycle's stamp, or `UNIX_EPOCH`
289    /// before the first process). Device support that has to format the
290    /// record's time during `read()` — the std module's `devTimeOfDay.c`
291    /// `recGblGetTimeStamp(psi)` call, which runs *before* the framework's
292    /// per-cycle timestamp application — resolves the stamp with
293    /// [`crate::server::recgbl::get_time_stamp`]`(tse, time)`. The `time`
294    /// member is the device-provided value that helper returns verbatim on
295    /// the `TSE == epicsTimeEventDeviceTime (-2)` branch.
296    pub time: std::time::SystemTime,
297    /// `dbCommon.tsel` — time-stamp event link string.
298    pub tsel: String,
299    /// `dbCommon.dtyp` — device-support type name. A record's
300    /// `process()` / pre-process hooks can branch on the DTYP to mirror
301    /// C device support that lives in a separate DSET (e.g. the epid
302    /// record's `devEpidSoftCallback` callback DSET drives the TRIG
303    /// readback link, whereas `devEpidSoft` does not).
304    pub dtyp: String,
305}
306
307/// C `epicsTime.h`: `epicsTimeEventDeviceTime` — the `TSE` sentinel
308/// meaning "device support provides the time stamp". `timestampRecord.c`
309/// uses it to take the OS-clock branch instead of `recGblGetTimeStamp`.
310pub const EPICS_TIME_EVENT_DEVICE_TIME: i16 = -2;
311
312/// Snapshot of changes from a process cycle, used for notify outside lock.
313pub struct ProcessSnapshot {
314    /// `(field, value, mask)` — every posted field carries its own
315    /// `DBE_*` posting mask, mirroring C's per-field
316    /// `db_post_events(prec, &field, mask)`. One process cycle posts
317    /// different classes per field: a deadband-gated readback narrows
318    /// to the deadbands that actually crossed (MDEL → `DBE_VALUE`,
319    /// ADEL → `DBE_LOG`; motorRecord.cc `monitor()` 3477-3507,
320    /// aiRecord.c `monitor()`), while a change-detected auxiliary
321    /// field posts `DBE_VALUE | DBE_LOG` (motorRecord.cc 3522-3645
322    /// `DBE_VAL_LOG`; calcRecord.c:420). A single record-wide mask
323    /// collapses that granularity — an archive-only deadband crossing
324    /// would wrongly reach `DBE_VALUE` subscribers whenever any other
325    /// field changed in the same pass.
326    pub changed_fields: Vec<(String, EpicsValue, crate::server::recgbl::EventMask)>,
327}
328
329/// Trait that all EPICS record types must implement.
330pub trait Record: Send + Sync + 'static {
331    /// Return the record type name (e.g., "ai", "ao", "bi").
332    fn record_type(&self) -> &'static str;
333
334    /// Process the record (scan/compute cycle).
335    ///
336    /// Returns a `ProcessOutcome` containing the processing result and any
337    /// side-effect actions for the framework to execute.
338    fn process(&mut self) -> CaResult<ProcessOutcome> {
339        Ok(ProcessOutcome::complete())
340    }
341
342    /// Optional: report whether this record's last `process()` call
343    /// mutated a metadata-class field (EGU/PREC/HOPR/LOPR/HLM/LLM/
344    /// alarm limits / DRVH/DRVL / state strings).
345    ///
346    /// The framework checks this after every `process()` call and, if
347    /// true, invalidates the record's metadata cache so the next
348    /// snapshot rebuilds from the new values.
349    ///
350    /// Default: `false` — most records never touch metadata fields
351    /// during processing. Override only when your record dynamically
352    /// adjusts limits or unit strings (e.g., a motor that recomputes
353    /// HLM/LLM after a hardware homing operation).
354    ///
355    /// Implementations should reset their internal flag after returning
356    /// `true` so the next cycle starts clean.
357    fn took_metadata_change(&mut self) -> bool {
358        false
359    }
360
361    /// Get a field value by name.
362    fn get_field(&self, name: &str) -> Option<EpicsValue>;
363
364    /// Set a field value by name.
365    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()>;
366
367    /// Return the list of field descriptors.
368    fn field_list(&self) -> &'static [FieldDesc];
369
370    /// Choice strings for a record-specific `DBF_MENU` field served as
371    /// `DBR_ENUM`, keyed by field name (uppercase, as declared).
372    ///
373    /// EPICS dbStaticLib serves a `DBF_MENU` field as `DBR_ENUM`: the value
374    /// is the menu index and the field carries its `menu()` choice strings,
375    /// so `caget`/`pvget` present the labels rather than a bare number
376    /// (`dbStaticLib.c` `dbGetMenuChoices`; `dbAccess.c` `get_enum_str`).
377    /// A record returns the label table (in index order) for each field it
378    /// serves as [`DbFieldType::Enum`] from a `menu()`; the framework
379    /// attaches it to the field snapshot's `EnumInfo` so the CA/PVA enum
380    /// encoders present the labels — the same mechanism `bi`/`bo`/`mbbi`/
381    /// `mbbo` already use for their `VAL` state strings, but per field
382    /// rather than per record (a record can carry several distinct menus).
383    ///
384    /// This is the single owner of "menu field -> choice table": a record
385    /// declares its menu fields here once, and `get_field` returns the menu
386    /// index as [`EpicsValue::Enum`]. Default: no record-specific menu
387    /// fields. The dbCommon menu fields (`SCAN`, etc.) are handled
388    /// separately by the framework, not here.
389    fn menu_field_choices(&self, _field: &str) -> Option<&'static [&'static str]> {
390        None
391    }
392
393    /// Per-field override of the record-level display/control metadata
394    /// for a GET / monitor snapshot of `field`.
395    ///
396    /// C record support serves metadata PER FIELD: the RSET functions
397    /// `get_units` / `get_precision` / `get_graphic_double` /
398    /// `get_control_double` / `get_alarm_double` all key on
399    /// `dbGetFieldIndex(paddr)` and fall back to the `recGbl*` defaults
400    /// for unlisted fields. The framework's metadata cache is per
401    /// record (built by `populate_display_info` /
402    /// `populate_control_info` from the VAL-class fields); a record
403    /// whose RSET serves different metadata for non-VAL fields
404    /// overrides this hook to patch the cached values for that field
405    /// (e.g. the motor record: VELO's display range is VMAX/VBAS, not
406    /// HLM/LLM — `motorRecord.cc:3247-3250`).
407    ///
408    /// Applied on both the GET path (`snapshot_for_field`) and the
409    /// monitor path (`make_monitor_snapshot`), AFTER the cached
410    /// record-level metadata — and computed live on each call, so an
411    /// override derived from non-cached fields can never go stale.
412    /// `field` is uppercase, as declared in [`Record::field_list`].
413    /// Default: `None` — record-level metadata serves every field.
414    fn field_metadata_override(&self, _field: &str) -> Option<FieldMetadataOverride> {
415        None
416    }
417
418    /// Field names this record serves as a *long string*: a `DBF_CHAR`
419    /// array field that semantically holds a NUL-terminated string.
420    ///
421    /// In EPICS such a field is declared `DBF_NOACCESS` (or carries a `$`
422    /// modifier) and is accessed through a `DBR_CHAR` array view whose
423    /// `form` is `"String"`; pvxs maps that view to a scalar `pvString`
424    /// rather than an `int8[]` (`ioc/channel.cpp:58-68`,
425    /// `ioc/iocsource.cpp:619-643`). QSRV uses this list to serve those
426    /// fields as scalar-string NTScalar values instead of byte scalars.
427    ///
428    /// The record keeps its `CharArray` storage; the QSRV boundary does
429    /// the `CharArray <-> String` conversion. Default empty — only
430    /// long-string record types (`lsi`/`lso` VAL/OVAL, `printf` VAL)
431    /// override this. Names are matched case-insensitively.
432    fn long_string_fields(&self) -> &'static [&'static str] {
433        &[]
434    }
435
436    /// Field names declared `pp(TRUE)` in this record type's DBD (empty if
437    /// none, e.g. `event`/`histogram`, or if the type is unmodeled).
438    ///
439    /// Drives the `dbPutField` processing gate: C `dbAccess.c:1263`
440    /// re-processes a record on a put only when the put field is `PROC` or it
441    /// is `pp(TRUE)` **and** `SCAN == Passive`. The table is total and
442    /// fail-safe — an unmodeled type returns `&[]` (and warns once), so its
443    /// field puts never auto-process (only `PROC` does). The default consults
444    /// the central DBD-sourced table keyed by [`Record::record_type`]; record
445    /// types can override.
446    fn process_passive_fields(&self) -> &'static [&'static str] {
447        super::process_passive::pp_fields_for(self.record_type())
448    }
449
450    /// Whether a put to `field` should reprocess this Passive record.
451    ///
452    /// The default is pure `pp(TRUE)` membership — the put gate's
453    /// `field in process_passive_fields()` test. A record type overrides this
454    /// when its C `special()` conditionally returns ERROR to suppress the
455    /// reprocess for a `pp(TRUE)` field on certain values (e.g. motor STUP:
456    /// only a `STUP == ON` put runs the status-update process; any other value
457    /// is clamped to OFF and C returns ERROR so no process runs). Modeling that
458    /// here keeps the suppression at the same gate as the pp test, with no
459    /// per-put one-shot state — the post-clamp field value is deterministic.
460    fn processes_after_put(&self, field: &str) -> bool {
461        self.process_passive_fields()
462            .iter()
463            .any(|f| f.eq_ignore_ascii_case(field))
464    }
465
466    /// Validate a put before it is applied. Return Err to reject.
467    fn validate_put(&self, _field: &str, _value: &EpicsValue) -> CaResult<()> {
468        Ok(())
469    }
470
471    /// Hook called after a successful put_field.
472    fn on_put(&mut self, _field: &str) {}
473
474    /// Primary field name (default "VAL"). Override for waveform etc.
475    fn primary_field(&self) -> &'static str {
476        "VAL"
477    }
478
479    /// Get the primary value.
480    fn val(&self) -> Option<EpicsValue> {
481        self.get_field(self.primary_field())
482    }
483
484    /// Set the primary value.
485    ///
486    /// Matches C EPICS `dbPut` behavior: if the value type doesn't match
487    /// the field type, it is automatically coerced (e.g., Long→Double for
488    /// ai, Long→Enum for bi/mbbi). This prevents silent failures when
489    /// asyn device support provides Int32 values to Enum-typed records.
490    fn set_val(&mut self, value: EpicsValue) -> CaResult<()> {
491        let field = self.primary_field();
492        match self.put_field(field, value.clone()) {
493            Ok(()) => Ok(()),
494            Err(crate::error::CaError::TypeMismatch(_)) => {
495                // Auto-coerce: determine target type from current VAL
496                let target_type = self
497                    .get_field(field)
498                    .map(|v| v.db_field_type())
499                    .unwrap_or(DbFieldType::Double);
500                let coerced = value.convert_to(target_type);
501                self.put_field(field, coerced)
502            }
503            Err(e) => Err(e),
504        }
505    }
506
507    /// Whether this record implements the `DTYP="Raw Soft Channel"`
508    /// read path via [`Record::apply_raw_input`]. Records that return
509    /// `true` opt into framework routing of the INP link value through
510    /// `apply_raw_input` (RVAL + MASK) instead of the default
511    /// soft-channel `VAL` direct write.
512    ///
513    /// Default `false` keeps any record that has not been wired for
514    /// raw soft channel on the legacy path (which sets VAL directly).
515    fn accepts_raw_soft_input(&self) -> bool {
516        false
517    }
518
519    /// Apply a value read from a `DTYP="Raw Soft Channel"` INP link.
520    ///
521    /// Mirrors the C `devXxxSoftRaw.c` `read_xxx()` convention: the
522    /// raw value goes to `RVAL` (so the record's `process()` then runs
523    /// the standard `RVAL → VAL` conversion). Records that expose a
524    /// `MASK` field must apply it here, matching epics-base
525    /// `f2fe9d12` (devBiSoftRaw: `prec->rval &= prec->mask`).
526    ///
527    /// Only invoked by the framework when
528    /// [`Record::accepts_raw_soft_input`] returns `true`.
529    fn apply_raw_input(&mut self, value: EpicsValue) -> CaResult<()> {
530        self.set_val(value)
531    }
532
533    /// Apply a raw device value read *back* from an output record's device
534    /// support (the asyn init seed and driver readback callback), the output
535    /// analogue of [`Record::apply_raw_input`]. An output record whose
536    /// `convert()` is forward (engineering → raw) must invert it here — store
537    /// the raw value into `RVAL` and compute the engineering `VAL` — because
538    /// the framework's forward convert would otherwise recompute `RVAL` from
539    /// the stale `VAL` and discard the readback (C `processAo`/`initAo` set
540    /// `rval`/`val` directly, devAsynInt32.c:955-957/:973-994).
541    ///
542    /// Returns `true` when the record fully produced `VAL` from the raw value
543    /// (the asyn store path then reports `computed` so the forward convert is
544    /// skipped). The default returns `false`: records whose own `convert()` is
545    /// already `raw → eng` (`ai`) or that need no conversion (`longout`,
546    /// `mbbo`, whose `set_val` re-derives from the raw value) keep the legacy
547    /// raw → `RVAL` / direct-`VAL` path.
548    fn apply_raw_readback(&mut self, _raw: i32) -> bool {
549        false
550    }
551
552    /// Apply a float64 device value read *back* from an output record's asyn
553    /// device support — the `asynFloat64` analogue of
554    /// [`Record::apply_raw_readback`]. A float64 output (`ao`) whose device
555    /// value carries an `ASLO`/`AOFF` linear scaling must seed the engineering
556    /// `VAL` here (`VAL = value * ASLO + AOFF`), because the asyn store path
557    /// would otherwise write the raw device value straight into `VAL` and drop
558    /// the scaling. Sets `VAL` only (a float64 `ao` carries no `RVAL`); the
559    /// reverse scaling `(OVAL - AOFF) / ASLO` is applied on the device-write
560    /// side. Mirrors C `initAo`/`processAo` (devAsynFloat64.c:627-629/:646-649).
561    ///
562    /// Returns `true` when the record produced `VAL` from the raw value (the
563    /// asyn store path then reports `computed`, skipping the forward convert).
564    /// The default returns `false`: records with no float64 readback scaling
565    /// keep the raw `set_val` path.
566    fn apply_float64_readback(&mut self, _raw: f64) -> bool {
567        false
568    }
569
570    /// Hand the record the database's breakpoint-table registry so an `ai`/`ao`
571    /// with `LINR >= 3` can resolve and cache the table its `LINR` selects.
572    /// Called once at iocInit, before the first `process`/`convert`. The record
573    /// resolves the table lazily on the first conversion (and re-resolves when
574    /// `LINR` changes at runtime), mirroring C `cvtRawToEngBpt`'s
575    /// `init || *ppbrk == NULL` cache. The default is a no-op: only `ai`/`ao`
576    /// carry `LINR`.
577    fn install_breaktable_registry(
578        &mut self,
579        _registry: std::sync::Arc<crate::server::cvt_bpt::BreakTableRegistry>,
580    ) {
581    }
582
583    /// Apply IVOA=2 ("set outputs to IVOV") semantics: copy the
584    /// IVOV value into whatever output staging field the OUT
585    /// writeback consumes for this record type. Mirrors the
586    /// per-record C `recXxx.c` behaviour:
587    ///
588    /// - `ao`/`lso`: `OVAL = IVOV; VAL = OVAL`
589    /// - `bo`/`busy`/`mbbo`/`mbboDirect`: `RVAL = IVOV; VAL = IVOV`
590    /// - `calcout`/`scalcout`: `OVAL = IVOV` (VAL is calc input, not
591    ///   touched on invalid-output)
592    /// - `dfanout`: `VAL = IVOV` (the broadcast value)
593    ///
594    /// Default uses [`Record::set_val`] for records whose OUT path
595    /// reads VAL only.
596    fn apply_invalid_output_value(&mut self, ivov: EpicsValue) -> CaResult<()> {
597        self.set_val(ivov)
598    }
599
600    /// Whether this record type supports device write (output records only).
601    /// `aao` is included here even though it's served by the same
602    /// concrete struct as `waveform`/`aai`/`subArray` — the
603    /// WaveformRecord's `can_device_write` override picks the right
604    /// answer per [`ArrayKind`], but this default matters for code that
605    /// only has the record-type string.
606    fn can_device_write(&self) -> bool {
607        matches!(
608            self.record_type(),
609            "ao" | "bo"
610                | "longout"
611                | "int64out"
612                | "mbbo"
613                | "mbboDirect"
614                | "stringout"
615                | "lso"
616                | "printf"
617                | "aao"
618        )
619    }
620
621    /// Whether async processing has completed and put_notify can respond.
622    /// Records that return AsyncPendingNotify should return false while
623    /// async work is in progress, and true when done.
624    /// Default: true (synchronous records are always complete).
625    fn is_put_complete(&self) -> bool {
626        true
627    }
628
629    /// Whether this record should fire its forward link after processing.
630    fn should_fire_forward_link(&self) -> bool {
631        true
632    }
633
634    /// Whether this record's OUT link should be written after processing.
635    /// Defaults to true. Override in calcout / longout to implement OOPT
636    /// conditional output (epics-base 7.0.8).
637    fn should_output(&self) -> bool {
638        true
639    }
640
641    /// Notify the record that the OUT-link / device write completed
642    /// successfully on this cycle. The framework calls this right after
643    /// the actual write so transition-detection state (e.g.
644    /// `longout.pval`) can update for the next cycle's
645    /// [`Self::should_output`] check. Default: no-op.
646    fn on_output_complete(&mut self) {}
647
648    /// Whether this record uses MDEL/ADEL deadband for monitor posting.
649    /// Binary records (bi, bo, busy, mbbi, mbbo) return false because
650    /// C EPICS always posts monitors for these record types regardless
651    /// of whether the value changed.
652    fn uses_monitor_deadband(&self) -> bool {
653        true
654    }
655
656    /// Per-record VALUE/LOG monitor gate for record types that post a
657    /// monitor *only when the value actually changed* — and have no
658    /// MDEL/ADEL deadband to express that.
659    ///
660    /// `Some(changed)` makes the framework post the VALUE and LOG
661    /// monitors iff `changed`; `None` (the default) leaves the decision
662    /// to the deadband / always-post path.
663    ///
664    /// C `lsiRecord.c`/`lsoRecord.c` `monitor()` raise `DBE_VALUE |
665    /// DBE_LOG` only when `len != olen || memcmp(oval, val, len)`. Those
666    /// records return [`Self::uses_monitor_deadband`]`== false`, which
667    /// otherwise routes them to the unconditional always-post path
668    /// (correct for binary records, wrong for lsi/lso). Because the
669    /// framework posts monitors *after* `process()` — by which point the
670    /// record has already committed `oval`/`olen` — the implementation
671    /// captures the comparison result during `process()` and returns the
672    /// captured flag here, not a live re-comparison.
673    fn monitor_value_changed(&self) -> Option<bool> {
674        None
675    }
676
677    /// `menuPost` "Always" override for the VALUE / LOG monitor masks.
678    ///
679    /// Returns `(post_value_always, post_archive_always)`. The framework
680    /// ORs these into the change-gated mask from
681    /// [`Self::monitor_value_changed`], so an *unchanged* process cycle
682    /// still posts `DBE_VALUE` (resp. `DBE_LOG`) when the record's MPST
683    /// (resp. APST) menu field is set to `Always`.
684    ///
685    /// C `lsiRecord.c`/`lsoRecord.c` `monitor()` compute the VAL post
686    /// mask from three independent inputs:
687    ///
688    /// * the change test `len != olen || memcmp(oval, val, len)` →
689    ///   `DBE_VALUE | DBE_LOG`,
690    /// * `if (mpst == menuPost_Always) events |= DBE_VALUE;`,
691    /// * `if (apst == menuPost_Always) events |= DBE_LOG;`.
692    ///
693    /// [`Self::monitor_value_changed`] carries the first input; this hook
694    /// carries the other two. Records without a `menuPost` field keep the
695    /// default `(false, false)`, which leaves the change gate unchanged.
696    fn monitor_always_post(&self) -> (bool, bool) {
697        (false, false)
698    }
699
700    /// The value the MDEL/ADEL deadband is evaluated against.
701    ///
702    /// For most records C `monitor()` applies the value deadband to
703    /// `VAL`, so the default is [`Self::val`]. A record whose monitored
704    /// quantity is not its primary value must override this: the motor
705    /// record, for instance, has `VAL` as the setpoint and applies
706    /// MDEL/ADEL to `RBV` (the readback) — its C `monitor()` deadbands
707    /// `RBV`, not `VAL`. Such a record returns its readback field here.
708    ///
709    /// Default is `val()`, so existing records are unaffected.
710    fn monitor_deadband_value(&self) -> Option<EpicsValue> {
711        self.val()
712    }
713
714    /// The FIELD whose VALUE/LOG monitor delivery the MDEL/ADEL
715    /// deadband gates — the field [`Self::monitor_deadband_value`]
716    /// reads. A record overriding one must override both consistently.
717    ///
718    /// For most records the deadband gates the primary value itself,
719    /// so the default returns [`Self::primary_field`] and nothing
720    /// changes. The motor record deadbands RBV: C `monitor()`
721    /// (motorRecord.cc:3468-3507) throttles the RBV post with
722    /// MDEL/ADEL, while VAL is posted only when an actual setpoint
723    /// change marked it (M_VAL). When this returns a non-primary
724    /// field, the framework's snapshot builders:
725    ///
726    /// * deliver THIS field on the deadband triggers (instead of raw
727    ///   change-detection), and
728    /// * route the primary field through generic change-detection, so
729    ///   an unchanged setpoint is not re-posted on every readback
730    ///   poll.
731    fn monitor_deadband_field(&self) -> &'static str {
732        self.primary_field()
733    }
734
735    /// Fields the record's C `monitor()` posts on every cycle whose
736    /// alarm transition fired, even when their value did not change.
737    ///
738    /// C motorRecord.cc `monitor()` (3513-3645) computes
739    /// `local_mask = monitor_mask | (MARKED(x) ? DBE_VAL_LOG : 0)`
740    /// for each field in its posting list — when the alarm moved
741    /// (`monitor_mask != 0`), `local_mask` is non-zero for UNMARKED
742    /// fields too, so every listed field posts with `DBE_ALARM` and a
743    /// `DBE_ALARM`-only subscriber observes the alarm moment on any of
744    /// them. The framework's change-detection loop posts a listed,
745    /// subscribed, unchanged field with the cycle's alarm bits when
746    /// this list names it.
747    ///
748    /// Default: empty — most C record types post only their value
749    /// field(s) on an alarm transition (aiRecord.c `monitor()` posts
750    /// VAL with `monitor_mask` and RVAL only when it changed), which
751    /// the deadband-field post already covers.
752    fn alarm_cycle_monitored_fields(&self) -> &'static [&'static str] {
753        &[]
754    }
755
756    /// Fields the record's C `monitor()` re-posts with `DBE_VAL_LOG` on
757    /// every cycle that recomputed them, even when the value did not
758    /// change — the analogue of an unconditional `MARK(field)` in C.
759    ///
760    /// Unlike [`Self::alarm_cycle_monitored_fields`] (which posts unchanged
761    /// fields only on a cycle whose alarm transition fired), these post on
762    /// any cycle the record names them, with `DBE_VALUE | DBE_LOG` (plus the
763    /// cycle's alarm bits when one fired). The framework's change-detection
764    /// loop posts a listed, subscribed, unchanged field with that mask.
765    ///
766    /// C motorRecord `process_motor_info` (motorRecord.cc:3764-3767)
767    /// `MARK`s `M_DIFF`/`M_RDIF` unconditionally on every `CALLBACK_DATA`
768    /// pass, and `monitor()` (3522-3531) posts them with `monitor_mask |
769    /// DBE_VAL_LOG`; a `camonitor DIFF` on an axis parked at a constant
770    /// non-zero following error thus gets an event every poll. The record
771    /// returns the fields ONLY on the cycles it actually re-marked them (it
772    /// reads its own per-cycle state), so a pass that did not recompute them
773    /// does not over-post.
774    ///
775    /// Default: empty — most record types post a field only when it
776    /// changed (or on an alarm transition), which the existing gates cover.
777    fn force_posted_fields(&self) -> &'static [&'static str] {
778        &[]
779    }
780
781    /// Fields the record's C `monitor()` re-posts with `DBE_LOG` ONLY on
782    /// every cycle it names them, regardless of change — the analogue of
783    /// an unconditional `db_post_events(field, DBE_LOG)` sweep.
784    ///
785    /// Distinct from [`Self::force_posted_fields`], which posts with
786    /// `DBE_VALUE | DBE_LOG`: these post with `DBE_LOG` alone, so only a
787    /// `DBE_LOG` (archiver) subscriber receives the unchanged-value
788    /// event. The LOG sweep lands only for fields that did not change
789    /// this cycle (the change/no-change branches are disjoint), so a
790    /// field that also changed is not double-posted. For a field that is
791    /// ALSO a [`Self::value_only_change_fields`] member the change post
792    /// carries `DBE_VALUE` only, so this idle sweep is the sole source of
793    /// its `DBE_LOG` events — which is exactly C's split (counting cycle
794    /// → `DBE_VALUE`, idle `monitor()` → `DBE_LOG`); the scaler never
795    /// changes `Sn` on an idle cycle, so the two never collide.
796    ///
797    /// C `scalerRecord.c` `monitor()` (scalerRecord.c:770-787) runs on
798    /// every IDLE process and posts each active channel `S1..Snch` with a
799    /// literal `DBE_LOG`. The scaler returns those channel field names
800    /// here ONLY while idle (it reads its own `ss` state), so an archiver
801    /// `camonitor SCALER:Sn` gets an event every idle scan even when the
802    /// count is unchanged — while a counting cycle (which does not run C
803    /// `monitor()`) returns empty.
804    ///
805    /// Default: empty — most record types have no LOG-only sweep.
806    fn log_swept_fields(&self) -> &'static [&'static str] {
807        &[]
808    }
809
810    /// Fields whose change-detected monitor post must carry `DBE_VALUE`
811    /// only — the LOG bit is stripped — instead of the framework default
812    /// `DBE_VALUE | DBE_LOG`.
813    ///
814    /// The generic change-detection post (and the deadband post for a
815    /// deadband field named here) normally bundles `DBE_LOG` so an
816    /// archiver subscribed `DBE_LOG` sees every value change. A record
817    /// whose C `db_post_events` calls pass a literal `DBE_VALUE` for
818    /// these fields names them here so the framework drops the LOG bit;
819    /// the cycle's alarm bits are still OR'd in (alarm posting is a
820    /// separate per-field contract, unaffected by this hook).
821    ///
822    /// C `scalerRecord.c` posts CNT/T/VAL/PR1/TP/FREQ and each active
823    /// channel `S1..Snch` with a literal `DBE_VALUE` on a value change
824    /// (scalerRecord.c:372,478,582,588 et al.); `DBE_LOG` appears ONLY in
825    /// the idle `monitor()` sweep ([`Self::log_swept_fields`],
826    /// scalerRecord.c:771). The two hooks are complementary: a `DBE_LOG`
827    /// subscriber on `Sn` is served by the idle sweep, never by a
828    /// counting-cycle value change — matching C.
829    ///
830    /// Default: empty — most record types post changes with
831    /// `DBE_VALUE | DBE_LOG` (C `monitor_mask | DBE_VALUE | DBE_LOG`,
832    /// calcRecord.c:420, subRecord.c:400).
833    fn value_only_change_fields(&self) -> &'static [&'static str] {
834        &[]
835    }
836
837    /// Secondary value fields a record posts with the *primary VAL
838    /// monitor mask*, gated INSIDE C's `if (monitor_mask)` guard — i.e.
839    /// only on a cycle where VAL itself is posted (an alarm change or an
840    /// MDEL/ADEL crossing) AND the field actually changed, NOT a forced
841    /// `DBE_VALUE | DBE_LOG` on every change.
842    ///
843    /// Mirrors C records that drive a raw secondary field with the shared
844    /// `monitor_mask` rather than `monitor_mask | DBE_VALUE | DBE_LOG`. The
845    /// canonical case is `ai` `RVAL`: `db_post_events(prec, &prec->rval,
846    /// monitor_mask)` nested in `if (monitor_mask)` (aiRecord.c:460-465).
847    /// Under a non-default MDEL the raw count (RVAL) can change while VAL
848    /// stays inside the deadband, so C is silent on RVAL that cycle and —
849    /// on an alarm-only cycle — posts RVAL with `DBE_ALARM` alone, never the
850    /// forced `DBE_VALUE | DBE_LOG`.
851    ///
852    /// Distinct from the default change-detected aux post (which carries
853    /// `DBE_VALUE | DBE_LOG` unconditionally): ao `RVAL`/`RBV`, mbbo/
854    /// mbboDirect/mbbiDirect `RVAL`/`RBV`, sel `SELN` and compress `NUSE`
855    /// are all posted by C with the `DBE_VALUE | DBE_LOG`-forced mask, so
856    /// they stay on the default path and must NOT be named here.
857    ///
858    /// Default: empty.
859    fn fields_posted_with_value_mask(&self) -> &'static [&'static str] {
860        &[]
861    }
862
863    /// The array-style monitor decision (C waveform/aai/aao `monitor()`,
864    /// waveformRecord.c:291-326). `None` (the default) means the record has
865    /// no MPST/APST/HASH mechanism and the generic MDEL/ADEL deadband
866    /// decision applies. `Some(_)` lets the record replace that with its
867    /// "Always vs On Change" rule: it hashes the array content, compares to
868    /// the stored `HASH`, updates it, and reports whether `DBE_VALUE` /
869    /// `DBE_LOG` should be on the VAL post this cycle and whether the hash
870    /// changed (so the owner posts `HASH` with `DBE_VALUE`). Called by
871    /// `check_deadband_ext` (the single owner of the VAL-mask decision).
872    fn array_monitor_post(&mut self) -> Option<ArrayMonitorPost> {
873        None
874    }
875
876    /// Fields the record posts itself via an event-driven, individually
877    /// masked path rather than the generic change-detection loop. The
878    /// framework excludes these from that loop so they are neither
879    /// double-posted nor spuriously posted on a cycle the event did not
880    /// fire. C waveform/aai/aao `monitor()` posts `HASH` this way —
881    /// `db_post_events(prec, &prec->hash, DBE_VALUE)` only when the content
882    /// hash changed (waveformRecord.c:317-319), never via VAL's change.
883    ///
884    /// Default: empty.
885    fn event_posted_fields(&self) -> &'static [&'static str] {
886        &[]
887    }
888
889    /// Initialize record (pass 0: field defaults; pass 1: dependent init).
890    fn init_record(&mut self, _pass: u8) -> CaResult<()> {
891        Ok(())
892    }
893
894    /// Post-init finalisation hook with mutable access to the
895    /// framework's UDF flag. Called once after both `init_record`
896    /// passes complete. Default implementation is a no-op.
897    ///
898    /// epics-base PR `dabcf89` (mbboDirect): when VAL is undefined
899    /// at init time but the user populated B0..B1F bits, the bits
900    /// should be folded into VAL and UDF cleared. The framework
901    /// owns `common.udf`, so the record cannot mutate it from
902    /// `init_record` alone — this hook is the controlled point of
903    /// access.
904    fn post_init_finalize_undef(&mut self, _udf: &mut bool) -> CaResult<()> {
905        Ok(())
906    }
907
908    /// Seed the monitor/archive/alarm deadband trackers (MLST/ALST/LALM)
909    /// from the initial value at iocInit, called once by the builder after
910    /// both `init_record` passes and `post_init_finalize_undef`.
911    ///
912    /// Every C value record's `init_record` ends with
913    /// `prec->mlst = prec->alst = prec->lalm = prec->val`
914    /// (e.g. `longinRecord.c:120-122`, `aiRecord.c`), so the first
915    /// `monitor()` evaluates `DELTA(mlst, val) > mdel` with `mlst == val`
916    /// (= 0) and posts no DBE_VALUE/DBE_LOG event when the value is
917    /// unchanged from its initial state. Records expose MLST/ALST/LALM as
918    /// plain `f64` fields default-initialised to `0.0`; that default
919    /// conflates "never published" with "published 0", so a record
920    /// initialised to a *nonzero* value (constant DOL, initial VAL) used
921    /// to post a spurious first-cycle update that C does not.
922    ///
923    /// The default seeds whichever of MLST/ALST/LALM the record actually
924    /// serves from its monitor-deadband value (`val` for most records),
925    /// making the invariant hold by construction for every record rather
926    /// than per-type `init_record` code. It is idempotent for the record
927    /// types that already seed inside `init_record`, and a no-op for
928    /// records that serve none of these fields.
929    fn seed_deadband_tracking(&mut self) {
930        let seed = match self.monitor_deadband_value().and_then(|v| v.to_f64()) {
931            Some(v) if v.is_finite() => v,
932            _ => return,
933        };
934        for field in ["MLST", "ALST", "LALM"] {
935            if self.get_field(field).is_some() {
936                let _ = self.put_field(field, EpicsValue::Double(seed));
937            }
938        }
939    }
940
941    /// Called by the framework immediately after applying this cycle's
942    /// [`Record::multi_input_links`] fetches, before `process()`.
943    ///
944    /// `resolved` lists the `link_field` names (the first element of
945    /// each `multi_input_links` pair) whose fetch actually produced a
946    /// value this cycle — i.e. the link was non-empty and the read
947    /// succeeded. A link field absent from the slice either had no link
948    /// configured or its DB/CA fetch failed.
949    ///
950    /// This is the framework analogue of C device support inspecting
951    /// `RTN_SUCCESS(dbGetLink(...))` — e.g. `epidRecord.c:191-193`
952    /// clears `udf` only when `dbGetLink(&prec->stpl, ...)` returns
953    /// success. A record's `process()` cannot otherwise observe whether
954    /// an input link's fetch succeeded, because a failed fetch simply
955    /// leaves the target field unwritten.
956    ///
957    /// Additive, framework-set-hook pattern (same shape as
958    /// [`Record::set_process_context`]). Default: ignore.
959    fn set_resolved_input_links(&mut self, _resolved: &[&'static str]) {}
960
961    /// Report that a record which gates its value update on a *selected*
962    /// input read (currently sel in `Specified` mode) had that gating
963    /// fetch fail this cycle. C `selRecord.c::process` (line 114) runs
964    /// `do_sel` only when `fetch_values` succeeds; on failure VAL/UDF
965    /// freeze. `failed == true` ⇒ the configured selected input or NVL
966    /// link did not resolve, so `process()` must hold the previous output.
967    /// Default: ignore (records with no fetch gate). Same framework-set
968    /// hook pattern as [`Record::set_resolved_input_links`].
969    fn set_fetch_gate_failed(&mut self, _failed: bool) {}
970
971    /// Called before/after a field put for side-effect processing.
972    fn special(&mut self, _field: &str, _after: bool) -> CaResult<()> {
973        Ok(())
974    }
975
976    /// Other fields whose monitors must be posted because a put to
977    /// `put_field` changed them as a side effect, without driving a full
978    /// process cycle.
979    ///
980    /// Mirrors the explicit `db_post_events` calls a C `special()` makes:
981    /// e.g. `compressRecord.c::reset` (invoked on a `SPC_RESET` write to
982    /// `RES`) posts `NUSE` and `VAL` even though `RES` is not `pp(TRUE)`
983    /// and so does not process. The framework posts a `VALUE|LOG` monitor
984    /// for each returned field after the put. Default: none.
985    fn monitor_side_effect_fields(&self, _put_field: &str) -> &'static [&'static str] {
986        &[]
987    }
988
989    /// Downcast to concrete type for device support init injection.
990    /// Override in record types that need device support to inject state (e.g., MotorRecord).
991    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
992        None
993    }
994
995    /// Whether processing this record should clear UDF.
996    /// Override to return false for record types that don't produce a valid value every cycle.
997    fn clears_udf(&self) -> bool {
998        true
999    }
1000
1001    /// Whether the record's current `VAL` is undefined (UDF must
1002    /// stay set).
1003    ///
1004    /// C parity: `aiRecord.c:285` / `calcRecord.c::checkAlarms` /
1005    /// `int64inRecord.c:144` clear `UDF` **only** when the computed /
1006    /// read value is valid — `if (status == 0)` and, for floating
1007    /// records, only when `VAL` is not NaN. The framework owns
1008    /// `common.udf`; it calls `clears_udf()` to decide whether this
1009    /// record type clears UDF at all, then this method to decide
1010    /// whether the *value produced this cycle* is actually defined.
1011    ///
1012    /// Default: a floating `VAL` that is NaN (e.g. a calc
1013    /// divide-by-zero, or a soft input whose link read failed and
1014    /// left VAL un-updated) is undefined; everything else is defined.
1015    /// A record whose `val()` yields `None` (no primary value) is
1016    /// also treated as undefined.
1017    fn value_is_undefined(&self) -> bool {
1018        match self.val() {
1019            Some(EpicsValue::Double(v)) => v.is_nan(),
1020            Some(EpicsValue::Float(v)) => v.is_nan(),
1021            Some(_) => false,
1022            None => true,
1023        }
1024    }
1025
1026    /// Per-record alarm hook — evaluate record-type-specific alarms
1027    /// (STATE / COS / analog limit / SOFT) and accumulate them into
1028    /// `nsta`/`nsev` via `recGblSetSevr`.
1029    ///
1030    /// The framework centralises the generic alarm machinery (UDF
1031    /// check, `recGblResetAlarms` transfer, MS/MSI/MSS link-alarm
1032    /// inheritance). The record-type-specific severity logic that C
1033    /// puts in each record's `checkAlarms()` belongs here so a record
1034    /// can raise its own alarms without the framework hardcoding a
1035    /// per-type `match` on `record_type()`.
1036    ///
1037    /// `common` is the record's [`CommonFields`]; implementations
1038    /// raise alarms with [`crate::server::recgbl::rec_gbl_set_sevr`]
1039    /// / [`crate::server::recgbl::rec_gbl_set_sevr_msg`].
1040    ///
1041    /// Default: no-op — records that have not yet migrated their
1042    /// `checkAlarms` logic here are still covered by the framework's
1043    /// legacy centralised `evaluate_alarms` match.
1044    fn check_alarms(&mut self, _common: &mut crate::server::record::CommonFields) {}
1045
1046    /// Return multi-input link field pairs: (link_field, value_field).
1047    /// Override in calc, calcout, sel, sub to return INPA..INPL → A..L mappings.
1048    fn multi_input_links(&self) -> &[(&'static str, &'static str)] {
1049        &[]
1050    }
1051
1052    /// The subset of [`Self::multi_input_links`] the framework should
1053    /// actually fetch this cycle, given an optional externally-resolved
1054    /// selector index (sel's NVL→SELN value, or `None` when no NVL link
1055    /// drove it). Default `None` = fetch every input link.
1056    ///
1057    /// C `selRecord.c::fetch_values` (lines 421-431) fetches ONLY INP[SELN]
1058    /// in `Specified` mode and all inputs otherwise; sel returns
1059    /// `Some(vec![INP[SELN]])` so the non-selected inputs are never read and
1060    /// raise no monitors or link-alarm SEVR.
1061    fn select_input_links(
1062        &self,
1063        _selector: Option<u16>,
1064    ) -> Option<Vec<(&'static str, &'static str)>> {
1065        None
1066    }
1067
1068    /// Return multi-output link field pairs: (link_field, value_field).
1069    /// Override in transform to return OUTA..OUTP → A..P mappings.
1070    fn multi_output_links(&self) -> &[(&'static str, &'static str)] {
1071        &[]
1072    }
1073
1074    /// Return the name of the output event (`OEVT`) to post this cycle, or
1075    /// `None`. The event-subsystem twin of the OUT write: a downstream
1076    /// `SCAN="Event"` / `EVNT="<name>"` record is woken each time the record
1077    /// drives output. Mirrors C `calcout`/`sCalcout`/`aCalcout` `execOutput`,
1078    /// which calls `postEvent(epvt)` / `post_event(oevt)` immediately after
1079    /// `writeValue` in every OUT-driving branch.
1080    ///
1081    /// The override MUST fold in the record's own output-fire decision
1082    /// (`should_output()` for `calcout`; the cached OOPT/calc-fail/ODLY
1083    /// decision for `sCalcout`/`aCalcout`) and return `None` when output did
1084    /// not fire or when `OEVT` is unset. The framework adds the only gate the
1085    /// record cannot see — the IVOA `Don't_drive` veto on an INVALID cycle —
1086    /// so the post fires on exactly the cycles the OUT write does. Numeric
1087    /// `OEVT` (DBF_USHORT) stringifies to match the `EVNT` ingest; a string
1088    /// `OEVT` (DBF_STRING) is the event name verbatim.
1089    fn output_event(&self) -> Option<String> {
1090        None
1091    }
1092
1093    /// Internal field write that bypasses read-only checks.
1094    /// Used by the framework to write values from ReadDbLink actions
1095    /// into fields that are normally read-only (e.g., epid.CVAL).
1096    /// Default implementation delegates to put_field().
1097    ///
1098    /// On the `ReadDbLink` path this is also where a pvalink NTEnum
1099    /// carrier ([`EpicsValue::EnumWithChoices`]) is resolved. The
1100    /// dbrType-blind link resolver produces it for an NTEnum source;
1101    /// pvxs `pvaGetValue` (`pvalink_lset.cpp:330-360`) picks
1102    /// label-vs-index by the TARGET field's dbrType — only a DBR_STRING
1103    /// target gets the `choices[index]` label, every other type takes
1104    /// the numeric index. Route it through [`EpicsValue::convert_to`]
1105    /// (the single value-coercion owner) against the target field's
1106    /// `db_field_type`, so the transient carrier is consumed before any
1107    /// record `put_field` / storage / wire path can see it. The
1108    /// single-INP→VAL apply path reaches the same `convert_to` via
1109    /// `set_val`'s `TypeMismatch` auto-coerce.
1110    fn put_field_internal(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
1111        // Input-link / internal delivery coerces the source to the target
1112        // field's stored type before `put_field`, mirroring C
1113        // `dbGetLink(DBF_<target>)`: the link layer converts any numeric
1114        // source to the requested type, so a record's typed `put_field`
1115        // arm never sees a mismatched type. This is the single owner of
1116        // that coercion, covering every `ReadDbLink` target by construction
1117        // (e.g. a `compress` INP from a `DBF_LONG` record delivers a
1118        // `Long`/`LongArray` that must become `Double`/`DoubleArray` for the
1119        // Double-only VAL arm, which otherwise drops it and never advances
1120        // the buffer). An `EnumWithChoices` carrier is always collapsed to a
1121        // bare index by `convert_to`, even when the target is already `Enum`.
1122        let target_type = self
1123            .field_list()
1124            .iter()
1125            .find(|f| f.name.eq_ignore_ascii_case(name))
1126            .map(|f| f.dbf_type)
1127            .or_else(|| self.get_field(name).map(|v| v.db_field_type()));
1128        let is_enum_carrier = matches!(value, EpicsValue::EnumWithChoices { .. });
1129        let value = match target_type {
1130            Some(target)
1131                if is_enum_carrier
1132                    || (value.db_field_type() != target && !value.is_empty_array()) =>
1133            {
1134                value.convert_to(target)
1135            }
1136            // Carrier with no known target field: collapse to a bare index
1137            // (the prior fallback) rather than letting it reach storage.
1138            None if is_enum_carrier => value.convert_to(DbFieldType::Long),
1139            _ => value,
1140        };
1141        self.put_field(name, value)
1142    }
1143
1144    /// Return pre-process actions (ReadDbLink) that the framework should
1145    /// execute BEFORE calling process(). This is called once per cycle.
1146    /// Default returns empty. Override in records that need link reads
1147    /// to be available during process().
1148    fn pre_process_actions(&mut self) -> Vec<ProcessAction> {
1149        Vec::new()
1150    }
1151
1152    /// Return actions the framework must execute BEFORE the input-link
1153    /// (`multi_input_links`, INP -> value-field) fetch for this cycle.
1154    ///
1155    /// This is strictly earlier than [`Self::pre_process_actions`]: the
1156    /// framework resolves input links *before* it calls
1157    /// `pre_process_actions`, so an action that must affect what an
1158    /// input link reads cannot be expressed there.
1159    ///
1160    /// The motivating case is the epid record's `devEpidSoftCallback`
1161    /// DB-type TRIG link: C `devEpidSoftCallback.c:120-132` writes the
1162    /// readback-trigger link with `dbPutLink` — which synchronously
1163    /// processes the triggered source chain — and only *then*
1164    /// (`devEpidSoftCallback.c:151`) does `dbGetLink(&pepid->inp, ...)`
1165    /// read `CVAL`. The trigger write therefore has to land before the
1166    /// `INP -> CVAL` fetch, in the same process pass.
1167    ///
1168    /// Called once per cycle, while a record write lock is held; the
1169    /// framework executes the returned actions (currently `WriteDbLink`
1170    /// and `ReadDbLink`) and then performs the input-link fetch.
1171    /// Default returns empty.
1172    fn pre_input_link_actions(&mut self) -> Vec<ProcessAction> {
1173        Vec::new()
1174    }
1175
1176    /// Called by the framework immediately before `process()` to push a
1177    /// read-only snapshot of framework-owned [`CommonFields`] state
1178    /// ([`ProcessContext`]) that the record's `process()` needs to see.
1179    ///
1180    /// The framework owns `RecordInstance.common`; a record `process()`
1181    /// only gets `&mut self`. C records read `dbCommon` directly — e.g.
1182    /// `epidRecord.c:195` checks `pepid->udf` at the top of `process()`,
1183    /// `timestampRecord.c:90` branches on `ptimestamp->tse`. This hook
1184    /// is the controlled equivalent: a record that needs `udf`/`phas`/
1185    /// `tse`/`tsel` during `process()` overrides this to stash the
1186    /// values into its own fields.
1187    ///
1188    /// Additive, framework-set-hook pattern (same shape as
1189    /// [`Record::set_device_did_compute`]). Default: ignore — most
1190    /// records never need common state during `process()`.
1191    fn set_process_context(&mut self, _ctx: &ProcessContext) {}
1192
1193    /// Called once by the framework when the record is registered
1194    /// (`add_record`), delivering the record its own canonical name plus a
1195    /// cycle-free [`crate::server::database::AsyncDbHandle`] for driving
1196    /// async-side updates from OUTSIDE a `process()` cycle.
1197    ///
1198    /// The handle wraps a `Weak` reference to the database, so a record
1199    /// that stashes it creates no ownership cycle (the database owns the
1200    /// record; a stored strong handle would leak it). It is the controlled
1201    /// equivalent of C device support capturing `precord` plus the
1202    /// dbCommon scan lock for an out-of-band `db_post_events` /
1203    /// `callbackRequest`: e.g. the asyn TRACE/exception callback posts
1204    /// trace-flag fields immediately from the driver thread, and AQR
1205    /// cancels a queued I/O re-entry — neither happens inside `process()`.
1206    ///
1207    /// The in-band counterpart for a record's *own* process cycle is the
1208    /// completion-driven [`ProcessAction`] family
1209    /// ([`ProcessAction::WriteDbLinkNotify`],
1210    /// [`ProcessAction::CancelReprocess`],
1211    /// [`ProcessAction::ReprocessAfter`]); this hook exists for the
1212    /// out-of-band path that has no `process()` return to ride on.
1213    ///
1214    /// Additive, framework-set-hook pattern (same shape as
1215    /// [`Self::set_process_context`]). Default: ignore — most records do
1216    /// no out-of-band async posting.
1217    fn set_async_context(&mut self, _name: String, _db: crate::server::database::AsyncDbHandle) {}
1218
1219    /// Framework init hook: called once at record load *after* the common
1220    /// link fields (`INP`/`OUT`/`FLNK`/...) have been resolved and the
1221    /// `init_record` passes have run, with the record's resolved
1222    /// [`CommonFields`](crate::server::record::CommonFields).
1223    ///
1224    /// This is the seam for records that classify their links into status
1225    /// diagnostics at init the way C `init_record` does (e.g. calcout's
1226    /// `INAV..INUV`/`OUTV` `menu(calcoutINAV)` checkLinks loop): a record's
1227    /// *common* link strings (`OUT` is a common field, not a record field)
1228    /// are invisible to [`Self::set_async_context`] — which runs at
1229    /// `add_record`, *before* the common fields are applied — and to
1230    /// `init_record`, which carries no `CommonFields`. The record captures
1231    /// whichever common links it needs here so a passive, never-processed
1232    /// record already exposes its link status. Records whose links are all
1233    /// record-owned (e.g. sseq DOLn/LNKn) do not need this hook.
1234    ///
1235    /// Additive, framework-set-hook pattern. Default: ignore.
1236    fn init_links(&mut self, _common: &crate::server::record::CommonFields) {}
1237
1238    /// Called by the framework before process() to indicate whether device
1239    /// support's read() already performed the record's compute step.
1240    /// Override in records that have a built-in compute (e.g., epid PID)
1241    /// to skip it when device support already ran it.
1242    /// Default: ignore.
1243    fn set_device_did_compute(&mut self, _did_compute: bool) {}
1244
1245    /// Whether this record has a raw-to-engineering (`RVAL → VAL`)
1246    /// `convert()` step that must be skipped on a `Soft Channel` input.
1247    ///
1248    /// C `devAiSoft.c:65` `read_ai` (and the other soft-channel input
1249    /// `read_xxx`) always returns 2 ("don't convert"), so `aiRecord.c`'s
1250    /// `if (status==0) convert(prec)` is bypassed for a `Soft Channel`
1251    /// input record. The framework expresses this by calling
1252    /// [`Record::set_device_did_compute(true)`] on the record before
1253    /// `process()`.
1254    ///
1255    /// This hook exists so the framework only suppresses `convert()` —
1256    /// NOT a record's entire built-in compute. Records like `epid` also
1257    /// override `set_device_did_compute` but interpret it as "skip the
1258    /// whole compute step" (the PID loop); those records have no
1259    /// `RVAL → VAL` convert and MUST keep the default `false` so a
1260    /// `Soft Channel` `epid` still runs `do_pid()` in `process()`.
1261    ///
1262    /// Default `false`: a record is only opted into the soft-channel
1263    /// convert-skip when it explicitly returns `true`.
1264    fn soft_channel_skips_convert(&self) -> bool {
1265        false
1266    }
1267}
1268
1269/// Subroutine function type for `sub`/`aSub` records.
1270///
1271/// The return value is the subroutine's C `long` status
1272/// (`subRecord.c::do_sub` / `aSubRecord.c::do_sub`): `< 0` raises
1273/// `SOFT_ALARM` at the record's `BRSV` severity, and for `aSub` the status
1274/// is published as `VAL` (`aSubRecord.c:223`). Return `Ok(0)` for the
1275/// normal no-alarm path. `Err(..)` is reserved for an infrastructure
1276/// failure inside the closure (e.g. a field write error), which aborts
1277/// processing — it is distinct from a negative status.
1278pub type SubroutineFn = Box<dyn Fn(&mut dyn Record) -> CaResult<i64> + Send + Sync>;