Skip to main content

std_rs/records/
throttle.rs

1use std::time::Instant;
2
3use epics_base_rs::error::{CaError, CaResult};
4use epics_base_rs::server::record::{
5    FieldDesc, LinkType, ProcessAction, ProcessOutcome, Record, link_field_type,
6};
7use epics_base_rs::types::{DbFieldType, EpicsValue};
8
9/// Record-specific `DBF_MENU` choice tables, in `.dbd` value order (the
10/// index↔string mapping is wire-visible to clients). Source: the C
11/// `throttleRecord.dbd` menu definitions (std module). `OV`/`SIV` share
12/// `menu(throttleOV)`.
13const THROTTLE_WAIT_CHOICES: &[&str] = &["False", "True"];
14const THROTTLE_DRVLC_CHOICES: &[&str] = &["Off", "On"];
15const THROTTLE_DRVLS_CHOICES: &[&str] = &["Normal", "Low Limit", "High Limit"];
16const THROTTLE_STS_CHOICES: &[&str] = &["Unknown", "Error", "Success"];
17const THROTTLE_OV_CHOICES: &[&str] = &["Ext PV NC", "Ext PV OK", "Local PV", "Constant"];
18const THROTTLE_SYNC_CHOICES: &[&str] = &["Idle", "Process"];
19
20/// Throttle record — rate-limits value changes to prevent device damage.
21///
22/// Ported from EPICS std module `throttleRecord.c`.
23///
24/// When VAL is written, the record checks drive limits, optionally clips
25/// the value, sets WAIT=True, then writes SENT to the OUT link only after
26/// the minimum delay (DLY) has elapsed since the last output. If a new
27/// value arrives during the delay, it queues the latest value and sends
28/// it when the delay expires.
29pub struct ThrottleRecord {
30    /// Set value (VAL)
31    pub val: f64,
32    /// Previous set value (OVAL), read-only
33    pub oval: f64,
34    /// Last sent value (SENT), read-only
35    pub sent: f64,
36    /// Previous sent value (OSENT), read-only
37    pub osent: f64,
38    /// Busy flag (WAIT): 0=False, 1=True, read-only
39    pub wait: i16,
40    /// High operating range (HOPR)
41    pub hopr: f64,
42    /// Low operating range (LOPR)
43    pub lopr: f64,
44    /// High drive limit (DRVLH)
45    pub drvlh: f64,
46    /// Low drive limit (DRVLL)
47    pub drvll: f64,
48    /// Limit status: 0=Normal, 1=Low, 2=High (DRVLS), read-only
49    pub drvls: i16,
50    /// Limit clipping: 0=Off, 1=On (DRVLC)
51    pub drvlc: i16,
52    /// Code version string (VER), read-only
53    pub ver: String,
54    /// Record status: 0=Unknown, 1=Error, 2=Success (STS), read-only
55    pub sts: i16,
56    /// Display precision (PREC)
57    pub prec: i16,
58    /// Delay display precision (DPREC)
59    pub dprec: i16,
60    /// Delay between outputs in seconds (DLY)
61    pub dly: f64,
62    /// Output link (OUT)
63    pub out: String,
64    /// Output link valid: 0=ExtNC, 1=Ext, 2=Local, 3=Constant (OV), read-only
65    pub ov: i16,
66    /// Sync input link (SINP)
67    pub sinp: String,
68    /// Sync input link valid (SIV), read-only
69    pub siv: i16,
70    /// Sync trigger: 0=Idle, 1=Process (SYNC)
71    pub sync: i16,
72
73    // --- Private runtime state ---
74    /// Whether limits are active (drvlh > drvll)
75    limit_flag: bool,
76    /// Whether a delay is currently in progress
77    delay_active: bool,
78    /// When the last output was sent (for delay enforcement)
79    last_send_time: Option<Instant>,
80    /// Value queued during delay period (sent when delay expires)
81    pending_value: Option<f64>,
82    /// Whether the most recent `process()` cycle actually issued an OUT
83    /// write. C `throttleRecord.c:308` has `recGblFwdLink` commented out
84    /// in `process()`; the forward link fires ONLY inside `valuePut`
85    /// (`throttleRecord.c:580`), i.e. only on a cycle where the OUT link
86    /// was written. `should_fire_forward_link` returns this flag so a
87    /// queuing-during-delay cycle or a rejected out-of-range cycle does
88    /// NOT fire FLNK.
89    out_written: bool,
90}
91
92impl Default for ThrottleRecord {
93    fn default() -> Self {
94        Self {
95            val: 0.0,
96            oval: 0.0,
97            sent: 0.0,
98            osent: 0.0,
99            wait: 0,
100            hopr: 0.0,
101            lopr: 0.0,
102            drvlh: 0.0,
103            drvll: 0.0,
104            drvls: 0, // Normal
105            drvlc: 0, // Off
106            // C `throttleRecord.c:51` `#define VERSION "0-2-1"`,
107            // copied into VER by `init_record` pass 0 (line 149).
108            ver: "0-2-1".to_string(),
109            sts: 0, // Unknown
110            prec: 0,
111            dprec: 0,
112            dly: 0.0,
113            out: String::new(),
114            ov: 3, // Constant
115            sinp: String::new(),
116            siv: 3,  // Constant
117            sync: 0, // Idle
118            limit_flag: false,
119            delay_active: false,
120            last_send_time: None,
121            pending_value: None,
122            out_written: false,
123        }
124    }
125}
126
127/// Upper bound (exclusive) on the `DLY` field, in seconds.
128///
129/// `process()` converts `self.dly` into a `std::time::Duration` via
130/// `Duration::from_secs_f64`, which panics not only on a non-finite
131/// argument but on any finite value too large for a `Duration` to
132/// represent (≈ `u64::MAX` seconds ≈ 1.8e19, message "value is either
133/// too big or NaN"). A CA put of e.g. `DLY = 1e300` is a perfectly
134/// finite f64 and would otherwise slip past an `is_finite()` guard and
135/// panic the record task.
136///
137/// A throttle delay of 24 hours is already far past any realistic
138/// device-protection interval, so this finite cap is the operational
139/// ceiling for `DLY`. It is also orders of magnitude below the
140/// `Duration` overflow point, so any `self.dly` accepted by the writer
141/// guard is guaranteed safe for `Duration::from_secs_f64`.
142const MAX_DLY: f64 = 86_400.0;
143
144/// Validate a candidate `DLY` value (seconds).
145///
146/// Returns `Ok(())` only for a value that can never make
147/// `Duration::from_secs_f64(self.dly)` panic in `process()`: it must
148/// be finite and at most [`MAX_DLY`]. A negative value is accepted
149/// here — C `special()` clamps it to 0 and `process()` treats any
150/// `dly <= 0.0` as "no delay" without constructing a `Duration` — so
151/// negativity is not a panic hazard. This is the single guard every
152/// writer of `self.dly` must pass through to hold the invariant
153/// "`self.dly` can never make `Duration::from_secs_f64` panic".
154fn validate_dly(v: f64) -> CaResult<()> {
155    if !v.is_finite() {
156        return Err(CaError::InvalidValue(format!(
157            "throttle DLY must be finite, got {v}"
158        )));
159    }
160    if v > MAX_DLY {
161        return Err(CaError::InvalidValue(format!(
162            "throttle DLY must not exceed {MAX_DLY} seconds, got {v}"
163        )));
164    }
165    Ok(())
166}
167
168impl ThrottleRecord {
169    /// Check drive limits and optionally clip the value.
170    ///
171    /// Mirrors the limit block of C `throttleRecord.c:242-283`. When
172    /// `limit_flag` is set the value is tested against the low limit
173    /// first, then the high limit (same order as C lines 246/260).
174    /// `DRVLS` is updated to the resulting limit status; when limits
175    /// are inactive it is forced to Normal (C line 275 sets
176    /// `throttleDRVLS_NORM`).
177    ///
178    /// Returns `Ok(value)` when the value is acceptable (clipped to the
179    /// limit when `DRVLC` is On), or `Err(())` when it is out of range
180    /// and clipping is Off — C's `proc_flag = 0` rejection path. C does
181    /// **not** touch `STS` on a rejection (lines 254-257, 268-271); the
182    /// caller must not set it either.
183    fn check_limits(&mut self, val: f64) -> Result<f64, ()> {
184        if !self.limit_flag {
185            self.drvls = 0; // throttleDRVLS_NORM
186            return Ok(val);
187        }
188
189        if val < self.drvll {
190            self.drvls = 1; // throttleDRVLS_LOW
191            if self.drvlc == 1 {
192                return Ok(self.drvll);
193            }
194            return Err(());
195        }
196
197        if val > self.drvlh {
198            self.drvls = 2; // throttleDRVLS_HIGH
199            if self.drvlc == 1 {
200                return Ok(self.drvlh);
201            }
202            return Err(());
203        }
204
205        self.drvls = 0; // throttleDRVLS_NORM
206        Ok(val)
207    }
208
209    /// Send the value to the output — C `throttleRecord.c::valuePut`
210    /// (lines 540-594).
211    ///
212    /// C `valuePut` line 557 branches on the OUT link type:
213    ///   - `if (plink->type != CONSTANT)` — `dbPutLink` is issued and
214    ///     STS is set from its result (`throttleSTS_SUC` on success,
215    ///     `throttleSTS_ERR` on failure), SENT/OSENT advance, the
216    ///     forward link fires (line 580).
217    ///   - `else` (CONSTANT/empty OUT) — no write happens, STS is forced
218    ///     to `throttleSTS_ERR`, SENT/OSENT do NOT advance, no FLNK.
219    ///
220    /// Returns `true` when the caller must emit the `WriteDbLink{OUT}`
221    /// action (a real, non-CONSTANT link). The port cannot observe the
222    /// `dbPutLink` result inline, so a real link is treated optimistically
223    /// as STS=Success — the emitted write either lands or the framework
224    /// raises its own link alarm.
225    fn send_value(&mut self, value: f64) -> bool {
226        if link_field_type(&self.out) == LinkType::Constant
227            || link_field_type(&self.out) == LinkType::Empty
228        {
229            // CONSTANT / empty OUT — C `valuePut` else branch: STS=Error,
230            // SENT/OSENT unchanged, no write, no FLNK.
231            self.sts = 1; // throttleSTS_ERR
232            self.out_written = false;
233            return false;
234        }
235        self.osent = self.sent;
236        self.sent = value;
237        self.last_send_time = Some(Instant::now());
238        self.sts = 2; // throttleSTS_SUC
239        self.out_written = true;
240        true
241    }
242
243    /// Check if the delay period has elapsed since last send.
244    fn delay_elapsed(&self) -> bool {
245        if self.dly <= 0.0 {
246            return true;
247        }
248        match self.last_send_time {
249            Some(t) => t.elapsed().as_secs_f64() >= self.dly,
250            None => true, // Never sent before
251        }
252    }
253}
254
255static FIELDS: &[FieldDesc] = &[
256    FieldDesc {
257        name: "VAL",
258        dbf_type: DbFieldType::Double,
259        read_only: false,
260    },
261    FieldDesc {
262        name: "OVAL",
263        dbf_type: DbFieldType::Double,
264        read_only: true,
265    },
266    FieldDesc {
267        name: "SENT",
268        dbf_type: DbFieldType::Double,
269        read_only: true,
270    },
271    FieldDesc {
272        name: "OSENT",
273        dbf_type: DbFieldType::Double,
274        read_only: true,
275    },
276    FieldDesc {
277        name: "WAIT",
278        dbf_type: DbFieldType::Short,
279        read_only: true,
280    },
281    FieldDesc {
282        name: "HOPR",
283        dbf_type: DbFieldType::Double,
284        read_only: false,
285    },
286    FieldDesc {
287        name: "LOPR",
288        dbf_type: DbFieldType::Double,
289        read_only: false,
290    },
291    FieldDesc {
292        name: "DRVLH",
293        dbf_type: DbFieldType::Double,
294        read_only: false,
295    },
296    FieldDesc {
297        name: "DRVLL",
298        dbf_type: DbFieldType::Double,
299        read_only: false,
300    },
301    FieldDesc {
302        name: "DRVLS",
303        dbf_type: DbFieldType::Short,
304        read_only: true,
305    },
306    FieldDesc {
307        name: "DRVLC",
308        dbf_type: DbFieldType::Short,
309        read_only: false,
310    },
311    FieldDesc {
312        name: "VER",
313        dbf_type: DbFieldType::String,
314        read_only: true,
315    },
316    FieldDesc {
317        name: "STS",
318        dbf_type: DbFieldType::Short,
319        read_only: true,
320    },
321    FieldDesc {
322        name: "PREC",
323        dbf_type: DbFieldType::Short,
324        read_only: false,
325    },
326    FieldDesc {
327        name: "DPREC",
328        dbf_type: DbFieldType::Short,
329        read_only: false,
330    },
331    FieldDesc {
332        name: "DLY",
333        dbf_type: DbFieldType::Double,
334        read_only: false,
335    },
336    FieldDesc {
337        name: "OUT",
338        dbf_type: DbFieldType::String,
339        read_only: false,
340    },
341    FieldDesc {
342        name: "OV",
343        dbf_type: DbFieldType::Short,
344        read_only: true,
345    },
346    FieldDesc {
347        name: "SINP",
348        dbf_type: DbFieldType::String,
349        read_only: false,
350    },
351    FieldDesc {
352        name: "SIV",
353        dbf_type: DbFieldType::Short,
354        read_only: true,
355    },
356    FieldDesc {
357        name: "SYNC",
358        dbf_type: DbFieldType::Short,
359        read_only: false,
360    },
361];
362
363impl Record for ThrottleRecord {
364    fn record_type(&self) -> &'static str {
365        "throttle"
366    }
367
368    fn pre_process_actions(&mut self) -> Vec<ProcessAction> {
369        // When SYNC=1, read SINP into VAL BEFORE process() runs.
370        // This matches C EPICS where dbGetLink is synchronous/immediate.
371        if self.sync == 1 {
372            self.sync = 0;
373            return vec![ProcessAction::ReadDbLink {
374                link_field: "SINP",
375                target_field: "VAL",
376            }];
377        }
378        Vec::new()
379    }
380
381    fn process(&mut self) -> CaResult<ProcessOutcome> {
382        // C `throttleRecord.c:231-312`. The control flow here mirrors C's
383        // `process()`:
384        //
385        //   1. The drive-limit block (C lines 242-283) runs on EVERY
386        //      process() call, regardless of whether a delay is pending.
387        //      It updates DRVLS and, on a clip-off out-of-range value,
388        //      sets `proc_flag = 0` (reject: restore `val = oval`, skip
389        //      the send).
390        //   2. If `proc_flag` (C lines 285-296): the value is "entered".
391        //      C `enterValue()` sets `wait_flag = 1`; if no delay is in
392        //      progress (`!delay_flag`) it calls `valuePut()` to write
393        //      OUT immediately and arm the delay timer. If a delay IS in
394        //      progress the value just waits — the running delay timer
395        //      will pick up the latest `prec->val` when it fires.
396        //
397        // The Rust port has no `callbackRequestDelayed` handle, so the
398        // delay timer is modelled by `ReprocessAfter`: the current cycle
399        // writes OUT, then the framework re-invokes `process()` after
400        // DLY. `delay_active` is C's `delay_flag`; `pending_value` plus
401        // re-entry through this same limit block reproduces C taking the
402        // latest limit-checked `prec->val` at timer-fire time.
403        let mut actions = Vec::new();
404
405        // C `throttleRecord.c:308` keeps `recGblFwdLink` commented out in
406        // `process()`; the forward link fires ONLY from `valuePut`'s
407        // non-CONSTANT branch (line 580). Reset the per-cycle FLNK flag
408        // here so a queuing-during-delay cycle, a rejected out-of-range
409        // cycle, or a drain with nothing queued does NOT fire FLNK —
410        // only a real OUT write (via `send_value`) sets it true.
411        self.out_written = false;
412
413        // --- Drain path: the post-delay timer callback (C `valuePut()`
414        //     reached via `delayFuncCallback`, lines 530-538/540-594) ---
415        //
416        // C runs the drain in `valuePut()`, a code path SEPARATE from
417        // `process()`: it does NOT re-run the drive-limit block and does
418        // NOT touch the OVAL end-of-process update. The port models the
419        // timer with `ReprocessAfter`, so the drain arrives as a
420        // re-entrant `process()` call — identified here by an armed
421        // delay whose window has elapsed. It must therefore short-circuit
422        // BEFORE the limit block so a previously limit-checked queued
423        // value is sent as-is and DRVLS (set by the queuing process()) is
424        // left intact.
425        if self.delay_active && self.delay_elapsed() {
426            self.delay_active = false;
427            self.wait = 0;
428            match self.pending_value.take() {
429                // C `valuePut`: `wait_flag` set -> a value arrived during
430                // the delay; send the (already limit-checked) queued
431                // value, set SENT/OSENT/STS, and re-arm the timer.
432                Some(pv) => {
433                    // C `valuePut`: a CONSTANT/empty OUT yields STS=Error
434                    // and no write; a real link yields the WriteDbLink.
435                    if self.send_value(pv) {
436                        actions.push(ProcessAction::WriteDbLink {
437                            link_field: "OUT",
438                            value: EpicsValue::Double(self.sent),
439                        });
440                    }
441                    if self.dly > 0.0 {
442                        self.delay_active = true;
443                        self.wait = 1;
444                        let delay = std::time::Duration::from_secs_f64(self.dly);
445                        actions.push(ProcessAction::ReprocessAfter(delay));
446                    }
447                    return Ok(ProcessOutcome::complete_with(actions));
448                }
449                // C `valuePut`: `wait_flag` clear -> nothing queued; the
450                // callback merely clears `delay_flag` (line 597).
451                None => {
452                    return Ok(ProcessOutcome::complete_with(actions));
453                }
454            }
455        }
456
457        // --- Step 1: drive-limit block (C lines 242-283), runs on every
458        //     fresh process() call ---
459        //
460        // C restores `prec->val = prec->oval` and sets `proc_flag = 0` on
461        // a rejected (out-of-range, clipping Off) value; it does NOT set
462        // STS and does NOT touch WAIT. STS is only ever written after a
463        // real link operation (valuePut / valueSync).
464        let proc_flag = match self.check_limits(self.val) {
465            Ok(clamped) => {
466                self.val = clamped;
467                true
468            }
469            Err(()) => {
470                self.val = self.oval;
471                false
472            }
473        };
474
475        if !proc_flag {
476            // Rejected: skip enterValue entirely (C `proc_flag == 0`).
477            // A delay already in progress is left running — its
478            // ReprocessAfter still fires and drains whatever value was
479            // queued. C's end-of-process OVAL block is a no-op here
480            // because `val` was just restored to `oval`.
481            return Ok(ProcessOutcome::complete_with(actions));
482        }
483
484        // OVAL end-of-process update (C lines 299-303): on a fresh,
485        // accepted process() OVAL tracks the just-checked VAL.
486        self.oval = self.val;
487
488        // --- Step 2: enterValue() (C lines 518-528) ---
489        //
490        // A delay timer is in progress. C `enterValue()` sets
491        // `wait_flag = 1` and returns; the running `delayFuncCb` will
492        // call `valuePut()` and send whatever `prec->val` is when it
493        // fires. The port stashes the latest limit-checked value (last
494        // value wins, as in C) so the drain re-process sends it. WAIT
495        // stays True; the in-flight ReprocessAfter is left to fire.
496        if self.delay_active {
497            self.pending_value = Some(self.val);
498            self.wait = 1;
499            let remaining = self.dly
500                - self
501                    .last_send_time
502                    .map(|t| t.elapsed().as_secs_f64())
503                    .unwrap_or(0.0);
504            let delay = std::time::Duration::from_secs_f64(remaining.max(0.001));
505            actions.push(ProcessAction::ReprocessAfter(delay));
506            return Ok(ProcessOutcome::complete_with(actions));
507        }
508
509        // No delay in progress: send immediately (C `enterValue` calls
510        // `valuePut` directly when `!delay_flag`). C `valuePut` writes the
511        // OUT link and sets SENT/OSENT and STS=Success only for a
512        // non-CONSTANT OUT; a CONSTANT/empty OUT yields STS=Error and no
513        // write.
514        if self.send_value(self.val) {
515            actions.push(ProcessAction::WriteDbLink {
516                link_field: "OUT",
517                value: EpicsValue::Double(self.sent),
518            });
519        }
520
521        // Arm the delay timer (C `callbackRequestDelayed`, lines 592-593)
522        // when DLY > 0. WAIT is True for the duration of the delay: C
523        // sets `prec->wait = TRUE` before enterValue, and although
524        // `valuePut` clears it after the OUT write, the freshly-armed
525        // timer means the operator-visible post-cycle state is Busy
526        // until the drain completes.
527        if self.dly > 0.0 {
528            self.delay_active = true;
529            self.wait = 1;
530            let delay = std::time::Duration::from_secs_f64(self.dly);
531            actions.push(ProcessAction::ReprocessAfter(delay));
532            return Ok(ProcessOutcome::complete_with(actions));
533        }
534
535        // No delay: C `valuePut` sets WAIT=False after the immediate
536        // write (lines 575/587).
537        self.delay_active = false;
538        self.wait = 0;
539        Ok(ProcessOutcome::complete_with(actions))
540    }
541
542    fn can_device_write(&self) -> bool {
543        true
544    }
545
546    fn special(&mut self, field: &str, after: bool) -> CaResult<()> {
547        if !after {
548            return Ok(());
549        }
550        match field {
551            // C `special()` DLY case (lines 392-409). A negative delay
552            // is clamped to 0. C also cancels/restarts the in-flight
553            // `delayFuncCb` so a previously-set huge delay does not keep
554            // the record Busy; the port re-derives the remaining delay
555            // from `last_send_time` + the new DLY on the next process,
556            // so a shrunk DLY takes effect on the next drain attempt.
557            //
558            // `special()` runs after the field write. `put_field("DLY")`
559            // already rejects non-finite and huge-but-finite values via
560            // `validate_dly`, so a CA/db path can never leave `self.dly`
561            // out of range here. The clamp below additionally enforces
562            // the `Duration::from_secs_f64` invariant for any other
563            // writer of `self.dly` (e.g. in-process callers), so every
564            // reader downstream of `special()` is safe.
565            "DLY" => {
566                if self.dly < 0.0 {
567                    self.dly = 0.0;
568                } else if validate_dly(self.dly).is_err() {
569                    // Non-finite or >= MAX_DLY: clamp to the operational
570                    // ceiling so `process()` never panics.
571                    self.dly = MAX_DLY;
572                }
573            }
574            // C `special()` DRVLH/DRVLL case (lines 411-440). When the
575            // new limits disable limiting (`drvlh <= drvll`) DRVLS goes
576            // Normal. When limiting is (re)enabled DRVLS is recomputed
577            // immediately against the *current* VAL — Low if below the
578            // low limit, High if above the high limit, else Normal.
579            "DRVLH" | "DRVLL" => {
580                self.limit_flag = self.drvlh > self.drvll;
581                if !self.limit_flag {
582                    self.drvls = 0; // throttleDRVLS_NORM
583                } else if self.val < self.drvll {
584                    self.drvls = 1; // throttleDRVLS_LOW
585                } else if self.val > self.drvlh {
586                    self.drvls = 2; // throttleDRVLS_HIGH
587                } else {
588                    self.drvls = 0; // throttleDRVLS_NORM
589                }
590            }
591            _ => {}
592        }
593        Ok(())
594    }
595
596    fn get_field(&self, name: &str) -> Option<EpicsValue> {
597        match name {
598            "VAL" => Some(EpicsValue::Double(self.val)),
599            "OVAL" => Some(EpicsValue::Double(self.oval)),
600            "SENT" => Some(EpicsValue::Double(self.sent)),
601            "OSENT" => Some(EpicsValue::Double(self.osent)),
602            "WAIT" => Some(EpicsValue::Short(self.wait)),
603            "HOPR" => Some(EpicsValue::Double(self.hopr)),
604            "LOPR" => Some(EpicsValue::Double(self.lopr)),
605            "DRVLH" => Some(EpicsValue::Double(self.drvlh)),
606            "DRVLL" => Some(EpicsValue::Double(self.drvll)),
607            "DRVLS" => Some(EpicsValue::Short(self.drvls)),
608            "DRVLC" => Some(EpicsValue::Short(self.drvlc)),
609            "VER" => Some(EpicsValue::String(self.ver.clone().into())),
610            "STS" => Some(EpicsValue::Short(self.sts)),
611            "PREC" => Some(EpicsValue::Short(self.prec)),
612            "DPREC" => Some(EpicsValue::Short(self.dprec)),
613            "DLY" => Some(EpicsValue::Double(self.dly)),
614            "OUT" => Some(EpicsValue::String(self.out.clone().into())),
615            "OV" => Some(EpicsValue::Short(self.ov)),
616            "SINP" => Some(EpicsValue::String(self.sinp.clone().into())),
617            "SIV" => Some(EpicsValue::Short(self.siv)),
618            "SYNC" => Some(EpicsValue::Short(self.sync)),
619            _ => None,
620        }
621    }
622
623    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
624        match name {
625            "VAL" => match value {
626                EpicsValue::Double(v) => {
627                    self.val = v;
628                    Ok(())
629                }
630                _ => Err(CaError::TypeMismatch(name.into())),
631            },
632            "HOPR" => match value {
633                EpicsValue::Double(v) => {
634                    self.hopr = v;
635                    Ok(())
636                }
637                _ => Err(CaError::TypeMismatch(name.into())),
638            },
639            "LOPR" => match value {
640                EpicsValue::Double(v) => {
641                    self.lopr = v;
642                    Ok(())
643                }
644                _ => Err(CaError::TypeMismatch(name.into())),
645            },
646            "DRVLH" => match value {
647                EpicsValue::Double(v) => {
648                    self.drvlh = v;
649                    Ok(())
650                }
651                _ => Err(CaError::TypeMismatch(name.into())),
652            },
653            "DRVLL" => match value {
654                EpicsValue::Double(v) => {
655                    self.drvll = v;
656                    Ok(())
657                }
658                _ => Err(CaError::TypeMismatch(name.into())),
659            },
660            "DRVLC" => match value {
661                EpicsValue::Short(v) => {
662                    self.drvlc = v;
663                    Ok(())
664                }
665                _ => Err(CaError::TypeMismatch(name.into())),
666            },
667            "PREC" => match value {
668                EpicsValue::Short(v) => {
669                    self.prec = v;
670                    Ok(())
671                }
672                _ => Err(CaError::TypeMismatch(name.into())),
673            },
674            "DPREC" => match value {
675                EpicsValue::Short(v) => {
676                    self.dprec = v;
677                    Ok(())
678                }
679                _ => Err(CaError::TypeMismatch(name.into())),
680            },
681            "DLY" => match value {
682                EpicsValue::Double(v) => {
683                    // C `throttleRecord.c` models the delay with
684                    // `Duration::from_secs_f64(self.dly)` in `process()`,
685                    // which panics not only on a non-finite argument but
686                    // on any finite value too large for a `Duration`
687                    // (≈ 1.8e19; message "value is either too big or
688                    // NaN"). C's `special()` DLY handler (lines 392-409)
689                    // only ever anticipated a negative delay; a CA put of
690                    // `+inf`, `NaN`, or a huge-but-finite f64 like `1e300`
691                    // is not a value any real delay can represent. Reject
692                    // it here, at the single writer of `self.dly`, so the
693                    // record task can never panic — `validate_dly` is the
694                    // gate that holds the invariant "`self.dly` can never
695                    // make `Duration::from_secs_f64` panic".
696                    validate_dly(v)?;
697                    self.dly = v;
698                    Ok(())
699                }
700                _ => Err(CaError::TypeMismatch(name.into())),
701            },
702            "OUT" => match value {
703                EpicsValue::String(v) => {
704                    self.out = v.as_str_lossy().into_owned();
705                    Ok(())
706                }
707                _ => Err(CaError::TypeMismatch(name.into())),
708            },
709            "SINP" => match value {
710                EpicsValue::String(v) => {
711                    self.sinp = v.as_str_lossy().into_owned();
712                    Ok(())
713                }
714                _ => Err(CaError::TypeMismatch(name.into())),
715            },
716            "SYNC" => match value {
717                EpicsValue::Short(v) => {
718                    self.sync = v;
719                    Ok(())
720                }
721                _ => Err(CaError::TypeMismatch(name.into())),
722            },
723            // Read-only fields
724            "OVAL" | "SENT" | "OSENT" | "WAIT" | "DRVLS" | "VER" | "STS" | "OV" | "SIV" => {
725                Err(CaError::ReadOnlyField(name.into()))
726            }
727            _ => Err(CaError::FieldNotFound(name.into())),
728        }
729    }
730
731    fn field_list(&self) -> &'static [FieldDesc] {
732        FIELDS
733    }
734
735    /// Record-specific `DBF_MENU` fields, served as `DBR_ENUM` with the
736    /// menu's choice labels in `.dbd` index order (C `throttleRecord.dbd`):
737    /// `WAIT`=`throttleWAIT`, `DRVLC`=`throttleDRVLC`,
738    /// `DRVLS`=`throttleDRVLS`, `STS`=`throttleSTS`,
739    /// `OV`/`SIV`=`throttleOV`, `SYNC`=`throttleSYNC`.
740    fn menu_field_choices(&self, field: &str) -> Option<&'static [&'static str]> {
741        match field {
742            "WAIT" => Some(THROTTLE_WAIT_CHOICES),
743            "DRVLC" => Some(THROTTLE_DRVLC_CHOICES),
744            "DRVLS" => Some(THROTTLE_DRVLS_CHOICES),
745            "STS" => Some(THROTTLE_STS_CHOICES),
746            "OV" | "SIV" => Some(THROTTLE_OV_CHOICES),
747            "SYNC" => Some(THROTTLE_SYNC_CHOICES),
748            _ => None,
749        }
750    }
751
752    /// C `throttleRecord.c:308` keeps `recGblFwdLink(prec)` commented
753    /// out in `process()` — the forward link is fired ONLY from
754    /// `valuePut`'s non-CONSTANT branch (`throttleRecord.c:580`), i.e.
755    /// only on a cycle where a real OUT write actually occurred. The
756    /// framework default fires FLNK every `process()`, which would also
757    /// fire it on a queuing-during-delay cycle, a rejected out-of-range
758    /// cycle, a drain with nothing queued, and a CONSTANT-OUT cycle —
759    /// none of which write OUT in C. `process()` maintains `out_written`
760    /// (reset to false each cycle, set true only by `send_value` on a
761    /// real OUT write); this hook returns it.
762    fn should_fire_forward_link(&self) -> bool {
763        self.out_written
764    }
765
766    fn init_record(&mut self, pass: u8) -> CaResult<()> {
767        // C `init_record` (throttleRecord.c:133-228). Pass 0 copies the
768        // VERSION string into VER; the Rust port sets VER in `Default`
769        // instead (the framework constructs the record before init).
770        //
771        // Pass 1 (C lines 156-167): STS is reset to Unknown and VAL to
772        // 0, and `limit_flag` is derived from `drvlh > drvll`. C also
773        // resets the private delay/wait/sync flags to 0 — mirrored by
774        // the runtime-state fields below.
775        if pass == 1 {
776            self.sts = 0; // throttleSTS_UNK
777            self.val = 0.0;
778            self.limit_flag = self.drvlh > self.drvll;
779            self.delay_active = false;
780            self.last_send_time = None;
781            self.pending_value = None;
782            self.out_written = false;
783        }
784        Ok(())
785    }
786}
787
788#[cfg(test)]
789mod menu_choice_tests {
790    use super::ThrottleRecord;
791    use epics_base_rs::server::record::Record;
792
793    // Choice tables must match throttleRecord.dbd menu value order — the
794    // index↔string mapping is wire-visible to clients.
795    #[test]
796    fn throttle_menu_field_choices_match_dbd() {
797        let rec = ThrottleRecord::default();
798        assert_eq!(rec.menu_field_choices("WAIT"), Some(&["False", "True"][..]));
799        assert_eq!(rec.menu_field_choices("DRVLC"), Some(&["Off", "On"][..]));
800        assert_eq!(
801            rec.menu_field_choices("DRVLS"),
802            Some(&["Normal", "Low Limit", "High Limit"][..])
803        );
804        assert_eq!(
805            rec.menu_field_choices("STS"),
806            Some(&["Unknown", "Error", "Success"][..])
807        );
808        // OV and SIV share menu(throttleOV).
809        let ov = &["Ext PV NC", "Ext PV OK", "Local PV", "Constant"][..];
810        assert_eq!(rec.menu_field_choices("OV"), Some(ov));
811        assert_eq!(rec.menu_field_choices("SIV"), Some(ov));
812        assert_eq!(
813            rec.menu_field_choices("SYNC"),
814            Some(&["Idle", "Process"][..])
815        );
816        assert_eq!(rec.menu_field_choices("VAL"), None);
817    }
818}