Skip to main content

std_rs/device_support/
time_of_day.rs

1use std::time::{SystemTime, UNIX_EPOCH};
2
3use epics_base_rs::error::CaResult;
4use epics_base_rs::server::device_support::{DeviceReadOutcome, DeviceSupport, DeviceUdf};
5use epics_base_rs::server::recgbl::get_time_stamp;
6use epics_base_rs::server::record::{ProcessContext, Record};
7use epics_base_rs::types::EpicsValue;
8
9use chrono::{DateTime, Local};
10
11/// EPICS epoch offset: seconds from Unix epoch (1970-01-01) to EPICS epoch (1990-01-01).
12const EPICS_EPOCH_OFFSET: u64 = 631152000;
13
14/// `(secPastEpoch, nsec)` of a resolved time stamp, counted from the EPICS
15/// epoch (1990-01-01) exactly as C `epicsTimeStamp`.
16///
17/// Wrapping, not saturating. C reaches `secPastEpoch` through
18/// `epicsTimeFromTime_t` (`$EPICS_BASE/modules/libcom/src/osi/epicsTime.cpp:305-310`
19/// at `R7.0.10`), which assigns an `epicsInt64` difference into an
20/// `epicsUInt32`, so a clock before 1990 wraps to a 2106 stamp and formats as
21/// a date. Saturating pinned it to `0` — C's *uninitialized* value — so a
22/// running IOC whose RTC never started answered `<undefined>` where C answers
23/// a date, and the `ai` path read `0.0` where C reads ~4.29e9.
24fn epics_time_parts(ts: SystemTime) -> (u32, u32) {
25    // C's uninitialized `epicsTimeStamp` is the literal `{0, 0}`; this port
26    // carries it as `UNIX_EPOCH`, and the `secPastEpoch == 0 && nsec == 0`
27    // test below is C's own check for it (`epicsTime.cpp:176`). A sentinel,
28    // not a boundary of the conversion.
29    if ts == UNIX_EPOCH {
30        return (0, 0);
31    }
32    // `unwrap_or_default` collapses a pre-1970 stamp to the Unix epoch; that
33    // is this port's own limit and predates the wrap below.
34    let dur = ts.duration_since(UNIX_EPOCH).unwrap_or_default();
35    (
36        dur.as_secs().wrapping_sub(EPICS_EPOCH_OFFSET) as u32,
37        dur.subsec_nanos(),
38    )
39}
40
41/// "Time of Day" device support for stringin records.
42///
43/// Reads the current time and formats it as a string.
44/// Format depends on PHAS field:
45/// - PHAS=0: "Mon DD, YYYY HH:MM:SS"
46/// - PHAS!=0: "MM/DD/YY HH:MM:SS"
47///
48/// Ported from `devTimeOfDay.c` (`devSiTodString`).
49pub struct TimeOfDayStringDeviceSupport {
50    /// `dbCommon.phas`, captured from the framework's
51    /// [`ProcessContext`] before `read()`. C `devTimeOfDay.c:122`
52    /// (`createString`) selects the time format from `psi->phas`;
53    /// `read()` only gets `&mut dyn Record` and PHAS is a
54    /// `CommonFields` field, not a `stringin` record field, so the
55    /// framework pushes it through `set_process_context`.
56    phas: i16,
57    /// `dbCommon.tse` and the record's current `dbCommon.time`, captured
58    /// from [`ProcessContext`]. C `createString` calls
59    /// `recGblGetTimeStamp(psi)` to resolve `psi->time` from `psi->tse`
60    /// *before* formatting, then formats that resolved stamp — not the
61    /// wall clock. `read()` resolves the same way via
62    /// [`get_time_stamp`]`(tse, time)`.
63    tse: i16,
64    time: SystemTime,
65}
66
67impl Default for TimeOfDayStringDeviceSupport {
68    fn default() -> Self {
69        Self {
70            phas: 0,
71            tse: 0,
72            time: SystemTime::UNIX_EPOCH,
73        }
74    }
75}
76
77impl TimeOfDayStringDeviceSupport {
78    pub fn new() -> Self {
79        Self::default()
80    }
81}
82
83impl DeviceSupport for TimeOfDayStringDeviceSupport {
84    fn dtyp(&self) -> &str {
85        "Time of Day"
86    }
87
88    fn set_process_context(&mut self, ctx: &ProcessContext) {
89        self.phas = ctx.phas;
90        self.tse = ctx.tse;
91        self.time = ctx.time;
92    }
93
94    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
95        // C `devTimeOfDay.c:121` `createString`: `recGblGetTimeStamp(psi)`
96        // resolves `psi->time` from TSE *now*, then formats that stamp.
97        // C `recGblGetTimeStamp` leaves `prec->time` alone when
98        // `epicsTimeGetEvent` fails, and the record-level owner
99        // (`apply_timestamp`) emits the errlog this cycle.
100        let ts = get_time_stamp(self.tse, self.time).unwrap_or(self.time);
101        let (sec_past_epoch, nsec) = epics_time_parts(ts);
102
103        // C `devTimeOfDay.c:122` `createString`: `if (psi->phas)` selects
104        // the slash format, else the long format. PHAS lives in
105        // `CommonFields`; the framework pushed it via `set_process_context`.
106        let phas = self.phas;
107
108        let formatted = if sec_past_epoch == 0 && nsec == 0 {
109            // C `epicsTimeToStrftime` (epicsTime.cpp:176-180): an epoch
110            // (uninitialized) stamp formats to the literal "<undefined>".
111            // `createString` then truncates at the last '.', which leaves
112            // this string unchanged (it has none).
113            "<undefined>".to_string()
114        } else {
115            // C formats with a trailing `.%09f` then truncates at the last
116            // '.', so the result is whole-second resolution either way.
117            let local: DateTime<Local> = DateTime::<Local>::from(ts);
118            if phas != 0 {
119                local.format("%m/%d/%y %H:%M:%S").to_string()
120            } else {
121                local.format("%b %d, %Y %H:%M:%S").to_string()
122            }
123        };
124
125        record.put_field("VAL", EpicsValue::String(formatted.into()))?;
126        // C `devTimeOfDay.c::stringinReadTs:135-137` — `psi->udf = 0;
127        // return(0)`. stringin has no RVAL, so 0 is a plain success.
128        Ok(DeviceReadOutcome::converted(DeviceUdf::Defined))
129    }
130
131    fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
132        Ok(())
133    }
134}
135
136/// "Sec Past Epoch" device support for ai records.
137///
138/// Reads the current time as seconds past the EPICS epoch (1990-01-01).
139/// If PHAS field is nonzero, includes fractional seconds.
140///
141/// Ported from `devTimeOfDay.c` (`devAiTodSeconds`).
142pub struct SecPastEpochDeviceSupport {
143    /// `dbCommon.phas`, captured from the framework's
144    /// [`ProcessContext`] before `read()`. C `devTimeOfDay.c:148`
145    /// (`aiReadTs`) adds fractional seconds when `pai->phas` is set.
146    phas: i16,
147    /// `dbCommon.tse` and the record's current `dbCommon.time`. C
148    /// `aiReadTs` calls `recGblGetTimeStamp(pai)` to resolve `pai->time`
149    /// from `pai->tse`, then reads `pai->time.secPastEpoch` — not the
150    /// wall clock. `read()` resolves the same way via
151    /// [`get_time_stamp`]`(tse, time)`.
152    tse: i16,
153    time: SystemTime,
154}
155
156impl Default for SecPastEpochDeviceSupport {
157    fn default() -> Self {
158        Self {
159            phas: 0,
160            tse: 0,
161            time: SystemTime::UNIX_EPOCH,
162        }
163    }
164}
165
166impl SecPastEpochDeviceSupport {
167    pub fn new() -> Self {
168        Self::default()
169    }
170}
171
172impl DeviceSupport for SecPastEpochDeviceSupport {
173    fn dtyp(&self) -> &str {
174        "Sec Past Epoch"
175    }
176
177    fn set_process_context(&mut self, ctx: &ProcessContext) {
178        self.phas = ctx.phas;
179        self.tse = ctx.tse;
180        self.time = ctx.time;
181    }
182
183    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
184        // C `devTimeOfDay.c:145` `aiReadTs`: `recGblGetTimeStamp(pai)`
185        // resolves `pai->time` from TSE, then `pai->val =
186        // pai->time.secPastEpoch`. An epoch (uninitialized) stamp yields
187        // `secPastEpoch == 0`, so `val == 0.0` — no separate sentinel is
188        // needed for the ai path.
189        // C `recGblGetTimeStamp` leaves `prec->time` alone when
190        // `epicsTimeGetEvent` fails, and the record-level owner
191        // (`apply_timestamp`) emits the errlog this cycle.
192        let ts = get_time_stamp(self.tse, self.time).unwrap_or(self.time);
193        let (sec_past_epoch, nsec) = epics_time_parts(ts);
194
195        // C `devTimeOfDay.c:148` `aiReadTs`: `if (pai->phas)` adds the
196        // nanosecond fraction. PHAS comes from the framework-pushed
197        // `ProcessContext`.
198        let phas = self.phas;
199
200        let val = if phas != 0 {
201            sec_past_epoch as f64 + (nsec as f64 / 1e9)
202        } else {
203            sec_past_epoch as f64
204        };
205
206        record.put_field("VAL", EpicsValue::Double(val))?;
207        // C `devTimeOfDay.c::aiReadTs:150-152` — `pai->udf = 0; return(2)`:
208        // VAL written directly and the record declared defined.
209        Ok(DeviceReadOutcome::computed(DeviceUdf::Defined))
210    }
211
212    fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
213        Ok(())
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use epics_base_rs::server::record::ProcessContext;
221    use epics_base_rs::server::records::stringin::StringinRecord;
222    use std::time::Duration;
223
224    fn ctx_with_phas(phas: i16) -> ProcessContext<'static> {
225        ProcessContext {
226            udf: false,
227            udfs: epics_base_rs::server::record::AlarmSeverity::Invalid,
228            nsev: epics_base_rs::server::record::AlarmSeverity::NoAlarm,
229            phas,
230            tse: 0,
231            time: SystemTime::UNIX_EPOCH,
232            dtyp: "",
233            callback_priority: epics_base_rs::runtime::task::CallbackPriority::Low,
234        }
235    }
236
237    /// A `ProcessContext` carrying an explicit device-time stamp with
238    /// `tse = -2` (`epicsTimeEventDeviceTime`), so `read()` resolves the
239    /// stamp to exactly `time` via `get_time_stamp(-2, time)`.
240    fn ctx_device_time(phas: i16, time: SystemTime) -> ProcessContext<'static> {
241        ProcessContext {
242            udf: false,
243            udfs: epics_base_rs::server::record::AlarmSeverity::Invalid,
244            nsev: epics_base_rs::server::record::AlarmSeverity::NoAlarm,
245            phas,
246            tse: -2,
247            time,
248            dtyp: "",
249            callback_priority: epics_base_rs::runtime::task::CallbackPriority::Low,
250        }
251    }
252
253    /// C `devTimeOfDay.c:122-127` `createString`: `if (psi->phas)`
254    /// picks the `MM/DD/YY HH:MM:SS` slash format, else
255    /// `Mon DD, YYYY HH:MM:SS`. PHAS is a `dbCommon` field, not a
256    /// `stringin` field, so the framework pushes it via
257    /// `set_process_context` — `record.get_field("PHAS")` returns None.
258    #[test]
259    fn time_of_day_phas_zero_uses_long_format() {
260        let mut dev = TimeOfDayStringDeviceSupport::new();
261        let mut rec = StringinRecord::new("");
262        dev.set_process_context(&ctx_with_phas(0));
263        dev.read(&mut rec).unwrap();
264        let val = match rec.get_field("VAL") {
265            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
266            other => panic!("expected String VAL, got {other:?}"),
267        };
268        // "%b %d, %Y %H:%M:%S" — contains a comma, no slashes.
269        assert!(val.contains(','), "PHAS=0 long format has a comma: {val}");
270        assert!(
271            !val.contains('/'),
272            "PHAS=0 long format has no slashes: {val}"
273        );
274    }
275
276    /// C `devTimeOfDay.c:123`: PHAS != 0 selects `%m/%d/%y %H:%M:%S`.
277    /// Before the framework `set_process_context` wiring this branch was
278    /// never reached — `get_field("PHAS")` always returned None.
279    #[test]
280    fn time_of_day_phas_nonzero_uses_slash_format() {
281        let mut dev = TimeOfDayStringDeviceSupport::new();
282        let mut rec = StringinRecord::new("");
283        dev.set_process_context(&ctx_with_phas(1));
284        dev.read(&mut rec).unwrap();
285        let val = match rec.get_field("VAL") {
286            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
287            other => panic!("expected String VAL, got {other:?}"),
288        };
289        // "%m/%d/%y %H:%M:%S" — two slashes, no comma.
290        assert_eq!(
291            val.matches('/').count(),
292            2,
293            "PHAS!=0 slash format has two slashes: {val}"
294        );
295        assert!(
296            !val.contains(','),
297            "PHAS!=0 slash format has no comma: {val}"
298        );
299    }
300
301    /// C `devTimeOfDay.c:148` `aiReadTs`: `if (pai->phas)` adds the
302    /// nanosecond fraction to the seconds count. PHAS=0 yields a whole
303    /// number; PHAS!=0 generally yields a fraction.
304    #[test]
305    fn sec_past_epoch_phas_zero_is_whole_seconds() {
306        let mut dev = SecPastEpochDeviceSupport::new();
307        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
308        dev.set_process_context(&ctx_with_phas(0));
309        dev.read(&mut rec).unwrap();
310        let val = match rec.get_field("VAL") {
311            Some(EpicsValue::Double(v)) => v,
312            other => panic!("expected Double VAL, got {other:?}"),
313        };
314        assert_eq!(
315            val.fract(),
316            0.0,
317            "PHAS=0 must yield whole seconds, got {val}"
318        );
319    }
320
321    /// C `epicsTimeToStrftime` (epicsTime.cpp:176-180): a stamp at the
322    /// Unix epoch (`secPastEpoch == 0`) is "uninitialized" and formats to
323    /// the literal "<undefined>". `read()` resolves `tse = -2` to the
324    /// supplied device time verbatim, so an epoch device time must reach
325    /// that branch instead of the wall clock.
326    #[test]
327    fn time_of_day_epoch_zero_is_undefined() {
328        let mut dev = TimeOfDayStringDeviceSupport::new();
329        let mut rec = StringinRecord::new("");
330        dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
331        dev.read(&mut rec).unwrap();
332        let val = match rec.get_field("VAL") {
333            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
334            other => panic!("expected String VAL, got {other:?}"),
335        };
336        assert_eq!(val, "<undefined>", "epoch stamp must format as sentinel");
337    }
338
339    /// C counts `secPastEpoch` from the EPICS epoch (1990-01-01), so a
340    /// stamp *at* the EPICS epoch also has `secPastEpoch == 0` and formats
341    /// to "<undefined>" (`epicsTime.cpp:176`).
342    #[test]
343    fn time_of_day_epics_epoch_is_undefined() {
344        let mut dev = TimeOfDayStringDeviceSupport::new();
345        let mut rec = StringinRecord::new("");
346        let epics_epoch = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET);
347        dev.set_process_context(&ctx_device_time(0, epics_epoch));
348        dev.read(&mut rec).unwrap();
349        let val = match rec.get_field("VAL") {
350            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
351            other => panic!("expected String VAL, got {other:?}"),
352        };
353        assert_eq!(val, "<undefined>", "EPICS-epoch stamp is also undefined");
354    }
355
356    /// One second BEFORE the EPICS epoch is a running clock, not an
357    /// uninitialized stamp. C's `epicsTimeFromTime_t` wraps it into
358    /// `epicsUInt32` (`epicsTime.cpp:305-310`), giving 0xFFFF_FFFF, which
359    /// `epicsTimeToStrftime` formats as a 2106 date. Saturating answered 0
360    /// and printed the "<undefined>" sentinel instead.
361    #[test]
362    fn time_of_day_pre_1990_clock_is_a_date_not_the_undefined_sentinel() {
363        let mut dev = TimeOfDayStringDeviceSupport::new();
364        let mut rec = StringinRecord::new("");
365        let before = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET - 1);
366        dev.set_process_context(&ctx_device_time(0, before));
367        dev.read(&mut rec).unwrap();
368        let val = match rec.get_field("VAL") {
369            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
370            other => panic!("expected String VAL, got {other:?}"),
371        };
372        assert_ne!(val, "<undefined>", "a pre-1990 clock is not C's {{0, 0}}");
373    }
374
375    /// The wrap itself, read straight off the conversion.
376    #[test]
377    fn epics_time_parts_wraps_both_ends_and_keeps_the_unset_sentinel() {
378        assert_eq!(epics_time_parts(SystemTime::UNIX_EPOCH), (0, 0));
379        let at = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET);
380        assert_eq!(epics_time_parts(at).0, 0);
381        let before = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET - 1);
382        assert_eq!(epics_time_parts(before).0, u32::MAX);
383        let last =
384            SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET + u32::MAX as u64);
385        assert_eq!(epics_time_parts(last).0, u32::MAX);
386        let past =
387            SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET + u32::MAX as u64 + 1);
388        assert_eq!(epics_time_parts(past).0, 0);
389    }
390
391    /// C `createString` formats `psi->time` (resolved from TSE), NOT the
392    /// wall clock. A `tse = -2` device time of Unix 1_000_000_000
393    /// (2001-09-09 UTC) must format to its own year, never the current
394    /// year, and is not the undefined sentinel.
395    #[test]
396    fn time_of_day_resolves_device_time_not_wall_clock() {
397        let mut dev = TimeOfDayStringDeviceSupport::new();
398        let mut rec = StringinRecord::new("");
399        let device_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
400        dev.set_process_context(&ctx_device_time(0, device_time));
401        dev.read(&mut rec).unwrap();
402        let val = match rec.get_field("VAL") {
403            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
404            other => panic!("expected String VAL, got {other:?}"),
405        };
406        // Unix 1e9 is 2001-09-09 01:46:40 UTC — every real time zone keeps
407        // the year 2001. Proves the device time, not 2026 wall clock, was
408        // formatted.
409        assert!(val.contains("2001"), "must format the device time: {val}");
410        assert_ne!(val, "<undefined>");
411    }
412
413    /// C `aiReadTs` reads `pai->time.secPastEpoch` from the TSE-resolved
414    /// stamp. An epoch device time yields `secPastEpoch == 0`, so
415    /// `val == 0.0` — no sentinel needed.
416    #[test]
417    fn sec_past_epoch_epoch_zero_is_zero() {
418        let mut dev = SecPastEpochDeviceSupport::new();
419        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
420        dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
421        dev.read(&mut rec).unwrap();
422        let val = match rec.get_field("VAL") {
423            Some(EpicsValue::Double(v)) => v,
424            other => panic!("expected Double VAL, got {other:?}"),
425        };
426        assert_eq!(val, 0.0, "epoch stamp yields secPastEpoch 0");
427    }
428
429    /// C `aiReadTs`: `pai->val = pai->time.secPastEpoch` (EPICS epoch
430    /// based). For Unix 1_000_000_000 that is `1_000_000_000 -
431    /// EPICS_EPOCH_OFFSET`. With PHAS != 0 the nanosecond fraction is
432    /// added (`devTimeOfDay.c:148`).
433    #[test]
434    fn sec_past_epoch_resolves_device_time_with_fraction() {
435        let device_time = SystemTime::UNIX_EPOCH
436            + Duration::from_secs(1_000_000_000)
437            + Duration::from_nanos(500_000_000);
438        let expected_secs = (1_000_000_000u64 - EPICS_EPOCH_OFFSET) as f64;
439
440        // PHAS=0: whole seconds, fraction dropped.
441        let mut dev = SecPastEpochDeviceSupport::new();
442        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
443        dev.set_process_context(&ctx_device_time(0, device_time));
444        dev.read(&mut rec).unwrap();
445        match rec.get_field("VAL") {
446            Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs),
447            other => panic!("expected Double VAL, got {other:?}"),
448        };
449
450        // PHAS!=0: adds the 0.5 s fraction.
451        let mut dev = SecPastEpochDeviceSupport::new();
452        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
453        dev.set_process_context(&ctx_device_time(1, device_time));
454        dev.read(&mut rec).unwrap();
455        match rec.get_field("VAL") {
456            Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs + 0.5),
457            other => panic!("expected Double VAL, got {other:?}"),
458        };
459    }
460}