Skip to main content

std_rs/records/
throttle.rs

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