use std::time::{SystemTime, UNIX_EPOCH};
use epics_base_rs::error::CaResult;
use epics_base_rs::server::device_support::{DeviceReadOutcome, DeviceSupport};
use epics_base_rs::server::recgbl::get_time_stamp;
use epics_base_rs::server::record::{ProcessContext, Record};
use epics_base_rs::types::EpicsValue;
use chrono::{DateTime, Local};
const EPICS_EPOCH_OFFSET: u64 = 631152000;
fn epics_time_parts(ts: SystemTime) -> (u64, u32) {
let dur = ts.duration_since(UNIX_EPOCH).unwrap_or_default();
(
dur.as_secs().saturating_sub(EPICS_EPOCH_OFFSET),
dur.subsec_nanos(),
)
}
pub struct TimeOfDayStringDeviceSupport {
phas: i16,
tse: i16,
time: SystemTime,
}
impl Default for TimeOfDayStringDeviceSupport {
fn default() -> Self {
Self {
phas: 0,
tse: 0,
time: SystemTime::UNIX_EPOCH,
}
}
}
impl TimeOfDayStringDeviceSupport {
pub fn new() -> Self {
Self::default()
}
}
impl DeviceSupport for TimeOfDayStringDeviceSupport {
fn dtyp(&self) -> &str {
"Time of Day"
}
fn set_process_context(&mut self, ctx: &ProcessContext) {
self.phas = ctx.phas;
self.tse = ctx.tse;
self.time = ctx.time;
}
fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
let ts = get_time_stamp(self.tse, self.time);
let (sec_past_epoch, nsec) = epics_time_parts(ts);
let phas = self.phas;
let formatted = if sec_past_epoch == 0 && nsec == 0 {
"<undefined>".to_string()
} else {
let local: DateTime<Local> = DateTime::<Local>::from(ts);
if phas != 0 {
local.format("%m/%d/%y %H:%M:%S").to_string()
} else {
local.format("%b %d, %Y %H:%M:%S").to_string()
}
};
record.put_field("VAL", EpicsValue::String(formatted.into()))?;
Ok(DeviceReadOutcome::computed())
}
fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
Ok(())
}
}
pub struct SecPastEpochDeviceSupport {
phas: i16,
tse: i16,
time: SystemTime,
}
impl Default for SecPastEpochDeviceSupport {
fn default() -> Self {
Self {
phas: 0,
tse: 0,
time: SystemTime::UNIX_EPOCH,
}
}
}
impl SecPastEpochDeviceSupport {
pub fn new() -> Self {
Self::default()
}
}
impl DeviceSupport for SecPastEpochDeviceSupport {
fn dtyp(&self) -> &str {
"Sec Past Epoch"
}
fn set_process_context(&mut self, ctx: &ProcessContext) {
self.phas = ctx.phas;
self.tse = ctx.tse;
self.time = ctx.time;
}
fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
let ts = get_time_stamp(self.tse, self.time);
let (sec_past_epoch, nsec) = epics_time_parts(ts);
let phas = self.phas;
let val = if phas != 0 {
sec_past_epoch as f64 + (nsec as f64 / 1e9)
} else {
sec_past_epoch as f64
};
record.put_field("VAL", EpicsValue::Double(val))?;
Ok(DeviceReadOutcome::computed())
}
fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use epics_base_rs::server::record::ProcessContext;
use epics_base_rs::server::records::stringin::StringinRecord;
use std::time::Duration;
fn ctx_with_phas(phas: i16) -> ProcessContext {
ProcessContext {
udf: false,
udfs: epics_base_rs::server::record::AlarmSeverity::Invalid,
phas,
tse: 0,
time: SystemTime::UNIX_EPOCH,
tsel: String::new(),
dtyp: String::new(),
}
}
fn ctx_device_time(phas: i16, time: SystemTime) -> ProcessContext {
ProcessContext {
udf: false,
udfs: epics_base_rs::server::record::AlarmSeverity::Invalid,
phas,
tse: -2,
time,
tsel: String::new(),
dtyp: String::new(),
}
}
#[test]
fn time_of_day_phas_zero_uses_long_format() {
let mut dev = TimeOfDayStringDeviceSupport::new();
let mut rec = StringinRecord::new("");
dev.set_process_context(&ctx_with_phas(0));
dev.read(&mut rec).unwrap();
let val = match rec.get_field("VAL") {
Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
other => panic!("expected String VAL, got {other:?}"),
};
assert!(val.contains(','), "PHAS=0 long format has a comma: {val}");
assert!(
!val.contains('/'),
"PHAS=0 long format has no slashes: {val}"
);
}
#[test]
fn time_of_day_phas_nonzero_uses_slash_format() {
let mut dev = TimeOfDayStringDeviceSupport::new();
let mut rec = StringinRecord::new("");
dev.set_process_context(&ctx_with_phas(1));
dev.read(&mut rec).unwrap();
let val = match rec.get_field("VAL") {
Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
other => panic!("expected String VAL, got {other:?}"),
};
assert_eq!(
val.matches('/').count(),
2,
"PHAS!=0 slash format has two slashes: {val}"
);
assert!(
!val.contains(','),
"PHAS!=0 slash format has no comma: {val}"
);
}
#[test]
fn sec_past_epoch_phas_zero_is_whole_seconds() {
let mut dev = SecPastEpochDeviceSupport::new();
let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
dev.set_process_context(&ctx_with_phas(0));
dev.read(&mut rec).unwrap();
let val = match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => v,
other => panic!("expected Double VAL, got {other:?}"),
};
assert_eq!(
val.fract(),
0.0,
"PHAS=0 must yield whole seconds, got {val}"
);
}
#[test]
fn time_of_day_epoch_zero_is_undefined() {
let mut dev = TimeOfDayStringDeviceSupport::new();
let mut rec = StringinRecord::new("");
dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
dev.read(&mut rec).unwrap();
let val = match rec.get_field("VAL") {
Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
other => panic!("expected String VAL, got {other:?}"),
};
assert_eq!(val, "<undefined>", "epoch stamp must format as sentinel");
}
#[test]
fn time_of_day_epics_epoch_is_undefined() {
let mut dev = TimeOfDayStringDeviceSupport::new();
let mut rec = StringinRecord::new("");
let epics_epoch = SystemTime::UNIX_EPOCH + Duration::from_secs(EPICS_EPOCH_OFFSET);
dev.set_process_context(&ctx_device_time(0, epics_epoch));
dev.read(&mut rec).unwrap();
let val = match rec.get_field("VAL") {
Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
other => panic!("expected String VAL, got {other:?}"),
};
assert_eq!(val, "<undefined>", "EPICS-epoch stamp is also undefined");
}
#[test]
fn time_of_day_resolves_device_time_not_wall_clock() {
let mut dev = TimeOfDayStringDeviceSupport::new();
let mut rec = StringinRecord::new("");
let device_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
dev.set_process_context(&ctx_device_time(0, device_time));
dev.read(&mut rec).unwrap();
let val = match rec.get_field("VAL") {
Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
other => panic!("expected String VAL, got {other:?}"),
};
assert!(val.contains("2001"), "must format the device time: {val}");
assert_ne!(val, "<undefined>");
}
#[test]
fn sec_past_epoch_epoch_zero_is_zero() {
let mut dev = SecPastEpochDeviceSupport::new();
let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
dev.set_process_context(&ctx_device_time(0, SystemTime::UNIX_EPOCH));
dev.read(&mut rec).unwrap();
let val = match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => v,
other => panic!("expected Double VAL, got {other:?}"),
};
assert_eq!(val, 0.0, "epoch stamp yields secPastEpoch 0");
}
#[test]
fn sec_past_epoch_resolves_device_time_with_fraction() {
let device_time = SystemTime::UNIX_EPOCH
+ Duration::from_secs(1_000_000_000)
+ Duration::from_nanos(500_000_000);
let expected_secs = (1_000_000_000u64 - EPICS_EPOCH_OFFSET) as f64;
let mut dev = SecPastEpochDeviceSupport::new();
let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
dev.set_process_context(&ctx_device_time(0, device_time));
dev.read(&mut rec).unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs),
other => panic!("expected Double VAL, got {other:?}"),
};
let mut dev = SecPastEpochDeviceSupport::new();
let mut rec = epics_base_rs::server::records::ai::AiRecord::new(0.0);
dev.set_process_context(&ctx_device_time(1, device_time));
dev.read(&mut rec).unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert_eq!(v, expected_secs + 0.5),
other => panic!("expected Double VAL, got {other:?}"),
};
}
}