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