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