Skip to main content

std_rs/records/
timestamp.rs

1use epics_base_rs::error::{CaError, CaResult};
2use epics_base_rs::server::record::{
3    EPICS_TIME_EVENT_DEVICE_TIME, FieldDesc, ProcessContext, ProcessOutcome, Record, ValuePostGate,
4};
5use epics_base_rs::types::{EpicsValue, PvString};
6
7use super::dbd_generated;
8use chrono::{Local, TimeZone};
9
10/// EPICS epoch: 1990-01-01 00:00:00 UTC
11const EPICS_EPOCH_OFFSET: i64 = 631152000;
12
13/// Maximum number of visible (non-NUL) bytes in the VAL/OVAL fields.
14///
15/// `timestampRecord.dbd` declares `VAL`/`OVAL` as `char val[40]`, and C
16/// `timestampRecord.c:140` calls `epicsTimeToStrftime(val, sizeof(val), ...)`.
17/// `epicsTimeToStrftime` wraps `strftime`, which writes at most
18/// `sizeof(val)` bytes *including* the terminating NUL — so the buffer
19/// holds at most 39 visible characters. A Rust `String` carries no NUL
20/// terminator, so the visible-byte bound is 39, not 40.
21const VAL_VISIBLE_MAX: usize = 39;
22
23/// Timestamp format strings indexed by TST field value.
24///
25/// Mirrors the `switch(tst)` in `timestampRecord.c:100-138`. Any TST value
26/// outside `0..=10` falls through C's `default:` branch to format 0
27/// (`YY/MM/DD HH:MM:SS`).
28const TIMESTAMP_FORMATS: &[&str] = &[
29    "%y/%m/%d %H:%M:%S", // 0  timestampTST_YY_MM_DD_HH_MM_SS
30    "%m/%d/%y %H:%M:%S", // 1  timestampTST_MM_DD_YY_HH_MM_SS
31    "%b %d %H:%M:%S %y", // 2  timestampTST_MM_DD_HH_MM_SS_YY
32    "%b %d %H:%M:%S",    // 3  timestampTST_MM_DD_HH_MM_SS
33    "%H:%M:%S",          // 4  timestampTST_HH_MM_SS
34    "%H:%M",             // 5  timestampTST_HH_MM
35    "%d/%m/%y %H:%M:%S", // 6  timestampTST_DD_MM_YY_HH_MM_SS
36    "%d %b %H:%M:%S %y", // 7  timestampTST_DD_MM_HH_MM_SS_YY
37    "%d-%b-%Y %H:%M:%S", // 8  timestampTST_VMS
38];
39
40/// Timestamp record — generates formatted timestamp strings.
41///
42/// Ported from EPICS std module `timestampRecord.c`.
43pub struct TimestampRecord {
44    /// Current formatted timestamp string (VAL).
45    pub val: PvString,
46    /// Previous value for change detection (OVAL).
47    pub oval: PvString,
48    /// Seconds past EPICS epoch (RVAL). DBF_ULONG in C; the Rust value
49    /// model has no unsigned-32 scalar, so this follows the project
50    /// convention of mapping DBF_ULONG to `i32`/`EpicsValue::Long`.
51    /// `field(RVAL,DBF_ULONG)` (`timestampRecord.dbd:28`) — C
52    /// `ptimestamp->rval = ptimestamp->time.secPastEpoch` (`timestampRecord.c:94`),
53    /// and `secPastEpoch` is an `epicsUInt32`. Stored `i32` and served
54    /// `EpicsValue::Long` while the port hand-wrote its own field table.
55    pub rval: u32,
56    /// Timestamp format selector (TST), a DBF_MENU. Values `0..=10`
57    /// select an explicit format; any other value is rendered with
58    /// format 0 (C `switch` `default:` branch).
59    pub tst: i16,
60    /// Framework-owned `dbCommon.tse`, pushed via
61    /// [`Record::set_process_context`] before `process()`. C
62    /// `timestampRecord.c:90` branches on
63    /// `tse == epicsTimeEventDeviceTime`: device-time takes the raw OS
64    /// clock (`epicsTimeFromTime_t(&time, time(0))`, whole seconds, no
65    /// fraction); any other value uses the EPICS time-stamp framework.
66    tse: i16,
67    /// Whether the last `process()` rendered a VAL string different from the
68    /// previous one — C `monitor()`'s `strncmp(oval, val, sizeof(val))` gate
69    /// (`timestampRecord.c:158`), captured during `process()` because the
70    /// framework asks for the decision after `oval` has already been committed.
71    /// It is the ONLY gate on this record's monitors: VAL and RVAL both post
72    /// exactly when it is true (`:159-160`).
73    val_changed: bool,
74}
75
76impl Default for TimestampRecord {
77    fn default() -> Self {
78        Self {
79            val: PvString::new(),
80            oval: PvString::new(),
81            rval: 0,
82            tst: 0,
83            tse: 0,
84            val_changed: false,
85        }
86    }
87}
88
89impl TimestampRecord {
90    fn format_timestamp(&self) -> (PvString, u32) {
91        // C `timestampRecord.c:90-93`: `tse == epicsTimeEventDeviceTime`
92        // takes the raw OS clock via `epicsTimeFromTime_t(&time, time(0))`
93        // — whole seconds only, the nanosecond field is zero. Any other
94        // TSE value goes through `recGblGetTimeStamp`, which carries
95        // sub-second precision. The Rust port mirrors the observable
96        // difference: device-time truncates `now` to whole seconds so
97        // the `.%03f` formats (TST 9/10) render `.000`.
98        let now = if self.tse == EPICS_TIME_EVENT_DEVICE_TIME {
99            let secs = Local::now().timestamp();
100            // `timestamp_opt(secs, 0)` is always `Single` for any
101            // in-range Unix second; fall back to the un-truncated clock
102            // on the impossible `None`/`Ambiguous` case rather than
103            // panicking.
104            Local
105                .timestamp_opt(secs, 0)
106                .single()
107                .unwrap_or_else(Local::now)
108        } else {
109            Local::now()
110        };
111        let unix_secs = now.timestamp();
112        let sec_past_epoch = (unix_secs - EPICS_EPOCH_OFFSET) as u32;
113
114        // C `timestampRecord.c:96`: `if (time.secPastEpoch == 0)` — the
115        // "-NULL-" sentinel is emitted only when the EPICS-epoch second
116        // count is exactly zero (an uninitialised/unset time stamp), not
117        // for any non-positive value.
118        if sec_past_epoch == 0 {
119            return (PvString::from("-NULL-"), sec_past_epoch);
120        }
121
122        // C `timestampRecord.c:100-138`: any TST outside the valid menu
123        // range falls through `default:` to format 0. The raw TST value
124        // is preserved (the field is a plain menu); only the format
125        // *selection* is bounded here.
126        let tst = self.tst;
127
128        let formatted = match tst {
129            0..=8 => now.format(TIMESTAMP_FORMATS[tst as usize]).to_string(),
130            // Formats 9 (timestampTST_MM_DD_YYYY) and 10
131            // (timestampTST_MM_DD_YY) carry `.%03f` fractional seconds.
132            // C `timestampRecord.c:130,133`. EPICS `%03f` is the
133            // 3-digit fractional-seconds field derived from the time
134            // stamp's nanoseconds; `subsec_millis()` is the equivalent
135            // 3-digit truncation of the same fraction.
136            9 | 10 => {
137                // C `epicsTime.cpp:234-239`: the `%03f` fractional field
138                // ROUNDS to the nearest millisecond (see
139                // `round_subsec_to_millis`). `timestamp_subsec_millis()`
140                // (= nsec / 1e6) truncates instead, shifting every value
141                // on a half-ms boundary down by one.
142                let ms = round_subsec_to_millis(now.timestamp_subsec_nanos());
143                let base = if tst == 9 {
144                    now.format("%b %d %Y %H:%M:%S").to_string()
145                } else {
146                    now.format("%m/%d/%y %H:%M:%S").to_string()
147                };
148                format!("{base}.{ms:03}")
149            }
150            // C `default:` branch — format 0 (`YY/MM/DD HH:MM:SS`).
151            _ => now.format(TIMESTAMP_FORMATS[0]).to_string(),
152        };
153
154        // C `timestampRecord.c:140` `epicsTimeToStrftime(val, sizeof(val), ...)`
155        // bounds the result to the `char val[40]` buffer; `strftime` keeps
156        // one byte for the NUL terminator, so at most 39 visible chars.
157        (
158            truncate_to(PvString::from(formatted), VAL_VISIBLE_MAX),
159            sec_past_epoch,
160        )
161    }
162}
163
164/// Truncate `s` to at most `max` bytes.
165///
166/// C stores VAL/OVAL in a fixed `char[40]` buffer whose last byte is the
167/// NUL terminator, so at most 39 visible bytes survive. C `strftime`
168/// truncates the buffer byte for byte, so this cut is on a raw byte
169/// boundary and a non-UTF-8 VAL written by a client round-trips verbatim.
170fn truncate_to(s: PvString, max: usize) -> PvString {
171    if s.len() > max {
172        PvString::from_bytes(s.as_bytes()[..max].to_vec())
173    } else {
174        s
175    }
176}
177
178/// Round a sub-second nanosecond count to a 3-digit millisecond field.
179///
180/// C `epicsTime.cpp:234-239` renders the `%03f` fractional field by
181/// ROUNDING the nanoseconds to the nearest millisecond, with a clamp
182/// that prevents the rounded value from carrying into whole seconds:
183/// ```text
184/// frac = nsec + div[fracWid]/2;            // div[3] = 1e6, so +5e5
185/// if (frac >= 1000000000) frac = 1000000000 - 1;
186/// frac /= div[fracWid];                    // /1e6 -> 0..=999
187/// ```
188/// A naive `nsec / 1_000_000` truncates, biasing every value on a
189/// half-millisecond boundary down by one (e.g. 1.7 ms → `.001` instead
190/// of `.002`). The `min` clamp keeps a near-`1e9` nanosecond count from
191/// rounding up to `1000` ms (which would need a carry into the seconds
192/// field C never performs here).
193fn round_subsec_to_millis(nsec: u32) -> u32 {
194    let frac = (nsec + 500_000).min(1_000_000_000 - 1);
195    frac / 1_000_000
196}
197
198impl Record for TimestampRecord {
199    fn record_type(&self) -> &'static str {
200        "timestamp"
201    }
202
203    fn process(&mut self) -> CaResult<ProcessOutcome> {
204        let (formatted, sec_past_epoch) = self.format_timestamp();
205        // C `monitor()` compares the freshly rendered string against OVAL and
206        // posts VAL *and* RVAL only if they differ (timestampRecord.c:158-162),
207        // then copies VAL into OVAL. RVAL itself is refreshed on every process
208        // (`:94`) — a caget of RVAL between posts reads the current second — so
209        // only the *posting* is gated, never the value.
210        self.val_changed = formatted != self.val;
211        self.oval = std::mem::replace(&mut self.val, formatted);
212        self.rval = sec_past_epoch;
213        Ok(ProcessOutcome::complete())
214    }
215
216    fn get_field(&self, name: &str) -> Option<EpicsValue> {
217        match name {
218            "VAL" => Some(EpicsValue::String(self.val.clone())),
219            "OVAL" => Some(EpicsValue::String(self.oval.clone())),
220            "RVAL" => Some(EpicsValue::ULong(self.rval)),
221            "TST" => Some(EpicsValue::Short(self.tst)),
222            _ => None,
223        }
224    }
225
226    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
227        match name {
228            "VAL" => match value {
229                EpicsValue::String(v) => {
230                    // VAL is a `char[40]` field in C; the last byte is the
231                    // NUL terminator, so 39 visible bytes at most.
232                    self.val = truncate_to(v, VAL_VISIBLE_MAX);
233                    Ok(())
234                }
235                _ => Err(CaError::TypeMismatch(name.into())),
236            },
237            "RVAL" => match value {
238                EpicsValue::ULong(v) => {
239                    self.rval = v;
240                    Ok(())
241                }
242                _ => Err(CaError::TypeMismatch(name.into())),
243            },
244            "TST" => match value {
245                EpicsValue::Short(v) => {
246                    // TST is a plain DBF_MENU field — C stores whatever
247                    // value is written and `format_timestamp` selects
248                    // the format via a `switch` whose `default:` branch
249                    // covers any out-of-range value. Do NOT clamp here:
250                    // C `timestampRecord.dbd` declares no field range,
251                    // and a read-back must reflect the raw value.
252                    self.tst = v;
253                    Ok(())
254                }
255                _ => Err(CaError::TypeMismatch(name.into())),
256            },
257            "OVAL" => Err(CaError::ReadOnlyField(name.into())),
258            _ => Err(CaError::FieldNotFound(name.into())),
259        }
260    }
261
262    fn declared_fields(&self) -> &'static [FieldDesc] {
263        dbd_generated::TIMESTAMP_FIELDS
264    }
265
266    /// C `timestampRecord.c:90` reads `ptimestamp->tse`. The framework
267    /// owns `dbCommon.tse`; this hook captures it so `process()` can
268    /// take the device-time branch.
269    fn set_process_context(&mut self, ctx: &ProcessContext) {
270        self.tse = ctx.tse;
271    }
272
273    /// The timestamp record has NO value deadband: its monitored
274    /// quantity is the formatted `VAL` string, and `timestampRecord.dbd`
275    /// declares no MDEL/ADEL. C `monitor()` (`timestampRecord.c:152-163`)
276    /// posts `VAL` (and `RVAL`) only inside
277    /// `if (strncmp(oval, val, sizeof(val)))` — i.e. exactly when the
278    /// formatted string changed since the previous process — then copies
279    /// `val` into `oval`. That is plain change-detection, not a deadband.
280    ///
281    /// The framework's snapshot builders force-post the deadband field on
282    /// every cycle the deadband gate fires (and the gate always fires for
283    /// a non-numeric value — see [`RecordInstance::check_deadband_ext`],
284    /// whose `to_f64()` returns `None` for a string `VAL`). Returning the
285    /// default `"VAL"` here would therefore re-post `VAL` on every scan,
286    /// even when the rendered string is unchanged — diverging from C's
287    /// `strncmp` gate. Returning `""` (a name no field resolves to)
288    /// suppresses that force-post (`resolve_field("")` is `None`, so the
289    /// `if let Some(val) = dval` push is skipped) and routes `VAL`
290    /// through the generic change-detection loop, which posts it only
291    /// when it differs from the last posted value — matching C exactly.
292    fn monitor_deadband_field(&self) -> &'static str {
293        ""
294    }
295
296    /// C `monitor()`'s single gate: the `strncmp(oval, val)` string change
297    /// (`timestampRecord.c:158`). Reported here so the framework's VAL monitor
298    /// mask is live only on a cycle that re-rendered a different string — which
299    /// is also the gate `RVAL` hangs off (see
300    /// [`Self::fields_posted_with_value_mask`]).
301    fn monitor_value_changed(&self) -> Option<bool> {
302        Some(self.val_changed)
303    }
304
305    /// C posts `RVAL` from *inside* the VAL-string-change guard, with VAL's own
306    /// monitor mask and with no test of RVAL's own value
307    /// (`db_post_events(&ptimestamp->rval, monitor_mask)`,
308    /// `timestampRecord.c:160`) — so the seconds count reaches monitors exactly
309    /// when the rendered string moves, and no more often.
310    ///
311    /// Left to the generic change-detection loop instead, `RVAL` posts on every
312    /// process that crosses a second — ~59 spurious `DBE_VALUE|DBE_LOG` events a
313    /// minute per subscriber under a coarse TST such as `HH:MM`, whose VAL only
314    /// changes once a minute.
315    fn fields_posted_with_value_mask(&self) -> &'static [(&'static str, ValuePostGate)] {
316        &[("RVAL", ValuePostGate::WithValue)]
317    }
318
319    fn clears_udf(&self) -> bool {
320        true
321    }
322}
323
324#[cfg(test)]
325mod subsec_round_tests {
326    use super::round_subsec_to_millis;
327
328    // C `epicsTime.cpp:234-239` rounds the `%03f` fractional field to
329    // the nearest millisecond; the previous `nsec / 1e6` truncated.
330    #[test]
331    fn rounds_to_nearest_millisecond() {
332        // Below the half-ms point: rounds down.
333        assert_eq!(round_subsec_to_millis(0), 0);
334        assert_eq!(round_subsec_to_millis(499_999), 0);
335        assert_eq!(round_subsec_to_millis(1_400_000), 1);
336        // Exactly half a millisecond: C's `+ div/2` rounds up.
337        assert_eq!(round_subsec_to_millis(500_000), 1);
338        assert_eq!(round_subsec_to_millis(1_500_000), 2);
339        // Above the half-ms point: rounds up — the case truncation got
340        // wrong (1.7 ms truncated to .001, now rounds to .002).
341        assert_eq!(round_subsec_to_millis(1_700_000), 2);
342    }
343
344    // The clamp keeps a near-1e9 nanosecond count from rounding up to
345    // 1000 ms (C `if (frac >= 1e9) frac = 1e9 - 1`), which would need a
346    // carry into the seconds field the record never performs.
347    #[test]
348    fn clamps_instead_of_carrying_into_seconds() {
349        assert_eq!(round_subsec_to_millis(999_500_000), 999);
350        assert_eq!(round_subsec_to_millis(999_999_999), 999);
351    }
352}
353
354#[cfg(test)]
355mod menu_choice_tests {
356    use super::{TimestampRecord, dbd_generated};
357    use epics_base_rs::server::record::FieldDeclaration;
358    use epics_base_rs::server::record::{Record, RecordInstance};
359    use epics_base_rs::types::EpicsValue;
360
361    // TST is menu(timestampTST) served as Short; the base snapshot path
362    // promotes it to DBR_ENUM and attaches the wire-visible format labels.
363    #[test]
364    fn timestamp_tst_snapshot_is_enum_with_labels() {
365        let mut rec = TimestampRecord::default();
366        rec.put_field("TST", EpicsValue::Short(4)).unwrap(); // HH:MM:SS
367        let inst = RecordInstance::new("TS:TST".into(), rec);
368
369        let snap = inst.snapshot_for_field("TST").unwrap();
370        assert_eq!(snap.value, EpicsValue::Enum(4));
371        let strings = &snap.enums.as_ref().unwrap().strings;
372        assert_eq!(strings.len(), 11);
373        assert_eq!(strings[4], "HH:MM:SS");
374    }
375
376    /// The choices are the DECLARATION's, not a record hook's: `TST` is
377    /// `DBF_MENU menu(timestampTST)` in `timestampRecord.dbd`, so its
378    /// `FieldDesc` carries the choices and every consumer reads them from
379    /// there. This used to assert a hand-written `TIMESTAMP_TST_CHOICES` that
380    /// `menu_field_choices` returned — a second declaration of the same menu.
381    #[test]
382    fn timestamp_tst_choices_come_from_the_declaration() {
383        let rec = TimestampRecord::default();
384        let tst = rec
385            .field_list()
386            .iter()
387            .find(|f| f.name == "TST")
388            .expect("TST is declared");
389        assert_eq!(tst.menu, Some(dbd_generated::MENU_TIMESTAMP_TST));
390        let val = rec
391            .field_list()
392            .iter()
393            .find(|f| f.name == "VAL")
394            .expect("VAL is declared");
395        assert_eq!(val.menu, None);
396    }
397
398    // C `timestampRecord.c:152-163`: `monitor()` posts VAL (and RVAL)
399    // only inside `if (strncmp(oval, val))` — when the formatted string
400    // changed. There is no value deadband. The record routes VAL through
401    // the framework's generic change-detection loop by reporting an
402    // empty deadband field; the framework's deadband force-post is then
403    // skipped because that name resolves to nothing.
404    #[test]
405    fn timestamp_has_no_deadband_field_so_val_change_detects() {
406        let rec = TimestampRecord::default();
407        // No numeric value deadband — the sentinel routes VAL to the
408        // change-detection loop (cf. motor's "RBV").
409        assert_eq!(rec.monitor_deadband_field(), "");
410
411        // The framework's deadband force-post fires only
412        // `if let Some(val) = resolve_field(deadband_field)`. The "" name
413        // must resolve to None so VAL is never force-posted on an
414        // unchanged-string cycle.
415        let inst = RecordInstance::new("TS:DB".into(), TimestampRecord::default());
416        assert_eq!(inst.resolve_field(""), None);
417    }
418}