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 {
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            tsel: String::new(),
233            dtyp: String::new(),
234            callback_priority: epics_base_rs::runtime::task::CallbackPriority::Low,
235        }
236    }
237
238    /// A `ProcessContext` carrying an explicit device-time stamp with
239    /// `tse = -2` (`epicsTimeEventDeviceTime`), so `read()` resolves the
240    /// stamp to exactly `time` via `get_time_stamp(-2, time)`.
241    fn ctx_device_time(phas: i16, time: SystemTime) -> ProcessContext {
242        ProcessContext {
243            udf: false,
244            udfs: epics_base_rs::server::record::AlarmSeverity::Invalid,
245            nsev: epics_base_rs::server::record::AlarmSeverity::NoAlarm,
246            phas,
247            tse: -2,
248            time,
249            tsel: String::new(),
250            dtyp: String::new(),
251            callback_priority: epics_base_rs::runtime::task::CallbackPriority::Low,
252        }
253    }
254
255    /// C `devTimeOfDay.c:122-127` `createString`: `if (psi->phas)`
256    /// picks the `MM/DD/YY HH:MM:SS` slash format, else
257    /// `Mon DD, YYYY HH:MM:SS`. PHAS is a `dbCommon` field, not a
258    /// `stringin` field, so the framework pushes it via
259    /// `set_process_context` — `record.get_field("PHAS")` returns None.
260    #[test]
261    fn time_of_day_phas_zero_uses_long_format() {
262        let mut dev = TimeOfDayStringDeviceSupport::new();
263        let mut rec = StringinRecord::new("");
264        dev.set_process_context(&ctx_with_phas(0));
265        dev.read(&mut rec).unwrap();
266        let val = match rec.get_field("VAL") {
267            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
268            other => panic!("expected String VAL, got {other:?}"),
269        };
270        // "%b %d, %Y %H:%M:%S" — contains a comma, no slashes.
271        assert!(val.contains(','), "PHAS=0 long format has a comma: {val}");
272        assert!(
273            !val.contains('/'),
274            "PHAS=0 long format has no slashes: {val}"
275        );
276    }
277
278    /// C `devTimeOfDay.c:123`: PHAS != 0 selects `%m/%d/%y %H:%M:%S`.
279    /// Before the framework `set_process_context` wiring this branch was
280    /// never reached — `get_field("PHAS")` always returned None.
281    #[test]
282    fn time_of_day_phas_nonzero_uses_slash_format() {
283        let mut dev = TimeOfDayStringDeviceSupport::new();
284        let mut rec = StringinRecord::new("");
285        dev.set_process_context(&ctx_with_phas(1));
286        dev.read(&mut rec).unwrap();
287        let val = match rec.get_field("VAL") {
288            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
289            other => panic!("expected String VAL, got {other:?}"),
290        };
291        // "%m/%d/%y %H:%M:%S" — two slashes, no comma.
292        assert_eq!(
293            val.matches('/').count(),
294            2,
295            "PHAS!=0 slash format has two slashes: {val}"
296        );
297        assert!(
298            !val.contains(','),
299            "PHAS!=0 slash format has no comma: {val}"
300        );
301    }
302
303    /// C `devTimeOfDay.c:148` `aiReadTs`: `if (pai->phas)` adds the
304    /// nanosecond fraction to the seconds count. PHAS=0 yields a whole
305    /// number; PHAS!=0 generally yields a fraction.
306    #[test]
307    fn sec_past_epoch_phas_zero_is_whole_seconds() {
308        let mut dev = SecPastEpochDeviceSupport::new();
309        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
310        dev.set_process_context(&ctx_with_phas(0));
311        dev.read(&mut rec).unwrap();
312        let val = match rec.get_field("VAL") {
313            Some(EpicsValue::Double(v)) => v,
314            other => panic!("expected Double VAL, got {other:?}"),
315        };
316        assert_eq!(
317            val.fract(),
318            0.0,
319            "PHAS=0 must yield whole seconds, got {val}"
320        );
321    }
322
323    /// C `epicsTimeToStrftime` (epicsTime.cpp:176-180): a stamp at the
324    /// Unix epoch (`secPastEpoch == 0`) is "uninitialized" and formats to
325    /// the literal "<undefined>". `read()` resolves `tse = -2` to the
326    /// supplied device time verbatim, so an epoch device time must reach
327    /// that branch instead of the wall clock.
328    #[test]
329    fn time_of_day_epoch_zero_is_undefined() {
330        let mut dev = TimeOfDayStringDeviceSupport::new();
331        let mut rec = StringinRecord::new("");
332        dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
333        dev.read(&mut rec).unwrap();
334        let val = match rec.get_field("VAL") {
335            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
336            other => panic!("expected String VAL, got {other:?}"),
337        };
338        assert_eq!(val, "<undefined>", "epoch stamp must format as sentinel");
339    }
340
341    /// C counts `secPastEpoch` from the EPICS epoch (1990-01-01), so a
342    /// stamp *at* the EPICS epoch also has `secPastEpoch == 0` and formats
343    /// to "<undefined>" (`epicsTime.cpp:176`).
344    #[test]
345    fn time_of_day_epics_epoch_is_undefined() {
346        let mut dev = TimeOfDayStringDeviceSupport::new();
347        let mut rec = StringinRecord::new("");
348        let epics_epoch = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET);
349        dev.set_process_context(&ctx_device_time(0, epics_epoch));
350        dev.read(&mut rec).unwrap();
351        let val = match rec.get_field("VAL") {
352            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
353            other => panic!("expected String VAL, got {other:?}"),
354        };
355        assert_eq!(val, "<undefined>", "EPICS-epoch stamp is also undefined");
356    }
357
358    /// One second BEFORE the EPICS epoch is a running clock, not an
359    /// uninitialized stamp. C's `epicsTimeFromTime_t` wraps it into
360    /// `epicsUInt32` (`epicsTime.cpp:305-310`), giving 0xFFFF_FFFF, which
361    /// `epicsTimeToStrftime` formats as a 2106 date. Saturating answered 0
362    /// and printed the "<undefined>" sentinel instead.
363    #[test]
364    fn time_of_day_pre_1990_clock_is_a_date_not_the_undefined_sentinel() {
365        let mut dev = TimeOfDayStringDeviceSupport::new();
366        let mut rec = StringinRecord::new("");
367        let before = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET - 1);
368        dev.set_process_context(&ctx_device_time(0, before));
369        dev.read(&mut rec).unwrap();
370        let val = match rec.get_field("VAL") {
371            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
372            other => panic!("expected String VAL, got {other:?}"),
373        };
374        assert_ne!(val, "<undefined>", "a pre-1990 clock is not C's {{0, 0}}");
375    }
376
377    /// The wrap itself, read straight off the conversion.
378    #[test]
379    fn epics_time_parts_wraps_both_ends_and_keeps_the_unset_sentinel() {
380        assert_eq!(epics_time_parts(SystemTime::UNIX_EPOCH), (0, 0));
381        let at = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET);
382        assert_eq!(epics_time_parts(at).0, 0);
383        let before = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET - 1);
384        assert_eq!(epics_time_parts(before).0, u32::MAX);
385        let last =
386            SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET + u32::MAX as u64);
387        assert_eq!(epics_time_parts(last).0, u32::MAX);
388        let past =
389            SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET + u32::MAX as u64 + 1);
390        assert_eq!(epics_time_parts(past).0, 0);
391    }
392
393    /// C `createString` formats `psi->time` (resolved from TSE), NOT the
394    /// wall clock. A `tse = -2` device time of Unix 1_000_000_000
395    /// (2001-09-09 UTC) must format to its own year, never the current
396    /// year, and is not the undefined sentinel.
397    #[test]
398    fn time_of_day_resolves_device_time_not_wall_clock() {
399        let mut dev = TimeOfDayStringDeviceSupport::new();
400        let mut rec = StringinRecord::new("");
401        let device_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
402        dev.set_process_context(&ctx_device_time(0, device_time));
403        dev.read(&mut rec).unwrap();
404        let val = match rec.get_field("VAL") {
405            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
406            other => panic!("expected String VAL, got {other:?}"),
407        };
408        // Unix 1e9 is 2001-09-09 01:46:40 UTC — every real time zone keeps
409        // the year 2001. Proves the device time, not 2026 wall clock, was
410        // formatted.
411        assert!(val.contains("2001"), "must format the device time: {val}");
412        assert_ne!(val, "<undefined>");
413    }
414
415    /// C `aiReadTs` reads `pai->time.secPastEpoch` from the TSE-resolved
416    /// stamp. An epoch device time yields `secPastEpoch == 0`, so
417    /// `val == 0.0` — no sentinel needed.
418    #[test]
419    fn sec_past_epoch_epoch_zero_is_zero() {
420        let mut dev = SecPastEpochDeviceSupport::new();
421        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
422        dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
423        dev.read(&mut rec).unwrap();
424        let val = match rec.get_field("VAL") {
425            Some(EpicsValue::Double(v)) => v,
426            other => panic!("expected Double VAL, got {other:?}"),
427        };
428        assert_eq!(val, 0.0, "epoch stamp yields secPastEpoch 0");
429    }
430
431    /// C `aiReadTs`: `pai->val = pai->time.secPastEpoch` (EPICS epoch
432    /// based). For Unix 1_000_000_000 that is `1_000_000_000 -
433    /// EPICS_EPOCH_OFFSET`. With PHAS != 0 the nanosecond fraction is
434    /// added (`devTimeOfDay.c:148`).
435    #[test]
436    fn sec_past_epoch_resolves_device_time_with_fraction() {
437        let device_time = SystemTime::UNIX_EPOCH
438            + Duration::from_secs(1_000_000_000)
439            + Duration::from_nanos(500_000_000);
440        let expected_secs = (1_000_000_000u64 - EPICS_EPOCH_OFFSET) as f64;
441
442        // PHAS=0: whole seconds, fraction dropped.
443        let mut dev = SecPastEpochDeviceSupport::new();
444        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
445        dev.set_process_context(&ctx_device_time(0, device_time));
446        dev.read(&mut rec).unwrap();
447        match rec.get_field("VAL") {
448            Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs),
449            other => panic!("expected Double VAL, got {other:?}"),
450        };
451
452        // PHAS!=0: adds the 0.5 s fraction.
453        let mut dev = SecPastEpochDeviceSupport::new();
454        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
455        dev.set_process_context(&ctx_device_time(1, device_time));
456        dev.read(&mut rec).unwrap();
457        match rec.get_field("VAL") {
458            Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs + 0.5),
459            other => panic!("expected Double VAL, got {other:?}"),
460        };
461    }
462}