Skip to main content

std_rs/records/
epid.rs

1use std::any::Any;
2use std::time::Instant;
3
4use epics_base_rs::error::{CaError, CaResult};
5use epics_base_rs::server::recgbl::{self, alarm_status};
6use epics_base_rs::server::record::{
7    AlarmSeverity, CommonFields, FieldDesc, LinkType, ProcessAction, ProcessContext,
8    ProcessOutcome, Record, link_field_type,
9};
10use epics_base_rs::types::{DbFieldType, EpicsValue};
11
12/// Feedback mode for the epid record.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14#[repr(i16)]
15pub enum FeedbackMode {
16    #[default]
17    Pid = 0,
18    MaxMin = 1,
19}
20
21impl From<i16> for FeedbackMode {
22    fn from(v: i16) -> Self {
23        match v {
24            1 => FeedbackMode::MaxMin,
25            _ => FeedbackMode::Pid,
26        }
27    }
28}
29
30/// Feedback on/off state.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[repr(i16)]
33pub enum FeedbackState {
34    #[default]
35    Off = 0,
36    On = 1,
37}
38
39impl From<i16> for FeedbackState {
40    fn from(v: i16) -> Self {
41        match v {
42            1 => FeedbackState::On,
43            _ => FeedbackState::Off,
44        }
45    }
46}
47
48/// Extended PID feedback control record.
49///
50/// Ported from EPICS std module `epidRecord.c`.
51/// Supports PID and Max/Min feedback modes with anti-windup,
52/// bumpless turn-on, output deadband, and hysteresis-based alarms.
53pub struct EpidRecord {
54    // --- PID control ---
55    /// Setpoint (VAL)
56    pub val: f64,
57    /// Setpoint mode: 0=supervisory, 1=closed_loop (SMSL)
58    pub smsl: i16,
59    /// Setpoint input link (STPL) — resolved by framework
60    pub stpl: String,
61    /// Controlled value input link (INP) — resolved by framework
62    pub inp: String,
63    /// Output link (OUTL) — resolved by framework
64    pub outl: String,
65    /// Readback trigger link (TRIG)
66    pub trig: String,
67    /// Trigger value (TVAL)
68    pub tval: f64,
69    /// Controlled value (CVAL), read-only
70    pub cval: f64,
71    /// Previous controlled value (CVLP), read-only
72    pub cvlp: f64,
73    /// Output value (OVAL), read-only
74    pub oval: f64,
75    /// Previous output value (OVLP), read-only
76    pub ovlp: f64,
77    /// Proportional gain (KP)
78    pub kp: f64,
79    /// Integral gain — repeats per second (KI)
80    pub ki: f64,
81    /// Derivative gain (KD)
82    pub kd: f64,
83    /// Proportional component (P), read-only
84    pub p: f64,
85    /// Previous P (PP), read-only
86    pub pp: f64,
87    /// Integral component (I), writable for bumpless init
88    pub i: f64,
89    /// Previous I (IP)
90    pub ip: f64,
91    /// Derivative component (D), read-only
92    pub d: f64,
93    /// Previous D (DP), read-only
94    pub dp: f64,
95    /// Error = setpoint - controlled value (ERR), read-only
96    pub err: f64,
97    /// Previous error (ERRP), read-only
98    pub errp: f64,
99    /// Delta time in seconds (DT), writable for fast mode
100    pub dt: f64,
101    /// Previous delta time (DTP)
102    pub dtp: f64,
103    /// Minimum delta time between calculations (MDT)
104    pub mdt: f64,
105    /// Feedback mode: PID or MaxMin (FMOD)
106    pub fmod: i16,
107    /// Feedback on/off (FBON)
108    pub fbon: i16,
109    /// Previous feedback on/off (FBOP)
110    pub fbop: i16,
111    /// Output deadband (ODEL)
112    pub odel: f64,
113
114    // --- Display ---
115    /// Display precision (PREC)
116    pub prec: i16,
117    /// Engineering units (EGU)
118    pub egu: String,
119    /// High operating range (HOPR)
120    pub hopr: f64,
121    /// Low operating range (LOPR)
122    pub lopr: f64,
123    /// High drive limit (DRVH)
124    pub drvh: f64,
125    /// Low drive limit (DRVL)
126    pub drvl: f64,
127
128    // --- Alarm ---
129    /// Hihi deviation limit (HIHI)
130    pub hihi: f64,
131    /// Lolo deviation limit (LOLO)
132    pub lolo: f64,
133    /// High deviation limit (HIGH)
134    pub high: f64,
135    /// Low deviation limit (LOW)
136    pub low: f64,
137    /// Hihi severity (HHSV)
138    pub hhsv: i16,
139    /// Lolo severity (LLSV)
140    pub llsv: i16,
141    /// High severity (HSV)
142    pub hsv: i16,
143    /// Low severity (LSV)
144    pub lsv: i16,
145    /// Alarm deadband / hysteresis (HYST)
146    pub hyst: f64,
147    /// Last value alarmed (LALM), read-only
148    pub lalm: f64,
149
150    // --- Monitor deadband ---
151    /// Archive deadband (ADEL)
152    pub adel: f64,
153    /// Monitor deadband (MDEL)
154    pub mdel: f64,
155    /// Last value archived (ALST), read-only
156    pub alst: f64,
157    /// Last value monitored (MLST), read-only
158    pub mlst: f64,
159
160    // --- Internal time tracking ---
161    /// Current time (CT) — used for delta-T computation
162    pub(crate) ct: Instant,
163    /// Previous time (CTP) — tracked for monitor change detection
164    #[allow(dead_code)]
165    pub(crate) ctp: Instant,
166
167    // --- Internal flags ---
168    /// Set by the framework (via set_device_did_compute) to indicate
169    /// device support's read() already performed the PID computation.
170    /// process() checks this to avoid running the built-in PID a second time.
171    device_did_compute: bool,
172    /// Set by `do_pid` when the `INP` link is a CONSTANT link (a literal
173    /// value, not a PV reference). C `devEpidSoft.c:110-112`
174    /// (`if (pepid->inp.type == CONSTANT) recGblSetSevr(...,SOFT_ALARM,
175    /// INVALID_ALARM)`): with a constant INP there is "nothing to
176    /// control", so the PID compute is skipped and SOFT/INVALID is
177    /// raised. The framework `check_alarms` hook reads this flag and
178    /// applies the severity via `recGblSetSevr`.
179    pub inp_constant: bool,
180    /// Framework-owned `dbCommon.udf`, pushed by the framework via
181    /// [`Record::set_process_context`] immediately before `process()`.
182    /// C `epidRecord.c:195` reads `pepid->udf` at the top of
183    /// `process()` and skips `do_pid` entirely while it is set. The
184    /// matching `UDF_ALARM` (C `epidRecord.c:199`,
185    /// `recGblSetSevr(pepid,UDF_ALARM,pepid->udfs)`) is raised by the
186    /// framework's centralised `rec_gbl_check_udf` after `process()`.
187    udf: bool,
188    /// Set by `process()` for a cycle on which the UDF gate skipped
189    /// `do_pid`. C `epidRecord.c:201` `return(0)` is reached before
190    /// `recGblFwdLink` and before `do_pid` writes the output, so on
191    /// such a cycle the framework must NOT write the OUTL link
192    /// (`multi_output_links`) or fire the forward link.
193    compute_skipped: bool,
194    /// True iff the framework's input-link fetch for `STPL` actually
195    /// produced a value this cycle — the framework analogue of C
196    /// `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))`. Pushed by the
197    /// framework via [`Record::set_resolved_input_links`] after the
198    /// `multi_input_links` fetch (STPL is only in that list when
199    /// `SMSL == closed_loop`). C `epidRecord.c:191-193` clears `udf`
200    /// only on this success — a STPL that is empty, or a DB/CA link
201    /// whose fetch failed, leaves `udf` set.
202    stpl_resolved: bool,
203    /// Framework-owned `dbCommon.dtyp`, pushed by the framework via
204    /// [`Record::set_process_context`] before the input-link fetch.
205    /// C device support for the epid record lives in two distinct
206    /// DSETs — `devEpidSoft` (`devEpidSoft.c`, no TRIG handling) and
207    /// `devEpidSoftCallback` (`devEpidSoftCallback.c`, which drives the
208    /// TRIG readback link). [`Record::pre_input_link_actions`] checks
209    /// this to emit the TRIG write only when the callback DSET (DTYP
210    /// `"Epid Async Soft"`) is selected.
211    dtyp: String,
212    /// Epid-owned `dbCommon.udf` projection, returned by
213    /// [`Record::value_is_undefined`]. C `epidRecord.c` has
214    /// `special = NULL` (line 105) — there is no operator UDF clear,
215    /// and `udf` is cleared ONLY by the two C conditions:
216    ///
217    /// - `epidRecord.c:160-164` init: a CONSTANT `STPL` link holding
218    ///   a valid constant clears `udf` (mirrored by
219    ///   [`Record::post_init_finalize_undef`] / a CONSTANT `STPL`
220    ///   making `value_is_undefined()` return `false`).
221    /// - `epidRecord.c:191-193` process: closed-loop (`SMSL=1`) with
222    ///   a successful `dbGetLink(stpl)` clears `udf`.
223    ///
224    /// `process()` recomputes this each cycle; the framework's
225    /// post-process `common.udf = value_is_undefined()` then keeps a
226    /// supervisory / empty-STPL epid permanently undefined, exactly as
227    /// C leaves `udf == TRUE` forever for such a record.
228    value_undefined: bool,
229    /// Set by [`crate::device_support::epid_soft_callback::
230    /// EpidSoftCallbackDeviceSupport::read`] on the first (trigger) pass
231    /// of a CA-type TRIG link, cleared by `process()`.
232    ///
233    /// C `devEpidSoftCallback.c:143-145`: a CA TRIG link fires the
234    /// readback trigger asynchronously (`dbCaPutLinkCallback`), sets
235    /// `pepid->pact = TRUE` and `return(0)`. C `epidRecord.c:207`
236    /// `if (!pact && pepid->pact) return(0)` then returns BEFORE
237    /// `recGblGetTimeStamp` / `checkAlarms` / `monitor` /
238    /// `recGblFwdLink` — so the trigger pass runs NONE of the
239    /// process tail; the tail runs exactly once, on the callback
240    /// (reprocess) pass.
241    ///
242    /// The Rust framework runs device support `read()` before
243    /// `process()`; `read()` cannot itself short-circuit the cycle.
244    /// This flag is `read()`'s signal to `process()` that the cycle is
245    /// a CA-trigger pass — `process()` consumes it and returns
246    /// `ProcessOutcome::async_pending()`, which makes the framework
247    /// skip the alarm/timestamp/snapshot/OUT/FLNK tail for this cycle
248    /// (the `read()`-returned `WriteDbLink{TRIG}` + `ReprocessAfter`
249    /// actions are still executed). The reprocess pass runs `do_pid`
250    /// and the tail exactly once.
251    ca_trig_pending: bool,
252}
253
254impl Default for EpidRecord {
255    fn default() -> Self {
256        let now = Instant::now();
257        Self {
258            val: 0.0,
259            smsl: 0,
260            stpl: String::new(),
261            inp: String::new(),
262            outl: String::new(),
263            trig: String::new(),
264            tval: 0.0,
265            cval: 0.0,
266            cvlp: 0.0,
267            oval: 0.0,
268            ovlp: 0.0,
269            kp: 0.0,
270            ki: 0.0,
271            kd: 0.0,
272            p: 0.0,
273            pp: 0.0,
274            i: 0.0,
275            ip: 0.0,
276            d: 0.0,
277            dp: 0.0,
278            err: 0.0,
279            errp: 0.0,
280            dt: 0.0,
281            dtp: 0.0,
282            mdt: 0.0,
283            fmod: 0,
284            fbon: 0,
285            fbop: 0,
286            odel: 0.0,
287            prec: 0,
288            egu: String::new(),
289            hopr: 0.0,
290            lopr: 0.0,
291            drvh: 0.0,
292            drvl: 0.0,
293            hihi: 0.0,
294            lolo: 0.0,
295            high: 0.0,
296            low: 0.0,
297            hhsv: 0,
298            llsv: 0,
299            hsv: 0,
300            lsv: 0,
301            hyst: 0.0,
302            lalm: 0.0,
303            adel: 0.0,
304            mdel: 0.0,
305            alst: 0.0,
306            mlst: 0.0,
307            ct: now,
308            ctp: now,
309            device_did_compute: false,
310            inp_constant: false,
311            dtyp: String::new(),
312            udf: true,
313            compute_skipped: false,
314            stpl_resolved: false,
315            // C `epidRecord.c` init: `udf` starts TRUE and is cleared
316            // only by the two clear-conditions — see `value_undefined`.
317            value_undefined: true,
318            ca_trig_pending: false,
319        }
320    }
321}
322
323impl EpidRecord {
324    /// Decide the alarm condition using hysteresis-based threshold
325    /// comparison on VAL. Ported from epidRecord.c `checkAlarms()`,
326    /// which mirrors `aiRecord.c::checkAlarms` — per-level hysteresis
327    /// against VAL with `lalm` tracking the last-alarmed threshold.
328    ///
329    /// Returns `Some((stat, sevr, alev))` where `stat` is the canonical
330    /// `epicsAlarmCondition` status code (`HIHI_ALARM`, `HIGH_ALARM`,
331    /// `LOLO_ALARM`, `LOW_ALARM`), `sevr` the configured severity, and
332    /// `alev` the threshold that fired (the candidate `lalm` value).
333    /// Returns `None` when VAL is inside the (hysteresis-adjusted) limits.
334    ///
335    /// `lalm` (last-alarmed threshold) is committed by the caller, NOT
336    /// here, for the alarm case. C `aiRecord.c:403-406` gates the `lalm`
337    /// update on `recGblSetSevr` actually raising the severity:
338    /// `if (recGblSetSevr(...)) prec->lalm = alev;`. A lower-severity
339    /// alarm that loses to an already-higher pending severity must NOT
340    /// advance `lalm`, or the hysteresis band would be silently re-based.
341    /// The [`Record::check_alarms`] trait hook below performs that gate.
342    ///
343    /// The no-alarm case writes `lalm = val` here unconditionally,
344    /// matching C `aiRecord.c:409` (`prec->lalm = val;` — not gated).
345    pub fn check_alarms(&mut self) -> Option<(u16, AlarmSeverity, f64)> {
346        let val = self.val;
347        let hyst = self.hyst;
348        let lalm = self.lalm;
349
350        // HIHI alarm
351        if self.hhsv != 0 && (val >= self.hihi || (lalm == self.hihi && val >= self.hihi - hyst)) {
352            return Some((
353                alarm_status::HIHI_ALARM,
354                AlarmSeverity::from_u16(self.hhsv as u16),
355                self.hihi,
356            ));
357        }
358
359        // LOLO alarm
360        if self.llsv != 0 && (val <= self.lolo || (lalm == self.lolo && val <= self.lolo + hyst)) {
361            return Some((
362                alarm_status::LOLO_ALARM,
363                AlarmSeverity::from_u16(self.llsv as u16),
364                self.lolo,
365            ));
366        }
367
368        // HIGH alarm
369        if self.hsv != 0 && (val >= self.high || (lalm == self.high && val >= self.high - hyst)) {
370            return Some((
371                alarm_status::HIGH_ALARM,
372                AlarmSeverity::from_u16(self.hsv as u16),
373                self.high,
374            ));
375        }
376
377        // LOW alarm
378        if self.lsv != 0 && (val <= self.low || (lalm == self.low && val <= self.low + hyst)) {
379            return Some((
380                alarm_status::LOW_ALARM,
381                AlarmSeverity::from_u16(self.lsv as u16),
382                self.low,
383            ));
384        }
385
386        // No alarm — C `aiRecord.c:409` resets LALM to VAL unconditionally.
387        self.lalm = val;
388        None
389    }
390
391    /// Mark this cycle as a CA-TRIG trigger pass.
392    ///
393    /// Called by [`crate::device_support::epid_soft_callback::
394    /// EpidSoftCallbackDeviceSupport::read`] on the first pass of a
395    /// CA-type TRIG link, before `process()` runs. `process()` consumes
396    /// the flag and returns `ProcessOutcome::async_pending()` so the
397    /// trigger pass skips the process tail (checkAlarms / monitor /
398    /// recGblFwdLink) — C `devEpidSoftCallback.c:143-145` +
399    /// `epidRecord.c:205-210`. See [`EpidRecord::ca_trig_pending`].
400    pub fn set_ca_trig_pending(&mut self) {
401        self.ca_trig_pending = true;
402    }
403
404    /// Update monitor tracking fields. Returns list of fields that changed.
405    /// Ported from epidRecord.c `monitor()`.
406    pub fn update_monitors(&mut self) {
407        // Update previous-value fields for change detection
408        self.ovlp = self.oval;
409        self.pp = self.p;
410        self.ip = self.i;
411        self.dp = self.d;
412        self.dtp = self.dt;
413        self.errp = self.err;
414        self.cvlp = self.cval;
415
416        // VAL deadband tracking
417        if self.mdel == 0.0 || (self.mlst - self.val).abs() > self.mdel {
418            self.mlst = self.val;
419        }
420        if self.adel == 0.0 || (self.alst - self.val).abs() > self.adel {
421            self.alst = self.val;
422        }
423    }
424}
425
426static FIELDS: &[FieldDesc] = &[
427    // PID control
428    FieldDesc {
429        name: "VAL",
430        dbf_type: DbFieldType::Double,
431        read_only: false,
432    },
433    FieldDesc {
434        name: "SMSL",
435        dbf_type: DbFieldType::Short,
436        read_only: false,
437    },
438    FieldDesc {
439        name: "STPL",
440        dbf_type: DbFieldType::String,
441        read_only: false,
442    },
443    FieldDesc {
444        name: "INP",
445        dbf_type: DbFieldType::String,
446        read_only: false,
447    },
448    FieldDesc {
449        name: "OUTL",
450        dbf_type: DbFieldType::String,
451        read_only: false,
452    },
453    FieldDesc {
454        name: "TRIG",
455        dbf_type: DbFieldType::String,
456        read_only: false,
457    },
458    FieldDesc {
459        name: "TVAL",
460        dbf_type: DbFieldType::Double,
461        read_only: false,
462    },
463    FieldDesc {
464        name: "CVAL",
465        dbf_type: DbFieldType::Double,
466        read_only: true,
467    },
468    FieldDesc {
469        name: "CVLP",
470        dbf_type: DbFieldType::Double,
471        read_only: true,
472    },
473    FieldDesc {
474        name: "OVAL",
475        dbf_type: DbFieldType::Double,
476        read_only: true,
477    },
478    FieldDesc {
479        name: "OVLP",
480        dbf_type: DbFieldType::Double,
481        read_only: true,
482    },
483    FieldDesc {
484        name: "KP",
485        dbf_type: DbFieldType::Double,
486        read_only: false,
487    },
488    FieldDesc {
489        name: "KI",
490        dbf_type: DbFieldType::Double,
491        read_only: false,
492    },
493    FieldDesc {
494        name: "KD",
495        dbf_type: DbFieldType::Double,
496        read_only: false,
497    },
498    FieldDesc {
499        name: "P",
500        dbf_type: DbFieldType::Double,
501        read_only: true,
502    },
503    FieldDesc {
504        name: "PP",
505        dbf_type: DbFieldType::Double,
506        read_only: true,
507    },
508    FieldDesc {
509        name: "I",
510        dbf_type: DbFieldType::Double,
511        read_only: false,
512    },
513    FieldDesc {
514        name: "IP",
515        dbf_type: DbFieldType::Double,
516        read_only: true,
517    },
518    FieldDesc {
519        name: "D",
520        dbf_type: DbFieldType::Double,
521        read_only: true,
522    },
523    FieldDesc {
524        name: "DP",
525        dbf_type: DbFieldType::Double,
526        read_only: true,
527    },
528    FieldDesc {
529        name: "ERR",
530        dbf_type: DbFieldType::Double,
531        read_only: true,
532    },
533    FieldDesc {
534        name: "ERRP",
535        dbf_type: DbFieldType::Double,
536        read_only: true,
537    },
538    FieldDesc {
539        name: "DT",
540        dbf_type: DbFieldType::Double,
541        read_only: false,
542    },
543    FieldDesc {
544        name: "DTP",
545        dbf_type: DbFieldType::Double,
546        read_only: true,
547    },
548    FieldDesc {
549        name: "MDT",
550        dbf_type: DbFieldType::Double,
551        read_only: false,
552    },
553    FieldDesc {
554        name: "FMOD",
555        dbf_type: DbFieldType::Short,
556        read_only: false,
557    },
558    FieldDesc {
559        name: "FBON",
560        dbf_type: DbFieldType::Short,
561        read_only: false,
562    },
563    FieldDesc {
564        name: "FBOP",
565        dbf_type: DbFieldType::Short,
566        read_only: true,
567    },
568    FieldDesc {
569        name: "ODEL",
570        dbf_type: DbFieldType::Double,
571        read_only: false,
572    },
573    // Display
574    FieldDesc {
575        name: "PREC",
576        dbf_type: DbFieldType::Short,
577        read_only: false,
578    },
579    FieldDesc {
580        name: "EGU",
581        dbf_type: DbFieldType::String,
582        read_only: false,
583    },
584    FieldDesc {
585        name: "HOPR",
586        dbf_type: DbFieldType::Double,
587        read_only: false,
588    },
589    FieldDesc {
590        name: "LOPR",
591        dbf_type: DbFieldType::Double,
592        read_only: false,
593    },
594    FieldDesc {
595        name: "DRVH",
596        dbf_type: DbFieldType::Double,
597        read_only: false,
598    },
599    FieldDesc {
600        name: "DRVL",
601        dbf_type: DbFieldType::Double,
602        read_only: false,
603    },
604    // Alarm
605    FieldDesc {
606        name: "HIHI",
607        dbf_type: DbFieldType::Double,
608        read_only: false,
609    },
610    FieldDesc {
611        name: "LOLO",
612        dbf_type: DbFieldType::Double,
613        read_only: false,
614    },
615    FieldDesc {
616        name: "HIGH",
617        dbf_type: DbFieldType::Double,
618        read_only: false,
619    },
620    FieldDesc {
621        name: "LOW",
622        dbf_type: DbFieldType::Double,
623        read_only: false,
624    },
625    FieldDesc {
626        name: "HHSV",
627        dbf_type: DbFieldType::Short,
628        read_only: false,
629    },
630    FieldDesc {
631        name: "LLSV",
632        dbf_type: DbFieldType::Short,
633        read_only: false,
634    },
635    FieldDesc {
636        name: "HSV",
637        dbf_type: DbFieldType::Short,
638        read_only: false,
639    },
640    FieldDesc {
641        name: "LSV",
642        dbf_type: DbFieldType::Short,
643        read_only: false,
644    },
645    FieldDesc {
646        name: "HYST",
647        dbf_type: DbFieldType::Double,
648        read_only: false,
649    },
650    FieldDesc {
651        name: "LALM",
652        dbf_type: DbFieldType::Double,
653        read_only: true,
654    },
655    // Monitor deadband
656    FieldDesc {
657        name: "ADEL",
658        dbf_type: DbFieldType::Double,
659        read_only: false,
660    },
661    FieldDesc {
662        name: "MDEL",
663        dbf_type: DbFieldType::Double,
664        read_only: false,
665    },
666    FieldDesc {
667        name: "ALST",
668        dbf_type: DbFieldType::Double,
669        read_only: true,
670    },
671    FieldDesc {
672        name: "MLST",
673        dbf_type: DbFieldType::Double,
674        read_only: true,
675    },
676];
677
678impl Record for EpidRecord {
679    fn record_type(&self) -> &'static str {
680        "epid"
681    }
682
683    /// Bumpless-transfer readback — C `devEpidSoft.c:153-158` (PID) and
684    /// `devEpidSoft.c:178-184` / `devEpidSoftCallback.c:214-220`
685    /// (MaxMin).
686    ///
687    /// On the feedback OFF->ON edge (`FBOP==0 && FBON!=0`) C seeds the
688    /// turn-on state from the `OUTL` output link's *actual current
689    /// value* via `dbGetLink(&pepid->outl, DBR_DOUBLE, ...)`, guarded by
690    /// `outl.type != CONSTANT`. The seeded field differs by FMOD:
691    ///
692    ///   - PID (`fmod==0`), C `devEpidSoft.c:155`:
693    ///     `dbGetLink(&pepid->outl, DBR_DOUBLE, &i, ...)` — the OUTL
694    ///     readback lands in the integral term `I`.
695    ///   - MaxMin (`fmod==1`), C `devEpidSoft.c:181` /
696    ///     `devEpidSoftCallback.c:217`:
697    ///     `dbGetLink(&pepid->outl, DBR_DOUBLE, &oval, ...)` — the OUTL
698    ///     readback lands in the output value `OVAL`.
699    ///
700    /// The Rust framework's `ReadDbLink` pre-process action performs
701    /// exactly that synchronous read of the DB link's target value into
702    /// a record field, executed BEFORE `process()` / `do_pid` runs.
703    ///
704    /// `FBOP` still holds the *previous* cycle's `FBON` at this point
705    /// (it is committed at the end of `do_pid`), so the edge is
706    /// detectable here. The action is emitted only for a non-CONSTANT
707    /// `OUTL` link, mirroring C's `outl.type != CONSTANT` guard — for a
708    /// CONSTANT/empty `OUTL` the seeded field keeps its prior value.
709    fn pre_process_actions(&mut self) -> Vec<ProcessAction> {
710        let edge = self.fbon != 0 && self.fbop == 0;
711        if edge {
712            // PID seeds `I` from OUTL (devEpidSoft.c:153-158);
713            // MaxMin seeds `OVAL` from OUTL (devEpidSoft.c:178-184).
714            let target_field = if self.fmod == 0 { "I" } else { "OVAL" };
715            match link_field_type(&self.outl) {
716                LinkType::Db | LinkType::Ca => {
717                    return vec![ProcessAction::ReadDbLink {
718                        link_field: "OUTL",
719                        target_field,
720                    }];
721                }
722                _ => {}
723            }
724        }
725        Vec::new()
726    }
727
728    fn process(&mut self) -> CaResult<ProcessOutcome> {
729        // In the C code, process() always calls pdset->do_pid() — a custom
730        // device support function unique to the epid record. In Rust, the
731        // framework has a generic DeviceSupport trait with read()/write()
732        // and no custom function pointers.
733        //
734        // For non-"Soft Channel" DTYPs (e.g. "Fast Epid"), the framework
735        // calls DeviceSupport::read() BEFORE process(). That read() runs
736        // the driver-specific PID and sets pid_done = true.
737        //
738        // For "Soft Channel" or no device support, the framework skips
739        // read(), so pid_done stays false and process() runs the built-in
740        // PID here.
741
742        // C `epidRecord.c:189-203`: the UDF gate is taken only on the
743        // non-callback pass (`if (!pact)`). `device_did_compute` is the
744        // Rust equivalent of "device support already ran do_pid" — the
745        // callback pass — so the gate applies only when it is false.
746        //
747        // C `epidRecord.c` clears `udf` ONLY at two sites (`special` is
748        // NULL — there is no operator UDF clear):
749        //   - `epidRecord.c:160-164` init: a CONSTANT `STPL` link with a
750        //     valid constant. A constant link's value never changes, so
751        //     it is "defined" on every cycle thereafter.
752        //   - `epidRecord.c:191-193` process: closed-loop (`SMSL=1`)
753        //     with `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))` — an
754        //     ACTUAL fetch success. `self.stpl_resolved` is the
755        //     framework's report of exactly that (a STPL that is empty,
756        //     or whose DB/CA fetch failed, leaves it false).
757        // Otherwise `udf` stays TRUE forever and C `epidRecord.c:195`
758        // `return(0)` skips `do_pid` every cycle — e.g. a supervisory
759        // (`SMSL=0`) epid with an empty/non-constant STPL NEVER runs
760        // `do_pid`.
761        //
762        // `self.udf` is the framework `dbCommon.udf` pushed before
763        // `process()`; it is last cycle's value because the framework
764        // recomputes `common.udf` (from `value_is_undefined()`) only
765        // *after* `process()`. C reads `pepid->udf` at process-start
766        // identically. `udf` is sticky-false: once C clears it, it is
767        // never re-set — so the gate keys off `self.udf`, and a closed-
768        // loop epid whose STPL later fails keeps running `do_pid`.
769        //
770        // `value_undefined` is recomputed here for the framework's
771        // post-process `common.udf = value_is_undefined()`.
772        self.compute_skipped = false;
773
774        // CA-TRIG trigger pass — C `devEpidSoftCallback.c:143-145` +
775        // `epidRecord.c:205-210`. `EpidSoftCallbackDeviceSupport::read`
776        // ran first this cycle, saw a CA-type TRIG link, fired the
777        // asynchronous readback trigger (returning `WriteDbLink{TRIG}` +
778        // `ReprocessAfter` actions), and set `ca_trig_pending` — the
779        // analogue of C `do_pid` setting `pepid->pact = TRUE` and
780        // `return(0)`.
781        //
782        // C `epidRecord.c:207` `if (!pact && pepid->pact) return(0)`
783        // then returns BEFORE `recGblGetTimeStamp` / `checkAlarms` /
784        // `monitor` / `recGblFwdLink`: the trigger pass runs NONE of
785        // the process tail. Return `async_pending` so the framework
786        // skips the alarm/timestamp/snapshot/OUT/FLNK tail for this
787        // cycle. The `read()`-returned actions were merged by the
788        // framework and are still executed; the reprocess pass runs
789        // `do_pid` and the tail exactly once.
790        //
791        // `device_did_compute` is cleared here because the trigger pass
792        // performed NO compute — without this reset the reprocess pass
793        // could observe a stale `true`.
794        if self.ca_trig_pending {
795            self.ca_trig_pending = false;
796            self.device_did_compute = false;
797            return Ok(ProcessOutcome::async_pending());
798        }
799
800        // C clear-conditions, evaluated at process-start:
801        //  - CONSTANT STPL link  → init `recGblInitConstantLink` cleared
802        //    udf permanently (`epidRecord.c:160-164`).
803        //  - closed-loop STPL fetch succeeded this cycle
804        //    (`epidRecord.c:191-193`).
805        //
806        // `stpl_resolved` is a per-cycle signal: consume it and reset
807        // so a later `process_local`-path cycle (which performs no
808        // link resolution and never calls `set_resolved_input_links`)
809        // cannot read a stale "resolved" from an earlier links-path
810        // cycle.
811        let stpl_resolved = self.stpl_resolved;
812        self.stpl_resolved = false;
813        let stpl_clears_udf =
814            link_field_type(&self.stpl) == LinkType::Constant || (self.smsl == 1 && stpl_resolved);
815        // udf state this cycle: undefined unless already cleared
816        // (`!self.udf`) or a clear-condition fires now.
817        self.value_undefined = self.udf && !stpl_clears_udf;
818        if !self.device_did_compute {
819            if self.value_undefined {
820                // C `epidRecord.c:195-202`: while `udf==TRUE`, skip
821                // `do_pid` entirely and `return 0` — *before*
822                // `recGblGetTimeStamp`, `checkAlarms`, `monitor` and
823                // `recGblFwdLink`. The framework's centralised UDF
824                // check (`rec_gbl_check_udf`, run after process())
825                // raises `UDF_ALARM` with `udfs` severity, matching C's
826                // `recGblSetSevr(pepid, UDF_ALARM, pepid->udfs)`.
827                //
828                // `update_monitors()` is deliberately NOT called here:
829                // C's early `return(0)` skips `monitor()`, so the
830                // previous-value fields (`pp`/`ip`/`dp`/...) and the
831                // `mlst`/`alst` deadband baselines must NOT advance
832                // while the record is undefined.
833                //
834                // C `return(0)` is reached before `recGblFwdLink` and
835                // the `do_pid` output write. The Rust framework drives
836                // the OUTL write (`multi_output_links`) and FLNK; flag
837                // this cycle so `multi_output_links` and
838                // `should_fire_forward_link` suppress them — otherwise
839                // a stale OVAL would be pushed to the OUTL target.
840                self.device_did_compute = false;
841                self.compute_skipped = true;
842                return Ok(ProcessOutcome::complete());
843            }
844        }
845
846        if !self.device_did_compute {
847            crate::device_support::epid_soft::EpidSoftDeviceSupport::do_pid(self);
848        }
849        self.device_did_compute = false; // Reset for next cycle
850
851        // Alarm evaluation is NOT done here. The framework invokes the
852        // `Record::check_alarms` trait hook (below) after `process()`,
853        // which is where the computed severity is applied to SEVR/STAT
854        // via `recGblSetSevr`. Calling the inherent `check_alarms` here
855        // would advance `lalm` an extra time and double-step the
856        // hysteresis state, so it is deliberately omitted.
857        self.update_monitors();
858
859        // Device support actions are now merged by the framework
860        let actions = Vec::new();
861        Ok(ProcessOutcome::complete_with(actions))
862    }
863
864    /// Per-record alarm hook — C `epidRecord.c::checkAlarms`.
865    ///
866    /// The framework calls this after `process()`; it computes the
867    /// HIHI/HIGH/LOW/LOLO condition (with `lalm` hysteresis) via the
868    /// inherent [`EpidRecord::check_alarms`] and applies the result to
869    /// the record's pending alarm state with `recGblSetSevr`. That
870    /// accumulates into `nsta`/`nsev` (raise-only / maximize-severity),
871    /// which the framework later transfers to `STAT`/`SEVR` via
872    /// `recGblResetAlarms`. Returning `None` raises nothing, so a value
873    /// that stays inside the limits leaves the record un-alarmed and a
874    /// held value does not re-fire.
875    fn check_alarms(&mut self, common: &mut CommonFields) {
876        // C `devEpidSoft.c:110-112` / `devEpidSoftCallback.c:115-117`:
877        // a CONSTANT `INP` link means "nothing to control" — raise
878        // SOFT_ALARM/INVALID_ALARM. `do_pid` set `inp_constant` and
879        // skipped the compute; apply the severity here (the framework
880        // calls this hook after `process()`).
881        if self.inp_constant {
882            recgbl::rec_gbl_set_sevr(common, alarm_status::SOFT_ALARM, AlarmSeverity::Invalid);
883        }
884        if let Some((stat, sevr, alev)) = EpidRecord::check_alarms(self) {
885            // C `aiRecord.c:403-406`: `if (recGblSetSevr(...)) prec->lalm = alev;`
886            // — the LALM update is gated on `recGblSetSevr` returning TRUE,
887            // i.e. on the alarm actually raising the pending severity.
888            // `rec_gbl_set_sevr` is raise-only and returns nothing, so detect
889            // the raise by observing whether `nsev` increased across the call.
890            let before = common.nsev;
891            recgbl::rec_gbl_set_sevr(common, stat, sevr);
892            if common.nsev != before {
893                self.lalm = alev;
894            }
895        }
896    }
897
898    fn get_field(&self, name: &str) -> Option<EpicsValue> {
899        match name {
900            "VAL" => Some(EpicsValue::Double(self.val)),
901            "SMSL" => Some(EpicsValue::Short(self.smsl)),
902            "STPL" => Some(EpicsValue::String(self.stpl.clone())),
903            "INP" => Some(EpicsValue::String(self.inp.clone())),
904            "OUTL" => Some(EpicsValue::String(self.outl.clone())),
905            "TRIG" => Some(EpicsValue::String(self.trig.clone())),
906            "TVAL" => Some(EpicsValue::Double(self.tval)),
907            "CVAL" => Some(EpicsValue::Double(self.cval)),
908            "CVLP" => Some(EpicsValue::Double(self.cvlp)),
909            "OVAL" => Some(EpicsValue::Double(self.oval)),
910            "OVLP" => Some(EpicsValue::Double(self.ovlp)),
911            "KP" => Some(EpicsValue::Double(self.kp)),
912            "KI" => Some(EpicsValue::Double(self.ki)),
913            "KD" => Some(EpicsValue::Double(self.kd)),
914            "P" => Some(EpicsValue::Double(self.p)),
915            "PP" => Some(EpicsValue::Double(self.pp)),
916            "I" => Some(EpicsValue::Double(self.i)),
917            "IP" => Some(EpicsValue::Double(self.ip)),
918            "D" => Some(EpicsValue::Double(self.d)),
919            "DP" => Some(EpicsValue::Double(self.dp)),
920            "ERR" => Some(EpicsValue::Double(self.err)),
921            "ERRP" => Some(EpicsValue::Double(self.errp)),
922            "DT" => Some(EpicsValue::Double(self.dt)),
923            "DTP" => Some(EpicsValue::Double(self.dtp)),
924            "MDT" => Some(EpicsValue::Double(self.mdt)),
925            "FMOD" => Some(EpicsValue::Short(self.fmod)),
926            "FBON" => Some(EpicsValue::Short(self.fbon)),
927            "FBOP" => Some(EpicsValue::Short(self.fbop)),
928            "ODEL" => Some(EpicsValue::Double(self.odel)),
929            "PREC" => Some(EpicsValue::Short(self.prec)),
930            "EGU" => Some(EpicsValue::String(self.egu.clone())),
931            "HOPR" => Some(EpicsValue::Double(self.hopr)),
932            "LOPR" => Some(EpicsValue::Double(self.lopr)),
933            "DRVH" => Some(EpicsValue::Double(self.drvh)),
934            "DRVL" => Some(EpicsValue::Double(self.drvl)),
935            "HIHI" => Some(EpicsValue::Double(self.hihi)),
936            "LOLO" => Some(EpicsValue::Double(self.lolo)),
937            "HIGH" => Some(EpicsValue::Double(self.high)),
938            "LOW" => Some(EpicsValue::Double(self.low)),
939            "HHSV" => Some(EpicsValue::Short(self.hhsv)),
940            "LLSV" => Some(EpicsValue::Short(self.llsv)),
941            "HSV" => Some(EpicsValue::Short(self.hsv)),
942            "LSV" => Some(EpicsValue::Short(self.lsv)),
943            "HYST" => Some(EpicsValue::Double(self.hyst)),
944            "LALM" => Some(EpicsValue::Double(self.lalm)),
945            "ADEL" => Some(EpicsValue::Double(self.adel)),
946            "MDEL" => Some(EpicsValue::Double(self.mdel)),
947            "ALST" => Some(EpicsValue::Double(self.alst)),
948            "MLST" => Some(EpicsValue::Double(self.mlst)),
949            _ => None,
950        }
951    }
952
953    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
954        match name {
955            "VAL" => match value {
956                EpicsValue::Double(v) => {
957                    self.val = v;
958                    Ok(())
959                }
960                _ => Err(CaError::TypeMismatch(name.into())),
961            },
962            "SMSL" => match value {
963                EpicsValue::Short(v) => {
964                    self.smsl = v;
965                    Ok(())
966                }
967                _ => Err(CaError::TypeMismatch(name.into())),
968            },
969            "STPL" => match value {
970                EpicsValue::String(v) => {
971                    self.stpl = v;
972                    Ok(())
973                }
974                _ => Err(CaError::TypeMismatch(name.into())),
975            },
976            "INP" => match value {
977                EpicsValue::String(v) => {
978                    self.inp = v;
979                    Ok(())
980                }
981                _ => Err(CaError::TypeMismatch(name.into())),
982            },
983            "OUTL" => match value {
984                EpicsValue::String(v) => {
985                    self.outl = v;
986                    Ok(())
987                }
988                _ => Err(CaError::TypeMismatch(name.into())),
989            },
990            "TRIG" => match value {
991                EpicsValue::String(v) => {
992                    self.trig = v;
993                    Ok(())
994                }
995                _ => Err(CaError::TypeMismatch(name.into())),
996            },
997            "TVAL" => match value {
998                EpicsValue::Double(v) => {
999                    self.tval = v;
1000                    Ok(())
1001                }
1002                _ => Err(CaError::TypeMismatch(name.into())),
1003            },
1004            "KP" => match value {
1005                EpicsValue::Double(v) => {
1006                    self.kp = v;
1007                    Ok(())
1008                }
1009                _ => Err(CaError::TypeMismatch(name.into())),
1010            },
1011            "KI" => match value {
1012                EpicsValue::Double(v) => {
1013                    self.ki = v;
1014                    Ok(())
1015                }
1016                _ => Err(CaError::TypeMismatch(name.into())),
1017            },
1018            "KD" => match value {
1019                EpicsValue::Double(v) => {
1020                    self.kd = v;
1021                    Ok(())
1022                }
1023                _ => Err(CaError::TypeMismatch(name.into())),
1024            },
1025            "I" => match value {
1026                EpicsValue::Double(v) => {
1027                    self.i = v;
1028                    Ok(())
1029                }
1030                _ => Err(CaError::TypeMismatch(name.into())),
1031            },
1032            "IP" => match value {
1033                EpicsValue::Double(v) => {
1034                    self.ip = v;
1035                    Ok(())
1036                }
1037                _ => Err(CaError::TypeMismatch(name.into())),
1038            },
1039            "DT" => match value {
1040                EpicsValue::Double(v) => {
1041                    self.dt = v;
1042                    Ok(())
1043                }
1044                _ => Err(CaError::TypeMismatch(name.into())),
1045            },
1046            "MDT" => match value {
1047                EpicsValue::Double(v) => {
1048                    self.mdt = v;
1049                    Ok(())
1050                }
1051                _ => Err(CaError::TypeMismatch(name.into())),
1052            },
1053            "FMOD" => match value {
1054                EpicsValue::Short(v) => {
1055                    self.fmod = v;
1056                    Ok(())
1057                }
1058                _ => Err(CaError::TypeMismatch(name.into())),
1059            },
1060            "FBON" => match value {
1061                EpicsValue::Short(v) => {
1062                    self.fbon = v;
1063                    Ok(())
1064                }
1065                _ => Err(CaError::TypeMismatch(name.into())),
1066            },
1067            "ODEL" => match value {
1068                EpicsValue::Double(v) => {
1069                    self.odel = v;
1070                    Ok(())
1071                }
1072                _ => Err(CaError::TypeMismatch(name.into())),
1073            },
1074            "PREC" => match value {
1075                EpicsValue::Short(v) => {
1076                    self.prec = v;
1077                    Ok(())
1078                }
1079                _ => Err(CaError::TypeMismatch(name.into())),
1080            },
1081            "EGU" => match value {
1082                EpicsValue::String(v) => {
1083                    self.egu = v;
1084                    Ok(())
1085                }
1086                _ => Err(CaError::TypeMismatch(name.into())),
1087            },
1088            "HOPR" => match value {
1089                EpicsValue::Double(v) => {
1090                    self.hopr = v;
1091                    Ok(())
1092                }
1093                _ => Err(CaError::TypeMismatch(name.into())),
1094            },
1095            "LOPR" => match value {
1096                EpicsValue::Double(v) => {
1097                    self.lopr = v;
1098                    Ok(())
1099                }
1100                _ => Err(CaError::TypeMismatch(name.into())),
1101            },
1102            "DRVH" => match value {
1103                EpicsValue::Double(v) => {
1104                    self.drvh = v;
1105                    Ok(())
1106                }
1107                _ => Err(CaError::TypeMismatch(name.into())),
1108            },
1109            "DRVL" => match value {
1110                EpicsValue::Double(v) => {
1111                    self.drvl = v;
1112                    Ok(())
1113                }
1114                _ => Err(CaError::TypeMismatch(name.into())),
1115            },
1116            "HIHI" => match value {
1117                EpicsValue::Double(v) => {
1118                    self.hihi = v;
1119                    Ok(())
1120                }
1121                _ => Err(CaError::TypeMismatch(name.into())),
1122            },
1123            "LOLO" => match value {
1124                EpicsValue::Double(v) => {
1125                    self.lolo = v;
1126                    Ok(())
1127                }
1128                _ => Err(CaError::TypeMismatch(name.into())),
1129            },
1130            "HIGH" => match value {
1131                EpicsValue::Double(v) => {
1132                    self.high = v;
1133                    Ok(())
1134                }
1135                _ => Err(CaError::TypeMismatch(name.into())),
1136            },
1137            "LOW" => match value {
1138                EpicsValue::Double(v) => {
1139                    self.low = v;
1140                    Ok(())
1141                }
1142                _ => Err(CaError::TypeMismatch(name.into())),
1143            },
1144            "HHSV" => match value {
1145                EpicsValue::Short(v) => {
1146                    self.hhsv = v;
1147                    Ok(())
1148                }
1149                _ => Err(CaError::TypeMismatch(name.into())),
1150            },
1151            "LLSV" => match value {
1152                EpicsValue::Short(v) => {
1153                    self.llsv = v;
1154                    Ok(())
1155                }
1156                _ => Err(CaError::TypeMismatch(name.into())),
1157            },
1158            "HSV" => match value {
1159                EpicsValue::Short(v) => {
1160                    self.hsv = v;
1161                    Ok(())
1162                }
1163                _ => Err(CaError::TypeMismatch(name.into())),
1164            },
1165            "LSV" => match value {
1166                EpicsValue::Short(v) => {
1167                    self.lsv = v;
1168                    Ok(())
1169                }
1170                _ => Err(CaError::TypeMismatch(name.into())),
1171            },
1172            "HYST" => match value {
1173                EpicsValue::Double(v) => {
1174                    self.hyst = v;
1175                    Ok(())
1176                }
1177                _ => Err(CaError::TypeMismatch(name.into())),
1178            },
1179            "ADEL" => match value {
1180                EpicsValue::Double(v) => {
1181                    self.adel = v;
1182                    Ok(())
1183                }
1184                _ => Err(CaError::TypeMismatch(name.into())),
1185            },
1186            "MDEL" => match value {
1187                EpicsValue::Double(v) => {
1188                    self.mdel = v;
1189                    Ok(())
1190                }
1191                _ => Err(CaError::TypeMismatch(name.into())),
1192            },
1193            // Read-only fields
1194            "CVAL" | "CVLP" | "OVAL" | "OVLP" | "P" | "PP" | "D" | "DP" | "ERR" | "ERRP"
1195            | "DTP" | "FBOP" | "LALM" | "ALST" | "MLST" => Err(CaError::ReadOnlyField(name.into())),
1196            _ => Err(CaError::FieldNotFound(name.into())),
1197        }
1198    }
1199
1200    fn field_list(&self) -> &'static [FieldDesc] {
1201        FIELDS
1202    }
1203
1204    fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
1205        Some(self)
1206    }
1207
1208    /// C `epidRecord.c` UDF ownership — see [`EpidRecord::value_undefined`].
1209    ///
1210    /// The framework's post-`process()` step runs
1211    /// `common.udf = value_is_undefined()` (gated on `clears_udf()`,
1212    /// left at its `true` default). Returning the epid-owned
1213    /// `value_undefined` — recomputed in `process()` from the two C
1214    /// clear-conditions — keeps `udf` TRUE for a supervisory / empty-
1215    /// STPL epid (so its UDF gate fires every cycle, as C does) and
1216    /// clears it only on a CONSTANT STPL or a successful closed-loop
1217    /// `dbGetLink(stpl)`.
1218    ///
1219    /// The default `value_is_undefined()` keys off `VAL` being NaN,
1220    /// which for an epid (`VAL` defaults to a finite `0.0`, never NaN)
1221    /// would wrongly clear `udf` after the first cycle — the bug this
1222    /// override fixes.
1223    fn value_is_undefined(&self) -> bool {
1224        self.value_undefined
1225    }
1226
1227    fn set_device_did_compute(&mut self, did_compute: bool) {
1228        self.device_did_compute = did_compute;
1229    }
1230
1231    /// C `epidRecord.c:195` reads `pepid->udf` at the top of
1232    /// `process()`. The framework owns `dbCommon.udf`; this hook
1233    /// captures it so `process()` can gate `do_pid` on it.
1234    fn set_process_context(&mut self, ctx: &ProcessContext) {
1235        self.udf = ctx.udf;
1236        self.dtyp.clear();
1237        self.dtyp.push_str(&ctx.dtyp);
1238    }
1239
1240    /// C `devEpidSoftCallback.c:120-132` — the DB-type TRIG readback
1241    /// link write.
1242    ///
1243    /// `devEpidSoftCallback.c::do_pid`, within ONE process pass, does:
1244    ///   1. `if (ptriglink->type != CA_LINK)` →
1245    ///      `dbPutLink(ptriglink, DBR_DOUBLE, &pepid->tval, 1)`
1246    ///      (`devEpidSoftCallback.c:121-127`) — a synchronous write that
1247    ///      processes the triggered source chain;
1248    ///   2. `dbGetLink(&pepid->inp, DBR_DOUBLE, &pepid->cval, ...)`
1249    ///      (`devEpidSoftCallback.c:151`) — read CVAL from INP;
1250    ///   3. run the PID.
1251    ///
1252    /// So for a DB-type TRIG link the trigger write must land BEFORE
1253    /// this cycle's `INP -> CVAL` fetch. The framework resolves input
1254    /// links before `pre_process_actions`, so the TRIG write is emitted
1255    /// here, from `pre_input_link_actions`, which the framework runs
1256    /// strictly before the input-link fetch.
1257    ///
1258    /// Only the `devEpidSoftCallback` DSET (DTYP `"Epid Async Soft"`)
1259    /// drives the TRIG link — `devEpidSoft` (`devEpidSoft.c`) has no
1260    /// TRIG handling at all. The action is therefore gated on `dtyp`.
1261    ///
1262    /// The CA-type TRIG link is deliberately NOT emitted here: C
1263    /// `devEpidSoftCallback.c:133-147` cannot wait synchronously on a
1264    /// CA link, so it uses `dbCaPutLinkCallback` + `pact=TRUE` and
1265    /// re-processes on the callback. That two-pass path stays in
1266    /// `EpidSoftCallbackDeviceSupport::read` (`WriteDbLink` +
1267    /// `ReprocessAfter`).
1268    fn pre_input_link_actions(&mut self) -> Vec<ProcessAction> {
1269        if self.dtyp != "Epid Async Soft" {
1270            return Vec::new();
1271        }
1272        if link_field_type(&self.trig) == LinkType::Db {
1273            return vec![ProcessAction::WriteDbLink {
1274                link_field: "TRIG",
1275                value: EpicsValue::Double(self.tval),
1276            }];
1277        }
1278        Vec::new()
1279    }
1280
1281    /// Framework report of which `multi_input_links` fetches produced a
1282    /// value this cycle — the analogue of C
1283    /// `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))` (`epidRecord.c:191`).
1284    /// `STPL` is only ever in `multi_input_links` when
1285    /// `SMSL == closed_loop`; its presence here means the closed-loop
1286    /// setpoint fetch actually succeeded this cycle. A STPL that is
1287    /// empty, or a DB/CA link whose fetch failed, is absent — so
1288    /// `stpl_resolved` is reset to false and `udf` is not cleared.
1289    fn set_resolved_input_links(&mut self, resolved: &[&'static str]) {
1290        self.stpl_resolved = resolved.contains(&"STPL");
1291    }
1292
1293    /// C `epidRecord.c:160-164` `init_record`: when `STPL` is a
1294    /// CONSTANT link holding a valid constant, `recGblInitConstantLink`
1295    /// seeds `VAL` from the constant and `udf` is cleared. The
1296    /// framework owns `dbCommon.udf`; this hook is its controlled
1297    /// access point. Runs once after `init_record`.
1298    ///
1299    /// For `SMSL == closed_loop` the framework also fetches `STPL` into
1300    /// `VAL` via `multi_input_links` every cycle; the constant seed
1301    /// here matters for the supervisory (`SMSL=0`) case and for the
1302    /// first cycle before any process.
1303    fn post_init_finalize_undef(&mut self, udf: &mut bool) -> CaResult<()> {
1304        let parsed = epics_base_rs::server::record::parse_link_v2(&self.stpl);
1305        if parsed.link_type() == LinkType::Constant {
1306            if let Some(EpicsValue::Double(v)) = parsed.constant_value() {
1307                self.val = v;
1308                *udf = false;
1309                self.value_undefined = false;
1310            }
1311        }
1312        Ok(())
1313    }
1314
1315    fn put_field_internal(
1316        &mut self,
1317        name: &str,
1318        value: EpicsValue,
1319    ) -> epics_base_rs::error::CaResult<()> {
1320        // Bypass read-only checks for framework-internal writes (ReadDbLink).
1321        // This allows the framework to write to CVAL, OVAL, etc. from link resolution.
1322        match name {
1323            "CVAL" => match value {
1324                EpicsValue::Double(v) => {
1325                    self.cval = v;
1326                    Ok(())
1327                }
1328                _ => Err(CaError::TypeMismatch(name.into())),
1329            },
1330            "OVAL" => match value {
1331                EpicsValue::Double(v) => {
1332                    self.oval = v;
1333                    Ok(())
1334                }
1335                _ => Err(CaError::TypeMismatch(name.into())),
1336            },
1337            "P" => match value {
1338                EpicsValue::Double(v) => {
1339                    self.p = v;
1340                    Ok(())
1341                }
1342                _ => Err(CaError::TypeMismatch(name.into())),
1343            },
1344            "D" => match value {
1345                EpicsValue::Double(v) => {
1346                    self.d = v;
1347                    Ok(())
1348                }
1349                _ => Err(CaError::TypeMismatch(name.into())),
1350            },
1351            "ERR" => match value {
1352                EpicsValue::Double(v) => {
1353                    self.err = v;
1354                    Ok(())
1355                }
1356                _ => Err(CaError::TypeMismatch(name.into())),
1357            },
1358            _ => self.put_field(name, value),
1359        }
1360    }
1361
1362    fn multi_input_links(&self) -> &[(&'static str, &'static str)] {
1363        // INP -> CVAL is always resolved.
1364        // STPL -> VAL is only resolved when SMSL == closed_loop (1).
1365        // In supervisory mode (SMSL=0), the operator sets VAL directly
1366        // and STPL must not overwrite it.
1367        if self.smsl == 1 {
1368            // closed_loop: fetch setpoint from STPL into VAL
1369            static WITH_STPL: &[(&str, &str)] = &[("STPL", "VAL"), ("INP", "CVAL")];
1370            WITH_STPL
1371        } else {
1372            // supervisory: VAL is set by operator, don't fetch STPL
1373            static WITHOUT_STPL: &[(&str, &str)] = &[("INP", "CVAL")];
1374            WITHOUT_STPL
1375        }
1376    }
1377
1378    fn multi_output_links(&self) -> &[(&'static str, &'static str)] {
1379        // C `epidRecord.c:195-202`: on a UDF-gated cycle `process()`
1380        // returns before `do_pid` writes the output — suppress the
1381        // OUTL->OVAL write so a stale OVAL is not pushed downstream.
1382        if self.compute_skipped {
1383            return &[];
1384        }
1385        // OUTL -> OVAL (output link)
1386        static LINKS: &[(&str, &str)] = &[("OUTL", "OVAL")];
1387        LINKS
1388    }
1389
1390    fn should_fire_forward_link(&self) -> bool {
1391        // C `epidRecord.c:201` `return(0)` on a UDF-gated cycle is
1392        // reached before `recGblFwdLink` — no forward link this cycle.
1393        !self.compute_skipped
1394    }
1395}