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