use epics_base_rs::error::{CaError, CaResult};
use epics_base_rs::server::record::{
EPICS_TIME_EVENT_DEVICE_TIME, FieldDesc, ProcessContext, ProcessOutcome, Record,
};
use epics_base_rs::types::{DbFieldType, EpicsValue, PvString};
use chrono::{Local, TimeZone};
const TIMESTAMP_TST_CHOICES: &[&str] = &[
"YY/MM/DD HH:MM:SS",
"MM/DD/YY HH:MM:SS",
"Mon DD HH:MM:SS YY",
"Mon DD HH:MM:SS",
"HH:MM:SS",
"HH:MM",
"DD/MM/YY HH:MM:SS",
"DD Mon HH:MM:SS YY",
"DD-Mon-YYYY HH:MM:SS",
"Mon DD, YYYY HH:MM:SS.ns",
"MM/DD/YY HH:MM:SS.ns",
];
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: i32,
pub tst: i16,
tse: i16,
}
impl Default for TimestampRecord {
fn default() -> Self {
Self {
val: PvString::new(),
oval: PvString::new(),
rval: 0,
tst: 0,
tse: 0,
}
}
}
static FIELDS: &[FieldDesc] = &[
FieldDesc {
name: "VAL",
dbf_type: DbFieldType::String,
read_only: false,
},
FieldDesc {
name: "OVAL",
dbf_type: DbFieldType::String,
read_only: true,
},
FieldDesc {
name: "RVAL",
dbf_type: DbFieldType::Long,
read_only: false,
},
FieldDesc {
name: "TST",
dbf_type: DbFieldType::Short,
read_only: false,
},
];
impl TimestampRecord {
fn format_timestamp(&self) -> (PvString, i32) {
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 i32;
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 = now.timestamp_subsec_millis();
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
}
}
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.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::Long(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::Long(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 field_list(&self) -> &'static [FieldDesc] {
FIELDS
}
fn menu_field_choices(&self, field: &str) -> Option<&'static [&'static str]> {
match field {
"TST" => Some(TIMESTAMP_TST_CHOICES),
_ => None,
}
}
fn set_process_context(&mut self, ctx: &ProcessContext) {
self.tse = ctx.tse;
}
fn clears_udf(&self) -> bool {
true
}
}
#[cfg(test)]
mod menu_choice_tests {
use super::{TIMESTAMP_TST_CHOICES, TimestampRecord};
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_menu_field_choices_match_dbd() {
let rec = TimestampRecord::default();
assert_eq!(rec.menu_field_choices("TST"), Some(TIMESTAMP_TST_CHOICES));
assert_eq!(rec.menu_field_choices("VAL"), None);
}
}