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            phas,
208            tse: 0,
209            time: SystemTime::UNIX_EPOCH,
210            tsel: String::new(),
211            dtyp: String::new(),
212        }
213    }
214
215    /// A `ProcessContext` carrying an explicit device-time stamp with
216    /// `tse = -2` (`epicsTimeEventDeviceTime`), so `read()` resolves the
217    /// stamp to exactly `time` via `get_time_stamp(-2, time)`.
218    fn ctx_device_time(phas: i16, time: SystemTime) -> ProcessContext {
219        ProcessContext {
220            udf: false,
221            udfs: epics_base_rs::server::record::AlarmSeverity::Invalid,
222            phas,
223            tse: -2,
224            time,
225            tsel: String::new(),
226            dtyp: String::new(),
227        }
228    }
229
230    /// C `devTimeOfDay.c:122-127` `createString`: `if (psi->phas)`
231    /// picks the `MM/DD/YY HH:MM:SS` slash format, else
232    /// `Mon DD, YYYY HH:MM:SS`. PHAS is a `dbCommon` field, not a
233    /// `stringin` field, so the framework pushes it via
234    /// `set_process_context` — `record.get_field("PHAS")` returns None.
235    #[test]
236    fn time_of_day_phas_zero_uses_long_format() {
237        let mut dev = TimeOfDayStringDeviceSupport::new();
238        let mut rec = StringinRecord::new("");
239        dev.set_process_context(&ctx_with_phas(0));
240        dev.read(&mut rec).unwrap();
241        let val = match rec.get_field("VAL") {
242            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
243            other => panic!("expected String VAL, got {other:?}"),
244        };
245        // "%b %d, %Y %H:%M:%S" — contains a comma, no slashes.
246        assert!(val.contains(','), "PHAS=0 long format has a comma: {val}");
247        assert!(
248            !val.contains('/'),
249            "PHAS=0 long format has no slashes: {val}"
250        );
251    }
252
253    /// C `devTimeOfDay.c:123`: PHAS != 0 selects `%m/%d/%y %H:%M:%S`.
254    /// Before the framework `set_process_context` wiring this branch was
255    /// never reached — `get_field("PHAS")` always returned None.
256    #[test]
257    fn time_of_day_phas_nonzero_uses_slash_format() {
258        let mut dev = TimeOfDayStringDeviceSupport::new();
259        let mut rec = StringinRecord::new("");
260        dev.set_process_context(&ctx_with_phas(1));
261        dev.read(&mut rec).unwrap();
262        let val = match rec.get_field("VAL") {
263            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
264            other => panic!("expected String VAL, got {other:?}"),
265        };
266        // "%m/%d/%y %H:%M:%S" — two slashes, no comma.
267        assert_eq!(
268            val.matches('/').count(),
269            2,
270            "PHAS!=0 slash format has two slashes: {val}"
271        );
272        assert!(
273            !val.contains(','),
274            "PHAS!=0 slash format has no comma: {val}"
275        );
276    }
277
278    /// C `devTimeOfDay.c:148` `aiReadTs`: `if (pai->phas)` adds the
279    /// nanosecond fraction to the seconds count. PHAS=0 yields a whole
280    /// number; PHAS!=0 generally yields a fraction.
281    #[test]
282    fn sec_past_epoch_phas_zero_is_whole_seconds() {
283        let mut dev = SecPastEpochDeviceSupport::new();
284        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
285        dev.set_process_context(&ctx_with_phas(0));
286        dev.read(&mut rec).unwrap();
287        let val = match rec.get_field("VAL") {
288            Some(EpicsValue::Double(v)) => v,
289            other => panic!("expected Double VAL, got {other:?}"),
290        };
291        assert_eq!(
292            val.fract(),
293            0.0,
294            "PHAS=0 must yield whole seconds, got {val}"
295        );
296    }
297
298    /// C `epicsTimeToStrftime` (epicsTime.cpp:176-180): a stamp at the
299    /// Unix epoch (`secPastEpoch == 0`) is "uninitialized" and formats to
300    /// the literal "<undefined>". `read()` resolves `tse = -2` to the
301    /// supplied device time verbatim, so an epoch device time must reach
302    /// that branch instead of the wall clock.
303    #[test]
304    fn time_of_day_epoch_zero_is_undefined() {
305        let mut dev = TimeOfDayStringDeviceSupport::new();
306        let mut rec = StringinRecord::new("");
307        dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
308        dev.read(&mut rec).unwrap();
309        let val = match rec.get_field("VAL") {
310            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
311            other => panic!("expected String VAL, got {other:?}"),
312        };
313        assert_eq!(val, "<undefined>", "epoch stamp must format as sentinel");
314    }
315
316    /// C counts `secPastEpoch` from the EPICS epoch (1990-01-01), so a
317    /// stamp *at* the EPICS epoch also has `secPastEpoch == 0` and formats
318    /// to "<undefined>" (`epicsTime.cpp:176`).
319    #[test]
320    fn time_of_day_epics_epoch_is_undefined() {
321        let mut dev = TimeOfDayStringDeviceSupport::new();
322        let mut rec = StringinRecord::new("");
323        let epics_epoch = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET);
324        dev.set_process_context(&ctx_device_time(0, epics_epoch));
325        dev.read(&mut rec).unwrap();
326        let val = match rec.get_field("VAL") {
327            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
328            other => panic!("expected String VAL, got {other:?}"),
329        };
330        assert_eq!(val, "<undefined>", "EPICS-epoch stamp is also undefined");
331    }
332
333    /// C `createString` formats `psi->time` (resolved from TSE), NOT the
334    /// wall clock. A `tse = -2` device time of Unix 1_000_000_000
335    /// (2001-09-09 UTC) must format to its own year, never the current
336    /// year, and is not the undefined sentinel.
337    #[test]
338    fn time_of_day_resolves_device_time_not_wall_clock() {
339        let mut dev = TimeOfDayStringDeviceSupport::new();
340        let mut rec = StringinRecord::new("");
341        let device_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
342        dev.set_process_context(&ctx_device_time(0, device_time));
343        dev.read(&mut rec).unwrap();
344        let val = match rec.get_field("VAL") {
345            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
346            other => panic!("expected String VAL, got {other:?}"),
347        };
348        // Unix 1e9 is 2001-09-09 01:46:40 UTC — every real time zone keeps
349        // the year 2001. Proves the device time, not 2026 wall clock, was
350        // formatted.
351        assert!(val.contains("2001"), "must format the device time: {val}");
352        assert_ne!(val, "<undefined>");
353    }
354
355    /// C `aiReadTs` reads `pai->time.secPastEpoch` from the TSE-resolved
356    /// stamp. An epoch device time yields `secPastEpoch == 0`, so
357    /// `val == 0.0` — no sentinel needed.
358    #[test]
359    fn sec_past_epoch_epoch_zero_is_zero() {
360        let mut dev = SecPastEpochDeviceSupport::new();
361        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
362        dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
363        dev.read(&mut rec).unwrap();
364        let val = match rec.get_field("VAL") {
365            Some(EpicsValue::Double(v)) => v,
366            other => panic!("expected Double VAL, got {other:?}"),
367        };
368        assert_eq!(val, 0.0, "epoch stamp yields secPastEpoch 0");
369    }
370
371    /// C `aiReadTs`: `pai->val = pai->time.secPastEpoch` (EPICS epoch
372    /// based). For Unix 1_000_000_000 that is `1_000_000_000 -
373    /// EPICS_EPOCH_OFFSET`. With PHAS != 0 the nanosecond fraction is
374    /// added (`devTimeOfDay.c:148`).
375    #[test]
376    fn sec_past_epoch_resolves_device_time_with_fraction() {
377        let device_time = SystemTime::UNIX_EPOCH
378            + Duration::from_secs(1_000_000_000)
379            + Duration::from_nanos(500_000_000);
380        let expected_secs = (1_000_000_000u64 - EPICS_EPOCH_OFFSET) as f64;
381
382        // PHAS=0: whole seconds, fraction dropped.
383        let mut dev = SecPastEpochDeviceSupport::new();
384        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
385        dev.set_process_context(&ctx_device_time(0, device_time));
386        dev.read(&mut rec).unwrap();
387        match rec.get_field("VAL") {
388            Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs),
389            other => panic!("expected Double VAL, got {other:?}"),
390        };
391
392        // PHAS!=0: adds the 0.5 s fraction.
393        let mut dev = SecPastEpochDeviceSupport::new();
394        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
395        dev.set_process_context(&ctx_device_time(1, device_time));
396        dev.read(&mut rec).unwrap();
397        match rec.get_field("VAL") {
398            Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs + 0.5),
399            other => panic!("expected Double VAL, got {other:?}"),
400        };
401    }
402}