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