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::record::{ProcessContext, Record};
6use epics_base_rs::types::EpicsValue;
7
8use chrono::Local;
9
10/// EPICS epoch offset: seconds from Unix epoch (1970-01-01) to EPICS epoch (1990-01-01).
11const EPICS_EPOCH_OFFSET: u64 = 631152000;
12
13/// "Time of Day" device support for stringin records.
14///
15/// Reads the current time and formats it as a string.
16/// Format depends on PHAS field:
17/// - PHAS=0: "Mon DD, YYYY HH:MM:SS"
18/// - PHAS!=0: "MM/DD/YY HH:MM:SS"
19///
20/// Ported from `devTimeOfDay.c` (`devSiTodString`).
21#[derive(Default)]
22pub struct TimeOfDayStringDeviceSupport {
23    /// `dbCommon.phas`, captured from the framework's
24    /// [`ProcessContext`] before `read()`. C `devTimeOfDay.c:122`
25    /// (`createString`) selects the time format from `psi->phas`;
26    /// `read()` only gets `&mut dyn Record` and PHAS is a
27    /// `CommonFields` field, not a `stringin` record field, so the
28    /// framework pushes it through `set_process_context`.
29    phas: i16,
30}
31
32impl TimeOfDayStringDeviceSupport {
33    pub fn new() -> Self {
34        Self::default()
35    }
36}
37
38impl DeviceSupport for TimeOfDayStringDeviceSupport {
39    fn dtyp(&self) -> &str {
40        "Time of Day"
41    }
42
43    fn set_process_context(&mut self, ctx: &ProcessContext) {
44        self.phas = ctx.phas;
45    }
46
47    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
48        let now = Local::now();
49
50        // C `devTimeOfDay.c:122` `createString`: `if (psi->phas)`
51        // selects the slash format, else the long format. PHAS lives
52        // in `CommonFields`; the framework pushed it via
53        // `set_process_context`.
54        let phas = self.phas;
55
56        let formatted = if phas != 0 {
57            now.format("%m/%d/%y %H:%M:%S").to_string()
58        } else {
59            now.format("%b %d, %Y %H:%M:%S").to_string()
60        };
61
62        record.put_field("VAL", EpicsValue::String(formatted))?;
63        Ok(DeviceReadOutcome::computed())
64    }
65
66    fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
67        Ok(())
68    }
69}
70
71/// "Sec Past Epoch" device support for ai records.
72///
73/// Reads the current time as seconds past the EPICS epoch (1990-01-01).
74/// If PHAS field is nonzero, includes fractional seconds.
75///
76/// Ported from `devTimeOfDay.c` (`devAiTodSeconds`).
77#[derive(Default)]
78pub struct SecPastEpochDeviceSupport {
79    /// `dbCommon.phas`, captured from the framework's
80    /// [`ProcessContext`] before `read()`. C `devTimeOfDay.c:148`
81    /// (`aiReadTs`) adds fractional seconds when `pai->phas` is set.
82    phas: i16,
83}
84
85impl SecPastEpochDeviceSupport {
86    pub fn new() -> Self {
87        Self::default()
88    }
89}
90
91impl DeviceSupport for SecPastEpochDeviceSupport {
92    fn dtyp(&self) -> &str {
93        "Sec Past Epoch"
94    }
95
96    fn set_process_context(&mut self, ctx: &ProcessContext) {
97        self.phas = ctx.phas;
98    }
99
100    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
101        let now = SystemTime::now()
102            .duration_since(UNIX_EPOCH)
103            .unwrap_or_default();
104
105        let sec_past_epoch = now.as_secs().saturating_sub(EPICS_EPOCH_OFFSET);
106
107        // C `devTimeOfDay.c:148` `aiReadTs`: `if (pai->phas)` adds the
108        // nanosecond fraction. PHAS comes from the framework-pushed
109        // `ProcessContext`.
110        let phas = self.phas;
111
112        let val = if phas != 0 {
113            sec_past_epoch as f64 + (now.subsec_nanos() as f64 / 1e9)
114        } else {
115            sec_past_epoch as f64
116        };
117
118        record.put_field("VAL", EpicsValue::Double(val))?;
119        Ok(DeviceReadOutcome::computed())
120    }
121
122    fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
123        Ok(())
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use epics_base_rs::server::record::ProcessContext;
131    use epics_base_rs::server::records::stringin::StringinRecord;
132
133    fn ctx_with_phas(phas: i16) -> ProcessContext {
134        ProcessContext {
135            udf: false,
136            udfs: epics_base_rs::server::record::AlarmSeverity::Invalid,
137            phas,
138            tse: 0,
139            tsel: String::new(),
140            dtyp: String::new(),
141        }
142    }
143
144    /// C `devTimeOfDay.c:122-127` `createString`: `if (psi->phas)`
145    /// picks the `MM/DD/YY HH:MM:SS` slash format, else
146    /// `Mon DD, YYYY HH:MM:SS`. PHAS is a `dbCommon` field, not a
147    /// `stringin` field, so the framework pushes it via
148    /// `set_process_context` — `record.get_field("PHAS")` returns None.
149    #[test]
150    fn time_of_day_phas_zero_uses_long_format() {
151        let mut dev = TimeOfDayStringDeviceSupport::new();
152        let mut rec = StringinRecord::new("");
153        dev.set_process_context(&ctx_with_phas(0));
154        dev.read(&mut rec).unwrap();
155        let val = match rec.get_field("VAL") {
156            Some(EpicsValue::String(s)) => s,
157            other => panic!("expected String VAL, got {other:?}"),
158        };
159        // "%b %d, %Y %H:%M:%S" — contains a comma, no slashes.
160        assert!(val.contains(','), "PHAS=0 long format has a comma: {val}");
161        assert!(
162            !val.contains('/'),
163            "PHAS=0 long format has no slashes: {val}"
164        );
165    }
166
167    /// C `devTimeOfDay.c:123`: PHAS != 0 selects `%m/%d/%y %H:%M:%S`.
168    /// Before the framework `set_process_context` wiring this branch was
169    /// never reached — `get_field("PHAS")` always returned None.
170    #[test]
171    fn time_of_day_phas_nonzero_uses_slash_format() {
172        let mut dev = TimeOfDayStringDeviceSupport::new();
173        let mut rec = StringinRecord::new("");
174        dev.set_process_context(&ctx_with_phas(1));
175        dev.read(&mut rec).unwrap();
176        let val = match rec.get_field("VAL") {
177            Some(EpicsValue::String(s)) => s,
178            other => panic!("expected String VAL, got {other:?}"),
179        };
180        // "%m/%d/%y %H:%M:%S" — two slashes, no comma.
181        assert_eq!(
182            val.matches('/').count(),
183            2,
184            "PHAS!=0 slash format has two slashes: {val}"
185        );
186        assert!(
187            !val.contains(','),
188            "PHAS!=0 slash format has no comma: {val}"
189        );
190    }
191
192    /// C `devTimeOfDay.c:148` `aiReadTs`: `if (pai->phas)` adds the
193    /// nanosecond fraction to the seconds count. PHAS=0 yields a whole
194    /// number; PHAS!=0 generally yields a fraction.
195    #[test]
196    fn sec_past_epoch_phas_zero_is_whole_seconds() {
197        let mut dev = SecPastEpochDeviceSupport::new();
198        let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
199        dev.set_process_context(&ctx_with_phas(0));
200        dev.read(&mut rec).unwrap();
201        let val = match rec.get_field("VAL") {
202            Some(EpicsValue::Double(v)) => v,
203            other => panic!("expected Double VAL, got {other:?}"),
204        };
205        assert_eq!(
206            val.fract(),
207            0.0,
208            "PHAS=0 must yield whole seconds, got {val}"
209        );
210    }
211}