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,
4};
5use epics_base_rs::types::{DbFieldType, EpicsValue, PvString};
6
7use chrono::{Local, TimeZone};
8
9/// Choice labels for the timestamp string-format menu, in index order.
10/// C `menu(timestampTST)` (std `timestampRecord.dbd`): `TST` selects which
11/// of these `strftime`-style formats `VAL` is rendered with. The
12/// index↔string mapping is wire-visible to clients.
13const TIMESTAMP_TST_CHOICES: &[&str] = &[
14    "YY/MM/DD HH:MM:SS",
15    "MM/DD/YY HH:MM:SS",
16    "Mon DD HH:MM:SS YY",
17    "Mon DD HH:MM:SS",
18    "HH:MM:SS",
19    "HH:MM",
20    "DD/MM/YY HH:MM:SS",
21    "DD Mon HH:MM:SS YY",
22    "DD-Mon-YYYY HH:MM:SS",
23    "Mon DD, YYYY HH:MM:SS.ns",
24    "MM/DD/YY HH:MM:SS.ns",
25];
26
27/// EPICS epoch: 1990-01-01 00:00:00 UTC
28const EPICS_EPOCH_OFFSET: i64 = 631152000;
29
30/// Maximum number of visible (non-NUL) bytes in the VAL/OVAL fields.
31///
32/// `timestampRecord.dbd` declares `VAL`/`OVAL` as `char val[40]`, and C
33/// `timestampRecord.c:140` calls `epicsTimeToStrftime(val, sizeof(val), ...)`.
34/// `epicsTimeToStrftime` wraps `strftime`, which writes at most
35/// `sizeof(val)` bytes *including* the terminating NUL — so the buffer
36/// holds at most 39 visible characters. A Rust `String` carries no NUL
37/// terminator, so the visible-byte bound is 39, not 40.
38const VAL_VISIBLE_MAX: usize = 39;
39
40/// Timestamp format strings indexed by TST field value.
41///
42/// Mirrors the `switch(tst)` in `timestampRecord.c:100-138`. Any TST value
43/// outside `0..=10` falls through C's `default:` branch to format 0
44/// (`YY/MM/DD HH:MM:SS`).
45const TIMESTAMP_FORMATS: &[&str] = &[
46    "%y/%m/%d %H:%M:%S", // 0  timestampTST_YY_MM_DD_HH_MM_SS
47    "%m/%d/%y %H:%M:%S", // 1  timestampTST_MM_DD_YY_HH_MM_SS
48    "%b %d %H:%M:%S %y", // 2  timestampTST_MM_DD_HH_MM_SS_YY
49    "%b %d %H:%M:%S",    // 3  timestampTST_MM_DD_HH_MM_SS
50    "%H:%M:%S",          // 4  timestampTST_HH_MM_SS
51    "%H:%M",             // 5  timestampTST_HH_MM
52    "%d/%m/%y %H:%M:%S", // 6  timestampTST_DD_MM_YY_HH_MM_SS
53    "%d %b %H:%M:%S %y", // 7  timestampTST_DD_MM_HH_MM_SS_YY
54    "%d-%b-%Y %H:%M:%S", // 8  timestampTST_VMS
55];
56
57/// Timestamp record — generates formatted timestamp strings.
58///
59/// Ported from EPICS std module `timestampRecord.c`.
60pub struct TimestampRecord {
61    /// Current formatted timestamp string (VAL).
62    pub val: PvString,
63    /// Previous value for change detection (OVAL).
64    pub oval: PvString,
65    /// Seconds past EPICS epoch (RVAL). DBF_ULONG in C; the Rust value
66    /// model has no unsigned-32 scalar, so this follows the project
67    /// convention of mapping DBF_ULONG to `i32`/`EpicsValue::Long`.
68    pub rval: i32,
69    /// Timestamp format selector (TST), a DBF_MENU. Values `0..=10`
70    /// select an explicit format; any other value is rendered with
71    /// format 0 (C `switch` `default:` branch).
72    pub tst: i16,
73    /// Framework-owned `dbCommon.tse`, pushed via
74    /// [`Record::set_process_context`] before `process()`. C
75    /// `timestampRecord.c:90` branches on
76    /// `tse == epicsTimeEventDeviceTime`: device-time takes the raw OS
77    /// clock (`epicsTimeFromTime_t(&time, time(0))`, whole seconds, no
78    /// fraction); any other value uses the EPICS time-stamp framework.
79    tse: i16,
80}
81
82impl Default for TimestampRecord {
83    fn default() -> Self {
84        Self {
85            val: PvString::new(),
86            oval: PvString::new(),
87            rval: 0,
88            tst: 0,
89            tse: 0,
90        }
91    }
92}
93
94static FIELDS: &[FieldDesc] = &[
95    FieldDesc {
96        name: "VAL",
97        dbf_type: DbFieldType::String,
98        read_only: false,
99    },
100    FieldDesc {
101        name: "OVAL",
102        dbf_type: DbFieldType::String,
103        read_only: true,
104    },
105    FieldDesc {
106        name: "RVAL",
107        dbf_type: DbFieldType::Long,
108        read_only: false,
109    },
110    FieldDesc {
111        name: "TST",
112        dbf_type: DbFieldType::Short,
113        read_only: false,
114    },
115];
116
117impl TimestampRecord {
118    fn format_timestamp(&self) -> (PvString, i32) {
119        // C `timestampRecord.c:90-93`: `tse == epicsTimeEventDeviceTime`
120        // takes the raw OS clock via `epicsTimeFromTime_t(&time, time(0))`
121        // — whole seconds only, the nanosecond field is zero. Any other
122        // TSE value goes through `recGblGetTimeStamp`, which carries
123        // sub-second precision. The Rust port mirrors the observable
124        // difference: device-time truncates `now` to whole seconds so
125        // the `.%03f` formats (TST 9/10) render `.000`.
126        let now = if self.tse == EPICS_TIME_EVENT_DEVICE_TIME {
127            let secs = Local::now().timestamp();
128            // `timestamp_opt(secs, 0)` is always `Single` for any
129            // in-range Unix second; fall back to the un-truncated clock
130            // on the impossible `None`/`Ambiguous` case rather than
131            // panicking.
132            Local
133                .timestamp_opt(secs, 0)
134                .single()
135                .unwrap_or_else(Local::now)
136        } else {
137            Local::now()
138        };
139        let unix_secs = now.timestamp();
140        let sec_past_epoch = (unix_secs - EPICS_EPOCH_OFFSET) as i32;
141
142        // C `timestampRecord.c:96`: `if (time.secPastEpoch == 0)` — the
143        // "-NULL-" sentinel is emitted only when the EPICS-epoch second
144        // count is exactly zero (an uninitialised/unset time stamp), not
145        // for any non-positive value.
146        if sec_past_epoch == 0 {
147            return (PvString::from("-NULL-"), sec_past_epoch);
148        }
149
150        // C `timestampRecord.c:100-138`: any TST outside the valid menu
151        // range falls through `default:` to format 0. The raw TST value
152        // is preserved (the field is a plain menu); only the format
153        // *selection* is bounded here.
154        let tst = self.tst;
155
156        let formatted = match tst {
157            0..=8 => now.format(TIMESTAMP_FORMATS[tst as usize]).to_string(),
158            // Formats 9 (timestampTST_MM_DD_YYYY) and 10
159            // (timestampTST_MM_DD_YY) carry `.%03f` fractional seconds.
160            // C `timestampRecord.c:130,133`. EPICS `%03f` is the
161            // 3-digit fractional-seconds field derived from the time
162            // stamp's nanoseconds; `subsec_millis()` is the equivalent
163            // 3-digit truncation of the same fraction.
164            9 | 10 => {
165                let ms = now.timestamp_subsec_millis();
166                let base = if tst == 9 {
167                    now.format("%b %d %Y %H:%M:%S").to_string()
168                } else {
169                    now.format("%m/%d/%y %H:%M:%S").to_string()
170                };
171                format!("{base}.{ms:03}")
172            }
173            // C `default:` branch — format 0 (`YY/MM/DD HH:MM:SS`).
174            _ => now.format(TIMESTAMP_FORMATS[0]).to_string(),
175        };
176
177        // C `timestampRecord.c:140` `epicsTimeToStrftime(val, sizeof(val), ...)`
178        // bounds the result to the `char val[40]` buffer; `strftime` keeps
179        // one byte for the NUL terminator, so at most 39 visible chars.
180        (
181            truncate_to(PvString::from(formatted), VAL_VISIBLE_MAX),
182            sec_past_epoch,
183        )
184    }
185}
186
187/// Truncate `s` to at most `max` bytes.
188///
189/// C stores VAL/OVAL in a fixed `char[40]` buffer whose last byte is the
190/// NUL terminator, so at most 39 visible bytes survive. C `strftime`
191/// truncates the buffer byte for byte, so this cut is on a raw byte
192/// boundary and a non-UTF-8 VAL written by a client round-trips verbatim.
193fn truncate_to(s: PvString, max: usize) -> PvString {
194    if s.len() > max {
195        PvString::from_bytes(s.as_bytes()[..max].to_vec())
196    } else {
197        s
198    }
199}
200
201impl Record for TimestampRecord {
202    fn record_type(&self) -> &'static str {
203        "timestamp"
204    }
205
206    fn process(&mut self) -> CaResult<ProcessOutcome> {
207        let (formatted, sec_past_epoch) = self.format_timestamp();
208        self.oval = std::mem::replace(&mut self.val, formatted);
209        self.rval = sec_past_epoch;
210        Ok(ProcessOutcome::complete())
211    }
212
213    fn get_field(&self, name: &str) -> Option<EpicsValue> {
214        match name {
215            "VAL" => Some(EpicsValue::String(self.val.clone())),
216            "OVAL" => Some(EpicsValue::String(self.oval.clone())),
217            "RVAL" => Some(EpicsValue::Long(self.rval)),
218            "TST" => Some(EpicsValue::Short(self.tst)),
219            _ => None,
220        }
221    }
222
223    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
224        match name {
225            "VAL" => match value {
226                EpicsValue::String(v) => {
227                    // VAL is a `char[40]` field in C; the last byte is the
228                    // NUL terminator, so 39 visible bytes at most.
229                    self.val = truncate_to(v, VAL_VISIBLE_MAX);
230                    Ok(())
231                }
232                _ => Err(CaError::TypeMismatch(name.into())),
233            },
234            "RVAL" => match value {
235                EpicsValue::Long(v) => {
236                    self.rval = v;
237                    Ok(())
238                }
239                _ => Err(CaError::TypeMismatch(name.into())),
240            },
241            "TST" => match value {
242                EpicsValue::Short(v) => {
243                    // TST is a plain DBF_MENU field — C stores whatever
244                    // value is written and `format_timestamp` selects
245                    // the format via a `switch` whose `default:` branch
246                    // covers any out-of-range value. Do NOT clamp here:
247                    // C `timestampRecord.dbd` declares no field range,
248                    // and a read-back must reflect the raw value.
249                    self.tst = v;
250                    Ok(())
251                }
252                _ => Err(CaError::TypeMismatch(name.into())),
253            },
254            "OVAL" => Err(CaError::ReadOnlyField(name.into())),
255            _ => Err(CaError::FieldNotFound(name.into())),
256        }
257    }
258
259    fn field_list(&self) -> &'static [FieldDesc] {
260        FIELDS
261    }
262
263    /// `TST` is `DBF_MENU menu(timestampTST)` (std `timestampRecord.dbd`);
264    /// served as `DBR_ENUM` with the format-string choice labels.
265    fn menu_field_choices(&self, field: &str) -> Option<&'static [&'static str]> {
266        match field {
267            "TST" => Some(TIMESTAMP_TST_CHOICES),
268            _ => None,
269        }
270    }
271
272    /// C `timestampRecord.c:90` reads `ptimestamp->tse`. The framework
273    /// owns `dbCommon.tse`; this hook captures it so `process()` can
274    /// take the device-time branch.
275    fn set_process_context(&mut self, ctx: &ProcessContext) {
276        self.tse = ctx.tse;
277    }
278
279    fn clears_udf(&self) -> bool {
280        true
281    }
282}
283
284#[cfg(test)]
285mod menu_choice_tests {
286    use super::{TIMESTAMP_TST_CHOICES, TimestampRecord};
287    use epics_base_rs::server::record::{Record, RecordInstance};
288    use epics_base_rs::types::EpicsValue;
289
290    // TST is menu(timestampTST) served as Short; the base snapshot path
291    // promotes it to DBR_ENUM and attaches the wire-visible format labels.
292    #[test]
293    fn timestamp_tst_snapshot_is_enum_with_labels() {
294        let mut rec = TimestampRecord::default();
295        rec.put_field("TST", EpicsValue::Short(4)).unwrap(); // HH:MM:SS
296        let inst = RecordInstance::new("TS:TST".into(), rec);
297
298        let snap = inst.snapshot_for_field("TST").unwrap();
299        assert_eq!(snap.value, EpicsValue::Enum(4));
300        let strings = &snap.enums.as_ref().unwrap().strings;
301        assert_eq!(strings.len(), 11);
302        assert_eq!(strings[4], "HH:MM:SS");
303    }
304
305    #[test]
306    fn timestamp_menu_field_choices_match_dbd() {
307        let rec = TimestampRecord::default();
308        assert_eq!(rec.menu_field_choices("TST"), Some(TIMESTAMP_TST_CHOICES));
309        assert_eq!(rec.menu_field_choices("VAL"), None);
310    }
311}