Skip to main content

spvirit_server/
record_fields.rs

1//! IOC/QSRV-style record field access.
2//!
3//! Serves `<pvname>.<FIELD>` (and the obsolete `<pvname>.<FIELD>$` long-string
4//! form) as independent read-only channels, mimicking IOC field-access
5//! semantics so tools such as the EPICS Archiver Appliance can fetch record
6//! metadata (`RTYP`, `NAME`, `DESC`, dbCommon fields, ...).
7//!
8//! Ported from the p4pillon `RecordProvider` / `DynamicRecordFields` design
9//! (branch `50-access-to-ioc-fields`).
10
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14
15use tokio::sync::{Mutex, mpsc};
16
17use spvirit_codec::spvd_decode::DecodedValue;
18use spvirit_types::{NtPayload, NtScalar, NtScalarArray, ScalarArrayValue};
19
20use crate::pvstore::{PvInfo, Source};
21use crate::simple_store::{SimplePvStore, descriptor_for_payload};
22use crate::types::{RecordInstance, RecordType, ScalarValue};
23
24/// A parsed `<base>.<FIELD>[$]` channel-name reference.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct FieldRef {
27    pub base: String,
28    pub field: String,
29    /// `true` when the obsolete `$` long-string suffix was present.
30    pub long_string: bool,
31}
32
33/// Scalar type of a record field, per dbCommon.dbd semantics.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum FieldKind {
36    Str,
37    Int,
38    Double,
39}
40
41/// Split a channel name into a [`FieldRef`] on its last `.`.
42///
43/// Returns `None` for names that cannot be an IOC field reference: no dot,
44/// empty base or field, or a field part that is not all ASCII
45/// uppercase/digits (record field names are uppercase by convention, so
46/// `a.record.like.this` is never misclaimed).
47pub fn parse_field_ref(name: &str) -> Option<FieldRef> {
48    let (base, field_part) = name.rsplit_once('.')?;
49    if base.is_empty() {
50        return None;
51    }
52    let (field, long_string) = match field_part.strip_suffix('$') {
53        Some(stripped) => (stripped, true),
54        None => (field_part, false),
55    };
56    if field.is_empty()
57        || !field
58            .bytes()
59            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
60    {
61        return None;
62    }
63    Some(FieldRef {
64        base: base.to_string(),
65        field: field.to_string(),
66        long_string,
67    })
68}
69
70/// dbCommon field defaults: `(name, kind, default-as-string)`.
71///
72/// The default strings are parsed per `kind` when served. Table follows
73/// dbCommon.dbd (the fields every EPICS record carries), as p4pillon's
74/// `fields.py` encodes.
75const DBCOMMON_DEFAULTS: &[(&str, FieldKind, &str)] = &[
76    ("DESC", FieldKind::Str, ""),
77    ("SCAN", FieldKind::Str, "Passive"),
78    ("PINI", FieldKind::Str, "NO"),
79    ("PHAS", FieldKind::Int, "0"),
80    ("EVNT", FieldKind::Str, ""),
81    ("PRIO", FieldKind::Str, "LOW"),
82    ("DISV", FieldKind::Int, "1"),
83    ("DISA", FieldKind::Int, "0"),
84    ("SDIS", FieldKind::Str, ""),
85    ("DISS", FieldKind::Str, "NO_ALARM"),
86    ("PROC", FieldKind::Int, "0"),
87    ("STAT", FieldKind::Str, "UDF"),
88    ("SEVR", FieldKind::Str, "INVALID"),
89    ("UDF", FieldKind::Int, "1"),
90    ("TPRO", FieldKind::Int, "0"),
91    ("FLNK", FieldKind::Str, ""),
92    ("ADEL", FieldKind::Double, "0"),
93    ("MDEL", FieldKind::Double, "0"),
94    ("TSE", FieldKind::Int, "0"),
95    ("DISP", FieldKind::Int, "0"),
96    ("ACKS", FieldKind::Str, "NO_ALARM"),
97    ("ACKT", FieldKind::Str, "YES"),
98    ("ASG", FieldKind::Str, ""),
99];
100
101/// Look up the dbCommon default for `field`, if it is a common field.
102pub fn dbcommon_default(field: &str) -> Option<(FieldKind, &'static str)> {
103    DBCOMMON_DEFAULTS
104        .iter()
105        .find(|(name, _, _)| *name == field)
106        .map(|(_, kind, default)| (*kind, *default))
107}
108
109/// The `.db` record-type name for a [`RecordType`] (what `RTYP` reports).
110pub fn record_type_name(rt: &RecordType) -> &'static str {
111    match rt {
112        RecordType::Ai => "ai",
113        RecordType::Ao => "ao",
114        RecordType::Bi => "bi",
115        RecordType::Bo => "bo",
116        RecordType::StringIn => "stringin",
117        RecordType::StringOut => "stringout",
118        RecordType::Waveform => "waveform",
119        RecordType::Aai => "aai",
120        RecordType::Aao => "aao",
121        RecordType::SubArray => "subArray",
122        RecordType::NtTable => "ntTable",
123        RecordType::NtNdArray => "ntNDArray",
124        RecordType::Mbbi => "mbbi",
125        RecordType::Mbbo => "mbbo",
126        RecordType::Generic => "generic",
127    }
128}
129
130/// The record's MDEL monitor deadband (0.0 when absent or unparsable).
131pub fn mdel_of(record: &RecordInstance) -> f64 {
132    record
133        .raw_fields
134        .get("MDEL")
135        .and_then(|s| s.trim().parse::<f64>().ok())
136        .unwrap_or(0.0)
137}
138
139/// Parse a raw field string as `kind`, falling back to `Str` on parse failure.
140fn typed_value(kind: FieldKind, raw: &str) -> ScalarValue {
141    match kind {
142        FieldKind::Int => raw
143            .trim()
144            .parse::<i32>()
145            .map(ScalarValue::I32)
146            .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
147        FieldKind::Double => raw
148            .trim()
149            .parse::<f64>()
150            .map(ScalarValue::F64)
151            .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
152        FieldKind::Str => ScalarValue::Str(raw.to_string()),
153    }
154}
155
156/// Resolve the value of `field` for `record`.
157///
158/// Lookup order: computed fields (`RTYP`, `NAME`, `DTYP`), then any field
159/// literally present in the parsed `.db` (`raw_fields`), then dbCommon
160/// defaults. Returns `None` for fields an IOC would not serve either.
161pub fn field_value(record: &RecordInstance, field: &str) -> Option<ScalarValue> {
162    match field {
163        "RTYP" => {
164            return Some(ScalarValue::Str(
165                record_type_name(&record.record_type).to_string(),
166            ));
167        }
168        "NAME" => return Some(ScalarValue::Str(record.name.clone())),
169        "VAL" => return Some(record.current_value()),
170        "DTYP" => {
171            let dtyp = record
172                .raw_fields
173                .get("DTYP")
174                .cloned()
175                .unwrap_or_else(|| "Soft Channel".to_string());
176            return Some(ScalarValue::Str(dtyp));
177        }
178        _ => {}
179    }
180
181    let kind = dbcommon_default(field).map(|(kind, _)| kind);
182    if let Some(raw) = record.raw_fields.get(field) {
183        return Some(typed_value(kind.unwrap_or(FieldKind::Str), raw));
184    }
185    if field == "DESC" {
186        return Some(ScalarValue::Str(record.common.desc.clone()));
187    }
188    dbcommon_default(field).map(|(kind, default)| typed_value(kind, default))
189}
190
191/// Build the wire payload for a resolved field reference.
192///
193/// Regular fields are served as an NTScalar; the `$` long-string form is
194/// served as an NTScalarArray of Int8 holding the UTF-8 bytes (QSRV
195/// long-string semantics). `$` on a non-string field resolves to `None`.
196pub fn payload_for(record: &RecordInstance, field_ref: &FieldRef) -> Option<NtPayload> {
197    let value = field_value(record, &field_ref.field)?;
198    if field_ref.long_string {
199        let ScalarValue::Str(s) = value else {
200            return None;
201        };
202        let bytes: Vec<i8> = s.into_bytes().into_iter().map(|b| b as i8).collect();
203        return Some(NtPayload::ScalarArray(NtScalarArray::from_value(
204            ScalarArrayValue::I8(bytes),
205        )));
206    }
207    let mut nt = NtScalar::from_value(value);
208    nt.display_description = record.common.desc.clone();
209    Some(NtPayload::Scalar(nt))
210}
211
212/// A read-only [`Source`] serving `<pvname>.<FIELD>` channels derived from
213/// the records in a [`SimplePvStore`].
214///
215/// Registered by `PvaServer::run` after the builtin store; the builtin only
216/// claims exact record names, so the two never compete.
217pub struct RecordFieldSource {
218    store: Arc<SimplePvStore>,
219    /// Senders for open field-PV subscriptions. Field values are static, so
220    /// each channel only ever carries the initial snapshot; the senders are
221    /// retained here purely to keep the channels open.
222    open_subs: Mutex<Vec<mpsc::Sender<NtPayload>>>,
223}
224
225impl RecordFieldSource {
226    pub fn new(store: Arc<SimplePvStore>) -> Self {
227        Self {
228            store,
229            open_subs: Mutex::new(Vec::new()),
230        }
231    }
232
233    async fn resolve(&self, name: &str) -> Option<NtPayload> {
234        let field_ref = parse_field_ref(name)?;
235        let record = self.store.get_record(&field_ref.base).await?;
236        payload_for(&record, &field_ref)
237    }
238}
239
240impl Source for RecordFieldSource {
241    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
242        let name = name.to_string();
243        Box::pin(async move {
244            let payload = self.resolve(&name).await?;
245            Some(PvInfo {
246                descriptor: descriptor_for_payload(&payload),
247                writable: false,
248            })
249        })
250    }
251
252    fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
253        let name = name.to_string();
254        Box::pin(async move { self.resolve(&name).await })
255    }
256
257    fn put(
258        &self,
259        name: &str,
260        _value: &DecodedValue,
261    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
262        let name = name.to_string();
263        Box::pin(async move { Err(format!("field PV '{}' is read-only", name)) })
264    }
265
266    fn subscribe(
267        &self,
268        name: &str,
269    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
270        let name = name.to_string();
271        Box::pin(async move {
272            let initial = self.resolve(&name).await?;
273            let (tx, rx) = mpsc::channel(4);
274            let _ = tx.try_send(initial);
275            self.open_subs.lock().await.push(tx);
276            Some(rx)
277        })
278    }
279
280    fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
281        // Field PVs are derived on demand; enumerating every possible
282        // <record>.<FIELD> combination would flood name listings.
283        Box::pin(async move { Vec::new() })
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn parses_simple_field_ref() {
293        let r = parse_field_ref("SIM:AO.RTYP").unwrap();
294        assert_eq!(
295            (r.base.as_str(), r.field.as_str(), r.long_string),
296            ("SIM:AO", "RTYP", false)
297        );
298    }
299
300    #[test]
301    fn parses_long_string_suffix() {
302        let r = parse_field_ref("SIM:AO.DESC$").unwrap();
303        assert_eq!((r.field.as_str(), r.long_string), ("DESC", true));
304    }
305
306    #[test]
307    fn rejects_non_field_names() {
308        assert!(parse_field_ref("SIM:AO").is_none()); // no dot
309        assert!(parse_field_ref("SIM:AO.").is_none()); // empty field
310        assert!(parse_field_ref("SIM:AO.rtyp").is_none()); // lowercase = not a field
311        assert!(parse_field_ref("SIM:AO.$").is_none()); // bare $
312        assert!(parse_field_ref(".RTYP").is_none()); // empty base
313    }
314
315    #[test]
316    fn dbcommon_defaults_cover_key_fields() {
317        assert!(matches!(
318            dbcommon_default("SCAN"),
319            Some((FieldKind::Str, "Passive"))
320        ));
321        assert!(matches!(
322            dbcommon_default("PINI"),
323            Some((FieldKind::Str, "NO"))
324        ));
325        assert!(matches!(
326            dbcommon_default("PHAS"),
327            Some((FieldKind::Int, "0"))
328        ));
329        assert!(matches!(
330            dbcommon_default("MDEL"),
331            Some((FieldKind::Double, "0"))
332        ));
333        assert!(dbcommon_default("NOTAFIELD").is_none());
334    }
335
336    #[test]
337    fn record_type_names_match_db_names() {
338        assert_eq!(record_type_name(&RecordType::Ao), "ao");
339        assert_eq!(record_type_name(&RecordType::StringIn), "stringin");
340    }
341
342    fn test_record() -> RecordInstance {
343        let recs = crate::db::parse_db(
344            r#"
345record(ao, "SIM:AO") {
346    field(VAL, "2.34")
347    field(DESC, "A test output")
348    field(EGU, "V")
349    field(MDEL, "0.5")
350}"#,
351        )
352        .expect("parse");
353        recs.get("SIM:AO").expect("record present").clone()
354    }
355
356    #[test]
357    fn computed_fields_resolve() {
358        let r = test_record();
359        assert_eq!(field_value(&r, "RTYP"), Some(ScalarValue::Str("ao".into())));
360        assert_eq!(
361            field_value(&r, "NAME"),
362            Some(ScalarValue::Str("SIM:AO".into()))
363        );
364        assert_eq!(
365            field_value(&r, "DTYP"),
366            Some(ScalarValue::Str("Soft Channel".into()))
367        );
368        assert_eq!(field_value(&r, "VAL"), Some(ScalarValue::F64(2.34)));
369    }
370
371    #[test]
372    fn raw_db_fields_take_precedence_over_defaults() {
373        let r = test_record();
374        assert_eq!(
375            field_value(&r, "DESC"),
376            Some(ScalarValue::Str("A test output".into()))
377        );
378        assert_eq!(field_value(&r, "EGU"), Some(ScalarValue::Str("V".into())));
379        assert_eq!(field_value(&r, "MDEL"), Some(ScalarValue::F64(0.5)));
380    }
381
382    #[test]
383    fn dbcommon_defaults_fill_absent_fields() {
384        let r = test_record();
385        assert_eq!(
386            field_value(&r, "SCAN"),
387            Some(ScalarValue::Str("Passive".into()))
388        );
389        assert_eq!(field_value(&r, "PHAS"), Some(ScalarValue::I32(0)));
390        assert_eq!(field_value(&r, "ADEL"), Some(ScalarValue::F64(0.0)));
391        assert_eq!(field_value(&r, "NOTAFIELD"), None);
392    }
393
394    fn test_source() -> RecordFieldSource {
395        let recs = crate::db::parse_db(
396            r#"
397record(ao, "SIM:AO") {
398    field(VAL, "2.34")
399    field(DESC, "A test output")
400    field(MDEL, "0.5")
401}"#,
402        )
403        .expect("parse");
404        let store = SimplePvStore::new(recs, std::collections::HashMap::new(), Vec::new(), false);
405        RecordFieldSource::new(Arc::new(store))
406    }
407
408    #[tokio::test]
409    async fn claims_field_pvs_read_only() {
410        let src = test_source();
411        let info = src.claim("SIM:AO.RTYP").await.expect("claimed");
412        assert!(!info.writable);
413        match src.get("SIM:AO.RTYP").await.expect("payload") {
414            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
415            other => panic!("expected scalar, got {other:?}"),
416        }
417    }
418
419    #[tokio::test]
420    async fn does_not_claim_non_field_names() {
421        let src = test_source();
422        assert!(src.claim("SIM:AO").await.is_none()); // base PV: builtin's job
423        assert!(src.claim("SIM:AO.NOTAFIELD").await.is_none());
424        assert!(src.claim("SIM:MISSING.RTYP").await.is_none());
425        assert!(src.claim("SIM:AO.MDEL$").await.is_none()); // $ on non-string
426    }
427
428    #[tokio::test]
429    async fn put_is_rejected() {
430        let src = test_source();
431        let err = src
432            .put("SIM:AO.DESC", &DecodedValue::Int32(1))
433            .await
434            .expect_err("put must fail");
435        assert!(err.contains("read-only"), "unexpected error: {err}");
436    }
437
438    #[tokio::test]
439    async fn long_string_serves_utf8_bytes() {
440        let src = test_source();
441        match src.get("SIM:AO.DESC$").await.expect("payload") {
442            NtPayload::ScalarArray(arr) => {
443                let ScalarArrayValue::I8(bytes) = arr.value else {
444                    panic!("expected Int8 array, got {:?}", arr.value);
445                };
446                let expected: Vec<i8> = "A test output".bytes().map(|b| b as i8).collect();
447                assert_eq!(bytes, expected);
448            }
449            other => panic!("expected scalar array, got {other:?}"),
450        }
451    }
452
453    #[tokio::test]
454    async fn subscribe_delivers_initial_snapshot() {
455        let src = test_source();
456        let mut rx = src.subscribe("SIM:AO.RTYP").await.expect("subscribed");
457        match rx.recv().await.expect("initial value") {
458            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
459            other => panic!("expected scalar, got {other:?}"),
460        }
461        // Channel stays open (sender retained) — no immediate close.
462        assert!(rx.try_recv().is_err());
463    }
464}