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