epics-base-rs 0.21.0

Pure Rust EPICS IOC core — record system, database, iocsh, calc engine
Documentation
//! `Db State` device support — EPICS base `devBiDbState.c` / `devBoDbState.c`
//! (`devSoft.dbd`).
//!
//! One DTYP (`"Db State"`, INST_IO) shared by two records — `bi` (input,
//! `devBiDbState`) and `bo` (output, `devBoDbState`). The INST_IO `INP` (bi) /
//! `OUT` (bo) instio string names a process-global named boolean in the
//! `dbState` registry; `bi` reads the bit into `VAL`, `bo` sets/clears it from
//! `VAL`.
//!
//! C `read_bi` (`devBiDbState.c:63-71`): `if (dpvt) { prec->val =
//! dbStateGet(dpvt); prec->udf = FALSE; } return 2` — VAL written directly, so
//! the bi conversion is skipped (return 2). C `write_bo` (`devBoDbState.c:62-69`):
//! `if (prec->val) dbStateSet(dpvt) else dbStateClear(dpvt)`. The state handle
//! is resolved once by `add_record` from the instio string via `dbStateFind`,
//! creating it (with a one-time `errlogInfo` notice) when absent; an empty
//! instio string leaves `dpvt = NULL`, so read/write no-op.
//!
//! The same `dbState` registry backs the `sync` JSON filter (`database::
//! filters::sync`), so a `Db State` bo and a `sync`-filtered monitor observe
//! the same named bit.

use std::sync::Arc;

use crate::error::{CaError, CaResult};
use crate::runtime::log::{ErrlogSevEnum, errlog_sev_printf};
use crate::server::database::filters::sync::{DbState, db_state_registry};
use crate::server::device_support::{DeviceReadOutcome, DeviceSupport};
use crate::server::record::Record;
use crate::types::EpicsValue;

/// `Db State` device support for the `bi` / `bo` records.
pub struct DbStateDeviceSupport {
    /// INST_IO `INP` string — the `bi` state-name source.
    inp: String,
    /// INST_IO `OUT` string — the `bo` state-name source.
    out: String,
    /// Resolved named state; `None` when the instio name was empty
    /// (C `dpvt == NULL` → read/write no-op).
    state: Option<Arc<DbState>>,
}

impl DbStateDeviceSupport {
    /// Construct from the record's INST_IO `INP` and `OUT`
    /// ([`DeviceSupportContext`](crate::server::ioc_app::DeviceSupportContext)).
    /// The state name is resolved in [`init`](DbStateDeviceSupport::init),
    /// where the record type selects `INP` (bi) or `OUT` (bo).
    pub fn new(inp: &str, out: &str) -> Self {
        Self {
            inp: inp.to_string(),
            out: out.to_string(),
            state: None,
        }
    }
}

impl DeviceSupport for DbStateDeviceSupport {
    fn dtyp(&self) -> &str {
        "Db State"
    }

