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