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