Skip to main content

std_rs/records/
throttle.rs

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