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