use epics_base_rs::error::{CaError, CaResult};
use epics_base_rs::server::record::{
EPICS_TIME_EVENT_DEVICE_TIME, FieldDesc, ProcessContext, ProcessOutcome, Record, ValuePostGate,
};
use epics_base_rs::types::{EpicsValue, PvString};
use super::dbd_generated;
use chrono::{Local, TimeZone};
const EPICS_EPOCH_OFFSET: i64 = 631152000;
const VAL_VISIBLE_MAX: usize = 39;
const TIMESTAMP_FORMATS: &[&str] = &[
"%y/%m/%d %H:%M:%S", "%m/%d/%y %H:%M:%S", "%b %d %H:%M:%S %y", "%b %d %H:%M:%S", "%H:%M:%S", "%H:%M", "%d/%m/%y %H:%M:%S", "%d %b %H:%M:%S %y", "%d-%b-%Y %H:%M:%S", ];
pub struct TimestampRecord {
pub val: PvString,
pub oval: PvString,
pub rval: u32,
pub tst: i16,
tse: i16,
val_changed: bool,
}
impl Default for TimestampRecord {
fn default() -> Self {
Self {
val: PvString::new(),
oval: PvString::new(),
rval: 0,
tst: 0,
tse: 0,
val_changed: false,
}
}
}
impl TimestampRecord {
fn format_timestamp(&self) -> (PvString, u32) {
let now = if self.tse == EPICS_TIME_EVENT_DEVICE_TIME {
let secs = Local::now().timestamp();
Local
.timestamp_opt(secs, 0)
.single()
.unwrap_or_else(Local::now)
} else {
Local::now()
};
let unix_secs = now.timestamp();
let sec_past_epoch = (unix_secs - EPICS_EPOCH_OFFSET) as u32;
if sec_past_epoch == 0 {
return (PvString::from("-NULL-"), sec_past_epoch);
}
let tst = self.tst;
let formatted = match tst {
0..=8 => now.format(TIMESTAMP_FORMATS[tst as usize]).to_string(),
9 | 10 => {
let ms = round_subsec_to_millis(now.timestamp_subsec_nanos());
let base = if tst == 9 {
now.format("%b %d %Y %H:%M:%S").to_string()
} else {
now.format("%m/%d/%y %H:%M:%S").to_string()
};
format!("{base}.{ms:03}")
}
_ => now.format(TIMESTAMP_FORMATS[0]).to_string(),
};
(
truncate_to(PvString::from(formatted), VAL_VISIBLE_MAX),
sec_past_epoch,
)
}
}
fn truncate_to(s: PvString, max: usize) -> PvString {
if s.len() > max {
PvString::from_bytes(s.as_bytes()[..max].to_vec())
} else {
s
}
}
fn round_subsec_to_millis(nsec: u32) -> u32 {
let frac = (nsec + 500_000).min(1_000_000_000 - 1);
frac / 1_000_000
}
impl Record for TimestampRecord {
fn record_type(&self) -> &'static str {
"timestamp"
}
fn process(&mut self) -> CaResult<ProcessOutcome> {
let (formatted, sec_past_epoch) = self.format_timestamp();
self.val_changed = formatted != self.val;
self.oval = std::mem::replace(&mut self.val, formatted);
self.rval = sec_past_epoch;
Ok(ProcessOutcome::complete())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::String(self.val.clone())),
"OVAL" => Some(EpicsValue::String(self.oval.clone())),
"RVAL" => Some(EpicsValue::ULong(self.rval)),
"TST" => Some(EpicsValue::Short(self.tst)),
_ => None,
}
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match name {
"VAL" => match value {
EpicsValue::String(v) => {
self.val = truncate_to(v, VAL_VISIBLE_MAX);
Ok(())
}
_ => Err(CaError::TypeMismatch(name.into())),
},
"RVAL" => match value {
EpicsValue::ULong(v) => {
self.rval = v;
Ok(())
}
_ => Err(CaError::TypeMismatch(name.into())),
},
"TST" => match value {
EpicsValue::Short(v) => {
self.tst = v;
Ok(())
}
_ => Err(CaError::TypeMismatch(name.into())),
},
"OVAL" => Err(CaError::ReadOnlyField(name.into())),
_ => Err(CaError::FieldNotFound(name.into())),
}
}
fn declared_fields(&self) -> &'static [FieldDesc] {
dbd_generated::TIMESTAMP_FIELDS
}
fn set_process_context(&mut self, ctx: &ProcessContext) {
self.tse = ctx.tse;
}
fn monitor_deadband_field(&self) -> &'static str {
""
}
fn monitor_value_changed(&self) -> Option<bool> {
Some(self.val_changed)
}
fn fields_posted_with_value_mask(&self) -> &'static [(&'static str, ValuePostGate)] {
&[("RVAL", ValuePostGate::WithValue)]
}
fn clears_udf(&self) -> bool {
true
}
}
#[cfg(test)]
mod subsec_round_tests {
use super::round_subsec_to_millis;
#[test]
fn rounds_to_nearest_millisecond() {
assert_eq!(round_subsec_to_millis(0), 0);
assert_eq!(round_subsec_to_millis(499_999), 0);
assert_eq!(round_subsec_to_millis(1_400_000), 1);
assert_eq!(round_subsec_to_millis(500_000), 1);
assert_eq!(round_subsec_to_millis(1_500_000), 2);
assert_eq!(round_subsec_to_millis(1_700_000), 2);
}
#[test]
fn clamps_instead_of_carrying_into_seconds() {
assert_eq!(round_subsec_to_millis(999_500_000), 999);
assert_eq!(round_subsec_to_millis(999_999_999), 999);
}
}
#[cfg(test)]
mod menu_choice_tests {
use super::{TimestampRecord, dbd_generated};
use epics_base_rs::server::record::FieldDeclaration;
use epics_base_rs::server::record::{Record, RecordInstance};
use epics_base_rs::types::EpicsValue;
#[test]
fn timestamp_tst_snapshot_is_enum_with_labels() {
let mut rec = TimestampRecord::default();
rec.put_field("TST", EpicsValue::Short(4)).unwrap(); let inst = RecordInstance::new("TS:TST".into(), rec);
let snap = inst.snapshot_for_field("TST").unwrap();
assert_eq!(snap.value, EpicsValue::Enum(4));
let strings = &snap.enums.as_ref().unwrap().strings;
assert_eq!(strings.len(), 11);
assert_eq!(strings[4], "HH:MM:SS");
}
#[test]
fn timestamp_tst_choices_come_from_the_declaration() {
let rec = TimestampRecord::default();
let tst = rec
.field_list()
.iter()
.find(|f| f.name == "TST")
.expect("TST is declared");
assert_eq!(tst.menu, Some(dbd_generated::MENU_TIMESTAMP_TST));
let val = rec
.field_list()
.iter()
.find(|f| f.name == "VAL")
.expect("VAL is declared");
assert_eq!(val.menu, None);
}
#[test]
fn timestamp_has_no_deadband_field_so_val_change_detects() {
let rec = TimestampRecord::default();
assert_eq!(rec.monitor_deadband_field(), "");
let inst = RecordInstance::new("TS:DB".into(), TimestampRecord::default());
assert_eq!(inst.resolve_field(""), None);
}
}