std_rs/records/timestamp.rs
1use epics_base_rs::error::{CaError, CaResult};
2use epics_base_rs::server::record::{
3 EPICS_TIME_EVENT_DEVICE_TIME, FieldDesc, ProcessContext, ProcessOutcome, Record,
4};
5use epics_base_rs::types::{DbFieldType, EpicsValue};
6
7use chrono::{Local, TimeZone};
8
9/// EPICS epoch: 1990-01-01 00:00:00 UTC
10const EPICS_EPOCH_OFFSET: i64 = 631152000;
11
12/// Maximum number of visible (non-NUL) bytes in the VAL/OVAL fields.
13///
14/// `timestampRecord.dbd` declares `VAL`/`OVAL` as `char val[40]`, and C
15/// `timestampRecord.c:140` calls `epicsTimeToStrftime(val, sizeof(val), ...)`.
16/// `epicsTimeToStrftime` wraps `strftime`, which writes at most
17/// `sizeof(val)` bytes *including* the terminating NUL — so the buffer
18/// holds at most 39 visible characters. A Rust `String` carries no NUL
19/// terminator, so the visible-byte bound is 39, not 40.
20const VAL_VISIBLE_MAX: usize = 39;
21
22/// Timestamp format strings indexed by TST field value.
23///
24/// Mirrors the `switch(tst)` in `timestampRecord.c:100-138`. Any TST value
25/// outside `0..=10` falls through C's `default:` branch to format 0
26/// (`YY/MM/DD HH:MM:SS`).
27const TIMESTAMP_FORMATS: &[&str] = &[
28 "%y/%m/%d %H:%M:%S", // 0 timestampTST_YY_MM_DD_HH_MM_SS
29 "%m/%d/%y %H:%M:%S", // 1 timestampTST_MM_DD_YY_HH_MM_SS
30 "%b %d %H:%M:%S %y", // 2 timestampTST_MM_DD_HH_MM_SS_YY
31 "%b %d %H:%M:%S", // 3 timestampTST_MM_DD_HH_MM_SS
32 "%H:%M:%S", // 4 timestampTST_HH_MM_SS
33 "%H:%M", // 5 timestampTST_HH_MM
34 "%d/%m/%y %H:%M:%S", // 6 timestampTST_DD_MM_YY_HH_MM_SS
35 "%d %b %H:%M:%S %y", // 7 timestampTST_DD_MM_HH_MM_SS_YY
36 "%d-%b-%Y %H:%M:%S", // 8 timestampTST_VMS
37];
38
39/// Timestamp record — generates formatted timestamp strings.
40///
41/// Ported from EPICS std module `timestampRecord.c`.
42pub struct TimestampRecord {
43 /// Current formatted timestamp string (VAL).
44 pub val: String,
45 /// Previous value for change detection (OVAL).
46 pub oval: String,
47 /// Seconds past EPICS epoch (RVAL). DBF_ULONG in C; the Rust value
48 /// model has no unsigned-32 scalar, so this follows the project
49 /// convention of mapping DBF_ULONG to `i32`/`EpicsValue::Long`.
50 pub rval: i32,
51 /// Timestamp format selector (TST), a DBF_MENU. Values `0..=10`
52 /// select an explicit format; any other value is rendered with
53 /// format 0 (C `switch` `default:` branch).
54 pub tst: i16,
55 /// Framework-owned `dbCommon.tse`, pushed via
56 /// [`Record::set_process_context`] before `process()`. C
57 /// `timestampRecord.c:90` branches on
58 /// `tse == epicsTimeEventDeviceTime`: device-time takes the raw OS
59 /// clock (`epicsTimeFromTime_t(&time, time(0))`, whole seconds, no
60 /// fraction); any other value uses the EPICS time-stamp framework.
61 tse: i16,
62}
63
64impl Default for TimestampRecord {
65 fn default() -> Self {
66 Self {
67 val: String::new(),
68 oval: String::new(),
69 rval: 0,
70 tst: 0,
71 tse: 0,
72 }
73 }
74}
75
76static FIELDS: &[FieldDesc] = &[
77 FieldDesc {
78 name: "VAL",
79 dbf_type: DbFieldType::String,
80 read_only: false,
81 },
82 FieldDesc {
83 name: "OVAL",
84 dbf_type: DbFieldType::String,
85 read_only: true,
86 },
87 FieldDesc {
88 name: "RVAL",
89 dbf_type: DbFieldType::Long,
90 read_only: false,
91 },
92 FieldDesc {
93 name: "TST",
94 dbf_type: DbFieldType::Short,
95 read_only: false,
96 },
97];
98
99impl TimestampRecord {
100 fn format_timestamp(&self) -> (String, i32) {
101 // C `timestampRecord.c:90-93`: `tse == epicsTimeEventDeviceTime`
102 // takes the raw OS clock via `epicsTimeFromTime_t(&time, time(0))`
103 // — whole seconds only, the nanosecond field is zero. Any other
104 // TSE value goes through `recGblGetTimeStamp`, which carries
105 // sub-second precision. The Rust port mirrors the observable
106 // difference: device-time truncates `now` to whole seconds so
107 // the `.%03f` formats (TST 9/10) render `.000`.
108 let now = if self.tse == EPICS_TIME_EVENT_DEVICE_TIME {
109 let secs = Local::now().timestamp();
110 // `timestamp_opt(secs, 0)` is always `Single` for any
111 // in-range Unix second; fall back to the un-truncated clock
112 // on the impossible `None`/`Ambiguous` case rather than
113 // panicking.
114 Local
115 .timestamp_opt(secs, 0)
116 .single()
117 .unwrap_or_else(Local::now)
118 } else {
119 Local::now()
120 };
121 let unix_secs = now.timestamp();
122 let sec_past_epoch = (unix_secs - EPICS_EPOCH_OFFSET) as i32;
123
124 // C `timestampRecord.c:96`: `if (time.secPastEpoch == 0)` — the
125 // "-NULL-" sentinel is emitted only when the EPICS-epoch second
126 // count is exactly zero (an uninitialised/unset time stamp), not
127 // for any non-positive value.
128 if sec_past_epoch == 0 {
129 return ("-NULL-".to_string(), sec_past_epoch);
130 }
131
132 // C `timestampRecord.c:100-138`: any TST outside the valid menu
133 // range falls through `default:` to format 0. The raw TST value
134 // is preserved (the field is a plain menu); only the format
135 // *selection* is bounded here.
136 let tst = self.tst;
137
138 let formatted = match tst {
139 0..=8 => now.format(TIMESTAMP_FORMATS[tst as usize]).to_string(),
140 // Formats 9 (timestampTST_MM_DD_YYYY) and 10
141 // (timestampTST_MM_DD_YY) carry `.%03f` fractional seconds.
142 // C `timestampRecord.c:130,133`. EPICS `%03f` is the
143 // 3-digit fractional-seconds field derived from the time
144 // stamp's nanoseconds; `subsec_millis()` is the equivalent
145 // 3-digit truncation of the same fraction.
146 9 | 10 => {
147 let ms = now.timestamp_subsec_millis();
148 let base = if tst == 9 {
149 now.format("%b %d %Y %H:%M:%S").to_string()
150 } else {
151 now.format("%m/%d/%y %H:%M:%S").to_string()
152 };
153 format!("{base}.{ms:03}")
154 }
155 // C `default:` branch — format 0 (`YY/MM/DD HH:MM:SS`).
156 _ => now.format(TIMESTAMP_FORMATS[0]).to_string(),
157 };
158
159 // C `timestampRecord.c:140` `epicsTimeToStrftime(val, sizeof(val), ...)`
160 // bounds the result to the `char val[40]` buffer; `strftime` keeps
161 // one byte for the NUL terminator, so at most 39 visible chars.
162 (truncate_to(formatted, VAL_VISIBLE_MAX), sec_past_epoch)
163 }
164}
165
166/// Truncate `s` to at most `max` bytes, respecting UTF-8 char boundaries.
167///
168/// C stores VAL/OVAL in a fixed `char[40]` buffer whose last byte is the
169/// NUL terminator, so at most 39 visible bytes survive. Timestamp format
170/// strings only ever emit ASCII, so this is a plain byte truncation in
171/// practice.
172fn truncate_to(mut s: String, max: usize) -> String {
173 if s.len() > max {
174 let mut cut = max;
175 while cut > 0 && !s.is_char_boundary(cut) {
176 cut -= 1;
177 }
178 s.truncate(cut);
179 }
180 s
181}
182
183impl Record for TimestampRecord {
184 fn record_type(&self) -> &'static str {
185 "timestamp"
186 }
187
188 fn process(&mut self) -> CaResult<ProcessOutcome> {
189 let (formatted, sec_past_epoch) = self.format_timestamp();
190 self.oval = std::mem::replace(&mut self.val, formatted);
191 self.rval = sec_past_epoch;
192 Ok(ProcessOutcome::complete())
193 }
194
195 fn get_field(&self, name: &str) -> Option<EpicsValue> {
196 match name {
197 "VAL" => Some(EpicsValue::String(self.val.clone())),
198 "OVAL" => Some(EpicsValue::String(self.oval.clone())),
199 "RVAL" => Some(EpicsValue::Long(self.rval)),
200 "TST" => Some(EpicsValue::Short(self.tst)),
201 _ => None,
202 }
203 }
204
205 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
206 match name {
207 "VAL" => match value {
208 EpicsValue::String(v) => {
209 // VAL is a `char[40]` field in C; the last byte is the
210 // NUL terminator, so 39 visible bytes at most.
211 self.val = truncate_to(v, VAL_VISIBLE_MAX);
212 Ok(())
213 }
214 _ => Err(CaError::TypeMismatch(name.into())),
215 },
216 "RVAL" => match value {
217 EpicsValue::Long(v) => {
218 self.rval = v;
219 Ok(())
220 }
221 _ => Err(CaError::TypeMismatch(name.into())),
222 },
223 "TST" => match value {
224 EpicsValue::Short(v) => {
225 // TST is a plain DBF_MENU field — C stores whatever
226 // value is written and `format_timestamp` selects
227 // the format via a `switch` whose `default:` branch
228 // covers any out-of-range value. Do NOT clamp here:
229 // C `timestampRecord.dbd` declares no field range,
230 // and a read-back must reflect the raw value.
231 self.tst = v;
232 Ok(())
233 }
234 _ => Err(CaError::TypeMismatch(name.into())),
235 },
236 "OVAL" => Err(CaError::ReadOnlyField(name.into())),
237 _ => Err(CaError::FieldNotFound(name.into())),
238 }
239 }
240
241 fn field_list(&self) -> &'static [FieldDesc] {
242 FIELDS
243 }
244
245 /// C `timestampRecord.c:90` reads `ptimestamp->tse`. The framework
246 /// owns `dbCommon.tse`; this hook captures it so `process()` can
247 /// take the device-time branch.
248 fn set_process_context(&mut self, ctx: &ProcessContext) {
249 self.tse = ctx.tse;
250 }
251
252 fn clears_udf(&self) -> bool {
253 true
254 }
255}