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