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}