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        sched.schedule_record_init(async move {
210            // Let `add_record` finish registering before the init post —
211            // this task may be spawned from `set_async_context`, which runs
212            // just before the record is inserted into the map.
213            tokio::task::yield_now().await;
214            // OUT is written to, SINP is read from — the classifier answers a
215            // CONSTANT link's field-type code by direction.
216            let (ov, _) = classify_link(&handle, &out, LinkRole::Output).await;
217            let (siv, _) = classify_link(&handle, &sinp, LinkRole::Input).await;
218            if link_gen.is_current(token) {
219                let _ = handle
220                    .post_fields(
221                        &name,
222                        vec![
223                            ("OV".to_string(), EpicsValue::Short(ov)),
224                            ("SIV".to_string(), EpicsValue::Short(siv)),
225                        ],
226                    )
227                    .await;
228            }
229        });
230    }
231
232    /// C `valueSync` (throttleRecord.c:616-656): read SINP into VAL as
233    /// `DBR_DOUBLE` and post VAL/STS/SYNC — NO OUT write, NO process, NO
234    /// FLNK. A CONSTANT SINP (SIV=`Constant`) yields STS=Error with no read
235    /// (C's `plink->type == CONSTANT` else branch); a local read failure
236    /// also yields STS=Error. SYNC is reset to Idle on completion. Only
237    /// called for SIV ∈ {Local, Constant} (the `EXT_NC` skip is in
238    /// `special`). Not generation-gated: a rare double-SYNC resolves
239    /// last-scheduled-wins, benign because VAL is latest-value anyway.
240    fn spawn_value_sync(&self) {
241        let Some((name, handle)) = &self.async_ctx else {
242            return;
243        };
244        let name = name.clone();
245        let handle = handle.clone();
246        let sinp = self.sinp.clone();
247        let siv = self.siv;
248        tokio::spawn(async move {
249            tokio::task::yield_now().await;
250            let mut fields: Vec<(String, EpicsValue)> = Vec::with_capacity(3);
251            if siv == LINK_CON {
252                // C `valueSync`: a CONSTANT SINP is never read → STS=Error.
253                fields.push(("STS".to_string(), EpicsValue::Short(THROTTLE_STS_ERR)));
254            } else {
255                // SIV=Local: C `dbGetLink(SINP, DBR_DOUBLE, &sival)` — read
256                // the source coerced to double, regardless of its native type.
257                match handle.read_link_value(&sinp).await.and_then(|v| v.to_f64()) {
258                    Some(v) => {
259                        fields.push(("VAL".to_string(), EpicsValue::Double(v)));
260                        fields.push(("STS".to_string(), EpicsValue::Short(THROTTLE_STS_SUC)));
261                    }
262                    None => fields.push(("STS".to_string(), EpicsValue::Short(THROTTLE_STS_ERR))),
263                }
264            }
265            // C posts SYNC=Idle last (throttleRecord.c:651).
266            fields.push(("SYNC".to_string(), EpicsValue::Short(THROTTLE_SYNC_IDLE)));
267            let _ = handle.post_fields(&name, fields).await;
268        });
269    }
270
271    /// Check drive limits and optionally clip the value.
272    ///
273    /// Mirrors the limit block of C `throttleRecord.c:242-283`. When
274    /// `limit_flag` is set the value is tested against the low limit
275    /// first, then the high limit (same order as C lines 246/260).
276    /// `DRVLS` is updated to the resulting limit status; when limits
277    /// are inactive it is forced to Normal (C line 275 sets
278    /// `throttleDRVLS_NORM`).
279    ///
280    /// Returns `Ok(value)` when the value is acceptable (clipped to the
281    /// limit when `DRVLC` is On), or `Err(())` when it is out of range
282    /// and clipping is Off — C's `proc_flag = 0` rejection path. C does
283    /// **not** touch `STS` on a rejection (lines 254-257, 268-271); the
284    /// caller must not set it either.
285    fn check_limits(&mut self, val: f64) -> Result<f64, ()> {
286        if !self.limit_flag {
287            self.drvls = 0; // throttleDRVLS_NORM
288            return Ok(val);
289        }
290
291        if val < self.drvll {
292            self.drvls = 1; // throttleDRVLS_LOW
293            if self.drvlc == 1 {
294                return Ok(self.drvll);
295            }
296            return Err(());
297        }
298
299        if val > self.drvlh {
300            self.drvls = 2; // throttleDRVLS_HIGH
301            if self.drvlc == 1 {
302                return Ok(self.drvlh);
303            }
304            return Err(());
305        }
306
307        self.drvls = 0; // throttleDRVLS_NORM
308        Ok(val)
309    }
310
311    /// Send the value to the output — C `throttleRecord.c::valuePut`
312    /// (lines 540-594).
313    ///
314    /// C `valuePut` line 557 branches on the OUT link type:
315    ///   - `if (plink->type != CONSTANT)` — `dbPutLink` is issued and
316    ///     STS is set from its result (`throttleSTS_SUC` on success,
317    ///     `throttleSTS_ERR` on failure), SENT/OSENT advance, the
318    ///     forward link fires (line 580).
319    ///   - `else` (CONSTANT/empty OUT) — no write happens, STS is forced
320    ///     to `throttleSTS_ERR`, SENT/OSENT do NOT advance, no FLNK.
321    ///
322    /// Returns `true` when the caller must emit the `WriteDbLink{OUT}`
323    /// action (a real, non-CONSTANT link). The port cannot observe the
324    /// `dbPutLink` result inline, so a real link is treated optimistically
325    /// as STS=Success — the emitted write either lands or the framework
326    /// raises its own link alarm.
327    fn send_value(&mut self, value: f64) -> bool {
328        if link_field_type(&self.out) == LinkType::Constant
329            || link_field_type(&self.out) == LinkType::Empty
330        {
331            // CONSTANT / empty OUT — C `valuePut` else branch: STS=Error,
332            // SENT/OSENT unchanged, no write, no FLNK.
333            self.sts = 1; // throttleSTS_ERR
334            self.out_written = false;
335            return false;
336        }
337        self.osent = self.sent;
338        self.sent = value;
339        self.last_send_time = Some(Instant::now());
340        self.sts = 2; // throttleSTS_SUC
341        self.out_written = true;
342        true
343    }
344
345    /// Check if the delay period has elapsed since last send.
346    fn delay_elapsed(&self) -> bool {
347        if self.dly <= 0.0 {
348            return true;
349        }
350        match self.last_send_time {
351            Some(t) => t.elapsed().as_secs_f64() >= self.dly,
352            None => true, // Never sent before
353        }
354    }
355}
356
357impl Record for ThrottleRecord {
358    fn record_type(&self) -> &'static str {
359        "throttle"
360    }
361
362    fn process(&mut self) -> CaResult<ProcessOutcome> {
363        // C `throttleRecord.c:231-312`. The control flow here mirrors C's
364        // `process()`:
365        //
366        //   1. The drive-limit block (C lines 242-283) runs on EVERY
367        //      process() call, regardless of whether a delay is pending.
368        //      It updates DRVLS and, on a clip-off out-of-range value,
369        //      sets `proc_flag = 0` (reject: restore `val = oval`, skip
370        //      the send).
371        //   2. If `proc_flag` (C lines 285-296): the value is "entered".
372        //      C `enterValue()` sets `wait_flag = 1`; if no delay is in
373        //      progress (`!delay_flag`) it calls `valuePut()` to write
374        //      OUT immediately and arm the delay timer. If a delay IS in
375        //      progress the value just waits — the running delay timer
376        //      will pick up the latest `prec->val` when it fires.
377        //
378        // The Rust port has no `callbackRequestDelayed` handle, so the
379        // delay timer is modelled by `ReprocessAfter`: the current cycle
380        // writes OUT, then the framework re-invokes `process()` after
381        // DLY. `delay_active` is C's `delay_flag`; `pending_value` plus
382        // re-entry through this same limit block reproduces C taking the
383        // latest limit-checked `prec->val` at timer-fire time.
384        let mut actions = Vec::new();
385
386        // C `throttleRecord.c:308` keeps `recGblFwdLink` commented out in
387        // `process()`; the forward link fires ONLY from `valuePut`'s
388        // non-CONSTANT branch (line 580). Reset the per-cycle FLNK flag
389        // here so a queuing-during-delay cycle, a rejected out-of-range
390        // cycle, or a drain with nothing queued does NOT fire FLNK —
391        // only a real OUT write (via `send_value`) sets it true.
392        self.out_written = false;
393
394        // --- Drain path: the post-delay timer callback (C `valuePut()`
395        //     reached via `delayFuncCallback`, lines 530-538/540-594) ---
396        //
397        // C runs the drain in `valuePut()`, a code path SEPARATE from
398        // `process()`: it does NOT re-run the drive-limit block and does
399        // NOT touch the OVAL end-of-process update. The port models the
400        // timer with `ReprocessAfter`, so the drain arrives as a
401        // re-entrant `process()` call — identified here by an armed
402        // delay whose window has elapsed. It must therefore short-circuit
403        // BEFORE the limit block so a previously limit-checked queued
404        // value is sent as-is and DRVLS (set by the queuing process()) is
405        // left intact.
406        if self.delay_active && self.delay_elapsed() {
407            self.delay_active = false;
408            self.wait = 0;
409            match self.pending_value.take() {
410                // C `valuePut`: `wait_flag` set -> a value arrived during
411                // the delay; send the (already limit-checked) queued
412                // value, set SENT/OSENT/STS, and re-arm the timer.
413                Some(pv) => {
414                    // C `valuePut`: a CONSTANT/empty OUT yields STS=Error
415                    // and no write; a real link yields the WriteDbLink.
416                    if self.send_value(pv) {
417                        actions.push(ProcessAction::WriteDbLink {
418                            link_field: "OUT",
419                            value: EpicsValue::Double(self.sent),
420                        });
421                    }
422                    // C `valuePut` clears `prec->wait = FALSE` immediately
423                    // after the OUT write (throttleRecord.c:575) and only
424                    // THEN re-arms the timer (:592-593). WAIT means "an
425                    // un-written value is pending", not "a delay timer is
426                    // running": the just-drained value is now written, the
427                    // queue is empty (`pending_value` was taken), so WAIT
428                    // stays clear through the new cooldown. A value that
429                    // arrives during it re-sets WAIT in the queue branch
430                    // below.
431                    if self.dly > 0.0 {
432                        self.delay_active = true;
433                        self.wait = 0;
434                        let delay = std::time::Duration::from_secs_f64(self.dly);
435                        actions.push(ProcessAction::ReprocessAfter(delay));
436                    }
437                    return Ok(ProcessOutcome::complete_with(actions));
438                }
439                // C `valuePut`: `wait_flag` clear -> nothing queued; the
440                // callback merely clears `delay_flag` (line 597).
441                None => {
442                    return Ok(ProcessOutcome::complete_with(actions));
443                }
444            }
445        }
446
447        // --- Step 1: drive-limit block (C lines 242-283), runs on every
448        //     fresh process() call ---
449        //
450        // C restores `prec->val = prec->oval` and sets `proc_flag = 0` on
451        // a rejected (out-of-range, clipping Off) value; it does NOT set
452        // STS and does NOT touch WAIT. STS is only ever written after a
453        // real link operation (valuePut / valueSync).
454        let proc_flag = match self.check_limits(self.val) {
455            Ok(clamped) => {
456                self.val = clamped;
457                true
458            }
459            Err(()) => {
460                self.val = self.oval;
461                false
462            }
463        };
464
465        if !proc_flag {
466            // Rejected: skip enterValue entirely (C `proc_flag == 0`).
467            // A delay already in progress is left running — its
468            // ReprocessAfter still fires and drains whatever value was
469            // queued. C's end-of-process OVAL block is a no-op here
470            // because `val` was just restored to `oval`.
471            return Ok(ProcessOutcome::complete_with(actions));
472        }
473
474        // OVAL end-of-process update (C lines 299-303): on a fresh,
475        // accepted process() OVAL tracks the just-checked VAL.
476        self.oval = self.val;
477
478        // --- Step 2: enterValue() (C lines 518-528) ---
479        //
480        // A delay timer is in progress. C `enterValue()` sets
481        // `wait_flag = 1` and returns; the running `delayFuncCb` will
482        // call `valuePut()` and send whatever `prec->val` is when it
483        // fires. The port stashes the latest limit-checked value (last
484        // value wins, as in C) so the drain re-process sends it. This is
485        // the ONE state where C leaves WAIT set: `process()` set
486        // `prec->wait = TRUE` (throttleRecord.c:287) and, with a delay in
487        // progress, `enterValue` does NOT call `valuePut` (:525), so
488        // nothing clears it — the value is now queued, un-written. WAIT=1
489        // therefore means exactly `pending_value.is_some()`; the
490        // in-flight ReprocessAfter is left to fire.
491        if self.delay_active {
492            self.pending_value = Some(self.val);
493            self.wait = 1;
494            let remaining = self.dly
495                - self
496                    .last_send_time
497                    .map(|t| t.elapsed().as_secs_f64())
498                    .unwrap_or(0.0);
499            let delay = std::time::Duration::from_secs_f64(remaining.max(0.001));
500            actions.push(ProcessAction::ReprocessAfter(delay));
501            return Ok(ProcessOutcome::complete_with(actions));
502        }
503
504        // No delay in progress: send immediately (C `enterValue` calls
505        // `valuePut` directly when `!delay_flag`). C `valuePut` writes the
506        // OUT link and sets SENT/OSENT and STS=Success only for a
507        // non-CONSTANT OUT; a CONSTANT/empty OUT yields STS=Error and no
508        // write.
509        if self.send_value(self.val) {
510            actions.push(ProcessAction::WriteDbLink {
511                link_field: "OUT",
512                value: EpicsValue::Double(self.sent),
513            });
514        }
515
516        // Arm the delay timer (C `callbackRequestDelayed`, lines 592-593)
517        // when DLY > 0, and clear WAIT. C `process()` sets `prec->wait =
518        // TRUE` before `enterValue` (throttleRecord.c:287), but on this
519        // immediate path `valuePut` runs in the same cycle and clears
520        // `prec->wait = FALSE` right after the OUT write (:575) BEFORE
521        // re-arming the timer. C's WAIT means "an un-written value is
522        // pending", not "a delay timer is running": the value just
523        // written is no longer pending, so WAIT is clear through the
524        // cooldown even though the timer is armed. WAIT is re-set only
525        // when a value is queued during the delay (the branch above).
526        if self.dly > 0.0 {
527            self.delay_active = true;
528            self.wait = 0;
529            let delay = std::time::Duration::from_secs_f64(self.dly);
530            actions.push(ProcessAction::ReprocessAfter(delay));
531            return Ok(ProcessOutcome::complete_with(actions));
532        }
533
534        // No delay: C `valuePut` sets WAIT=False after the immediate
535        // write (lines 575/587).
536        self.delay_active = false;
537        self.wait = 0;
538        Ok(ProcessOutcome::complete_with(actions))
539    }
540
541    fn can_device_write(&self) -> bool {
542        true
543    }
544
545    fn special(&mut self, field: &str, after: bool) -> CaResult<()> {
546        if !after {
547            return Ok(());
548        }
549        match field {
550            // C `special()` DLY case (lines 392-409). A negative delay
551            // is clamped to 0. C also cancels/restarts the in-flight
552            // `delayFuncCb` so a previously-set huge delay does not keep
553            // the record Busy; the port re-derives the remaining delay
554            // from `last_send_time` + the new DLY on the next process,
555            // so a shrunk DLY takes effect on the next drain attempt.
556            //
557            // `special()` runs after the field write. `put_field("DLY")`
558            // already rejects non-finite and huge-but-finite values via
559            // `validate_dly`, so a CA/db path can never leave `self.dly`
560            // out of range here. The clamp below additionally enforces
561            // the `Duration::from_secs_f64` invariant for any other
562            // writer of `self.dly` (e.g. in-process callers), so every
563            // reader downstream of `special()` is safe.
564            "DLY" => {
565                if self.dly < 0.0 {
566                    self.dly = 0.0;
567                } else if validate_dly(self.dly).is_err() {
568                    // Non-finite or >= MAX_DLY: clamp to the operational
569                    // ceiling so `process()` never panics.
570                    self.dly = MAX_DLY;
571                }
572            }
573            // C `special()` DRVLH/DRVLL case (lines 411-440). When the
574            // new limits disable limiting (`drvlh <= drvll`) DRVLS goes
575            // Normal. When limiting is (re)enabled DRVLS is recomputed
576            // immediately against the *current* VAL — Low if below the
577            // low limit, High if above the high limit, else Normal.
578            "DRVLH" | "DRVLL" => {
579                self.limit_flag = self.drvlh > self.drvll;
580                if !self.limit_flag {
581                    self.drvls = 0; // throttleDRVLS_NORM
582                } else if self.val < self.drvll {
583                    self.drvls = 1; // throttleDRVLS_LOW
584                } else if self.val > self.drvlh {
585                    self.drvls = 2; // throttleDRVLS_HIGH
586                } else {
587                    self.drvls = 0; // throttleDRVLS_NORM
588                }
589            }
590            // C `special()` OUT/SINP case (throttleRecord.c:339-374,
591            // `SPC_MOD`): re-classify the changed link's validity menu
592            // (OV for OUT, SIV for SINP) — CONSTANT→`Constant`, a PV on
593            // this IOC→`Local PV`, else→`Ext PV NC`. The new link string the
594            // put just stored is classified off-thread (needs an async DB
595            // lookup, C `dbNameToAddr`); `refresh_link_status` re-does BOTH
596            // OV and SIV, which is harmless and keeps a single owner.
597            "OUT" | "SINP" => self.refresh_link_status(),
598            // C `special()` SYNC case (throttleRecord.c:376-389): a put of
599            // `SYNC=Process` triggers `valueSync` — read SINP into VAL and
600            // post (NO OUT write, NO process). C gates on `siv`: an
601            // unconnected external SINP (`EXT_NC`) is NOT synced (its
602            // `checkLink` can never connect here — no CA client), so SYNC
603            // is left in `Process`, matching C leaving it pending.
604            "SYNC" => {
605                if self.sync == THROTTLE_SYNC_PROCESS && self.siv != LINK_EXT_NC {
606                    self.spawn_value_sync();
607                }
608            }
609            _ => {}
610        }
611        Ok(())
612    }
613
614    fn get_field(&self, name: &str) -> Option<EpicsValue> {
615        match name {
616            "VAL" => Some(EpicsValue::Double(self.val)),
617            "OVAL" => Some(EpicsValue::Double(self.oval)),
618            "SENT" => Some(EpicsValue::Double(self.sent)),
619            "OSENT" => Some(EpicsValue::Double(self.osent)),
620            "WAIT" => Some(EpicsValue::Short(self.wait)),
621            "HOPR" => Some(EpicsValue::Double(self.hopr)),
622            "LOPR" => Some(EpicsValue::Double(self.lopr)),
623            "DRVLH" => Some(EpicsValue::Double(self.drvlh)),
624            "DRVLL" => Some(EpicsValue::Double(self.drvll)),
625            "DRVLS" => Some(EpicsValue::Short(self.drvls)),
626            "DRVLC" => Some(EpicsValue::Short(self.drvlc)),
627            "VER" => Some(EpicsValue::String(self.ver.clone().into())),
628            "STS" => Some(EpicsValue::Short(self.sts)),
629            "PREC" => Some(EpicsValue::Short(self.prec)),
630            "DPREC" => Some(EpicsValue::Short(self.dprec)),
631            "DLY" => Some(EpicsValue::Double(self.dly)),
632            "OUT" => Some(EpicsValue::String(self.out.clone().into())),
633            "OV" => Some(EpicsValue::Short(self.ov)),
634            "SINP" => Some(EpicsValue::String(self.sinp.clone().into())),
635            "SIV" => Some(EpicsValue::Short(self.siv)),
636            "SYNC" => Some(EpicsValue::Short(self.sync)),
637            _ => None,
638        }
639    }
640
641    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
642        match name {
643            "VAL" => match value {
644                EpicsValue::Double(v) => {
645                    self.val = v;
646                    Ok(())
647                }
648                _ => Err(CaError::TypeMismatch(name.into())),
649            },
650            "HOPR" => match value {
651                EpicsValue::Double(v) => {
652                    self.hopr = v;
653                    Ok(())
654                }
655                _ => Err(CaError::TypeMismatch(name.into())),
656            },
657            "LOPR" => match value {
658                EpicsValue::Double(v) => {
659                    self.lopr = v;
660                    Ok(())
661                }
662                _ => Err(CaError::TypeMismatch(name.into())),
663            },
664            "DRVLH" => match value {
665                EpicsValue::Double(v) => {
666                    self.drvlh = v;
667                    Ok(())
668                }
669                _ => Err(CaError::TypeMismatch(name.into())),
670            },
671            "DRVLL" => match value {
672                EpicsValue::Double(v) => {
673                    self.drvll = v;
674                    Ok(())
675                }
676                _ => Err(CaError::TypeMismatch(name.into())),
677            },
678            "DRVLC" => match value {
679                EpicsValue::Short(v) => {
680                    self.drvlc = v;
681                    Ok(())
682                }
683                _ => Err(CaError::TypeMismatch(name.into())),
684            },
685            "PREC" => match value {
686                EpicsValue::Short(v) => {
687                    self.prec = v;
688                    Ok(())
689                }
690                _ => Err(CaError::TypeMismatch(name.into())),
691            },
692            "DPREC" => match value {
693                EpicsValue::Short(v) => {
694                    self.dprec = v;
695                    Ok(())
696                }
697                _ => Err(CaError::TypeMismatch(name.into())),
698            },
699            "DLY" => match value {
700                EpicsValue::Double(v) => {
701                    // C `throttleRecord.c` models the delay with
702                    // `Duration::from_secs_f64(self.dly)` in `process()`,
703                    // which panics not only on a non-finite argument but
704                    // on any finite value too large for a `Duration`
705                    // (≈ 1.8e19; message "value is either too big or
706                    // NaN"). C's `special()` DLY handler (lines 392-409)
707                    // only ever anticipated a negative delay; a CA put of
708                    // `+inf`, `NaN`, or a huge-but-finite f64 like `1e300`
709                    // is not a value any real delay can represent. Reject
710                    // it here, at the single writer of `self.dly`, so the
711                    // record task can never panic — `validate_dly` is the
712                    // gate that holds the invariant "`self.dly` can never
713                    // make `Duration::from_secs_f64` panic".
714                    validate_dly(v)?;
715                    self.dly = v;
716                    Ok(())
717                }
718                _ => Err(CaError::TypeMismatch(name.into())),
719            },
720            "OUT" => match value {
721                EpicsValue::String(v) => {
722                    self.out = v.as_str_lossy().into_owned();
723                    Ok(())
724                }
725                _ => Err(CaError::TypeMismatch(name.into())),
726            },
727            "SINP" => match value {
728                EpicsValue::String(v) => {
729                    self.sinp = v.as_str_lossy().into_owned();
730                    Ok(())
731                }
732                _ => Err(CaError::TypeMismatch(name.into())),
733            },
734            "SYNC" => match value {
735                EpicsValue::Short(v) => {
736                    self.sync = v;
737                    Ok(())
738                }
739                _ => Err(CaError::TypeMismatch(name.into())),
740            },
741            // Read-only fields
742            "OVAL" | "SENT" | "OSENT" | "WAIT" | "DRVLS" | "VER" | "STS" | "OV" | "SIV" => {
743                Err(CaError::ReadOnlyField(name.into()))
744            }
745            _ => Err(CaError::FieldNotFound(name.into())),
746        }
747    }
748
749    fn declared_fields(&self) -> &'static [FieldDesc] {
750        dbd_generated::THROTTLE_FIELDS
751    }
752
753    /// C `throttleRecord.c:308` keeps `recGblFwdLink(prec)` commented
754    /// out in `process()` — the forward link is fired ONLY from
755    /// `valuePut`'s non-CONSTANT branch (`throttleRecord.c:580`), i.e.
756    /// only on a cycle where a real OUT write actually occurred. The
757    /// framework default fires FLNK every `process()`, which would also
758    /// fire it on a queuing-during-delay cycle, a rejected out-of-range
759    /// cycle, a drain with nothing queued, and a CONSTANT-OUT cycle —
760    /// none of which write OUT in C. `process()` maintains `out_written`
761    /// (reset to false each cycle, set true only by `send_value` on a
762    /// real OUT write); this hook returns it.
763    fn should_fire_forward_link(&self) -> bool {
764        self.out_written
765    }
766
767    fn init_record(&mut self, pass: u8) -> CaResult<()> {
768        // C `init_record` (throttleRecord.c:133-228). Pass 0 copies the
769        // VERSION string into VER; the Rust port sets VER in `Default`
770        // instead (the framework constructs the record before init).
771        //
772        // Pass 1 (C lines 156-167): STS is reset to Unknown and VAL to
773        // 0, and `limit_flag` is derived from `drvlh > drvll`. C also
774        // resets the private delay/wait/sync flags to 0 — mirrored by
775        // the runtime-state fields below.
776        if pass == 1 {
777            self.sts = 0; // throttleSTS_UNK
778            self.val = 0.0;
779            self.limit_flag = self.drvlh > self.drvll;
780            self.delay_active = false;
781            self.last_send_time = None;
782            self.pending_value = None;
783            self.out_written = false;
784        }
785        Ok(())
786    }
787
788    fn set_async_context(&mut self, name: String, db: AsyncDbHandle) {
789        self.async_ctx = Some((name, db));
790        // C `init_record` classifies the OUT/SINP links into OV/SIV and
791        // posts the initial status (throttleRecord.c:171-205). This is the
792        // framework's init-time async hook (the handle now exists), so
793        // classify here — the record's OUT/SINP db fields are already loaded.
794        self.refresh_link_status();
795    }
796
797    fn put_field_internal(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
798        // OV/SIV (link-status classifier) and STS (the SYNC SINP read) are
799        // read-only to *clients* — the field_io `SPC_NOMOD` gate rejects a
800        // client put — but the trusted out-of-band post (`post_fields` →
801        // here) must land. Store them directly; the strict `put_field` arm
802        // still rejects a client write (same split sseq uses for its
803        // read-only DOLnV/LNKnV diagnostics). Every other field, including
804        // the writable VAL/SYNC the SYNC post also writes, falls through to
805        // `put_field`.
806        match (name, &value) {
807            ("OV", EpicsValue::Short(v)) => {
808                self.ov = *v;
809                Ok(())
810            }
811            ("SIV", EpicsValue::Short(v)) => {
812                self.siv = *v;
813                Ok(())
814            }
815            ("STS", EpicsValue::Short(v)) => {
816                self.sts = *v;
817                Ok(())
818            }
819            _ => self.put_field(name, value),
820        }
821    }
822}
823
824#[cfg(test)]
825mod menu_choice_tests {
826    use super::ThrottleRecord;
827    use epics_base_rs::server::record::FieldDeclaration;
828
829    /// The choices a client sees are the DECLARATION's — `throttleRecord.dbd`'s
830    /// `menu()` on each field — and the index↔string mapping is wire-visible.
831    /// This used to assert them through `Record::menu_field_choices`, a hand
832    /// written table that declared the same menus a second time.
833    #[test]
834    fn throttle_menu_choices_come_from_the_declaration() {
835        let rec = ThrottleRecord::default();
836        let menu = |name: &str| {
837            rec.field_list()
838                .iter()
839                .find(|f| f.name == name)
840                .unwrap_or_else(|| panic!("{name} is declared"))
841                .menu
842        };
843        assert_eq!(menu("WAIT"), Some(&["False", "True"][..]));
844        assert_eq!(menu("DRVLC"), Some(&["Off", "On"][..]));
845        assert_eq!(
846            menu("DRVLS"),
847            Some(&["Normal", "Low Limit", "High Limit"][..])
848        );
849        assert_eq!(menu("STS"), Some(&["Unknown", "Error", "Success"][..]));
850        // OV and SIV share menu(throttleOV).
851        let ov = &["Ext PV NC", "Ext PV OK", "Local PV", "Constant"][..];
852        assert_eq!(menu("OV"), Some(ov));
853        assert_eq!(menu("SIV"), Some(ov));
854        assert_eq!(menu("SYNC"), Some(&["Idle", "Process"][..]));
855        assert_eq!(menu("VAL"), None);
856    }
857}