use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use crate::error::{CaError, CaResult};
use crate::server::event_queue::{EventReader, EventUser};
use crate::server::pv::{MonitorEvent, Subscriber};
use crate::server::recgbl::EventMask;
use crate::server::snapshot::{
ControlInfo, DisplayInfo, EnumInfo, EnumStringForm, PropertySupport,
};
use crate::types::{DbFieldType, EpicsValue, PvString, c_parse};
use super::alarm::{AlarmSeverity, AnalogAlarmConfig};
use super::common_fields::CommonFields;
use super::link::{
ParsedLink, out_link_discards_cp, parse_forward_link_v2, parse_link_v2, parse_output_link_v2,
};
use super::menu_choices::MenuBound;
use super::record_trait::{
AuxPostMask, CommonFieldPutResult, FieldDeclaration, FieldDesc, ProcessSnapshot, Record,
RecordProcessResult, SubroutineFn,
};
use super::scan::{ScanType, SimModeScan};
const DBCOMMON_NOMOD: &[&str] = &[
"NAME", "STAT", "SEVR", "AMSG", "NSTA", "NSEV", "NAMSG", "ACKS", "ACKT", "LCNT", "PACT",
"PUTF", "RPRO", "TIME", "UTAG",
];
thread_local! {
static AMBIENT_WRITE_ORIGIN: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
pub struct AmbientWriteOriginScope {
prev: u64,
}
pub fn ambient_write_origin_scope(origin: u64) -> AmbientWriteOriginScope {
let prev = AMBIENT_WRITE_ORIGIN.with(|c| c.replace(origin));
AmbientWriteOriginScope { prev }
}
impl Drop for AmbientWriteOriginScope {
fn drop(&mut self) {
AMBIENT_WRITE_ORIGIN.with(|c| c.set(self.prev));
}
}
pub(crate) fn ambient_write_origin() -> u64 {
AMBIENT_WRITE_ORIGIN.with(|c| c.get())
}
pub struct NotifyWaitSet {
pending: AtomicUsize,
tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
}
impl NotifyWaitSet {
pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
Arc::new(Self {
pending: AtomicUsize::new(1),
tx: StdMutex::new(Some(tx)),
})
}
pub fn enter(&self) {
self.pending.fetch_add(1, Ordering::AcqRel);
}
pub fn leave(&self) {
let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
if prev == 1 {
if let Some(tx) = self.tx.lock().unwrap().take() {
let _ = tx.send(());
}
}
}
pub fn completed(&self) -> bool {
self.pending.load(Ordering::Acquire) == 0
}
}
#[derive(Debug)]
pub enum ProcessCompletion {
Sync,
Async(crate::runtime::sync::oneshot::Receiver<()>),
}
impl ProcessCompletion {
pub(crate) fn from_signal(rx: Option<crate::runtime::sync::oneshot::Receiver<()>>) -> Self {
match rx {
Some(rx) => Self::Async(rx),
None => Self::Sync,
}
}
pub fn into_handle(self) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
match self {
Self::Sync => None,
Self::Async(rx) => Some(rx),
}
}
pub fn is_async(&self) -> bool {
matches!(self, Self::Async(_))
}
pub fn is_sync(&self) -> bool {
matches!(self, Self::Sync)
}
}
pub struct DeferredNotifyPut {
pub field: String,
pub value: crate::types::EpicsValue,
pub completion: crate::runtime::sync::oneshot::Sender<()>,
}
#[must_use = "a put-notify parked on this record is stranded unless the PactExit \
reaches PvDatabase::apply_pact_exit"]
pub struct PactExit(Option<DeferredNotifyPut>);
impl Drop for PactExit {
fn drop(&mut self) {
if self.0.is_some() {
tracing::error!(
"PactExit dropped with a put-notify still parked: a PACT release \
path is not routed through PvDatabase::apply_pact_exit"
);
}
}
}
impl PactExit {
pub(crate) fn into_deferred(mut self) -> Option<DeferredNotifyPut> {
self.0.take()
}
pub(crate) fn merge(mut self, mut other: PactExit) -> PactExit {
debug_assert!(
self.0.is_none() || other.0.is_none(),
"a parked put-notify can be released only once per cycle"
);
PactExit(self.0.take().or_else(|| other.0.take()))
}
pub(crate) fn none() -> PactExit {
PactExit(None)
}
}
#[derive(Clone, Default)]
pub(crate) struct MetadataSnapshot {
pub display: Option<DisplayInfo>,
pub control: Option<ControlInfo>,
pub enums: Option<EnumInfo>,
}
fn is_metadata_field(name: &str) -> bool {
matches!(
name,
"EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
| "HIHI" | "HIGH" | "LOW" | "LOLO"
| "HHSV" | "HSV" | "LSV" | "LLSV"
| "DRVH" | "DRVL"
| "VBAS" | "VMAX" | "MRES" | "DHLM" | "DLLM"
| "ZNAM" | "ONAM"
| "ZSV" | "OSV" | "COSV"
| "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
| "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
)
}
fn gated(severity: i16, limit: f64) -> f64 {
if severity != 0 { limit } else { f64::NAN }
}
fn menu_ordinal_raw(value: &EpicsValue) -> i16 {
match value {
EpicsValue::String(s) => match s.as_str_lossy().as_ref() {
"NO_ALARM" => 0,
"MINOR" => 1,
"MAJOR" => 2,
"INVALID" => 3,
other => other
.parse::<i64>()
.ok()
.map(|n| n as u16 as i16)
.unwrap_or(0),
},
other => other.to_f64().unwrap_or(0.0) as i64 as u16 as i16,
}
}
fn epics_parse_int32_base10(s: &str) -> Option<i32> {
let body = s.trim_start_matches(|c: char| c.is_ascii_whitespace());
let (sign, digits) = match body.strip_prefix(['+', '-']) {
Some(rest) if body.starts_with('-') => (-1i64, rest),
Some(rest) => (1i64, rest),
None => (1i64, body),
};
let end = digits
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(digits.len());
if end == 0 {
return None; }
if !digits[end..]
.trim_start_matches(|c: char| c.is_ascii_whitespace())
.is_empty()
{
return None;
}
let magnitude: i64 = digits[..end].parse().ok()?;
i32::try_from(sign * magnitude).ok()
}
fn stored_common_field_type(name: &str) -> Option<DbFieldType> {
Some(match name {
"SCAN" | "SSCN" | "PINI" => DbFieldType::Enum,
"TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" | "ACKS"
| "SEVR" | "STAT" | "NSEV" | "NSTA" => DbFieldType::Short,
"HIHI" | "HIGH" | "LOW" | "LOLO" | "HYST" => DbFieldType::Double,
"DISP" | "UDF" | "TPRO" | "RPRO" | "BKPT" | "PROC" => DbFieldType::Char,
_ => return None,
})
}
fn coerce_common_field(name: &str, value: EpicsValue, bound: MenuBound) -> CaResult<EpicsValue> {
let Some(dbf) = stored_common_field_type(name) else {
return Ok(value);
};
let EpicsValue::String(s) = &value else {
return Ok(value.convert_to(dbf));
};
let text = s.as_str_lossy();
if let Some(choices) = super::menu_choices::shared_menu_choices(name) {
return super::menu_choices::resolve_menu_field_string_bounded(
name, choices, dbf, &text, bound,
);
}
let declared = match dbf {
DbFieldType::Char => DbFieldType::UChar,
other => other,
};
let Some(target) = c_parse::NumericField::of(declared) else {
return Ok(value);
};
c_parse::put_string(name, target, text.trim()).map(|parsed| parsed.convert_to(dbf))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlarmAck {
Transient,
Severity,
}
pub struct RecordInstance {
pub name: String,
pub record: Box<dyn Record>,
pub common: CommonFields,
pub subscribers: HashMap<String, Vec<Subscriber>>,
pub parsed_inp: ParsedLink,
pub parsed_out: ParsedLink,
pub parsed_flnk: ParsedLink,
pub parsed_sdis: ParsedLink,
pub parsed_tsel: ParsedLink,
pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
pub subroutine: Option<Arc<SubroutineFn>>,
pact: AtomicBool,
pub notify: Option<Arc<NotifyWaitSet>>,
deferred_notify_put: Option<DeferredNotifyPut>,
last_posted: HashMap<String, EpicsValue>,
declared_overrides: HashMap<String, EpicsValue>,
pub(crate) array_hash_changed: bool,
pub(crate) suppress_subroutine_run: bool,
pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
pub watchdog_generation: Arc<std::sync::atomic::AtomicU64>,
pub info: HashMap<String, String>,
pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
}
const SUBROUTINE_STATUS_SKIPPED: i64 = -1;
const SUBROUTINE_STATUS_NO_SUB: i64 = -2;
const SUBROUTINE_STATUS_ERROR: i64 = -3;
pub(crate) struct DeadbandPost {
pub mask: EventMask,
pub field: Option<(String, EpicsValue)>,
}
pub(crate) fn field_desc_of<R: Record + ?Sized>(
record: &R,
field: &str,
) -> Option<&'static FieldDesc> {
let named = |t: &'static [FieldDesc]| t.iter().find(|f| f.name.eq_ignore_ascii_case(field));
named(record.field_list()).or_else(|| named(super::dbd_generated::DB_COMMON_FIELDS))
}
pub(crate) fn menu_choices_of<R: Record + ?Sized>(
record: &R,
field: &str,
) -> Option<&'static [&'static str]> {
if field.eq_ignore_ascii_case("DTYP") {
return super::dbd_generated::device_menu(record.record_type());
}
let desc = field_desc_of(record, field);
desc.and_then(|f| f.menu)
.or_else(|| record.menu_field_choices(field))
.or_else(|| {
if desc.is_some_and(|f| f.dbf_type == DbFieldType::String) {
None
} else {
super::menu_choices::shared_menu_choices(field)
}
})
}
pub(crate) fn declared_field_type_of<R: Record + ?Sized>(
record: &R,
field: &str,
) -> Option<DbFieldType> {
let desc = field_desc_of(record, field)?;
(!desc.runtime_typed).then_some(desc.dbf_type)
}
pub(crate) fn value_as_dbr_string(value: &EpicsValue) -> Option<PvString> {
match value {
EpicsValue::String(s) => Some(s.clone()),
EpicsValue::Enum(v) => Some(PvString::from(v.to_string())),
EpicsValue::EnumWithChoices { index, choices } => Some(
choices
.get(*index as usize)
.cloned()
.unwrap_or_else(|| PvString::from(index.to_string())),
),
other => match other.clone().convert_to(DbFieldType::String) {
EpicsValue::String(s) => Some(s),
_ => None,
},
}
}
pub const COMMON_LINK_FIELDS: [(&str, super::link::LinkFieldType); 5] = [
("INP", super::link::LinkFieldType::In),
("OUT", super::link::LinkFieldType::Out),
("TSEL", super::link::LinkFieldType::In),
("SDIS", super::link::LinkFieldType::In),
("FLNK", super::link::LinkFieldType::Fwd),
];
impl RecordInstance {
pub fn new(name: String, record: impl Record) -> Self {
Self::new_boxed(name, Box::new(record))
}
pub fn common_link_text(&self, field: &str) -> Option<&str> {
Some(match field {
"INP" => self.common.inp.as_str(),
"OUT" => self.common.out.as_str(),
"TSEL" => self.common.tsel.as_str(),
"SDIS" => self.common.sdis.as_str(),
"FLNK" => self.common.flnk.as_str(),
_ => return None,
})
}
pub fn common_link_cache_mut(&mut self, field: &str) -> Option<&mut ParsedLink> {
Some(match field {
"INP" => &mut self.parsed_inp,
"OUT" => &mut self.parsed_out,
"TSEL" => &mut self.parsed_tsel,
"SDIS" => &mut self.parsed_sdis,
"FLNK" => &mut self.parsed_flnk,
_ => return None,
})
}
pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
let rtype = record.record_type();
let analog_alarm = match rtype {
"ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
| "sub" | "scalcout" => Some(AnalogAlarmConfig::default()),
_ => None,
};
let mut common = CommonFields::default();
common.analog_alarm = analog_alarm;
Self {
name,
record,
common,
subscribers: HashMap::new(),
parsed_inp: ParsedLink::None,
parsed_out: ParsedLink::None,
parsed_flnk: ParsedLink::None,
parsed_sdis: ParsedLink::None,
parsed_tsel: ParsedLink::None,
device: None,
subroutine: None,
pact: AtomicBool::new(false),
notify: None,
deferred_notify_put: None,
last_posted: HashMap::new(),
declared_overrides: HashMap::new(),
array_hash_changed: false,
suppress_subroutine_run: false,
reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
watchdog_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
info: HashMap::new(),
metadata_cache: StdMutex::new(None),
}
}
pub(crate) fn run_init_passes(&mut self, name: &str) {
let deferred = self.leave_pact().into_deferred();
debug_assert!(
deferred.is_none(),
"a record cannot hold a parked put-notify at init"
);
drop(deferred);
if self.common.udf != 0
&& self.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM
{
self.common.sevr = AlarmSeverity::from_u16(self.common.udfs as u16);
}
if let Err(e) = self.record.init_record(0) {
eprintln!("init_record(0) failed for {name}: {e}");
}
if let Err(e) = self.record.init_record(1) {
eprintln!("init_record(1) failed for {name}: {e}");
}
let mut udf = self.common.udf != 0;
if let Err(e) = self.record.post_init_finalize_undef(&mut udf) {
eprintln!("post_init_finalize_undef failed for {name}: {e}");
}
self.common.udf = udf as u8;
if self.record.init_resets_alarms() {
self.common.udf = 0;
let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
}
if self.record.init_record_parks_pact() {
self.enter_pact();
}
}
pub fn soft_output_value(&self) -> Option<Option<EpicsValue>> {
if self.common.dtyp == "Raw Soft Channel" {
return Some(
self.record
.raw_soft_output_value()
.or_else(|| self.record.output_link_value()),
);
}
if self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel" {
return Some(self.record.output_link_value());
}
None
}
pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.info.insert(key.into(), value.into());
}
pub fn get_info(&self, key: &str) -> Option<&str> {
self.info.get(key).map(|s| s.as_str())
}
pub(crate) fn posted_value(&self, field: &str) -> Option<&EpicsValue> {
self.last_posted.get(field)
}
pub(crate) fn record_value_post(&mut self, field: &str, value: EpicsValue) {
if let Some(slot) = self.last_posted.get_mut(field) {
*slot = value;
} else {
self.last_posted.insert(field.to_string(), value);
}
}
pub fn invalidate_metadata_cache(&self) {
if let Ok(mut guard) = self.metadata_cache.lock() {
*guard = None;
}
}
pub fn notify_field_written(&self, field: &str) {
let upper = field.to_ascii_uppercase();
if is_metadata_field(&upper) {
self.invalidate_metadata_cache();
}
}
pub fn notify_field_written_if_changed(&mut self, field: &str, prev: Option<&EpicsValue>) {
let upper = field.to_ascii_uppercase();
if !is_metadata_field(&upper) {
return;
}
let now = self.record.get_field(&upper);
if prev != now.as_ref() {
self.invalidate_metadata_cache();
let fields: Vec<String> = self.subscribers.keys().cloned().collect();
for f in fields {
self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
}
}
}
fn cached_metadata(&self) -> MetadataSnapshot {
if let Ok(guard) = self.metadata_cache.lock()
&& let Some(cached) = guard.as_ref()
{
return cached.clone();
}
let mut tmp = super::super::snapshot::Snapshot::new(
EpicsValue::Double(0.0),
0,
0,
std::time::SystemTime::UNIX_EPOCH,
);
self.populate_display_info(&mut tmp);
self.populate_control_info(&mut tmp);
self.populate_enum_info(&mut tmp);
let meta = MetadataSnapshot {
display: tmp.display,
control: tmp.control,
enums: tmp.enums,
};
if let Ok(mut guard) = self.metadata_cache.lock() {
*guard = Some(meta.clone());
}
meta
}
pub fn is_no_mod(&self, field: &str) -> bool {
if DBCOMMON_NOMOD.iter().any(|f| f.eq_ignore_ascii_case(field)) {
return true;
}
if self.field_desc(field).is_some_and(|f| f.read_only) {
return true;
}
self.record.field_no_mod(field)
}
pub fn is_processing(&self) -> bool {
self.pact.load(std::sync::atomic::Ordering::Acquire)
}
pub fn enter_pact(&self) {
self.pact.store(true, std::sync::atomic::Ordering::Release);
}
pub fn leave_pact(&mut self) -> PactExit {
self.pact.store(false, std::sync::atomic::Ordering::Release);
PactExit(self.deferred_notify_put.take())
}
pub fn put_notify_busy(&self) -> bool {
self.notify.is_some() || self.deferred_notify_put.is_some()
}
pub fn park_notify_put(&mut self, put: DeferredNotifyPut) -> Result<(), DeferredNotifyPut> {
debug_assert!(
self.is_processing(),
"a put-notify may be parked only on a PACT record"
);
if self.put_notify_busy() {
return Err(put);
}
self.deferred_notify_put = Some(put);
Ok(())
}
pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
let name = name.to_ascii_uppercase();
self.record
.get_field(&name)
.or_else(|| self.get_common_field(&name))
.or_else(|| self.get_virtual_field(&name))
.or_else(|| self.declared_overrides.get(&name).cloned())
.or_else(|| self.declared_default(&name))
}
fn declared_default(&self, name: &str) -> Option<EpicsValue> {
let desc = self.field_desc(name)?;
if desc.runtime_typed {
return None;
}
let initial = desc.initial.unwrap_or("");
if let Some(choices) = desc
.menu
.or_else(|| self.record.menu_field_choices(name))
.or_else(|| super::shared_menu_choices(name))
{
if initial.is_empty() {
return Some(EpicsValue::Enum(0));
}
return super::resolve_menu_field_string_db_load(name, choices, desc.dbf_type, initial)
.ok();
}
EpicsValue::parse_bytes(desc.dbf_type, initial.as_bytes()).ok()
}
pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
match self.resolve_field(name)? {
v @ EpicsValue::String(_) => Some(v),
_ => None,
}
}
fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
menu_choices_of(self.record.as_ref(), field)
}
pub(crate) fn device_choices(&self) -> Option<Vec<PvString>> {
let record_type = self.record.record_type();
if super::dbd_generated::device_menu(record_type).is_none()
&& super::contributed_device_menu(record_type).is_empty()
{
return None;
}
let mut names: Vec<PvString> = super::merged_device_menu(record_type)
.into_iter()
.map(PvString::from)
.collect();
let dtyp = self.common.dtyp.as_str();
if !dtyp.is_empty() && !names.iter().any(|n| n.as_str_lossy() == dtyp) {
names.push(PvString::from(dtyp));
}
Some(names)
}
fn check_link_assignment(
&self,
upper_field: &str,
text: &str,
bound: MenuBound,
) -> CaResult<()> {
if matches!(bound, MenuBound::DbLoad) {
return Ok(());
}
super::check_link_assignment(
self.record.record_type(),
Some(self.common.dtyp.as_str()),
upper_field,
text,
)
}
pub(crate) fn dtyp_index(&self) -> u16 {
let dtyp = self.common.dtyp.as_str();
if dtyp.is_empty() {
return 0;
}
self.device_choices()
.unwrap_or_default()
.iter()
.position(|c| c.as_str_lossy() == dtyp)
.unwrap_or(0) as u16
}
pub(crate) fn enum_string_form_for(&self, field: &str) -> Option<EnumStringForm> {
if field.eq_ignore_ascii_case("DTYP") {
return self.device_choices().map(EnumStringForm::device);
}
if let Some(choices) = self.menu_choices_for(field) {
return Some(EnumStringForm::menu(
choices.iter().map(|c| PvString::from(*c)),
));
}
if field.eq_ignore_ascii_case("VAL") {
return self.record.enum_string_form();
}
None
}
pub(crate) fn field_puts_as_string(&self, field: &str) -> bool {
let Some(declared) = self.declared_field_type(field) else {
return false;
};
matches!(declared, DbFieldType::String | DbFieldType::Enum)
}
pub(crate) fn field_as_dbr_string(&self, field: &str) -> Option<PvString> {
let value = self.resolve_field(field)?;
let idx = match value {
EpicsValue::Enum(v) => Some(v),
EpicsValue::Short(v) => u16::try_from(v).ok(),
_ => None,
};
if let Some(idx) = idx
&& let Some(form) = self.enum_string_form_for(field)
{
return Some(form.render(idx));
}
value_as_dbr_string(&value)
}
pub(crate) fn field_desc(&self, field: &str) -> Option<&'static FieldDesc> {
field_desc_of(self.record.as_ref(), field)
}
pub fn declared_field_type(&self, field: &str) -> Option<DbFieldType> {
declared_field_type_of(self.record.as_ref(), field)
}
pub fn project_to_declared_type(&self, field: &str, value: EpicsValue) -> EpicsValue {
match self.declared_field_type(field) {
Some(declared) => value.convert_to(declared),
None => value,
}
}
pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
let value = self.resolve_field(field)?;
Some(self.project_to_declared_type(field, value))
}
fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
if !matches!(snap.value, EpicsValue::Enum(_)) {
return;
}
if field.eq_ignore_ascii_case("VAL") {
return;
}
let Some(form) = self.enum_string_form_for(field) else {
return;
};
snap.enums = Some(super::super::snapshot::EnumInfo::with_string_form(
form.slots.clone(),
form,
));
}
pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
let value = self.client_field_value(field)?;
let mut snap = super::super::snapshot::Snapshot::new(
value,
self.common.stat,
self.common.sevr as u16,
self.common.time,
);
snap.user_tag = self.common.utag as i32;
snap.alarm.amsg = self.common.amsg.clone();
let meta = self.cached_metadata();
snap.display = meta.display;
snap.control = meta.control;
snap.enums = meta.enums;
self.route_field_metadata(field, &mut snap);
self.apply_field_metadata_override(field, &mut snap);
self.attach_menu_enum(field, &mut snap);
self.assign_property_support(field, &mut snap);
crate::server::snapshot::apply_nsec_mask(&mut snap, self.qtime_nsec_mask());
Some(snap)
}
fn qtime_nsec_mask(&self) -> u64 {
let Some(rest) = self
.get_info("Q:time:tag")
.and_then(|v| v.strip_prefix("nsec:lsb:"))
else {
return 0;
};
let Some(dig) = epics_parse_int32_base10(rest) else {
return 0;
};
1u64.wrapping_shl(dig as u32) - 1
}
fn q_form_index(&self) -> Option<i16> {
const FORM_NAMES: [&str; 7] = [
"Default",
"String",
"Binary",
"Decimal",
"Hex",
"Exponential",
"Engineering",
];
let tag = self.info.get("Q:form")?;
FORM_NAMES
.iter()
.position(|name| name == tag)
.map(|i| i as i16)
}
fn assign_property_support(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
snap.properties = self.record.property_support().narrowed_to_field(
snap.value.db_field_type(),
self.menu_choices_for(field).is_some(),
);
}
pub fn property_support_for_field(&self, field: &str) -> PropertySupport {
let Some(value) = self.client_field_value(field) else {
return PropertySupport::NONE;
};
self.record.property_support().narrowed_to_field(
value.db_field_type(),
self.menu_choices_for(field).is_some(),
)
}
fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
let rtype = self.record.record_type();
match rtype {
"ai" | "ao" | "calc" | "calcout" => {
let egu = self
.record
.get_field("EGU")
.and_then(|v| {
if let EpicsValue::String(s) = v {
Some(s)
} else {
None
}
})
.unwrap_or_default();
let prec = self
.record
.get_field("PREC")
.and_then(|v| v.to_f64())
.unwrap_or(0.0) as i16;
let hopr = self
.record
.get_field("HOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lopr = self
.record
.get_field("LOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.display = Some(super::super::snapshot::DisplayInfo {
units: egu,
precision: prec,
upper_disp_limit: hopr,
lower_disp_limit: lopr,
..Default::default()
});
}
"longin" | "longout" | "int64in" | "int64out" => {
let egu = self
.record
.get_field("EGU")
.and_then(|v| {
if let EpicsValue::String(s) = v {
Some(s)
} else {
None
}
})
.unwrap_or_default();
let hopr = self
.record
.get_field("HOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lopr = self
.record
.get_field("LOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.display = Some(super::super::snapshot::DisplayInfo {
units: egu,
precision: 0,
upper_disp_limit: hopr,
lower_disp_limit: lopr,
..Default::default()
});
}
"waveform" | "aai" | "aao" => {
let egu = self
.record
.get_field("EGU")
.and_then(|v| {
if let EpicsValue::String(s) = v {
Some(s)
} else {
None
}
})
.unwrap_or_default();
let prec = self
.record
.get_field("PREC")
.and_then(|v| v.to_f64())
.unwrap_or(0.0) as i16;
let hopr = self
.record
.get_field("HOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lopr = self
.record
.get_field("LOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.display = Some(super::super::snapshot::DisplayInfo {
units: egu,
precision: prec,
upper_disp_limit: hopr,
lower_disp_limit: lopr,
..Default::default()
});
}
"compress" => {
let egu = self
.record
.get_field("EGU")
.and_then(|v| {
if let EpicsValue::String(s) = v {
Some(s)
} else {
None
}
})
.unwrap_or_default();
let prec = self
.record
.get_field("PREC")
.and_then(|v| v.to_f64())
.unwrap_or(0.0) as i16;
let hopr = self
.record
.get_field("HOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lopr = self
.record
.get_field("LOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.display = Some(super::super::snapshot::DisplayInfo {
units: egu,
precision: prec,
upper_disp_limit: hopr,
lower_disp_limit: lopr,
..Default::default()
});
}
"motor" => {
let egu = self
.record
.get_field("EGU")
.and_then(|v| {
if let EpicsValue::String(s) = v {
Some(s)
} else {
None
}
})
.unwrap_or_default();
let prec = self
.record
.get_field("PREC")
.and_then(|v| v.to_f64())
.unwrap_or(0.0) as i16;
let hlm = self
.record
.get_field("HLM")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let llm = self
.record
.get_field("LLM")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.display = Some(super::super::snapshot::DisplayInfo {
units: egu,
precision: prec,
upper_disp_limit: hlm,
lower_disp_limit: llm,
..Default::default()
});
}
_ => {}
}
if let Some(display) = snap.display.as_mut() {
if let Some(form) = self.q_form_index() {
display.form = form;
}
}
}
fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
let rtype = self.record.record_type();
match rtype {
"ao" => {
let upper = self
.record
.get_field("DRVH")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lower = self
.record
.get_field("DRVL")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.control = Some(super::super::snapshot::ControlInfo {
upper_ctrl_limit: upper,
lower_ctrl_limit: lower,
});
}
"longout" | "int64out" => {
let drvh = self
.record
.get_field("DRVH")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let drvl = self
.record
.get_field("DRVL")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let (upper, lower) = if drvh > drvl {
(drvh, drvl)
} else {
let hopr = self
.record
.get_field("HOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lopr = self
.record
.get_field("LOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
(hopr, lopr)
};
snap.control = Some(super::super::snapshot::ControlInfo {
upper_ctrl_limit: upper,
lower_ctrl_limit: lower,
});
}
"motor" => {
let hlm = self
.record
.get_field("HLM")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let llm = self
.record
.get_field("LLM")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.control = Some(super::super::snapshot::ControlInfo {
upper_ctrl_limit: hlm,
lower_ctrl_limit: llm,
});
}
"ai" | "int64in" | "longin" | "calc" | "calcout" => {
let hopr = self
.record
.get_field("HOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lopr = self
.record
.get_field("LOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.control = Some(super::super::snapshot::ControlInfo {
upper_ctrl_limit: hopr,
lower_ctrl_limit: lopr,
});
}
"waveform" | "aai" | "aao" | "compress" => {
let hopr = self
.record
.get_field("HOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let lopr = self
.record
.get_field("LOPR")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
snap.control = Some(super::super::snapshot::ControlInfo {
upper_ctrl_limit: hopr,
lower_ctrl_limit: lopr,
});
}
_ => {}
}
}
fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
if let Some(strings) = self.record.enum_state_strings() {
snap.enums = Some(match self.record.enum_string_form() {
Some(form) => super::super::snapshot::EnumInfo::with_string_form(strings, form),
None => super::super::snapshot::EnumInfo::new(strings),
});
}
}
pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
"STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
"NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
"NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
"AMSG" => Some(EpicsValue::String(self.common.amsg.clone().into())),
"NAMSG" => Some(EpicsValue::String(self.common.namsg.clone().into())),
"ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
"ACKT" => Some(EpicsValue::Short(if self.common.ackt { 1 } else { 0 })),
"UDF" => Some(EpicsValue::UChar(self.common.udf)),
"UDFS" => Some(EpicsValue::Short(self.common.udfs)),
"SCAN" => Some(EpicsValue::Enum(self.common.scan.to_u16())),
"SSCN" => Some(EpicsValue::Enum(self.common.sscn.to_u16())),
"OLDSIMM" => Some(EpicsValue::Short(self.common.oldsimm)),
"PINI" => Some(EpicsValue::Short(self.common.pini)),
"TPRO" => Some(EpicsValue::UChar(self.common.tpro)),
"BKPT" => Some(EpicsValue::Char(self.common.bkpt)),
"FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
"INP" if self.record.declares_inp_link() => {
Some(EpicsValue::String(self.common.inp.clone().into()))
}
"OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
"DTYP" => Some(EpicsValue::Enum(self.dtyp_index())),
"TSE" => Some(EpicsValue::Short(self.common.tse)),
"TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
"UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
"ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
"ASL" => Some(EpicsValue::Char(self.common.asl)),
"DESC" => Some(EpicsValue::String(self.common.desc.clone())),
"PHAS" => Some(EpicsValue::Short(self.common.phas)),
"EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
"PRIO" => Some(EpicsValue::Short(self.common.prio)),
"DISV" => Some(EpicsValue::Short(self.common.disv)),
"DISA" => Some(EpicsValue::Short(self.common.disa)),
"SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
"DISS" => Some(EpicsValue::Short(self.common.diss)),
"HYST" => Some(EpicsValue::Double(self.common.hyst)),
"LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
"DISP" => Some(EpicsValue::UChar(self.common.disp)),
"PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
"RPRO" => Some(EpicsValue::UChar(self.common.rpro)),
"PACT" => Some(EpicsValue::Char(if self.is_processing() { 1 } else { 0 })),
"PROC" => Some(EpicsValue::UChar(self.common.proc_field)),
"HIHI" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Double(a.hihi)),
"HIGH" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Double(a.high)),
"LOW" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Double(a.low)),
"LOLO" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Double(a.lolo)),
"HHSV" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Short(a.hhsv)),
"HSV" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Short(a.hsv)),
"LSV" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Short(a.lsv)),
"LLSV" => self
.common
.analog_alarm
.as_ref()
.map(|a| EpicsValue::Short(a.llsv)),
"OUTN" => {
if self.record.record_type() == "swait" {
Some(EpicsValue::String(self.common.out.clone().into()))
} else {
None
}
}
_ => None,
}
}
fn record_declares_field(&self, name: &str) -> bool {
self.record.implements_field(name)
}
pub fn put_common_field(
&mut self,
name: &str,
value: EpicsValue,
) -> CaResult<CommonFieldPutResult> {
self.put_common_field_bounded(name, value, MenuBound::DbPut)
}
pub fn set_scan(&mut self, new_scan: ScanType) -> CommonFieldPutResult {
let old_scan = self.common.scan;
self.common.scan = new_scan;
if old_scan == new_scan {
return CommonFieldPutResult::NoChange;
}
let was_io_intr = old_scan == ScanType::IoIntr;
let is_io_intr = new_scan == ScanType::IoIntr;
if was_io_intr != is_io_intr {
self.record.set_io_intr_scan(is_io_intr);
}
CommonFieldPutResult::ScanChanged {
old_scan,
new_scan,
phas: self.common.phas,
}
}
pub fn rec_gbl_save_simm(&mut self) {
if !self.record.uses_recgbl_simm_helpers() {
return;
}
if self.common.sscn.is_unset() {
return;
}
if let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") {
self.common.oldsimm = simm;
}
}
pub fn rec_gbl_check_simm(&mut self) -> CommonFieldPutResult {
if !self.record.uses_recgbl_simm_helpers() {
return CommonFieldPutResult::NoChange;
}
let Some(sim_scan) = self.common.sscn.scan() else {
return CommonFieldPutResult::NoChange;
};
let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") else {
return CommonFieldPutResult::NoChange;
};
if simm == self.common.oldsimm {
return CommonFieldPutResult::NoChange;
}
let previous_scan = self.common.scan;
let result = self.set_scan(sim_scan);
self.common.sscn = SimModeScan::from_scan(previous_scan);
result
}
pub fn put_ackt(&mut self, value: u16) {
let new_ackt = value != 0;
if new_ackt == self.common.ackt {
return;
}
use crate::server::recgbl::EventMask;
let ack_mask = EventMask::VALUE | EventMask::ALARM;
self.common.ackt = new_ackt;
self.cleanup_subscribers();
self.notify_field("ACKT", ack_mask);
if !new_ackt && self.common.acks > self.common.sevr {
self.common.acks = self.common.sevr;
self.notify_field("ACKS", ack_mask);
}
self.notify_record_alarm();
}
pub fn put_acks(&mut self, value: u16) {
let sev = AlarmSeverity::from_u16(value);
if sev < self.common.acks {
return;
}
use crate::server::recgbl::EventMask;
self.common.acks = AlarmSeverity::NoAlarm;
self.cleanup_subscribers();
self.notify_field("ACKS", EventMask::VALUE | EventMask::ALARM);
self.notify_record_alarm();
}
pub fn put_common_field_db_load(
&mut self,
name: &str,
value: EpicsValue,
) -> CaResult<CommonFieldPutResult> {
self.put_common_field_bounded(name, value, MenuBound::DbLoad)
}
fn put_common_field_bounded(
&mut self,
name: &str,
value: EpicsValue,
bound: MenuBound,
) -> CaResult<CommonFieldPutResult> {
let name = name.to_ascii_uppercase();
self.record.validate_put(&name, &value)?;
self.record.special(&name, false)?;
let value = coerce_common_field(&name, value, bound)?;
match name.as_str() {
"OLDSIMM" => return Err(CaError::ReadOnlyField(name)),
"SEVR" => {
if let EpicsValue::Short(v) = value {
self.common.sevr = AlarmSeverity::from_u16(v as u16);
}
}
"STAT" => {
if let EpicsValue::Short(v) = value {
self.common.stat = v as u16;
}
}
"NSEV" => {
if let EpicsValue::Short(v) = value {
self.common.nsev = AlarmSeverity::from_u16(v as u16);
}
}
"NSTA" => {
if let EpicsValue::Short(v) = value {
self.common.nsta = v as u16;
}
}
"AMSG" => {
if let EpicsValue::String(s) = value {
self.common.amsg = s.as_str_lossy().into_owned();
}
}
"NAMSG" => {
if let EpicsValue::String(s) = value {
self.common.namsg = s.as_str_lossy().into_owned();
}
}
"ACKS" => {
if let EpicsValue::Short(v) = value {
self.common.acks = AlarmSeverity::from_u16(v as u16);
}
}
"ACKT" => match value {
EpicsValue::Char(v) => self.common.ackt = v != 0,
EpicsValue::Short(v) => self.common.ackt = v != 0,
_ => return Ok(CommonFieldPutResult::NoChange),
},
"UDF" => {
if let EpicsValue::Char(v) = value {
self.common.udf = v;
}
}
"UDFS" => {
self.common.udfs = menu_ordinal_raw(&value);
}
"SCAN" => {
let new_scan = match &value {
EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
EpicsValue::Enum(v) => ScanType::from_u16(*v),
_ => return Ok(CommonFieldPutResult::NoChange),
};
let result = self.set_scan(new_scan);
if !matches!(result, CommonFieldPutResult::NoChange) {
self.record.on_put(&name);
self.record.special(&name, true)?;
return Ok(result);
}
}
"SSCN" => {
let new_sscn = match &value {
EpicsValue::Short(v) => SimModeScan::from_u16(*v as u16),
EpicsValue::Enum(v) => SimModeScan::from_u16(*v),
_ => return Ok(CommonFieldPutResult::NoChange),
};
self.common.sscn = new_sscn;
}
"PINI" => {
self.common.pini = match &value {
EpicsValue::Short(v) => *v,
EpicsValue::Char(v) => *v as i16,
EpicsValue::Enum(v) => *v as i16,
_ => return Ok(CommonFieldPutResult::NoChange),
};
}
"TPRO" => {
if let EpicsValue::Char(v) = value {
self.common.tpro = v;
}
}
"BKPT" => {
if let EpicsValue::Char(v) = value {
self.common.bkpt = v;
}
}
"FLNK" => {
if let EpicsValue::String(s) = value {
self.common.flnk = s.as_str_lossy().into_owned();
self.parsed_flnk = parse_forward_link_v2(&self.common.flnk);
}
}
"INP" => {
if !self.record.declares_inp_link() {
return Err(CaError::FieldNotFound("INP".to_string()));
}
if let EpicsValue::String(s) = value {
self.check_link_assignment("INP", &s.as_str_lossy(), bound)?;
self.common.inp = s.as_str_lossy().into_owned();
if !self.record_declares_field("INP") {
self.parsed_inp = parse_link_v2(&self.common.inp);
}
}
}
"OUT" => {
if let EpicsValue::String(s) = value {
let s = s.as_str_lossy();
self.check_link_assignment("OUT", &s, bound)?;
if out_link_discards_cp(&s) {
tracing::warn!(
target: "epics_base_rs::record",
record = %self.name,
field = "OUT",
link = %s,
"Discarding CP/CPP modifier in CA output link"
);
}
self.common.out = s.into_owned();
if !self.record_declares_field("OUT") {
self.parsed_out = parse_output_link_v2(&self.common.out);
}
self.record.special(&name, true)?;
}
}
"DTYP" => match value {
EpicsValue::String(s) => self.common.dtyp = s.as_str_lossy().into_owned(),
EpicsValue::Enum(i) => {
let merged = super::merged_device_menu(self.record.record_type());
match merged.get(i as usize) {
Some(name) => self.common.dtyp = (*name).to_string(),
None => return Ok(CommonFieldPutResult::NoChange),
}
}
_ => return Ok(CommonFieldPutResult::NoChange),
},
"TSE" => {
if let EpicsValue::Short(v) = value {
self.common.tse = v;
}
}
"TSEL" => {
if let EpicsValue::String(s) = value {
self.common.tsel = s.as_str_lossy().into_owned();
self.parsed_tsel = parse_link_v2(&self.common.tsel);
}
}
"UTAG" => {
match value {
EpicsValue::UInt64(v) => self.common.utag = v,
EpicsValue::Int64(v) => self.common.utag = v as u64,
EpicsValue::Long(v) => self.common.utag = v as u64,
EpicsValue::Short(v) => self.common.utag = v as u64,
EpicsValue::Enum(v) => self.common.utag = v as u64,
EpicsValue::Char(v) => self.common.utag = v as u64,
EpicsValue::Double(v) => self.common.utag = v as u64,
EpicsValue::String(s) => {
if let Ok(EpicsValue::UInt64(v)) =
EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
{
self.common.utag = v;
}
}
_ => {}
}
}
"ASG" => {
if let EpicsValue::String(s) = value {
self.common.asg = s.as_str_lossy().into_owned();
}
}
"ASL" => {
let n: i64 = match value {
EpicsValue::Char(v) => v as i64,
EpicsValue::Short(v) => v as i64,
EpicsValue::Long(v) => v as i64,
EpicsValue::Int64(v) => v,
EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
_ => return Ok(CommonFieldPutResult::NoChange),
};
self.common.asl = if n != 0 { 1 } else { 0 };
}
"DESC" => {
if let EpicsValue::String(s) = value {
self.common.desc = s;
}
}
"PHAS" => {
if let EpicsValue::Short(v) = value {
let old_phas = self.common.phas;
self.common.phas = v;
if old_phas != v && self.common.scan.scan_list().is_some() {
let scan = self.common.scan;
self.record.on_put(&name);
self.record.special(&name, true)?;
return Ok(CommonFieldPutResult::PhasChanged {
scan,
old_phas,
new_phas: v,
});
}
}
}
"EVNT" => {
match value {
EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
EpicsValue::Short(v) => self.common.evnt = v.to_string(),
EpicsValue::Long(v) => self.common.evnt = v.to_string(),
EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
EpicsValue::Double(v) => {
self.common.evnt = (v as i64).to_string();
}
_ => {}
}
}
"PRIO" => {
if let EpicsValue::Short(v) = value {
self.common.prio = v;
}
}
"DISV" => {
if let EpicsValue::Short(v) = value {
self.common.disv = v;
}
}
"DISA" => {
if let EpicsValue::Short(v) = value {
self.common.disa = v;
}
}
"SDIS" => {
if let EpicsValue::String(s) = value {
self.common.sdis = s.as_str_lossy().into_owned();
self.parsed_sdis = parse_link_v2(&self.common.sdis);
}
}
"DISS" => {
self.common.diss = menu_ordinal_raw(&value);
}
"HYST" => {
if let Some(v) = value.to_f64() {
self.common.hyst = v;
}
}
"LCNT" => {
if let EpicsValue::Short(v) = value {
self.common.lcnt = v;
}
}
"DISP" => {
if let EpicsValue::Char(v) = value {
self.common.disp = v;
}
}
"PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
"RPRO" => {
if let EpicsValue::Char(v) = value {
self.common.rpro = v;
}
}
"PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
"PROC" => {
if let EpicsValue::Char(v) = value {
self.common.proc_field = v;
}
}
"HIHI" => {
if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
a.hihi = v;
}
}
"HIGH" => {
if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
a.high = v;
}
}
"LOW" => {
if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
a.low = v;
}
}
"LOLO" => {
if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
a.lolo = v;
}
}
"HHSV" => {
if let Some(a) = &mut self.common.analog_alarm {
a.hhsv = menu_ordinal_raw(&value);
}
}
"HSV" => {
if let Some(a) = &mut self.common.analog_alarm {
a.hsv = menu_ordinal_raw(&value);
}
}
"LSV" => {
if let Some(a) = &mut self.common.analog_alarm {
a.lsv = menu_ordinal_raw(&value);
}
}
"LLSV" => {
if let Some(a) = &mut self.common.analog_alarm {
a.llsv = menu_ordinal_raw(&value);
}
}
"OUTN" => {
if self.record.record_type() != "swait" {
return Err(self.unknown_field_error(name));
}
if let EpicsValue::String(s) = value {
self.common.out = s.as_str_lossy().into_owned();
self.parsed_out = parse_output_link_v2(&self.common.out);
}
}
_ => return self.put_declared_override(&name, value),
}
self.record.on_put(&name);
self.record.special(&name, true)?;
Ok(CommonFieldPutResult::NoChange)
}
fn unknown_field_error(&self, name: String) -> CaError {
if self.get_virtual_field(&name).is_some() {
CaError::ReadOnlyField(name)
} else {
CaError::FieldNotFound(name)
}
}
fn put_declared_override(
&mut self,
name: &str,
value: EpicsValue,
) -> CaResult<CommonFieldPutResult> {
let Some(desc) = self.field_desc(name) else {
return Err(self.unknown_field_error(name.to_string()));
};
if desc.runtime_typed {
return Err(self.unknown_field_error(name.to_string()));
}
if self.is_no_mod(name) {
return Err(CaError::ReadOnlyField(name.to_string()));
}
if self.record.get_field(name).is_some() {
return Err(self.unknown_field_error(name.to_string()));
}
let target = desc.dbf_type;
let coerced =
crate::server::record::coerce_put_value(self.record.as_ref(), name, target, value)?;
self.declared_overrides
.insert(name.to_ascii_uppercase(), coerced);
Ok(CommonFieldPutResult::NoChange)
}
pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"NAME" => Some(EpicsValue::String(self.name.clone().into())),
"RTYP" => Some(EpicsValue::String(
self.record.record_type().to_string().into(),
)),
_ => None,
}
}
pub fn evaluate_alarms(&mut self) {
use crate::server::recgbl;
if self.record.raises_udf_alarm() {
recgbl::rec_gbl_check_udf(
&mut self.common,
self.record.udf_alarm_on_exact_one(),
self.record.udf_alarm_message(),
);
}
if let Some(ref alarm_cfg) = self.common.analog_alarm.clone() {
let val = match self.record.val() {
Some(EpicsValue::Double(v)) => v,
Some(EpicsValue::Long(v)) => v as f64,
Some(EpicsValue::Int64(v)) => v as f64,
_ => return,
};
self.evaluate_analog_alarm(val, alarm_cfg);
}
}
fn evaluate_analog_alarm(&mut self, val: f64, cfg: &AnalogAlarmConfig) {
use crate::server::recgbl::{self, alarm_status};
if self.common.udf != 0 {
if matches!(
self.record.record_type(),
"calc" | "ai" | "longin" | "int64in"
) && self.record.get_field("AFVL").and_then(|v| v.to_f64()) != Some(0.0)
{
let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
}
return;
}
let hyst = self.common.hyst;
let lalm = self
.record
.get_field("LALM")
.and_then(|v| v.to_f64())
.unwrap_or(val);
let (mut new_sevr, mut new_stat, mut alev, mut alarm_range) = if cfg.hhsv != 0
&& (val >= cfg.hihi || (lalm == cfg.hihi && val >= cfg.hihi - hyst))
{
(
AlarmSeverity::from_u16(cfg.hhsv as u16),
alarm_status::HIHI_ALARM,
cfg.hihi,
5u16,
)
} else if cfg.llsv != 0 && (val <= cfg.lolo || (lalm == cfg.lolo && val <= cfg.lolo + hyst))
{
(
AlarmSeverity::from_u16(cfg.llsv as u16),
alarm_status::LOLO_ALARM,
cfg.lolo,
1u16,
)
} else if cfg.hsv != 0 && (val >= cfg.high || (lalm == cfg.high && val >= cfg.high - hyst))
{
(
AlarmSeverity::from_u16(cfg.hsv as u16),
alarm_status::HIGH_ALARM,
cfg.high,
4u16,
)
} else if cfg.lsv != 0 && (val <= cfg.low || (lalm == cfg.low && val <= cfg.low + hyst)) {
(
AlarmSeverity::from_u16(cfg.lsv as u16),
alarm_status::LOW_ALARM,
cfg.low,
2u16,
)
} else {
(AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val, 3u16)
};
let aftc_capable = matches!(
self.record.record_type(),
"calc" | "ai" | "longin" | "int64in"
);
if aftc_capable {
let aftc = self
.record
.get_field("AFTC")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let afvl = self
.record
.get_field("AFVL")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
if aftc > 0.0 {
let now = crate::runtime::general_time::get_current();
let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
alarm_range,
aftc,
afvl,
self.common.time,
now,
);
let _ = self.record.put_field("AFVL", EpicsValue::Double(new_afvl));
if filtered_range != alarm_range {
let (mapped_sevr, mapped_stat, mapped_alev) = match filtered_range {
5 => (
AlarmSeverity::from_u16(cfg.hhsv as u16),
alarm_status::HIHI_ALARM,
cfg.hihi,
),
4 => (
AlarmSeverity::from_u16(cfg.hsv as u16),
alarm_status::HIGH_ALARM,
cfg.high,
),
2 => (
AlarmSeverity::from_u16(cfg.lsv as u16),
alarm_status::LOW_ALARM,
cfg.low,
),
1 => (
AlarmSeverity::from_u16(cfg.llsv as u16),
alarm_status::LOLO_ALARM,
cfg.lolo,
),
_ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val),
};
new_sevr = mapped_sevr;
new_stat = mapped_stat;
alev = mapped_alev;
alarm_range = filtered_range;
}
} else {
if afvl != 0.0 {
let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
}
}
}
let _ = alarm_range;
if new_sevr != AlarmSeverity::NoAlarm {
recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr);
let _ = self.record.put_field("LALM", EpicsValue::Double(alev));
} else {
let _ = self.record.put_field("LALM", EpicsValue::Double(val));
}
}
pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
let outcome = self.run_subroutine_body();
let status = *outcome.as_ref().unwrap_or(&SUBROUTINE_STATUS_ERROR);
self.record.set_subroutine_status(status);
outcome.map(|_| ())
}
fn run_subroutine_body(&mut self) -> CaResult<i64> {
use crate::server::recgbl::{self, alarm_status};
if std::mem::take(&mut self.suppress_subroutine_run) {
return Ok(SUBROUTINE_STATUS_SKIPPED);
}
let Some(sub_fn) = self.subroutine.clone() else {
let snam_empty = matches!(
self.record.get_field("SNAM"),
Some(EpicsValue::String(s)) if s.is_empty()
);
if snam_empty {
if self.record.record_type() == "aSub" {
let _ = self.record.put_field("VAL", EpicsValue::Long(0));
}
return Ok(0);
}
return Ok(SUBROUTINE_STATUS_NO_SUB);
};
let status = sub_fn(&mut *self.record)?;
if self.record.record_type() == "aSub" {
let _ = self
.record
.put_field("VAL", EpicsValue::Long(status as i32));
}
if status < 0 {
let brsv = self
.record
.get_field("BRSV")
.and_then(|v| v.to_f64())
.map(|f| AlarmSeverity::from_u16(f as u16))
.unwrap_or(AlarmSeverity::NoAlarm);
recgbl::rec_gbl_set_sevr(&mut self.common, alarm_status::SOFT_ALARM, brsv);
} else if self.record.record_type() == "aSub" {
self.common.udf = 0;
}
Ok(status)
}
pub(crate) fn collect_subscriber_posts(
&mut self,
deadband_field: &str,
deadband_mask: EventMask,
alarm_bits: EventMask,
aux_post: AuxPostMask,
include_val: bool,
) -> Vec<(String, EpicsValue, EventMask)> {
use crate::server::record::{CyclePostMask, ValuePostGate, value_gate};
let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
&[]
} else {
self.record.alarm_cycle_monitored_fields()
};
let force_fields = self.record.force_posted_fields();
let cycle_posted = self.record.take_cycle_posted_fields();
let log_swept = self.record.log_swept_fields();
let marked_only = self.record.fields_posted_only_when_marked();
let value_masked = self.record.fields_posted_with_value_mask();
let event_posted = self.record.event_posted_fields();
let process_posted = self.record.process_posted_fields();
let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
for (field, subs) in &self.subscribers {
if subs.is_empty()
|| field == deadband_field
|| crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS.contains(&field.as_str())
|| field == "UDF"
|| event_posted.contains(&field.as_str())
|| !process_posted.is_none_or(|allowed| allowed.contains(&field.as_str()))
{
continue;
}
let Some(val) = self.resolve_field(field) else {
continue;
};
let changed = match self.posted_value(field) {
Some(prev) => prev != &val,
None => true,
};
if let Some(gate) = value_gate(value_masked, field) {
let post = match gate {
ValuePostGate::OnChange => changed && !deadband_mask.is_empty(),
ValuePostGate::WithValue => include_val,
};
if post {
sub_updates.push((field.clone(), val.clone(), deadband_mask));
}
} else if changed && !marked_only.contains(&field.as_str()) {
sub_updates.push((
field.clone(),
val.clone(),
aux_post.mask_for(field, alarm_bits, deadband_mask),
));
} else if force_fields.contains(&field.as_str()) {
sub_updates.push((field.clone(), val.clone(), aux_mask));
} else if cycle_posted.iter().any(|(name, _)| *name == field) {
for (_, cycle_mask) in cycle_posted.iter().filter(|(name, _)| *name == field) {
let mask = match cycle_mask {
CyclePostMask::Value => EventMask::VALUE,
CyclePostMask::ValueLog => EventMask::VALUE | EventMask::LOG,
CyclePostMask::MonitorValueLog => aux_mask,
};
sub_updates.push((field.clone(), val.clone(), mask));
}
} else if alarm_fanout.contains(&field.as_str()) {
sub_updates.push((field.clone(), val.clone(), alarm_bits));
}
if log_swept.contains(&field.as_str()) {
sub_updates.push((field.clone(), val, EventMask::LOG | alarm_bits));
}
}
for (field, val, _) in &sub_updates {
self.record_value_post(field, val.clone());
}
sub_updates
}
pub fn process_local(
&mut self,
) -> CaResult<(
ProcessSnapshot,
Vec<(&'static str, crate::server::recgbl::EventMask)>,
)> {
use crate::server::recgbl::{self, EventMask};
const LCNT_ALARM_THRESHOLD: i16 = 10;
if self.pact.swap(true, std::sync::atomic::Ordering::AcqRel) {
let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
let lcnt_before = self.common.lcnt;
self.common.lcnt = lcnt_before.saturating_add(1);
if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
return Ok((
ProcessSnapshot {
changed_fields: Vec::new(),
},
Vec::new(),
));
}
recgbl::rec_gbl_set_sevr_msg(
&mut self.common,
recgbl::alarm_status::SCAN_ALARM,
AlarmSeverity::Invalid,
"Async in progress",
);
let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
let stat_mask = EventMask::ALARM | EventMask::VALUE;
let mut changed_fields = Vec::new();
if let Some(val) = self.record.val() {
changed_fields.push((
"VAL".to_string(),
val,
EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
));
}
changed_fields.push((
"SEVR".to_string(),
EpicsValue::Short(self.common.sevr as i16),
EventMask::VALUE,
));
changed_fields.push((
"STAT".to_string(),
EpicsValue::Short(self.common.stat as i16),
stat_mask,
));
changed_fields.push((
"AMSG".to_string(),
EpicsValue::String(self.common.amsg.clone().into()),
stat_mask,
));
return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
}
self.common.lcnt = 0;
debug_assert!(
self.deferred_notify_put.is_none(),
"PactExit invariant: a parked put-notify implies PACT, which the \
swap above proved was clear"
);
struct ProcessGuard(*const AtomicBool);
unsafe impl Send for ProcessGuard {}
impl Drop for ProcessGuard {
fn drop(&mut self) {
unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
}
}
let _guard = ProcessGuard(&self.pact as *const AtomicBool);
self.run_registered_subroutine()?;
{
let is_soft = self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel";
let is_output = self.record.can_device_write();
if is_soft && !is_output && self.record.soft_channel_skips_convert() {
self.record.set_device_did_compute(true);
}
}
{
let ctx = self.common.process_context();
self.record.set_process_context(&ctx);
}
let outcome = self.record.process()?;
let process_result = outcome.result;
if self.record.took_metadata_change() {
self.invalidate_metadata_cache();
let fields: Vec<String> = self.subscribers.keys().cloned().collect();
for f in fields {
self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
}
}
if process_result == RecordProcessResult::AsyncPending {
std::mem::forget(_guard);
return Ok((
ProcessSnapshot {
changed_fields: Vec::new(),
},
Vec::new(),
));
}
if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
self.common.time = crate::runtime::general_time::get_current();
let mut changed_fields = Vec::new();
for (name, val) in fields {
let changed = match self.posted_value(&name) {
Some(prev) => prev != &val,
None => true,
};
if changed {
if name == "VAL" {
if let Some(f) = val.to_f64() {
self.put_coerced("MLST", f);
self.common.mlst = Some(f);
}
}
self.record_value_post(&name, val.clone());
changed_fields.push((name, val, EventMask::VALUE | EventMask::LOG));
}
}
return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
}
if process_result == RecordProcessResult::CompleteNoEmit {
return Ok((
ProcessSnapshot {
changed_fields: Vec::new(),
},
Vec::new(),
));
}
if self.record.clears_udf() {
self.common.udf = self.record.value_is_undefined() as u8;
}
self.record.check_alarms(&mut self.common);
self.evaluate_alarms();
let alarm_result = recgbl::rec_gbl_reset_alarms(&mut self.common);
self.common.time = crate::runtime::general_time::get_current();
let (include_val, include_archive) = self.check_deadband_ext();
let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
EventMask::ALARM
} else {
EventMask::NONE
};
let mut changed_fields = Vec::new();
let deadband_field = self.record.monitor_deadband_field();
let aux_post = AuxPostMask::of(self.record.as_ref());
let deadband = self.deadband_post(alarm_bits, include_val, include_archive);
let deadband_mask = deadband.mask;
if let Some((field, value)) = deadband.field {
changed_fields.push((field, value, deadband_mask));
}
let sevr_changed = self.common.sevr != alarm_result.prev_sevr;
let stat_changed = self.common.stat != alarm_result.prev_stat;
let stat_mask = {
let mut m = EventMask::NONE;
if sevr_changed || alarm_result.amsg_changed {
m |= EventMask::ALARM;
}
if stat_changed {
m |= EventMask::VALUE;
}
m
};
let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
if sevr_changed {
alarm_posts.push(("SEVR", EventMask::VALUE));
}
if !stat_mask.is_empty() {
alarm_posts.push(("STAT", stat_mask));
alarm_posts.push(("AMSG", stat_mask));
}
if alarm_result.acks_posted {
alarm_posts.push(("ACKS", EventMask::VALUE));
}
changed_fields.extend(self.collect_subscriber_posts(
deadband_field,
deadband_mask,
alarm_bits,
aux_post,
include_val,
));
if self.array_hash_changed {
if let Some(h) = self.resolve_field("HASH") {
changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
}
}
Ok((ProcessSnapshot { changed_fields }, alarm_posts))
}
pub(crate) fn put_coerced(&mut self, field: &str, val: f64) {
use crate::types::EpicsValue;
let target_type = self
.record
.get_field(field)
.map(|v| v.db_field_type())
.unwrap_or(crate::types::DbFieldType::Double);
let coerced = EpicsValue::Double(val).convert_to(target_type);
let _ = self.record.put_field(field, coerced);
}
pub(crate) fn value_include_classes(&mut self) -> (bool, bool) {
if !self.record.process_posts_value_monitor() {
return (false, false);
}
match self.record.monitor_value_changed() {
Some(changed) => {
let (val_always, archive_always) = self.record.monitor_always_post();
(changed || val_always, changed || archive_always)
}
None => {
if self.record.uses_monitor_deadband() {
self.check_deadband_ext()
} else {
(true, true)
}
}
}
}
pub(crate) fn deadband_post(
&self,
alarm_bits: EventMask,
include_val: bool,
include_archive: bool,
) -> DeadbandPost {
let field = self.record.monitor_deadband_field();
let log_suppressed = self.record.value_only_change_fields().contains(&field)
|| self
.record
.fields_posted_with_monitor_mask()
.contains(&field);
let mut mask = alarm_bits;
if include_val {
mask |= EventMask::VALUE;
}
if include_archive && !log_suppressed {
mask |= EventMask::LOG;
}
let in_closed_set = self
.record
.process_posted_fields()
.is_none_or(|allowed| allowed.contains(&field));
let value = if mask.is_empty() || !in_closed_set {
None
} else if field == "VAL" {
self.record.val()
} else {
self.resolve_field(field)
};
DeadbandPost {
mask,
field: value.map(|v| (field.to_string(), v)),
}
}
pub fn check_deadband_ext(&mut self) -> (bool, bool) {
if let Some(post) = self.record.array_monitor_post() {
self.array_hash_changed = post.hash_changed;
return (post.post_value, post.post_archive);
}
self.array_hash_changed = false;
let val = match self
.record
.monitor_deadband_value()
.and_then(|v| v.to_f64())
{
Some(v) => v,
None => return (true, true),
};
let mdel = self
.record
.get_field("MDEL")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let adel = self
.record
.get_field("ADEL")
.and_then(|v| v.to_f64())
.unwrap_or(0.0);
let mlst = self
.record
.get_field("MLST")
.and_then(|v| v.to_f64())
.or(self.common.mlst)
.unwrap_or(f64::NAN);
let alst = self
.record
.get_field("ALST")
.and_then(|v| v.to_f64())
.or(self.common.alst)
.unwrap_or(f64::NAN);
let monitor_trigger = check_deadband(val, mlst, mdel);
let archive_trigger = check_deadband(val, alst, adel);
if archive_trigger {
self.put_coerced("ALST", val);
self.common.alst = Some(val);
}
if monitor_trigger {
self.put_coerced("MLST", val);
self.common.mlst = Some(val);
}
(monitor_trigger, archive_trigger)
}
pub fn make_monitor_snapshot(
&self,
field: &str,
value: EpicsValue,
) -> super::super::snapshot::Snapshot {
let value = self.project_to_declared_type(field, value);
let mut snap = super::super::snapshot::Snapshot::new(
value,
self.common.stat,
self.common.sevr as u16,
self.common.time,
);
snap.user_tag = self.common.utag as i32;
snap.alarm.amsg = self.common.amsg.clone();
let meta = self.cached_metadata();
snap.display = meta.display;
snap.control = meta.control;
snap.enums = meta.enums;
self.route_field_metadata(field, &mut snap);
self.apply_field_metadata_override(field, &mut snap);
self.attach_menu_enum(field, &mut snap);
self.assign_property_support(field, &mut snap);
snap
}
fn apply_field_metadata_override(
&self,
field: &str,
snap: &mut super::super::snapshot::Snapshot,
) {
if let Some(display) = snap.display.as_mut()
&& !crate::server::database::is_value_field(field)
{
display.form = 0;
}
let Some(ov) = self.record.field_metadata_override(field) else {
return;
};
if ov.units.is_some()
|| ov.precision.is_some()
|| ov.disp_limits.is_some()
|| ov.alarm_limits.is_some()
{
let d = snap.display.get_or_insert_with(Default::default);
if let Some(units) = ov.units {
d.units = units;
}
if let Some(precision) = ov.precision {
d.precision = precision;
}
if let Some((upper, lower)) = ov.disp_limits {
d.upper_disp_limit = upper;
d.lower_disp_limit = lower;
}
if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
d.upper_alarm_limit = hihi;
d.upper_warning_limit = high;
d.lower_warning_limit = low;
d.lower_alarm_limit = lolo;
}
}
if let Some((upper, lower)) = ov.ctrl_limits {
let c = snap.control.get_or_insert_with(Default::default);
c.upper_ctrl_limit = upper;
c.lower_ctrl_limit = lower;
}
}
fn route_field_metadata(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
let slots = self.record.property_support();
let rtype = self.record.record_type();
if slots.control_double && !Self::control_explicit_field(rtype, field) {
let (upper, lower) =
match super::record_trait::control_default_arm(self.record.record_type()) {
super::record_trait::RsetDefaultArm::RecGblRange => {
self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
}
super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
};
snap.control = Some(super::super::snapshot::ControlInfo {
upper_ctrl_limit: upper,
lower_ctrl_limit: lower,
});
}
if slots.graphic_double && !Self::graphic_explicit_field(rtype, field) {
let (upper, lower) = if Self::graphic_link_backed_field(rtype, field) {
(0.0, 0.0)
} else {
match super::record_trait::graphic_default_arm(rtype) {
super::record_trait::RsetDefaultArm::RecGblRange => {
self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
}
super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
}
};
let d = snap.display.get_or_insert_with(Default::default);
d.upper_disp_limit = upper;
d.lower_disp_limit = lower;
}
if slots.alarm_double {
let (hihi, high, low, lolo) = if Self::alarm_explicit_field(rtype, field) {
self.explicit_alarm_limits(rtype)
} else {
crate::server::recgbl::rec_gbl_get_alarm_double()
};
let d = snap.display.get_or_insert_with(Default::default);
d.upper_alarm_limit = hihi;
d.upper_warning_limit = high;
d.lower_warning_limit = low;
d.lower_alarm_limit = lolo;
}
}
fn explicit_alarm_limits(&self, rtype: &str) -> (f64, f64, f64, f64) {
let limit = |name: &str| {
self.resolve_field(name)
.and_then(|v| v.to_f64())
.unwrap_or(0.0)
};
let severity = |name: &str| {
self.resolve_field(name)
.and_then(|v| v.to_f64())
.unwrap_or(0.0) as i16
};
match super::record_trait::alarm_val_arm(rtype) {
super::record_trait::AlarmValArm::Unconditional => {
(limit("HIHI"), limit("HIGH"), limit("LOW"), limit("LOLO"))
}
super::record_trait::AlarmValArm::Gated => (
gated(severity("HHSV"), limit("HIHI")),
gated(severity("HSV"), limit("HIGH")),
gated(severity("LSV"), limit("LOW")),
gated(severity("LLSV"), limit("LOLO")),
),
}
}
fn control_explicit_field(rtype: &str, field: &str) -> bool {
if matches!(rtype, "aSub" | "seq" | "bo") {
return false;
}
if crate::server::database::is_value_field(field) {
return true;
}
let f = field.to_ascii_uppercase();
let bands: &[&str] = match rtype {
"dfanout" => &["LALM", "ALST", "MLST"],
"acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
"waveform" | "aai" | "aao" | "compress" | "histogram" | "subArray" | "motor" => &[],
_ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
};
if bands.contains(&f.as_str()) {
return true;
}
match rtype {
"sel" => Self::calc_arg_field(&f, 12),
"acalcout" | "scalcout" => {
Self::calc_arg_field(&f, 12)
|| matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
}
"epid" => f == "CVAL",
"motor" => f == "RBV",
_ => false,
}
}
fn alarm_explicit_field(rtype: &str, field: &str) -> bool {
super::record_trait::alarm_explicit_fields(rtype)
.iter()
.any(|f| field.eq_ignore_ascii_case(f))
}
fn calc_arg_field(field: &str, nargs: u8) -> bool {
let last = b'A' + nargs - 1;
match field.as_bytes() {
[c] => c.is_ascii_uppercase() && *c <= last,
[b'L', c] => c.is_ascii_uppercase() && *c <= last,
_ => false,
}
}
fn graphic_explicit_field(rtype: &str, field: &str) -> bool {
if matches!(rtype, "seq" | "aSub") {
return false;
}
if crate::server::database::is_value_field(field) {
return true;
}
let f = field.to_ascii_uppercase();
let bands: &[&str] = match rtype {
"acalcout" | "scalcout" => &["HIHI", "HIGH", "LOW", "LOLO"],
_ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
};
if bands.contains(&f.as_str()) {
return true;
}
match rtype {
"ai" | "longin" | "int64in" => f == "SVAL",
"ao" => matches!(f.as_str(), "OVAL" | "PVAL" | "IVOV"),
"compress" => matches!(f.as_str(), "IHIL" | "ILIL"),
"sel" => Self::calc_arg_field(&f, 12),
"acalcout" | "scalcout" => {
Self::calc_arg_field(&f, 12)
|| matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
}
_ => false,
}
}
fn graphic_link_backed_field(rtype: &str, field: &str) -> bool {
let f = field.to_ascii_uppercase();
match rtype {
"calc" | "calcout" | "sub" => Self::calc_arg_field(&f, 21),
"seq" => {
matches!(f.as_bytes(), [b'D', b'O', c] if c.is_ascii_digit() || (b'A'..=b'F').contains(c))
}
_ => false,
}
}
fn static_field_type(&self, field: &str) -> Option<crate::types::DbFieldType> {
let desc = self.field_desc(field)?;
(!desc.runtime_typed).then_some(desc.dbf_type)
}
fn rec_gbl_range_for(&self, field: &str) -> Option<(f64, f64)> {
let desc = self.field_desc(field)?;
crate::server::recgbl::rec_gbl_get_graphic_double(
self.static_field_type(field),
desc.menu.is_some(),
)
}
pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot) {
use crate::server::database::filters::FilteredMonitorEvent;
use crate::server::recgbl::EventMask;
let origin = ambient_write_origin();
for (field, value, posting_mask) in &snapshot.changed_fields {
let posting_mask = *posting_mask;
if let Some(subs) = self.subscribers.get(field) {
let mon_snap = self.make_monitor_snapshot(field, value.clone());
for sub in subs {
if !sub.active {
continue;
}
let sub_mask = EventMask::from_bits(sub.mask);
if !posting_mask.is_empty() && sub_mask.intersects(posting_mask) {
let event = MonitorEvent {
snapshot: mon_snap.clone(),
origin,
mask: posting_mask,
};
let filtered = if sub.filters.is_empty() {
Some(event)
} else {
sub.filters
.apply(FilteredMonitorEvent::new(event))
.map(|fe| fe.event)
};
let Some(event) = filtered else {
continue;
};
sub.post(event);
}
}
}
}
}
pub fn notify_field(&mut self, field: &str, mask: crate::server::recgbl::EventMask) {
self.notify_field_with_origin(field, mask, 0);
}
pub fn notify_record_alarm(&mut self) {
let fields: Vec<String> = self.subscribers.keys().cloned().collect();
for field in fields {
self.notify_field(&field, crate::server::recgbl::EventMask::ALARM);
}
}
pub fn notify_field_with_origin(
&mut self,
field: &str,
mask: crate::server::recgbl::EventMask,
origin: u64,
) {
use crate::server::database::filters::FilteredMonitorEvent;
let origin = if origin != 0 {
origin
} else {
ambient_write_origin()
};
let publishes_value = mask.intersects(
crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
);
let mut posted: Option<EpicsValue> = None;
if let Some(subs) = self.subscribers.get(field) {
if let Some(value) = self.resolve_field(field) {
if publishes_value {
posted = Some(value.clone());
}
let mon_snap = self.make_monitor_snapshot(field, value);
for sub in subs {
if !sub.active {
continue;
}
let sub_mask = crate::server::recgbl::EventMask::from_bits(sub.mask);
if mask.is_empty() || sub_mask.intersects(mask) {
let event = MonitorEvent {
snapshot: mon_snap.clone(),
origin,
mask,
};
let filtered = if sub.filters.is_empty() {
Some(event)
} else {
sub.filters
.apply(FilteredMonitorEvent::new(event))
.map(|fe| fe.event)
};
let Some(event) = filtered else {
continue;
};
sub.post(event);
}
}
}
}
if let Some(value) = posted {
self.record_value_post(field, value);
}
}
pub fn add_subscriber(
&mut self,
field: &str,
sid: u32,
data_type: DbFieldType,
mask: u16,
) -> Option<EventReader> {
self.add_subscriber_on(&EventUser::new(), field, sid, data_type, mask)
}
pub fn add_subscriber_on(
&mut self,
user: &EventUser,
field: &str,
sid: u32,
data_type: DbFieldType,
mask: u16,
) -> Option<EventReader> {
let cap = crate::server::pv::max_subscribers_per_pv();
let field_str = field.to_string();
let bucket = self.subscribers.entry(field_str.clone()).or_default();
bucket.retain(|s| !s.is_closed());
if bucket.len() >= cap {
tracing::warn!(
record = %self.name,
field = %field_str,
live = bucket.len(),
cap,
"record field subscriber cap reached, refusing add_subscriber"
);
return None;
}
let (sink, reader) = crate::server::event_queue::attach(user, sid);
bucket.push(Subscriber {
sid,
data_type,
mask,
sink,
filters: crate::server::database::filters::FilterChain::new(),
active: true,
});
if !self.last_posted.contains_key(&field_str) {
if let Some(val) = self.resolve_field(&field_str) {
self.last_posted.insert(field_str, val);
}
}
Some(reader)
}
pub fn attach_filter_to_last_subscriber(
&mut self,
field: &str,
filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
) -> bool {
if let Some(bucket) = self.subscribers.get_mut(field) {
if let Some(sub) = bucket.last_mut() {
sub.filters.push(filter);
return true;
}
}
false
}
pub fn remove_subscriber(&mut self, sid: u32) {
for subs in self.subscribers.values_mut() {
subs.retain(|s| s.sid != sid);
}
}
pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
for subs in self.subscribers.values_mut() {
for sub in subs.iter_mut() {
if sub.sid == sid {
sub.active = active;
}
}
}
}
pub fn cleanup_subscribers(&mut self) {
for subs in self.subscribers.values_mut() {
subs.retain(|s| !s.is_closed());
}
}
}
pub(crate) fn check_deadband(newval: f64, oldval: f64, deadband: f64) -> bool {
if oldval.is_nan() {
return true;
}
if deadband < 0.0 {
return true;
}
let new_finite = newval.is_finite();
let old_finite = oldval.is_finite();
if new_finite && old_finite {
return (newval - oldval).abs() > deadband;
}
if newval.is_nan() {
return true;
}
if new_finite != old_finite {
return true;
}
newval != oldval
}
#[cfg(test)]
mod device_menu_marking_tests {
use super::*;
use crate::server::records::ai::AiRecord;
use crate::server::records::calc::CalcRecord;
use crate::server::records::mbbo::MbboRecord;
#[test]
fn dtyp_of_a_record_type_with_no_device_support_supplies_no_choices() {
let inst = RecordInstance::new("X".into(), CalcRecord::default());
assert!(
super::super::dbd_generated::device_menu("calc").is_none(),
"precondition: calc declares no device() line (C ftPvt == NULL)"
);
assert!(
inst.device_choices().is_none(),
"a record type with no device menu must report None, not an empty list"
);
assert!(
inst.enum_string_form_for("DTYP").is_none(),
"DTYP must supply no enum-string form, so no `value.choices` is marked"
);
}
#[test]
fn dtyp_of_a_record_type_with_device_support_supplies_its_choices() {
let inst = RecordInstance::new("X".into(), AiRecord::default());
let choices = inst
.device_choices()
.expect("ai declares device() lines, so its menu exists");
assert!(
choices.iter().any(|c| c.as_str_lossy() == "Soft Channel"),
"ai's device menu must carry its declared choices, got {choices:?}"
);
assert!(inst.enum_string_form_for("DTYP").is_some());
}
#[test]
fn dtyp_index_is_zero_when_the_record_type_has_no_device_menu() {
let inst = RecordInstance::new("X".into(), CalcRecord::default());
assert_eq!(inst.dtyp_index(), 0);
}
#[test]
fn a_registered_device_menu_merges_after_the_base_declared_choices() {
static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
super::super::register_device_menu("mbbo", ASYN_MBBO);
let mut inst = RecordInstance::new("X".into(), MbboRecord::default());
let merged: Vec<String> = inst
.device_choices()
.expect("mbbo declares device() lines")
.iter()
.map(|c| c.as_str_lossy().into_owned())
.collect();
assert_eq!(
merged,
vec![
"Soft Channel",
"Raw Soft Channel",
"Async Soft Channel",
"asynInt32",
"asynUInt32Digital",
],
"base-declared choices first, asyn-contributed appended in asyn.dbd order"
);
inst.common.dtyp = "asynInt32".into();
assert_eq!(
inst.dtyp_index(),
3,
"an asyn DTYP indexes into the merged menu, not an appended own slot"
);
}
#[test]
fn calc_stays_none_after_asyn_menus_are_registered() {
static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
super::super::register_device_menu("mbbo", ASYN_MBBO);
let inst = RecordInstance::new("X".into(), CalcRecord::default());
assert!(
inst.device_choices().is_none(),
"calc declares no device() and gets no contribution — still None"
);
}
}
#[cfg(test)]
mod property_support_owner_tests {
use crate::server::record::record_trait::default_property_support;
use crate::server::snapshot::PropertySupport as P;
#[test]
fn sseq_supplies_only_precision() {
assert_eq!(
default_property_support("sseq"),
P {
precision: true,
..P::NONE
}
);
}
#[test]
fn an_untranscribed_record_type_keeps_the_permissive_default() {
assert_eq!(default_property_support("no-such-record-type"), P::NUMERIC);
}
}
#[cfg(test)]
mod metadata_cache_tests {
use super::*;
use crate::server::records::ai::AiRecord;
fn ai_instance() -> RecordInstance {
let mut rec = AiRecord::default();
let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
let _ = rec.put_field("PREC", EpicsValue::Short(2));
let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
RecordInstance::new("TEMP".to_string(), rec)
}
#[test]
fn bfr10_record_field_overflow_counts_dropped_event() {
use crate::server::event_queue::{event_que_size, events_per_que};
use crate::server::pv::dropped_monitor_events;
use crate::server::recgbl::EventMask;
let mut inst = ai_instance();
let _reader = inst
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Double,
EventMask::VALUE.bits(),
)
.expect("subscriber added");
let before = dropped_monitor_events();
let posts = event_que_size() - events_per_que() + 10;
for _ in 0..posts {
inst.notify_field_with_origin("VAL", EventMask::VALUE, 0);
}
let after = dropped_monitor_events();
assert!(
after > before,
"a post that replaces an unobserved queued entry must record a \
dropped monitor event (before={before}, after={after})"
);
}
#[test]
fn metadata_field_set_check() {
assert!(is_metadata_field("EGU"));
assert!(is_metadata_field("PREC"));
assert!(is_metadata_field("HOPR"));
assert!(is_metadata_field("LOPR"));
assert!(is_metadata_field("HIHI"));
assert!(is_metadata_field("DRVH"));
assert!(is_metadata_field("ZNAM"));
assert!(is_metadata_field("ZRST"));
assert!(is_metadata_field("FFST"));
assert!(!is_metadata_field("VAL"));
assert!(!is_metadata_field("DESC"));
assert!(!is_metadata_field("SCAN"));
assert!(!is_metadata_field("PHAS"));
}
#[test]
fn cache_starts_empty_then_populates_on_first_snapshot() {
let inst = ai_instance();
assert!(inst.metadata_cache.lock().unwrap().is_none());
let snap = inst.snapshot_for_field("VAL").unwrap();
let display = snap.display.expect("ai snapshot must have display");
assert_eq!(display.units, "degC");
assert_eq!(display.precision, 2);
assert_eq!(display.upper_disp_limit, 100.0);
assert_eq!(display.lower_disp_limit, 0.0);
assert!(inst.metadata_cache.lock().unwrap().is_some());
}
#[test]
fn q_form_info_tag_sets_display_form_index() {
let mut inst = ai_instance();
inst.set_info("Q:form", "Hex");
let snap = inst.snapshot_for_field("VAL").unwrap();
let display = snap.display.expect("ai snapshot must have display");
assert_eq!(display.form, 4, "Q:form=Hex -> display.form index 4");
}
#[test]
fn q_form_applies_to_the_val_field_only() {
let mut inst = ai_instance();
inst.set_info("Q:form", "Hex");
let val = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(val.display.expect("ai display").form, 4);
for non_val in ["RVAL", "SEVR", "HOPR"] {
let Some(snap) = inst.snapshot_for_field(non_val) else {
panic!("ai.{non_val} must resolve");
};
assert_eq!(
snap.display.expect("ai display").form,
0,
"Q:form must not reach ai.{non_val} — pvxs applies it to VAL only"
);
}
let update = inst.make_monitor_snapshot("RVAL", EpicsValue::Long(7));
assert_eq!(
update.display.expect("ai display").form,
0,
"a monitor update on a non-VAL field carries the default form too"
);
let update = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
assert_eq!(update.display.expect("ai display").form, 4);
}
#[test]
fn q_form_absent_or_unknown_leaves_form_default() {
let inst = ai_instance();
let snap = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap.display.expect("ai display").form, 0);
let mut inst2 = ai_instance();
inst2.set_info("Q:form", "Nonsense");
let snap2 = inst2.snapshot_for_field("VAL").unwrap();
assert_eq!(snap2.display.expect("ai display").form, 0);
}
#[test]
fn qtime_nsec_mask_matches_pvxs_updatensecmask() {
let cases: &[(&str, u64)] = &[
("nsec:lsb:20", (1 << 20) - 1),
("nsec:lsb:1", 1),
("nsec:lsb: 4 ", 0xF),
("nsec:lsb:+4", 0xF),
("nsec:lsb:31", 0x7FFF_FFFF),
("nsec:lsb:0", 0),
("NSEC:LSB:4", 0),
("Nsec:Lsb:4", 0),
("nsec: lsb: 4", 0),
(" nsec:lsb:4", 0),
("nsec:lsb:", 0),
("nsec:lsb:abc", 0),
("nsec:lsb:4x", 0),
("nsec:lsb:4 5", 0),
("nsec:lsb:99999999999999999999", 0),
("nsec:lsb:2147483648", 0),
];
for (tag, want) in cases {
let mut inst = ai_instance();
inst.set_info("Q:time:tag", *tag);
assert_eq!(
inst.qtime_nsec_mask(),
*want,
"info(Q:time:tag, {tag:?}) must resolve to nsecMask {want:#x}"
);
}
assert_eq!(ai_instance().qtime_nsec_mask(), 0);
}
#[test]
fn qtime_nsec_lsb_31_is_served_not_ignored() {
use std::time::{Duration, SystemTime};
let mut inst = ai_instance();
inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
inst.common.utag = 5;
inst.set_info("Q:time:tag", "nsec:lsb:31");
let snap = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap.user_tag, 123_456_700);
assert_eq!(snap.timestamp.subsec_nanos(), 0);
assert_eq!(snap.timestamp.unix_secs(), 42);
}
#[test]
fn qtime_uppercase_tag_leaves_timestamp_untouched() {
use std::time::{Duration, SystemTime};
let mut inst = ai_instance();
inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
inst.common.utag = 5;
inst.set_info("Q:time:tag", "NSEC:LSB:4");
let snap = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(
snap.user_tag, 5,
"record utag must survive a non-matching tag"
);
assert_eq!(snap.timestamp.subsec_nanos(), 123_456_700);
}
#[test]
fn snapshot_serves_record_utag_as_timestamp_usertag() {
let mut inst = ai_instance();
inst.common.utag = 0x9000_0000;
let want = 0x9000_0000u32 as i32;
let get = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(
get.user_tag, want,
"GET path must serve the record's utag as timeStamp.userTag"
);
let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
assert_eq!(
mon.user_tag, want,
"MONITOR path must carry the record's utag too"
);
}
#[test]
fn cache_hit_returns_same_metadata() {
let inst = ai_instance();
let snap1 = inst.snapshot_for_field("VAL").unwrap();
let display1 = snap1.display.unwrap();
let snap2 = inst.snapshot_for_field("VAL").unwrap();
let display2 = snap2.display.unwrap();
assert_eq!(display1.units, display2.units);
assert_eq!(display1.precision, display2.precision);
assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
}
#[test]
fn invalidate_clears_cache() {
let inst = ai_instance();
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
inst.invalidate_metadata_cache();
assert!(inst.metadata_cache.lock().unwrap().is_none());
}
#[test]
fn notify_field_written_invalidates_for_metadata_field() {
let inst = ai_instance();
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
inst.notify_field_written("EGU");
assert!(inst.metadata_cache.lock().unwrap().is_none());
}
#[test]
fn notify_field_written_skips_non_metadata_field() {
let inst = ai_instance();
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
inst.notify_field_written("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
inst.notify_field_written("DESC");
assert!(inst.metadata_cache.lock().unwrap().is_some());
}
#[test]
fn notify_field_written_is_case_insensitive() {
let inst = ai_instance();
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
inst.notify_field_written("egu");
assert!(inst.metadata_cache.lock().unwrap().is_none());
}
#[test]
fn notify_field_written_if_changed_skips_when_unchanged() {
let mut inst = ai_instance();
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
let prev = inst.record.get_field("EGU");
let _ = inst.record.put_field("EGU", prev.clone().unwrap());
inst.notify_field_written_if_changed("EGU", prev.as_ref());
assert!(
inst.metadata_cache.lock().unwrap().is_some(),
"no-op put must not invalidate the metadata cache"
);
}
#[test]
fn notify_field_written_if_changed_invalidates_on_real_change() {
let mut inst = ai_instance();
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
let prev = inst.record.get_field("EGU");
let _ = inst
.record
.put_field("EGU", EpicsValue::String("kPa".into()));
inst.notify_field_written_if_changed("EGU", prev.as_ref());
assert!(
inst.metadata_cache.lock().unwrap().is_none(),
"real metadata change must invalidate cache"
);
}
#[test]
fn notify_field_written_if_changed_skips_non_metadata_field() {
let mut inst = ai_instance();
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
inst.notify_field_written_if_changed("VAL", None);
assert!(inst.metadata_cache.lock().unwrap().is_some());
}
#[test]
fn cache_picks_up_new_value_after_invalidation() {
let mut inst = ai_instance();
let snap1 = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap1.display.unwrap().units, "degC");
let _ = inst
.record
.put_field("EGU", EpicsValue::String("mV".into()));
inst.notify_field_written("EGU");
let snap2 = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap2.display.unwrap().units, "mV");
}
#[test]
fn property_support_masks_what_the_record_type_does_not_supply() {
use crate::server::records::longout::LongoutRecord;
use crate::server::records::stringout::StringoutRecord;
use crate::server::records::waveform::WaveformRecord;
let ai = ai_instance();
let p = ai.snapshot_for_field("VAL").unwrap().properties;
assert_eq!(p, PropertySupport::NUMERIC);
assert_eq!(
ai.snapshot_for_field("VAL").unwrap().precision(),
Some(2),
"an ai supplies get_precision and VAL is DBF_DOUBLE"
);
let rval = ai.snapshot_for_field("RVAL").unwrap();
assert!(
!rval.properties.precision && rval.precision().is_none(),
"a non-float field supplies no precision even when the rset does"
);
assert!(
rval.properties.units,
"the other slots are unaffected by the field's type"
);
let lo = RecordInstance::new("LO".to_string(), LongoutRecord::default());
let lo = lo.snapshot_for_field("VAL").unwrap();
assert!(!lo.properties.precision && lo.precision().is_none());
assert!(lo.properties.units && lo.properties.graphic_double);
let so = RecordInstance::new("SO".to_string(), StringoutRecord::default());
let so = so.snapshot_for_field("VAL").unwrap();
assert_eq!(so.properties, PropertySupport::NONE);
assert!(so.units().is_none(), "a stringout supplies no EGU");
let wf = RecordInstance::new("WF".to_string(), WaveformRecord::default());
let wf = wf.snapshot_for_field("VAL").unwrap();
assert!(
!wf.properties.alarm_double && wf.alarm_limits().is_none(),
"a waveform supplies no alarm limits — a GUI must not draw bands at zero"
);
assert!(wf.properties.units && wf.properties.graphic_double);
}
#[test]
fn make_monitor_snapshot_uses_cache() {
let inst = ai_instance();
assert!(inst.metadata_cache.lock().unwrap().is_none());
let snap = inst.make_monitor_snapshot("VAL", EpicsValue::Double(42.0));
assert!(snap.display.is_some());
assert!(inst.metadata_cache.lock().unwrap().is_some());
let snap2 = inst.make_monitor_snapshot("VAL", EpicsValue::Double(43.0));
let d1 = snap.display.unwrap();
let d2 = snap2.display.unwrap();
assert_eq!(d1.units, d2.units);
assert_eq!(d1.precision, d2.precision);
}
struct PerFieldMetaRecord;
impl Record for PerFieldMetaRecord {
fn record_type(&self) -> &'static str {
"ai" }
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
"EGU" => Some(EpicsValue::String("mm".into())),
"PREC" => Some(EpicsValue::Short(3)),
"HOPR" => Some(EpicsValue::Double(100.0)),
"LOPR" => Some(EpicsValue::Double(-100.0)),
_ => None,
}
}
fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
Err(CaError::FieldNotFound(name.to_string()))
}
fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
&[]
}
fn field_metadata_override(
&self,
field: &str,
) -> Option<crate::server::record::FieldMetadataOverride> {
if field != "SPD" {
return None;
}
Some(crate::server::record::FieldMetadataOverride {
units: Some("mm/sec".into()),
precision: Some(1),
disp_limits: Some((5.0, 0.5)),
ctrl_limits: Some((4.0, 1.0)),
alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
})
}
}
#[test]
fn field_metadata_override_applies_on_get_and_monitor_paths() {
let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
let snap = inst.snapshot_for_field("VAL").unwrap();
let d = snap.display.unwrap();
assert_eq!(d.units, "mm");
assert_eq!(d.precision, 3);
assert_eq!(d.upper_disp_limit, 100.0);
let snap = inst.snapshot_for_field("SPD").unwrap();
let d = snap.display.unwrap();
assert_eq!(d.units, "mm/sec");
assert_eq!(d.precision, 1);
assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
assert_eq!(
(
d.upper_alarm_limit,
d.upper_warning_limit,
d.lower_warning_limit,
d.lower_alarm_limit
),
(9.0, 8.0, -8.0, -9.0)
);
let c = snap.control.unwrap();
assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
let snap = inst.make_monitor_snapshot("SPD", EpicsValue::Double(2.0));
let d = snap.display.unwrap();
assert_eq!(d.units, "mm/sec");
assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
let c = snap.control.unwrap();
assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
}
struct ReadbackDeadbandRecord {
val: f64,
rbv: f64,
deadband: f64,
}
impl Record for ReadbackDeadbandRecord {
fn record_type(&self) -> &'static str {
"ai"
}
fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
self.rbv += 30.0;
Ok(crate::server::record::ProcessOutcome::complete())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::Double(self.val)),
"RBV" => Some(EpicsValue::Double(self.rbv)),
"MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
_ => None,
}
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match (name, value) {
("VAL", EpicsValue::Double(v)) => {
self.val = v;
Ok(())
}
("MDEL", EpicsValue::Double(v)) => {
self.deadband = v;
Ok(())
}
_ => Err(CaError::FieldNotFound(name.to_string())),
}
}
fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
&[]
}
fn monitor_deadband_value(&self) -> Option<EpicsValue> {
Some(EpicsValue::Double(self.rbv))
}
fn monitor_deadband_field(&self) -> &'static str {
"RBV"
}
}
#[test]
fn deadband_field_routes_readback_and_val_posts_only_on_change() {
use crate::server::recgbl::EventMask;
let mut inst = RecordInstance::new(
"RDB".to_string(),
ReadbackDeadbandRecord {
val: 5.0,
rbv: 0.0,
deadband: 10.0,
},
);
let _val_rx = inst
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Double,
EventMask::VALUE.bits(),
)
.expect("VAL subscriber");
let _rbv_rx = inst
.add_subscriber(
"RBV",
2,
crate::types::DbFieldType::Double,
EventMask::VALUE.bits(),
)
.expect("RBV subscriber");
let names = |snap: &ProcessSnapshot| {
snap.changed_fields
.iter()
.map(|(n, _, _)| n.clone())
.collect::<Vec<_>>()
};
let (snap, _) = inst.process_local().unwrap();
let n = names(&snap);
assert!(n.contains(&"RBV".to_string()), "{n:?}");
assert!(
!n.contains(&"VAL".to_string()),
"VAL unchanged since subscribe must not post: {n:?}"
);
let (snap, _) = inst.process_local().unwrap();
let n = names(&snap);
assert!(n.contains(&"RBV".to_string()), "RBV crossed MDEL: {n:?}");
assert!(
!n.contains(&"VAL".to_string()),
"unchanged VAL must not post: {n:?}"
);
let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
let (snap, _) = inst.process_local().unwrap();
let n = names(&snap);
assert!(
!n.contains(&"RBV".to_string()),
"MDEL must throttle RBV: {n:?}"
);
let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
let (snap, _) = inst.process_local().unwrap();
let n = names(&snap);
assert!(
n.contains(&"VAL".to_string()),
"changed VAL must post: {n:?}"
);
assert!(
!n.contains(&"RBV".to_string()),
"MDEL must throttle RBV: {n:?}"
);
}
#[test]
fn subroutineless_asub_process_resets_val_and_stops_scan_overposting() {
use crate::server::recgbl::EventMask;
use crate::server::records::asub_record::ASubRecord;
let mut inst = RecordInstance::new("ASUB".to_string(), ASubRecord::default());
assert!(inst.subroutine.is_none());
let _val_rx = inst
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Long,
EventMask::VALUE.bits(),
)
.expect("VAL subscriber");
let posts_val =
|snap: &ProcessSnapshot| snap.changed_fields.iter().any(|(n, _, _)| n == "VAL");
let _ = inst.process_local().unwrap();
assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
assert_eq!(
inst.record.multi_output_links().len(),
21,
"empty-SNAM do_sub status must be 0, not S_db_BadSub"
);
inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
let (snap, _) = inst.process_local().unwrap();
assert_eq!(
inst.record.get_field("VAL"),
Some(EpicsValue::Long(0)),
"a scan must reset VAL to the do_sub status (0)"
);
assert!(
!posts_val(&snap),
"a scan that resets VAL to 0 must not re-post it"
);
inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
let (snap, _) = inst.process_local().unwrap();
assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
assert!(
!posts_val(&snap),
"repeated scans must not re-post the reset VAL"
);
}
struct ForcePostRecord {
diff: f64,
val: f64,
}
impl Record for ForcePostRecord {
fn record_type(&self) -> &'static str {
"ai"
}
fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
Ok(crate::server::record::ProcessOutcome::complete())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"DIFF" => Some(EpicsValue::Double(self.diff)),
"VAL" => Some(EpicsValue::Double(self.val)),
_ => None,
}
}
fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
Err(CaError::FieldNotFound(name.to_string()))
}
fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
&[]
}
fn force_posted_fields(&self) -> &'static [&'static str] {
&["DIFF"]
}
}
#[test]
fn force_posted_field_reposts_unchanged_value_each_cycle() {
use crate::server::recgbl::EventMask;
let mut inst = RecordInstance::new(
"FP".to_string(),
ForcePostRecord {
diff: 2.5,
val: 1.0,
},
);
let _diff_rx = inst
.add_subscriber(
"DIFF",
1,
crate::types::DbFieldType::Double,
EventMask::VALUE.bits(),
)
.expect("DIFF subscriber");
let _val_rx = inst
.add_subscriber(
"VAL",
2,
crate::types::DbFieldType::Double,
EventMask::VALUE.bits(),
)
.expect("VAL subscriber");
let names = |snap: &ProcessSnapshot| {
snap.changed_fields
.iter()
.map(|(n, _, _)| n.clone())
.collect::<Vec<_>>()
};
let (snap1, _) = inst.process_local().unwrap();
assert!(
names(&snap1).contains(&"DIFF".to_string()),
"DIFF posts on first publish: {:?}",
names(&snap1)
);
let (snap2, _) = inst.process_local().unwrap();
assert!(
names(&snap2).contains(&"DIFF".to_string()),
"force-posted DIFF must re-post when unchanged: {:?}",
names(&snap2)
);
assert!(
!names(&snap2).contains(&"VAL".to_string()),
"an unchanged non-force field must not re-post: {:?}",
names(&snap2)
);
let diff_mask = snap2
.changed_fields
.iter()
.find(|(n, _, _)| n == "DIFF")
.map(|(_, _, m)| *m)
.expect("DIFF post present");
assert_eq!(
diff_mask.bits(),
(EventMask::VALUE | EventMask::LOG).bits(),
"forced re-post mask is DBE_VAL_LOG"
);
}
struct LogSweepRecord {
s1: i32,
s2: i32,
}
impl Record for LogSweepRecord {
fn record_type(&self) -> &'static str {
"scaler"
}
fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
Ok(crate::server::record::ProcessOutcome::complete())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"S1" => Some(EpicsValue::Long(self.s1)),
"S2" => Some(EpicsValue::Long(self.s2)),
_ => None,
}
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match (name, value) {
("S1", EpicsValue::Long(v)) => {
self.s1 = v;
Ok(())
}
("S2", EpicsValue::Long(v)) => {
self.s2 = v;
Ok(())
}
_ => Err(CaError::FieldNotFound(name.to_string())),
}
}
fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
&[]
}
fn log_swept_fields(&self) -> &'static [&'static str] {
&["S1"]
}
}
#[test]
fn log_swept_field_reposts_unchanged_with_log_mask_only() {
use crate::server::recgbl::EventMask;
let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
let _s1_rx = inst
.add_subscriber(
"S1",
1,
crate::types::DbFieldType::Long,
EventMask::LOG.bits(),
)
.expect("S1 subscriber");
let _s2_rx = inst
.add_subscriber(
"S2",
2,
crate::types::DbFieldType::Long,
EventMask::VALUE.bits(),
)
.expect("S2 subscriber");
let names = |snap: &ProcessSnapshot| {
snap.changed_fields
.iter()
.map(|(n, _, _)| n.clone())
.collect::<Vec<_>>()
};
let count_of = |snap: &ProcessSnapshot, f: &str| {
snap.changed_fields
.iter()
.filter(|(n, _, _)| n == f)
.count()
};
let mask_of = |snap: &ProcessSnapshot, f: &str| {
snap.changed_fields
.iter()
.find(|(n, _, _)| n == f)
.map(|(_, _, m)| *m)
};
let (snap1, _) = inst.process_local().unwrap();
assert!(
names(&snap1).contains(&"S1".to_string()),
"log-swept S1 must re-post when unchanged: {:?}",
names(&snap1)
);
assert!(
!names(&snap1).contains(&"S2".to_string()),
"unchanged non-swept S2 must not re-post: {:?}",
names(&snap1)
);
assert_eq!(
mask_of(&snap1, "S1").unwrap().bits(),
(EventMask::LOG | EventMask::ALARM).bits(),
"idle sweep posts DBE_LOG + the alarm transition, never DBE_VALUE"
);
inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
let (snap2, _) = inst.process_local().unwrap();
assert_eq!(
count_of(&snap2, "S1"),
2,
"a changed swept field posts twice — change post + independent \
DBE_LOG sweep: {:?}",
snap2.changed_fields
);
let s1_masks: Vec<u16> = snap2
.changed_fields
.iter()
.filter(|(n, _, _)| n == "S1")
.map(|(_, _, m)| m.bits())
.collect();
assert_eq!(
s1_masks,
vec![
(EventMask::VALUE | EventMask::LOG).bits(),
EventMask::LOG.bits()
],
"change post first (VALUE|LOG here — this stub is not a \
value_only_change_fields record), then the sweep's literal DBE_LOG"
);
let (snap3, _) = inst.process_local().unwrap();
assert_eq!(
mask_of(&snap3, "S1").unwrap().bits(),
EventMask::LOG.bits(),
"unchanged-again S1 returns to the DBE_LOG-only sweep"
);
}
struct AlarmingLogSweepRecord {
s1: i32,
alarm: bool,
}
impl Record for AlarmingLogSweepRecord {
fn record_type(&self) -> &'static str {
"scaler"
}
fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
Ok(crate::server::record::ProcessOutcome::complete())
}
fn raises_udf_alarm(&self) -> bool {
false
}
fn check_alarms(&mut self, common: &mut crate::server::record::CommonFields) {
if self.alarm {
crate::server::recgbl::rec_gbl_set_sevr(
common,
crate::server::recgbl::alarm_status::UDF_ALARM,
crate::server::record::AlarmSeverity::Invalid,
);
}
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"S1" => Some(EpicsValue::Long(self.s1)),
_ => None,
}
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match (name, value) {
("S1", EpicsValue::Long(v)) => {
self.s1 = v;
Ok(())
}
_ => Err(CaError::FieldNotFound(name.to_string())),
}
}
fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
&[]
}
fn log_swept_fields(&self) -> &'static [&'static str] {
&["S1"]
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
#[test]
fn b19_log_swept_field_carries_the_alarm_transition_bits() {
use crate::server::recgbl::EventMask;
let mut inst = RecordInstance::new(
"SW".to_string(),
AlarmingLogSweepRecord {
s1: 7,
alarm: false,
},
);
let _s1_rx = inst
.add_subscriber(
"S1",
1,
crate::types::DbFieldType::Long,
(EventMask::LOG | EventMask::ALARM).bits(),
)
.expect("S1 subscriber");
let mask_of = |snap: &ProcessSnapshot, f: &str| {
snap.changed_fields
.iter()
.find(|(n, _, _)| n == f)
.map(|(_, _, m)| *m)
};
let _ = inst.process_local().unwrap();
let (snap1, _) = inst.process_local().unwrap();
assert_eq!(
mask_of(&snap1, "S1").unwrap().bits(),
EventMask::LOG.bits(),
"no alarm transition → the sweep is DBE_LOG only"
);
if let Some(r) = inst
.record
.as_any_mut()
.and_then(|a| a.downcast_mut::<AlarmingLogSweepRecord>())
{
r.alarm = true;
}
let (snap2, _) = inst.process_local().unwrap();
assert_eq!(
mask_of(&snap2, "S1").unwrap().bits(),
(EventMask::LOG | EventMask::ALARM).bits(),
"the severity transition must reach the swept field (C drops it)"
);
let (snap3, _) = inst.process_local().unwrap();
assert_eq!(
mask_of(&snap3, "S1").unwrap().bits(),
EventMask::LOG.bits(),
"a steady severity is not a transition"
);
}
struct MutatingMetaRecord {
val: f64,
egu: String,
took_change: bool,
}
impl Record for MutatingMetaRecord {
fn record_type(&self) -> &'static str {
"ai" }
fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
self.egu = "kV".into();
self.took_change = true;
Ok(crate::server::record::ProcessOutcome::complete())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::Double(self.val)),
"EGU" => Some(EpicsValue::String(self.egu.clone().into())),
"PREC" => Some(EpicsValue::Short(0)),
"HOPR" => Some(EpicsValue::Double(0.0)),
"LOPR" => Some(EpicsValue::Double(0.0)),
_ => None,
}
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match (name, value) {
("VAL", EpicsValue::Double(v)) => {
self.val = v;
Ok(())
}
("EGU", EpicsValue::String(s)) => {
self.egu = s.as_str_lossy().into_owned();
Ok(())
}
_ => Err(CaError::FieldNotFound(name.to_string())),
}
}
fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
&[]
}
fn took_metadata_change(&mut self) -> bool {
let was = self.took_change;
self.took_change = false; was
}
}
#[test]
fn process_local_invalidates_cache_on_took_metadata_change() {
let mut inst = RecordInstance::new(
"MUT".to_string(),
MutatingMetaRecord {
val: 1.0,
egu: "V".to_string(),
took_change: false,
},
);
let snap1 = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap1.display.unwrap().units, "V");
assert!(inst.metadata_cache.lock().unwrap().is_some());
let _ = inst.process_local();
assert!(
inst.metadata_cache.lock().unwrap().is_none(),
"process_local should invalidate cache when took_metadata_change is true"
);
let snap2 = inst.snapshot_for_field("VAL").unwrap();
assert_eq!(snap2.display.unwrap().units, "kV");
}
struct StableMetaRecord {
val: f64,
}
impl Record for StableMetaRecord {
fn record_type(&self) -> &'static str {
"ai"
}
fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
self.val += 1.0;
Ok(crate::server::record::ProcessOutcome::complete())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::Double(self.val)),
"EGU" => Some(EpicsValue::String("V".into())),
"PREC" => Some(EpicsValue::Short(0)),
"HOPR" => Some(EpicsValue::Double(0.0)),
"LOPR" => Some(EpicsValue::Double(0.0)),
_ => None,
}
}
fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
Ok(())
}
fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
&[]
}
}
#[test]
fn process_local_keeps_cache_when_no_metadata_change() {
let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
let _ = inst.snapshot_for_field("VAL");
assert!(inst.metadata_cache.lock().unwrap().is_some());
let _ = inst.process_local();
assert!(inst.metadata_cache.lock().unwrap().is_some());
let _ = inst.process_local();
assert!(inst.metadata_cache.lock().unwrap().is_some());
let _ = inst.process_local();
assert!(inst.metadata_cache.lock().unwrap().is_some());
}
#[test]
fn motor_prop_yes_fields_are_property_class() {
for f in ["VBAS", "VMAX", "MRES", "DHLM", "DLLM"] {
assert!(is_metadata_field(f), "{f} must be property-class");
}
}
#[test]
fn r47_property_event_delivered_on_changed_metadata() {
use crate::server::recgbl::EventMask;
let mut inst = ai_instance();
let mut rx = inst
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Double,
EventMask::PROPERTY.bits(),
)
.expect("subscriber added");
let prev = inst.record.get_field("EGU"); let _ = inst
.record
.put_field("EGU", EpicsValue::String("kPa".into()));
inst.notify_field_written_if_changed("EGU", prev.as_ref());
assert!(
rx.try_recv().is_ok(),
"PROPERTY subscriber must receive event when metadata field changes"
);
}
#[test]
fn r47_no_event_on_unchanged_metadata() {
use crate::server::recgbl::EventMask;
let mut inst = ai_instance();
let mut rx = inst
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Double,
EventMask::PROPERTY.bits(),
)
.expect("subscriber added");
let prev = inst.record.get_field("EGU"); let _ = inst.record.put_field("EGU", prev.clone().unwrap());
inst.notify_field_written_if_changed("EGU", prev.as_ref());
assert!(
rx.try_recv().is_err(),
"PROPERTY subscriber must NOT receive event when metadata value is unchanged"
);
}
#[test]
fn r47_value_only_subscriber_no_event_on_metadata_write() {
use crate::server::recgbl::EventMask;
let mut inst = ai_instance();
let mut rx = inst
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Double,
EventMask::VALUE.bits(),
)
.expect("subscriber added");
let prev = inst.record.get_field("EGU"); let _ = inst
.record
.put_field("EGU", EpicsValue::String("kPa".into()));
inst.notify_field_written_if_changed("EGU", prev.as_ref());
assert!(
rx.try_recv().is_err(),
"VALUE-only subscriber must NOT receive event from a metadata write"
);
}
#[test]
fn r47_process_local_property_event_on_took_metadata_change() {
use crate::server::recgbl::EventMask;
let mut inst = RecordInstance::new(
"MUT2".to_string(),
MutatingMetaRecord {
val: 1.0,
egu: "V".to_string(),
took_change: false,
},
);
let mut rx = inst
.add_subscriber(
"VAL",
1,
crate::types::DbFieldType::Double,
EventMask::PROPERTY.bits(),
)
.expect("subscriber added");
let _ = inst.process_local();
assert!(
rx.try_recv().is_ok(),
"PROPERTY subscriber must receive event after process_local reports took_metadata_change"
);
}
}
#[cfg(test)]
mod aftc_filter_tests {
use crate::server::records::alarm_filter::aftc_filter;
use std::time::{Duration, SystemTime};
fn at(secs: f64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
}
#[test]
fn disabled_when_aftc_le_zero() {
let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
assert_eq!(out, 2);
assert_eq!(afvl, 0.0);
}
#[test]
fn initial_sample_seeds_state_unchanged_alarm() {
let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
assert_eq!(out, 2);
assert_eq!(afvl, 2.0);
}
#[test]
fn raises_alarm_only_after_full_time_constant() {
let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
assert!(afvl > 0.0 && afvl < 2.0);
}
#[test]
fn dt_zero_is_no_op() {
let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
assert_eq!(out, 1); assert_eq!(afvl, 1.5);
}
#[test]
fn long_steady_state_converges_to_alarm() {
let aftc = 1.0;
let mut afvl = 0.0;
let mut last = at(0.0);
let mut alarm = 0;
for i in 1..=100 {
let now = at(i as f64 * 0.05);
let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
alarm = out;
afvl = new_afvl;
last = now;
}
assert_eq!(
alarm, 2,
"after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
);
assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
}
}
#[cfg(test)]
mod check_deadband_tests {
use super::check_deadband;
#[test]
fn nan_old_value_fires() {
assert!(check_deadband(0.0, f64::NAN, 1.0));
assert!(check_deadband(f64::NAN, f64::NAN, 1.0));
}
#[test]
fn within_finite_deadband_does_not_fire() {
assert!(!check_deadband(10.0, 10.5, 1.0));
assert!(!check_deadband(10.0, 9.5, 1.0));
assert!(!check_deadband(10.0, 11.0, 1.0));
}
#[test]
fn beyond_finite_deadband_fires() {
assert!(check_deadband(10.0, 12.0, 1.0));
}
#[test]
fn negative_deadband_fires() {
assert!(check_deadband(10.0, 10.0, -1.0));
}
#[test]
fn newval_nan_with_finite_oldval_fires() {
assert!(check_deadband(f64::NAN, 10.0, 1.0));
}
#[test]
fn one_finite_one_infinite_fires() {
assert!(check_deadband(f64::INFINITY, 10.0, 1.0));
assert!(check_deadband(10.0, f64::INFINITY, 1.0));
assert!(check_deadband(f64::NEG_INFINITY, 10.0, 1.0));
}
#[test]
fn opposite_signed_infinities_fire() {
assert!(check_deadband(f64::INFINITY, f64::NEG_INFINITY, 1.0));
assert!(check_deadband(f64::NEG_INFINITY, f64::INFINITY, 1.0));
}
#[test]
fn same_signed_infinity_does_not_fire() {
assert!(!check_deadband(f64::INFINITY, f64::INFINITY, 1.0));
assert!(!check_deadband(f64::NEG_INFINITY, f64::NEG_INFINITY, 1.0));
}
}
#[cfg(test)]
mod common_field_dbload_tests {
use super::*;
use crate::server::records::ai::AiRecord;
#[test]
fn db_loaded_string_common_fields_take_effect() {
let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
let put = |inst: &mut RecordInstance, f: &str, v: &str| {
inst.put_common_field_db_load(f, EpicsValue::String(v.into()))
.unwrap_or_else(|e| panic!("put_common_field_db_load({f}, {v:?}) failed: {e}"));
};
put(&mut inst, "PHAS", "1");
assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
put(&mut inst, "TSE", "-2");
assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
put(&mut inst, "DISV", "1");
assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
put(&mut inst, "DISA", "1");
assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
put(&mut inst, "LCNT", "3");
assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
put(&mut inst, "DISP", "1");
assert!(inst.common.disp != 0, "field(DISP, \"1\")");
put(&mut inst, "UDF", "0");
assert!(inst.common.udf == 0, "field(UDF, \"0\")");
put(&mut inst, "PRIO", "HIGH");
assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
put(&mut inst, "DISS", "MAJOR");
assert_eq!(
inst.common.diss,
AlarmSeverity::Major as i16,
"field(DISS, \"MAJOR\")"
);
put(&mut inst, "UDFS", "NO_ALARM");
assert_eq!(
inst.common.udfs,
AlarmSeverity::NoAlarm as i16,
"field(UDFS, \"NO_ALARM\")"
);
put(&mut inst, "ACKT", "NO");
assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
put(&mut inst, "PRIO", "0");
assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
put(&mut inst, "DESC", "a description");
assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
}
}
#[cfg(test)]
mod declared_override_tests {
use super::*;
use crate::server::records::dfanout::DfanoutRecord;
#[test]
fn declared_but_unmodeled_field_put_is_stored_and_served() {
let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(0.0)));
assert!(inst.declared_overrides.is_empty());
inst.put_common_field("HOPR", EpicsValue::String("10".into()))
.expect("caput dfanout.HOPR 10 must be accepted");
inst.put_common_field("PREC", EpicsValue::String("3".into()))
.expect("caput dfanout.PREC 3 must be accepted");
inst.put_common_field("EGU", EpicsValue::String("volts".into()))
.expect("caput dfanout.EGU volts must be accepted");
assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(10.0)));
assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
assert_eq!(
inst.resolve_field("EGU"),
Some(EpicsValue::String("volts".into()))
);
assert_eq!(inst.resolve_field("hopr"), Some(EpicsValue::Double(10.0)));
}
#[test]
fn declared_override_honors_declared_type_range() {
let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
inst.put_common_field("PREC", EpicsValue::String("3".into()))
.expect("in-range PREC accepted");
assert!(
inst.put_common_field("PREC", EpicsValue::String("99999".into()))
.is_err(),
"PREC 99999 overflows DBF_SHORT and must be refused"
);
assert!(
inst.put_common_field("PREC", EpicsValue::String("abc".into()))
.is_err(),
"non-numeric PREC must be refused"
);
assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
}
#[test]
fn undeclared_field_is_still_not_found() {
let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
assert!(matches!(
inst.put_common_field("XYZZY", EpicsValue::String("1".into())),
Err(CaError::FieldNotFound(_))
));
assert!(inst.declared_overrides.is_empty());
}
#[test]
fn partially_modeled_field_is_not_captured_by_override() {
use crate::server::records::calcout::CalcoutRecord;
let mut inst = RecordInstance::new("CO".to_string(), CalcoutRecord::default());
let _ = inst.put_common_field("PVAL", EpicsValue::String("1".into()));
assert!(
inst.declared_overrides.is_empty(),
"a field the record serves via get_field must not enter the override map"
);
}
}