Skip to main content

epics_base_rs/server/record/
scan.rs

1use super::menu_scan::{SCAN_1ST_PERIODIC, menu_scan};
2
3/// The value of a `menu(menuScan)` field — the SCAN field's domain.
4///
5/// The domain is the *loaded* menu, not a fixed list of rates. C fixes exactly
6/// three choices — `dbScan.c` tests `menuScanPassive`, `menuScanEvent` and
7/// `menuScanI_O_Intr` by name — and reads everything from
8/// `SCAN_1ST_PERIODIC` up out of `menuScan` at run time (`initPeriodic`,
9/// `dbScan.c:856-918` at `R7.0.10`). A site that ships its own `menuScan.dbd`
10/// with `60 Hz` or `5 minutes` gets those rates, which is why the periodic
11/// choices are carried here as a menu INDEX and resolved through
12/// [`menu_scan`], instead of being enumerated as variants.
13///
14/// [`Self::Menu`] therefore covers three cases that only the loaded menu can
15/// tell apart — a working rate, a menu entry whose choice string did not parse
16/// (C's `papPeriodic[i] == NULL`), and an index outside the menu entirely. The
17/// gate that separates them is [`ScanList::of`], exactly as C's `scanAdd` is.
18///
19/// An out-of-menu index is not a defensive case, it is C's state. `dbPut`
20/// stores the `epicsEnum16` the client wrote and only THEN calls `scanAdd`,
21/// which tests the index against the menu and, when it is outside,
22///
23/// ```c
24/// /* dbScan.c:248-251 */
25/// if (scan < 0 || scan >= nPeriodic + SCAN_1ST_PERIODIC) {
26///     recGblRecordError(-1, (void *)precord,
27///         "scanAdd detected illegal SCAN value");
28/// }
29/// ```
30///
31/// logs and adds the record to **no scan list**. The field itself keeps the
32/// written value: verified on the softIoc, `caput REC.SCAN 10` succeeds and
33/// `caget REC.SCAN` answers `10`.
34///
35/// Modelling SCAN as the legal choices alone forced `from_u16` to *erase* an
36/// out-of-menu index to `Passive`, which is wrong twice over: the field then
37/// read back `0` instead of `10`, and the record became put-processable
38/// (C tests `precord->scan == 0` literally in `dbPutField`, dbAccess.c:1263, so
39/// an illegal SCAN does NOT process on a `pp(TRUE)` put — a `Passive` one does).
40/// Carrying the index removes both.
41#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Default)]
42pub enum ScanType {
43    #[default]
44    Passive,
45    Event,
46    IoIntr,
47    /// A menu index at or above [`SCAN_1ST_PERIODIC`]. What it means — a rate,
48    /// a dead menu entry, or nothing at all — is a property of the loaded
49    /// `menuScan`, and [`ScanList::of`] is the single place that decides.
50    Menu(u16),
51}
52
53impl ScanType {
54    /// The stock `menuScan.dbd` rates, by the index each occupies in base's
55    /// own menu. These are the port's equivalent of the `menuScan10_second` …
56    /// `menuScan_1_second` constants `dbToMenuH` generates from a site's
57    /// `menuScan.dbd`, and they carry C's caveat with them: they name an
58    /// INDEX, so under a site menu that replaces the rates they name whatever
59    /// that menu put there. Nothing in the scan machinery uses them — it reads
60    /// [`menu_scan`] — they exist so a `.db` fixture or a caller that means
61    /// "base's 1 second rate" can say so.
62    pub const SEC10: Self = Self::Menu(3);
63    pub const SEC5: Self = Self::Menu(4);
64    pub const SEC2: Self = Self::Menu(5);
65    pub const SEC1: Self = Self::Menu(6);
66    pub const SEC05: Self = Self::Menu(7);
67    pub const SEC02: Self = Self::Menu(8);
68    pub const SEC01: Self = Self::Menu(9);
69
70    pub fn from_u16(v: u16) -> Self {
71        match v {
72            0 => Self::Passive,
73            1 => Self::Event,
74            2 => Self::IoIntr,
75            other => Self::Menu(other),
76        }
77    }
78
79    /// The `DBR_ENUM` index this value is served and stored as.
80    pub fn to_u16(self) -> u16 {
81        match self {
82            Self::Passive => 0,
83            Self::Event => 1,
84            Self::IoIntr => 2,
85            Self::Menu(v) => v,
86        }
87    }
88
89    /// The scan list this SCAN value names, if it names one — see [`ScanList`].
90    pub fn scan_list(self) -> Option<ScanList> {
91        ScanList::of(self)
92    }
93
94    /// This rate's period, from the loaded menu — C's `papPeriodic[scan -
95    /// SCAN_1ST_PERIODIC]->period`. `None` for the three fixed choices and for
96    /// any index the menu has no usable rate at.
97    pub fn interval(&self) -> Option<std::time::Duration> {
98        match self {
99            Self::Menu(v) => menu_scan().period_at(*v),
100            _ => None,
101        }
102    }
103
104    /// C's `ind` — the offset into `papPeriodic`, which is also the offset in
105    /// the scan-thread priority ladder (`dbScan.c:945`). `None` unless this is
106    /// a periodic choice with a usable rate.
107    pub fn periodic_index(self) -> Option<usize> {
108        match self {
109            Self::Menu(v) if menu_scan().period_at(v).is_some() => {
110                Some((v - SCAN_1ST_PERIODIC) as usize)
111            }
112            _ => None,
113        }
114    }
115}
116
117/// The key of a scan list: a `SCAN` value that actually names one.
118///
119/// C `scanAdd` (`dbScan.c:241-251`) is the sole gate on scan-list membership,
120/// and it admits neither of the SCAN values that name no list:
121///
122/// ```c
123/// if (scan == menuScanPassive) return;                       /* no list */
124/// if (scan < 0 || scan >= nPeriodic + SCAN_1ST_PERIODIC) {   /* no list */
125///     recGblRecordError(-1, precord, "scanAdd detected illegal SCAN value");
126/// } else if (scan == menuScanEvent) { ... }
127/// ...
128/// } else if (scan >= SCAN_1ST_PERIODIC) {
129///     periodic_scan_list *ppsl = papPeriodic[scan - SCAN_1ST_PERIODIC];
130///     if (ppsl) addToList(precord, &ppsl->scan_list);        /* no list if NULL */
131/// }
132/// ```
133///
134/// The scan index is keyed by this type rather than by [`ScanType`], so those
135/// cases are refused by construction at every insert site — a `Passive`
136/// record, an out-of-menu one, and one whose menu entry has no usable rate
137/// cannot be put in a bucket, and no consumer of a bucket has to re-check.
138/// Before this type existed the index was keyed by `ScanType` and each site
139/// spelled out `!= ScanType::Passive`, which admitted exactly the illegal
140/// index C refuses.
141#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
142pub struct ScanList(ScanType);
143
144impl ScanList {
145    /// `None` when this SCAN names no list — `Passive`, an index outside the
146    /// loaded `menuScan`, or a menu entry C would leave `papPeriodic[i] ==
147    /// NULL` for.
148    pub fn of(scan: ScanType) -> Option<Self> {
149        match scan {
150            ScanType::Passive => None,
151            ScanType::Event | ScanType::IoIntr => Some(Self(scan)),
152            ScanType::Menu(v) => menu_scan().period_at(v).is_some().then_some(Self(scan)),
153        }
154    }
155
156    /// The SCAN value this list holds the records of.
157    pub fn scan(self) -> ScanType {
158        self.0
159    }
160
161    /// How many scan lists exist: `Event`, `IoIntr` and one per periodic menu
162    /// entry. C sizes its own list array the same way — `nPeriodic +
163    /// SCAN_1ST_PERIODIC` (`dbScan.c:250`) over `papPeriodic` plus the two
164    /// special lists — and, like C, counts a menu entry whose choice string
165    /// did not parse: the slot exists and stays empty, so a bad entry does not
166    /// shift the index of the rates after it.
167    pub fn count() -> usize {
168        menu_scan().n_periodic() + (SCAN_1ST_PERIODIC as usize - 1)
169    }
170
171    /// Dense slot in `0..count()`, so a per-list table can be a fixed-length
172    /// allocation rather than a map behind its own lock.
173    ///
174    /// Total by construction: [`Self::of`] is the only constructor and it
175    /// refuses `Passive` (menu index 0) and every index the loaded menu has no
176    /// rate at, so the wrapped [`ScanType`] is always an index in
177    /// `1..count()+1`.
178    pub fn slot(self) -> usize {
179        self.0.to_u16() as usize - 1
180    }
181
182    /// Every scan list, in menu order — the enumeration a per-list table is
183    /// built from. Menu entries with no usable rate are absent, so this is
184    /// shorter than [`Self::count`] exactly when the site menu carries a
185    /// choice string C would reject.
186    pub fn all() -> Vec<Self> {
187        (1..=menu_scan().n_periodic() as u16 + SCAN_1ST_PERIODIC - 1)
188            .filter_map(|v| Self::of(ScanType::from_u16(v)))
189            .collect()
190    }
191}
192
193/// `SSCN` — the simulation-mode scan field (`DBF_MENU`, `menu(menuScan)`).
194///
195/// The same domain as `SCAN` — it is the same menu — so it is the same type,
196/// and an out-of-menu index is carried, not erased. Its dbd default is the
197/// out-of-range `65535` (`field(SSCN,DBF_MENU){ menu(menuScan) initial("65535")
198/// }`, identical across all 21 records that carry SSCN), which C reads as "not
199/// set — keep scanning at SCAN while in simulation mode".
200///
201/// That sentinel is `65535` and *only* `65535`. Both recGbl helpers test it
202/// literally —
203///
204/// ```c
205/// /* recGbl.c: recGblSaveSimm and recGblCheckSimm both open with */
206/// if (*psscn == USHRT_MAX) return;
207/// ```
208///
209/// — so an SSCN of, say, `10` is NOT "unset": C performs the swap, lands the
210/// illegal `10` in SCAN, and `scanAdd` then leaves the record in no scan list.
211/// Treating every illegal index as the sentinel would be a different behaviour
212/// from C, so the distinction is kept.
213#[derive(Clone, Copy, PartialEq, Eq, Debug)]
214pub struct SimModeScan(ScanType);
215
216impl Default for SimModeScan {
217    fn default() -> Self {
218        Self(ScanType::Menu(Self::DO_NOT_USE))
219    }
220}
221
222impl SimModeScan {
223    /// C's dbd default, and the one index the recGbl simulation helpers bail on.
224    pub const DO_NOT_USE: u16 = 65535;
225
226    pub fn from_u16(v: u16) -> Self {
227        Self(ScanType::from_u16(v))
228    }
229
230    pub fn from_scan(s: ScanType) -> Self {
231        Self(s)
232    }
233
234    /// The `DBR_ENUM`/wire index — whatever was written, sentinel included.
235    pub fn to_u16(self) -> u16 {
236        self.0.to_u16()
237    }
238
239    /// C's `*psscn == USHRT_MAX` test.
240    pub fn is_unset(self) -> bool {
241        self.to_u16() == Self::DO_NOT_USE
242    }
243
244    /// The scan SSCN swaps SCAN to. `None` only for the unset sentinel — an
245    /// illegal-but-not-sentinel index still swaps, exactly as it does in C.
246    pub fn scan(self) -> Option<ScanType> {
247        (!self.is_unset()).then_some(self.0)
248    }
249}
250
251impl std::fmt::Display for SimModeScan {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        self.0.fmt(f)
254    }
255}
256
257impl std::fmt::Display for ScanType {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        match menu_scan().label_at(self.to_u16()) {
260            Some(label) => write!(f, "{label}"),
261            // C has no label for an out-of-menu index; `caget` renders the
262            // number itself (measured: `caget REC.SCAN` -> `10`).
263            None => write!(f, "{}", self.to_u16()),
264        }
265    }
266}
267
268#[cfg(test)]
269mod sim_mode_scan_tests {
270    use super::*;
271
272    #[test]
273    fn default_is_the_65535_sentinel() {
274        // C dbd `field(SSCN,DBF_MENU){ initial("65535") }`.
275        assert!(SimModeScan::default().is_unset());
276        assert_eq!(SimModeScan::default().to_u16(), 65535);
277        assert_eq!(SimModeScan::default().scan(), None);
278    }
279
280    #[test]
281    fn sentinel_round_trips_through_u16() {
282        assert!(SimModeScan::from_u16(65535).is_unset());
283        assert_eq!(SimModeScan::from_u16(65535).to_u16(), 65535);
284    }
285
286    #[test]
287    fn valid_menu_indices_map_to_scan_choices() {
288        for v in 0u16..=9 {
289            assert_eq!(SimModeScan::from_u16(v).scan(), Some(ScanType::from_u16(v)));
290            assert_eq!(SimModeScan::from_u16(v).to_u16(), v);
291            assert!(!SimModeScan::from_u16(v).is_unset());
292        }
293    }
294
295    /// The labels this type renders are the loaded `menuScan`'s, so the one
296    /// menu converter (which owns every string→menu-index put, see
297    /// `tests/menu_common_field_scan_pini.rs`) and this type agree on every
298    /// choice — including the `".5 second"` spellings that the deleted
299    /// `ScanType::from_str` used to accept a `"0.5 second"` alias for.
300    #[test]
301    fn labels_match_the_loaded_menu() {
302        for (i, label) in menu_scan().choices().iter().enumerate() {
303            assert_eq!(ScanType::from_u16(i as u16).to_string(), *label);
304        }
305    }
306
307    /// CORRECTED — this test used to assert `from_u16(10) == DoNotUse`, i.e.
308    /// that an out-of-menu index collapses to the sentinel. It does not. C's
309    /// recGbl simulation helpers bail on `*psscn == USHRT_MAX` and on nothing
310    /// else, so `10` is an ordinary (illegal) index that still drives the swap;
311    /// and the field itself reads back `10`, not `65535`.
312    #[test]
313    fn an_out_of_menu_index_is_carried_and_is_not_the_sentinel() {
314        for v in [10u16, 40000] {
315            let s = SimModeScan::from_u16(v);
316            assert_eq!(s.to_u16(), v, "the written index is what reads back");
317            assert!(!s.is_unset(), "{v} is illegal, but it is not USHRT_MAX");
318            assert_eq!(
319                s.scan(),
320                Some(ScanType::Menu(v)),
321                "C swaps it into SCAN; scanAdd then scans nothing"
322            );
323        }
324    }
325
326    /// The SCAN field's own boundary: at the last legal choice, one past it,
327    /// and the `-1` a client sends as `65535`.
328    #[test]
329    fn scan_carries_an_illegal_index_instead_of_erasing_it_to_passive() {
330        assert_eq!(ScanType::from_u16(9), ScanType::SEC01);
331        assert_eq!(ScanType::from_u16(9).to_u16(), 9);
332
333        // One past the last choice. Measured: `caput REC.SCAN 10` succeeds and
334        // `caget REC.SCAN` answers 10 — it does NOT become Passive.
335        assert_eq!(ScanType::from_u16(10), ScanType::Menu(10));
336        assert_eq!(ScanType::from_u16(10).to_u16(), 10);
337        assert_ne!(ScanType::from_u16(10), ScanType::Passive);
338
339        // `caput REC.SCAN -1` reaches the field as the epicsEnum16 65535.
340        assert_eq!(ScanType::from_u16(65535), ScanType::Menu(65535));
341        assert_eq!(ScanType::from_u16(65535).to_u16(), 65535);
342
343        // An out-of-menu index is in no scan list: not periodic, not I/O Intr.
344        assert_eq!(ScanType::Menu(10).interval(), None);
345        assert_ne!(ScanType::Menu(10), ScanType::IoIntr);
346    }
347
348    /// `scanAdd`'s gate, at its boundaries: the last legal index names a list,
349    /// one past it names none, and `Passive` names none.
350    #[test]
351    fn only_a_menu_choice_other_than_passive_names_a_scan_list() {
352        assert_eq!(ScanType::Passive.scan_list(), None);
353        for v in 1u16..=9 {
354            let scan = ScanType::from_u16(v);
355            assert_eq!(
356                scan.scan_list().map(ScanList::scan),
357                Some(scan),
358                "index {v} is a menuScan choice"
359            );
360        }
361        for v in [10u16, 42, 65535] {
362            assert_eq!(
363                ScanType::from_u16(v).scan_list(),
364                None,
365                "index {v} is outside menuScan; scanAdd adds the record nowhere"
366            );
367        }
368    }
369
370    /// Every legal index survives the round trip, so nothing above is bought at
371    /// the cost of the ordinary path.
372    #[test]
373    fn every_legal_index_round_trips() {
374        for v in 0u16..=9 {
375            assert_eq!(ScanType::from_u16(v).to_u16(), v);
376        }
377    }
378
379    /// `slot()` is a total, collision-free index into a `count()`-sized array —
380    /// the property the per-list scan-index table indexes by, without a
381    /// bounds check or a fallible lookup.
382    ///
383    /// Boundary cases, not a narrative: every `of`-admissible SCAN maps into
384    /// `0..count()`; `all()` is exactly that set in slot order; and both values
385    /// `of` refuses (`Passive`, out-of-menu) yield no slot at all.
386    #[test]
387    fn every_scan_list_has_a_distinct_slot() {
388        let mut seen = vec![false; ScanList::count()];
389        for v in 0u16..=9 {
390            let Some(list) = ScanType::from_u16(v).scan_list() else {
391                assert_eq!(v, 0, "only Passive names no list inside the menu");
392                continue;
393            };
394            let slot = list.slot();
395            assert!(slot < ScanList::count(), "slot {slot} out of the table");
396            assert!(!seen[slot], "slot {slot} claimed twice");
397            seen[slot] = true;
398        }
399        assert!(seen.iter().all(|s| *s), "every slot must be claimed");
400
401        for (i, list) in ScanList::all().iter().enumerate() {
402            assert_eq!(list.slot(), i, "all() must be in slot order");
403        }
404        assert_eq!(ScanType::Menu(65535).scan_list(), None);
405    }
406
407    /// The stock menu's rates, read through the same path a site menu's would
408    /// be — the seven periods that used to be `interval()`'s match arms.
409    #[test]
410    fn the_stock_rates_come_back_through_the_loaded_menu() {
411        use std::time::Duration;
412        for (scan, period) in [
413            (ScanType::SEC10, Duration::from_secs(10)),
414            (ScanType::SEC5, Duration::from_secs(5)),
415            (ScanType::SEC2, Duration::from_secs(2)),
416            (ScanType::SEC1, Duration::from_secs(1)),
417            (ScanType::SEC05, Duration::from_millis(500)),
418            (ScanType::SEC02, Duration::from_millis(200)),
419            (ScanType::SEC01, Duration::from_millis(100)),
420        ] {
421            assert_eq!(scan.interval(), Some(period), "{scan}");
422        }
423        assert_eq!(ScanType::Passive.interval(), None);
424        assert_eq!(ScanType::Event.interval(), None);
425        assert_eq!(ScanType::IoIntr.interval(), None);
426    }
427}