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}