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