Skip to main content

std_rs/records/
throttle.rs

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