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