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