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, PvString};
6
7use chrono::{Local, TimeZone};
8
9/// Choice labels for the timestamp string-format menu, in index order.
10/// C `menu(timestampTST)` (std `timestampRecord.dbd`): `TST` selects which
11/// of these `strftime`-style formats `VAL` is rendered with. The
12/// index↔string mapping is wire-visible to clients.
13const TIMESTAMP_TST_CHOICES: &[&str] = &[
14 "YY/MM/DD HH:MM:SS",
15 "MM/DD/YY HH:MM:SS",
16 "Mon DD HH:MM:SS YY",
17 "Mon DD HH:MM:SS",
18 "HH:MM:SS",
19 "HH:MM",
20 "DD/MM/YY HH:MM:SS",
21 "DD Mon HH:MM:SS YY",
22 "DD-Mon-YYYY HH:MM:SS",
23 "Mon DD, YYYY HH:MM:SS.ns",
24 "MM/DD/YY HH:MM:SS.ns",
25];
26
27/// EPICS epoch: 1990-01-01 00:00:00 UTC
28const EPICS_EPOCH_OFFSET: i64 = 631152000;
29
30/// Maximum number of visible (non-NUL) bytes in the VAL/OVAL fields.
31///
32/// `timestampRecord.dbd` declares `VAL`/`OVAL` as `char val[40]`, and C
33/// `timestampRecord.c:140` calls `epicsTimeToStrftime(val, sizeof(val), ...)`.
34/// `epicsTimeToStrftime` wraps `strftime`, which writes at most
35/// `sizeof(val)` bytes *including* the terminating NUL — so the buffer
36/// holds at most 39 visible characters. A Rust `String` carries no NUL
37/// terminator, so the visible-byte bound is 39, not 40.
38const VAL_VISIBLE_MAX: usize = 39;
39
40/// Timestamp format strings indexed by TST field value.
41///
42/// Mirrors the `switch(tst)` in `timestampRecord.c:100-138`. Any TST value
43/// outside `0..=10` falls through C's `default:` branch to format 0
44/// (`YY/MM/DD HH:MM:SS`).
45const TIMESTAMP_FORMATS: &[&str] = &[
46 "%y/%m/%d %H:%M:%S", // 0 timestampTST_YY_MM_DD_HH_MM_SS
47 "%m/%d/%y %H:%M:%S", // 1 timestampTST_MM_DD_YY_HH_MM_SS
48 "%b %d %H:%M:%S %y", // 2 timestampTST_MM_DD_HH_MM_SS_YY
49 "%b %d %H:%M:%S", // 3 timestampTST_MM_DD_HH_MM_SS
50 "%H:%M:%S", // 4 timestampTST_HH_MM_SS
51 "%H:%M", // 5 timestampTST_HH_MM
52 "%d/%m/%y %H:%M:%S", // 6 timestampTST_DD_MM_YY_HH_MM_SS
53 "%d %b %H:%M:%S %y", // 7 timestampTST_DD_MM_HH_MM_SS_YY
54 "%d-%b-%Y %H:%M:%S", // 8 timestampTST_VMS
55];
56
57/// Timestamp record — generates formatted timestamp strings.
58///
59/// Ported from EPICS std module `timestampRecord.c`.
60pub struct TimestampRecord {
61 /// Current formatted timestamp string (VAL).
62 pub val: PvString,
63 /// Previous value for change detection (OVAL).
64 pub oval: PvString,
65 /// Seconds past EPICS epoch (RVAL). DBF_ULONG in C; the Rust value
66 /// model has no unsigned-32 scalar, so this follows the project
67 /// convention of mapping DBF_ULONG to `i32`/`EpicsValue::Long`.
68 pub rval: i32,
69 /// Timestamp format selector (TST), a DBF_MENU. Values `0..=10`
70 /// select an explicit format; any other value is rendered with
71 /// format 0 (C `switch` `default:` branch).
72 pub tst: i16,
73 /// Framework-owned `dbCommon.tse`, pushed via
74 /// [`Record::set_process_context`] before `process()`. C
75 /// `timestampRecord.c:90` branches on
76 /// `tse == epicsTimeEventDeviceTime`: device-time takes the raw OS
77 /// clock (`epicsTimeFromTime_t(&time, time(0))`, whole seconds, no
78 /// fraction); any other value uses the EPICS time-stamp framework.
79 tse: i16,
80}
81
82impl Default for TimestampRecord {
83 fn default() -> Self {
84 Self {
85 val: PvString::new(),
86 oval: PvString::new(),
87 rval: 0,
88 tst: 0,
89 tse: 0,
90 }
91 }
92}
93
94static FIELDS: &[FieldDesc] = &[
95 FieldDesc {
96 name: "VAL",
97 dbf_type: DbFieldType::String,
98 read_only: false,
99 },
100 FieldDesc {
101 name: "OVAL",
102 dbf_type: DbFieldType::String,
103 read_only: true,
104 },
105 FieldDesc {
106 name: "RVAL",
107 dbf_type: DbFieldType::Long,
108 read_only: false,
109 },
110 FieldDesc {
111 name: "TST",
112 dbf_type: DbFieldType::Short,
113 read_only: false,
114 },
115];
116
117impl TimestampRecord {
118 fn format_timestamp(&self) -> (PvString, i32) {
119 // C `timestampRecord.c:90-93`: `tse == epicsTimeEventDeviceTime`
120 // takes the raw OS clock via `epicsTimeFromTime_t(&time, time(0))`
121 // — whole seconds only, the nanosecond field is zero. Any other
122 // TSE value goes through `recGblGetTimeStamp`, which carries
123 // sub-second precision. The Rust port mirrors the observable
124 // difference: device-time truncates `now` to whole seconds so
125 // the `.%03f` formats (TST 9/10) render `.000`.
126 let now = if self.tse == EPICS_TIME_EVENT_DEVICE_TIME {
127 let secs = Local::now().timestamp();
128 // `timestamp_opt(secs, 0)` is always `Single` for any
129 // in-range Unix second; fall back to the un-truncated clock
130 // on the impossible `None`/`Ambiguous` case rather than
131 // panicking.
132 Local
133 .timestamp_opt(secs, 0)
134 .single()
135 .unwrap_or_else(Local::now)
136 } else {
137 Local::now()
138 };
139 let unix_secs = now.timestamp();
140 let sec_past_epoch = (unix_secs - EPICS_EPOCH_OFFSET) as i32;
141
142 // C `timestampRecord.c:96`: `if (time.secPastEpoch == 0)` — the
143 // "-NULL-" sentinel is emitted only when the EPICS-epoch second
144 // count is exactly zero (an uninitialised/unset time stamp), not
145 // for any non-positive value.
146 if sec_past_epoch == 0 {
147 return (PvString::from("-NULL-"), sec_past_epoch);
148 }
149
150 // C `timestampRecord.c:100-138`: any TST outside the valid menu
151 // range falls through `default:` to format 0. The raw TST value
152 // is preserved (the field is a plain menu); only the format
153 // *selection* is bounded here.
154 let tst = self.tst;
155
156 let formatted = match tst {
157 0..=8 => now.format(TIMESTAMP_FORMATS[tst as usize]).to_string(),
158 // Formats 9 (timestampTST_MM_DD_YYYY) and 10
159 // (timestampTST_MM_DD_YY) carry `.%03f` fractional seconds.
160 // C `timestampRecord.c:130,133`. EPICS `%03f` is the
161 // 3-digit fractional-seconds field derived from the time
162 // stamp's nanoseconds; `subsec_millis()` is the equivalent
163 // 3-digit truncation of the same fraction.
164 9 | 10 => {
165 // C `epicsTime.cpp:234-239`: the `%03f` fractional field
166 // ROUNDS to the nearest millisecond (see
167 // `round_subsec_to_millis`). `timestamp_subsec_millis()`
168 // (= nsec / 1e6) truncates instead, shifting every value
169 // on a half-ms boundary down by one.
170 let ms = round_subsec_to_millis(now.timestamp_subsec_nanos());
171 let base = if tst == 9 {
172 now.format("%b %d %Y %H:%M:%S").to_string()
173 } else {
174 now.format("%m/%d/%y %H:%M:%S").to_string()
175 };
176 format!("{base}.{ms:03}")
177 }
178 // C `default:` branch — format 0 (`YY/MM/DD HH:MM:SS`).
179 _ => now.format(TIMESTAMP_FORMATS[0]).to_string(),
180 };
181
182 // C `timestampRecord.c:140` `epicsTimeToStrftime(val, sizeof(val), ...)`
183 // bounds the result to the `char val[40]` buffer; `strftime` keeps
184 // one byte for the NUL terminator, so at most 39 visible chars.
185 (
186 truncate_to(PvString::from(formatted), VAL_VISIBLE_MAX),
187 sec_past_epoch,
188 )
189 }
190}
191
192/// Truncate `s` to at most `max` bytes.
193///
194/// C stores VAL/OVAL in a fixed `char[40]` buffer whose last byte is the
195/// NUL terminator, so at most 39 visible bytes survive. C `strftime`
196/// truncates the buffer byte for byte, so this cut is on a raw byte
197/// boundary and a non-UTF-8 VAL written by a client round-trips verbatim.
198fn truncate_to(s: PvString, max: usize) -> PvString {
199 if s.len() > max {
200 PvString::from_bytes(s.as_bytes()[..max].to_vec())
201 } else {
202 s
203 }
204}
205
206/// Round a sub-second nanosecond count to a 3-digit millisecond field.
207///
208/// C `epicsTime.cpp:234-239` renders the `%03f` fractional field by
209/// ROUNDING the nanoseconds to the nearest millisecond, with a clamp
210/// that prevents the rounded value from carrying into whole seconds:
211/// ```text
212/// frac = nsec + div[fracWid]/2; // div[3] = 1e6, so +5e5
213/// if (frac >= 1000000000) frac = 1000000000 - 1;
214/// frac /= div[fracWid]; // /1e6 -> 0..=999
215/// ```
216/// A naive `nsec / 1_000_000` truncates, biasing every value on a
217/// half-millisecond boundary down by one (e.g. 1.7 ms → `.001` instead
218/// of `.002`). The `min` clamp keeps a near-`1e9` nanosecond count from
219/// rounding up to `1000` ms (which would need a carry into the seconds
220/// field C never performs here).
221fn round_subsec_to_millis(nsec: u32) -> u32 {
222 let frac = (nsec + 500_000).min(1_000_000_000 - 1);
223 frac / 1_000_000
224}
225
226impl Record for TimestampRecord {
227 fn record_type(&self) -> &'static str {
228 "timestamp"
229 }
230
231 fn process(&mut self) -> CaResult<ProcessOutcome> {
232 let (formatted, sec_past_epoch) = self.format_timestamp();
233 self.oval = std::mem::replace(&mut self.val, formatted);
234 self.rval = sec_past_epoch;
235 Ok(ProcessOutcome::complete())
236 }
237
238 fn get_field(&self, name: &str) -> Option<EpicsValue> {
239 match name {
240 "VAL" => Some(EpicsValue::String(self.val.clone())),
241 "OVAL" => Some(EpicsValue::String(self.oval.clone())),
242 "RVAL" => Some(EpicsValue::Long(self.rval)),
243 "TST" => Some(EpicsValue::Short(self.tst)),
244 _ => None,
245 }
246 }
247
248 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
249 match name {
250 "VAL" => match value {
251 EpicsValue::String(v) => {
252 // VAL is a `char[40]` field in C; the last byte is the
253 // NUL terminator, so 39 visible bytes at most.
254 self.val = truncate_to(v, VAL_VISIBLE_MAX);
255 Ok(())
256 }
257 _ => Err(CaError::TypeMismatch(name.into())),
258 },
259 "RVAL" => match value {
260 EpicsValue::Long(v) => {
261 self.rval = v;
262 Ok(())
263 }
264 _ => Err(CaError::TypeMismatch(name.into())),
265 },
266 "TST" => match value {
267 EpicsValue::Short(v) => {
268 // TST is a plain DBF_MENU field — C stores whatever
269 // value is written and `format_timestamp` selects
270 // the format via a `switch` whose `default:` branch
271 // covers any out-of-range value. Do NOT clamp here:
272 // C `timestampRecord.dbd` declares no field range,
273 // and a read-back must reflect the raw value.
274 self.tst = v;
275 Ok(())
276 }
277 _ => Err(CaError::TypeMismatch(name.into())),
278 },
279 "OVAL" => Err(CaError::ReadOnlyField(name.into())),
280 _ => Err(CaError::FieldNotFound(name.into())),
281 }
282 }
283
284 fn field_list(&self) -> &'static [FieldDesc] {
285 FIELDS
286 }
287
288 /// `TST` is `DBF_MENU menu(timestampTST)` (std `timestampRecord.dbd`);
289 /// served as `DBR_ENUM` with the format-string choice labels.
290 fn menu_field_choices(&self, field: &str) -> Option<&'static [&'static str]> {
291 match field {
292 "TST" => Some(TIMESTAMP_TST_CHOICES),
293 _ => None,
294 }
295 }
296
297 /// C `timestampRecord.c:90` reads `ptimestamp->tse`. The framework
298 /// owns `dbCommon.tse`; this hook captures it so `process()` can
299 /// take the device-time branch.
300 fn set_process_context(&mut self, ctx: &ProcessContext) {
301 self.tse = ctx.tse;
302 }
303
304 /// The timestamp record has NO value deadband: its monitored
305 /// quantity is the formatted `VAL` string, and `timestampRecord.dbd`
306 /// declares no MDEL/ADEL. C `monitor()` (`timestampRecord.c:152-163`)
307 /// posts `VAL` (and `RVAL`) only inside
308 /// `if (strncmp(oval, val, sizeof(val)))` — i.e. exactly when the
309 /// formatted string changed since the previous process — then copies
310 /// `val` into `oval`. That is plain change-detection, not a deadband.
311 ///
312 /// The framework's snapshot builders force-post the deadband field on
313 /// every cycle the deadband gate fires (and the gate always fires for
314 /// a non-numeric value — see [`RecordInstance::check_deadband_ext`],
315 /// whose `to_f64()` returns `None` for a string `VAL`). Returning the
316 /// default `"VAL"` here would therefore re-post `VAL` on every scan,
317 /// even when the rendered string is unchanged — diverging from C's
318 /// `strncmp` gate. Returning `""` (a name no field resolves to)
319 /// suppresses that force-post (`resolve_field("")` is `None`, so the
320 /// `if let Some(val) = dval` push is skipped) and routes `VAL`
321 /// through the generic change-detection loop, which posts it only
322 /// when it differs from the last posted value — matching C exactly.
323 /// `RVAL` already change-detects through that same loop.
324 fn monitor_deadband_field(&self) -> &'static str {
325 ""
326 }
327
328 fn clears_udf(&self) -> bool {
329 true
330 }
331}
332
333#[cfg(test)]
334mod subsec_round_tests {
335 use super::round_subsec_to_millis;
336
337 // C `epicsTime.cpp:234-239` rounds the `%03f` fractional field to
338 // the nearest millisecond; the previous `nsec / 1e6` truncated.
339 #[test]
340 fn rounds_to_nearest_millisecond() {
341 // Below the half-ms point: rounds down.
342 assert_eq!(round_subsec_to_millis(0), 0);
343 assert_eq!(round_subsec_to_millis(499_999), 0);
344 assert_eq!(round_subsec_to_millis(1_400_000), 1);
345 // Exactly half a millisecond: C's `+ div/2` rounds up.
346 assert_eq!(round_subsec_to_millis(500_000), 1);
347 assert_eq!(round_subsec_to_millis(1_500_000), 2);
348 // Above the half-ms point: rounds up — the case truncation got
349 // wrong (1.7 ms truncated to .001, now rounds to .002).
350 assert_eq!(round_subsec_to_millis(1_700_000), 2);
351 }
352
353 // The clamp keeps a near-1e9 nanosecond count from rounding up to
354 // 1000 ms (C `if (frac >= 1e9) frac = 1e9 - 1`), which would need a
355 // carry into the seconds field the record never performs.
356 #[test]
357 fn clamps_instead_of_carrying_into_seconds() {
358 assert_eq!(round_subsec_to_millis(999_500_000), 999);
359 assert_eq!(round_subsec_to_millis(999_999_999), 999);
360 }
361}
362
363#[cfg(test)]
364mod menu_choice_tests {
365 use super::{TIMESTAMP_TST_CHOICES, TimestampRecord};
366 use epics_base_rs::server::record::{Record, RecordInstance};
367 use epics_base_rs::types::EpicsValue;
368
369 // TST is menu(timestampTST) served as Short; the base snapshot path
370 // promotes it to DBR_ENUM and attaches the wire-visible format labels.
371 #[test]
372 fn timestamp_tst_snapshot_is_enum_with_labels() {
373 let mut rec = TimestampRecord::default();
374 rec.put_field("TST", EpicsValue::Short(4)).unwrap(); // HH:MM:SS
375 let inst = RecordInstance::new("TS:TST".into(), rec);
376
377 let snap = inst.snapshot_for_field("TST").unwrap();
378 assert_eq!(snap.value, EpicsValue::Enum(4));
379 let strings = &snap.enums.as_ref().unwrap().strings;
380 assert_eq!(strings.len(), 11);
381 assert_eq!(strings[4], "HH:MM:SS");
382 }
383
384 #[test]
385 fn timestamp_menu_field_choices_match_dbd() {
386 let rec = TimestampRecord::default();
387 assert_eq!(rec.menu_field_choices("TST"), Some(TIMESTAMP_TST_CHOICES));
388 assert_eq!(rec.menu_field_choices("VAL"), None);
389 }
390
391 // C `timestampRecord.c:152-163`: `monitor()` posts VAL (and RVAL)
392 // only inside `if (strncmp(oval, val))` — when the formatted string
393 // changed. There is no value deadband. The record routes VAL through
394 // the framework's generic change-detection loop by reporting an
395 // empty deadband field; the framework's deadband force-post is then
396 // skipped because that name resolves to nothing.
397 #[test]
398 fn timestamp_has_no_deadband_field_so_val_change_detects() {
399 let rec = TimestampRecord::default();
400 // No numeric value deadband — the sentinel routes VAL to the
401 // change-detection loop (cf. motor's "RBV").
402 assert_eq!(rec.monitor_deadband_field(), "");
403
404 // The framework's deadband force-post fires only
405 // `if let Some(val) = resolve_field(deadband_field)`. The "" name
406 // must resolve to None so VAL is never force-posted on an
407 // unchanged-string cycle.
408 let inst = RecordInstance::new("TS:DB".into(), TimestampRecord::default());
409 assert_eq!(inst.resolve_field(""), None);
410 }
411}