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