    fn init(&mut self, record: &mut dyn Record) -> CaResult<()> {
        // C registers a SEPARATE dset per record type (devBiDbState for bi,
        // devBoDbState for bo); both carry DTYP "Db State". The bi dset reads
        // the state name from INP, the bo dset from OUT. The Rust dynamic
        // factory builds one device for the DTYP regardless of record type, so
        // gate the record type here and pick the matching instio source.
        let rt = record.record_type();
        let raw = match rt {
            "bi" => self.inp.as_str(),
            "bo" => self.out.as_str(),
            other => {
                return Err(CaError::InvalidValue(format!(
                    "DTYP='Db State': unsupported record type '{other}' (use bi or bo)"
                )));
            }
        };

        // C `add_record` reads `inp/out.value.instio.string` (the post-`@`
        // content). An empty string leaves `dpvt = NULL` and the record never
        // resolves a state — read/write no-op.
        let name = raw.strip_prefix('@').unwrap_or(raw);
        if name.is_empty() {
            self.state = None;
            return Ok(());
        }

        // C `add_record`: `dbStateFind`, and if absent AND non-empty, log an
        // `errlogInfo` notice then `dbStateCreate`. `errlogInfo` is below the
        // default `sevToLog` (Minor), so the notice is suppressed unless the
        // threshold is lowered — `errlog_sev_printf(Info, …)` mirrors both the
        // message and that suppression exactly.
        let registry = db_state_registry();
        self.state = Some(match registry.find(name) {
            Some(s) => s,
            None => {
                let devsup = if rt == "bi" {
                    "devBiDbState"
                } else {
                    "devBoDbState"
                };
                errlog_sev_printf(
                    ErrlogSevEnum::Info,
                    &format!("{devsup}: Creating new db state '{name}'"),
                );
                registry.get_or_create(name)
            }
        });
        Ok(())
    }

    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
        // C `read_bi` (devBiDbState.c:63-71): `if (dpvt) { prec->val =
        // dbStateGet(dpvt); prec->udf = FALSE; } return 2`. Writing VAL
        // directly + returning `computed()` skips the bi conversion (C return
        // 2); the framework then derives `udf` from the now-defined VAL
        // (`clears_udf()` → `value_is_undefined()` is false for an Enum) —
        // matching C clearing `udf = FALSE` on the configured path.
        //
        // With no state (empty instio name) VAL is left untouched, matching C's
        // `if (dpvt)` guard for VAL — but `udf` DIVERGES on this misconfig:
        // C leaves `udf` untouched, so an unconfigured bi stays `udf = TRUE`
        // (UDF_ALARM), whereas base-rs's framework recomputes `udf` from the
        // (always-defined) Enum VAL every cycle and clears it. This is the
        // framework's `value_is_undefined()`-derived udf model vs C's sticky
        // flag — pre-existing and workspace-wide, not specific to Db State, and
        // not cleanly special-caseable here (the recompute at processing.rs is
        // gated on `clears_udf()`, not the read outcome). Surfaces only on the
        // empty-INP misconfiguration.
        if let Some(state) = &self.state {
            let bit = u16::from(state.get());
            record.put_field("VAL", EpicsValue::Enum(bit))?;
        }
        Ok(DeviceReadOutcome::computed())
    }

    fn write(&mut self, record: &mut dyn Record) -> CaResult<()> {
        // C `write_bo` (devBoDbState.c:62-69): `if (prec->val)
        // dbStateSet(dpvt) else dbStateClear(dpvt)`. C tests the raw integer
        // VAL for nonzero; bo VAL is an `Enum` 0/1, but read it as any integer
        // so the nonzero test matches C regardless of the carried variant.
        if let Some(state) = &self.state {
            let on = match record.get_field("VAL") {
                Some(EpicsValue::Enum(v)) | Some(EpicsValue::UShort(v)) => v != 0,
                Some(EpicsValue::Short(v)) => v != 0,
                Some(EpicsValue::Long(v)) => v != 0,
                _ => false,
            };
            state.set(on);
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::server::records::ai::AiRecord;
    use crate::server::records::bi::BiRecord;
    use crate::server::records::bo::BoRecord;

    /// Each test names a distinct state — the registry is process-global, so a
    /// shared name would leak across tests run in one process.
    #[test]
    fn init_resolves_bo_state_from_out_and_write_sets_it() {
        let mut dev = DbStateDeviceSupport::new("", "@DBSTATE_TEST_BO");
        let mut rec = BoRecord::new(1);
        dev.init(&mut rec).unwrap();
        dev.write(&mut rec).unwrap();
        assert!(db_state_registry().get("DBSTATE_TEST_BO"));

        let mut rec0 = BoRecord::new(0);
        dev.write(&mut rec0).unwrap();
        assert!(!db_state_registry().get("DBSTATE_TEST_BO"));
    }

    /// bi reads the named bit into VAL and returns a computed outcome (C
    /// `return 2`, skip conversion).
    #[test]
    fn init_resolves_bi_state_from_inp_and_read_loads_it() {
        db_state_registry().set("DBSTATE_TEST_BI", true);
        let mut dev = DbStateDeviceSupport::new("@DBSTATE_TEST_BI", "");
        let mut rec = BiRecord::new(0);
        dev.init(&mut rec).unwrap();
        let outcome = dev.read(&mut rec).unwrap();
        assert!(outcome.did_compute);
        assert_eq!(rec.get_field("VAL"), Some(EpicsValue::Enum(1)));
    }

    /// C registers no `(ai, "Db State")` dset; the record-type gate Errs so
    /// `wire_device_to_record` flags the record INVALID.
    #[test]
    fn init_rejects_unsupported_record_type() {
        let mut dev = DbStateDeviceSupport::new("@X", "");
        let mut rec = AiRecord::new(0.0);
        assert!(dev.init(&mut rec).is_err());
    }

    /// An empty instio name leaves `state = None` (C `dpvt == NULL`): read is a
    /// no-op (VAL unchanged) and write is a no-op (no state touched).
    #[test]
    fn empty_instio_name_is_noop() {
        let mut dev = DbStateDeviceSupport::new("@", "");
        let mut rec = BiRecord::new(7);
        dev.init(&mut rec).unwrap();
        assert!(dev.state.is_none());
        dev.read(&mut rec).unwrap();
        assert_eq!(rec.get_field("VAL"), Some(EpicsValue::Enum(7)));
    }
}