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        RecordType::LongIn => "longin",
128        RecordType::LongOut => "longout",
129    }
130}
131
132/// The record's MDEL monitor deadband (0.0 when absent or unparsable).
133pub fn mdel_of(record: &RecordInstance) -> f64 {
134    record
135        .raw_fields
136        .get("MDEL")
137        .and_then(|s| s.trim().parse::<f64>().ok())
138        .unwrap_or(0.0)
139}
140
141/// Parse a raw field string as `kind`, falling back to `Str` on parse failure.
142fn typed_value(kind: FieldKind, raw: &str) -> ScalarValue {
143    match kind {
144        FieldKind::Int => raw
145            .trim()
146            .parse::<i32>()
147            .map(ScalarValue::I32)
148            .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
149        FieldKind::Double => raw
150            .trim()
151            .parse::<f64>()
152            .map(ScalarValue::F64)
153            .unwrap_or_else(|_| ScalarValue::Str(raw.to_string())),
154        FieldKind::Str => ScalarValue::Str(raw.to_string()),
155    }
156}
157
158/// Resolve the value of `field` for `record`.
159///
160/// Lookup order: computed fields (`RTYP`, `NAME`, `DTYP`), then any field
161/// literally present in the parsed `.db` (`raw_fields`), then dbCommon
162/// defaults. Returns `None` for fields an IOC would not serve either.
163pub fn field_value(record: &RecordInstance, field: &str) -> Option<ScalarValue> {
164    match field {
165        "RTYP" => {
166            return Some(ScalarValue::Str(
167                record_type_name(&record.record_type).to_string(),
168            ));
169        }
170        "NAME" => return Some(ScalarValue::Str(record.name.clone())),
171        "VAL" => return Some(record.current_value()),
172        "DTYP" => {
173            let dtyp = record
174                .raw_fields
175                .get("DTYP")
176                .cloned()
177                .unwrap_or_else(|| "Soft Channel".to_string());
178            return Some(ScalarValue::Str(dtyp));
179        }
180        _ => {}
181    }
182
183    let kind = dbcommon_default(field).map(|(kind, _)| kind);
184    if let Some(raw) = record.raw_fields.get(field) {
185        return Some(typed_value(kind.unwrap_or(FieldKind::Str), raw));
186    }
187    if field == "DESC" {
188        return Some(ScalarValue::Str(record.common.desc.clone()));
189    }
190    dbcommon_default(field).map(|(kind, default)| typed_value(kind, default))
191}
192
193/// Build the wire payload for a resolved field reference.
194///
195/// Regular fields are served as an NTScalar; the `$` long-string form is
196/// served as an NTScalarArray of Int8 holding the UTF-8 bytes (QSRV
197/// long-string semantics). `$` on a non-string field resolves to `None`.
198pub fn payload_for(record: &RecordInstance, field_ref: &FieldRef) -> Option<NtPayload> {
199    let value = field_value(record, &field_ref.field)?;
200    if field_ref.long_string {
201        let ScalarValue::Str(s) = value else {
202            return None;
203        };
204        let bytes: Vec<i8> = s.into_bytes().into_iter().map(|b| b as i8).collect();
205        return Some(NtPayload::ScalarArray(NtScalarArray::from_value(
206            ScalarArrayValue::I8(bytes),
207        )));
208    }
209    let mut nt = NtScalar::from_value(value);
210    nt.display_description = record.common.desc.clone();
211    Some(NtPayload::Scalar(nt))
212}
213
214/// A read-only [`Source`] serving `<pvname>.<FIELD>` channels derived from
215/// the records in a [`SimplePvStore`].
216///
217/// Registered by `PvaServer::run` after the builtin store; the builtin only
218/// claims exact record names, so the two never compete.
219pub struct RecordFieldSource {
220    store: Arc<SimplePvStore>,
221    /// Senders for open field-PV subscriptions. Field values are static, so
222    /// each channel only ever carries the initial snapshot; the senders are
223    /// retained here purely to keep the channels open.
224    open_subs: Mutex<Vec<mpsc::Sender<NtPayload>>>,
225}
226
227impl RecordFieldSource {
228    pub fn new(store: Arc<SimplePvStore>) -> Self {
229        Self {
230            store,
231            open_subs: Mutex::new(Vec::new()),
232        }
233    }
234
235    async fn resolve(&self, name: &str) -> Option<NtPayload> {
236        let field_ref = parse_field_ref(name)?;
237        let record = self.store.get_record(&field_ref.base).await?;
238        payload_for(&record, &field_ref)
239    }
240}
241
242impl Source for RecordFieldSource {
243    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
244        let name = name.to_string();
245        Box::pin(async move {
246            let payload = self.resolve(&name).await?;
247            Some(PvInfo {
248                descriptor: descriptor_for_payload(&payload),
249                writable: false,
250            })
251        })
252    }
253
254    fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
255        let name = name.to_string();
256        Box::pin(async move { self.resolve(&name).await })
257    }
258
259    fn put(
260        &self,
261        name: &str,
262        _value: &DecodedValue,
263    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
264        let name = name.to_string();
265        Box::pin(async move { Err(format!("field PV '{}' is read-only", name)) })
266    }
267
268    fn subscribe(
269        &self,
270        name: &str,
271    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
272        let name = name.to_string();
273        Box::pin(async move {
274            let initial = self.resolve(&name).await?;
275            let (tx, rx) = mpsc::channel(4);
276            let _ = tx.try_send(initial);
277            self.open_subs.lock().await.push(tx);
278            Some(rx)
279        })
280    }
281
282    fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
283        // Field PVs are derived on demand; enumerating every possible
284        // <record>.<FIELD> combination would flood name listings.
285        Box::pin(async move { Vec::new() })
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn parses_simple_field_ref() {
295        let r = parse_field_ref("SIM:AO.RTYP").unwrap();
296        assert_eq!(
297            (r.base.as_str(), r.field.as_str(), r.long_string),
298            ("SIM:AO", "RTYP", false)
299        );
300    }
301
302    #[test]
303    fn parses_long_string_suffix() {
304        let r = parse_field_ref("SIM:AO.DESC$").unwrap();
305        assert_eq!((r.field.as_str(), r.long_string), ("DESC", true));
306    }
307
308    #[test]
309    fn rejects_non_field_names() {
310        assert!(parse_field_ref("SIM:AO").is_none()); // no dot
311        assert!(parse_field_ref("SIM:AO.").is_none()); // empty field
312        assert!(parse_field_ref("SIM:AO.rtyp").is_none()); // lowercase = not a field
313        assert!(parse_field_ref("SIM:AO.$").is_none()); // bare $
314        assert!(parse_field_ref(".RTYP").is_none()); // empty base
315    }
316
317    #[test]
318    fn dbcommon_defaults_cover_key_fields() {
319        assert!(matches!(
320            dbcommon_default("SCAN"),
321            Some((FieldKind::Str, "Passive"))
322        ));
323        assert!(matches!(
324            dbcommon_default("PINI"),
325            Some((FieldKind::Str, "NO"))
326        ));
327        assert!(matches!(
328            dbcommon_default("PHAS"),
329            Some((FieldKind::Int, "0"))
330        ));
331        assert!(matches!(
332            dbcommon_default("MDEL"),
333            Some((FieldKind::Double, "0"))
334        ));
335        assert!(dbcommon_default("NOTAFIELD").is_none());
336    }
337
338    #[test]
339    fn record_type_names_match_db_names() {
340        assert_eq!(record_type_name(&RecordType::Ao), "ao");
341        assert_eq!(record_type_name(&RecordType::StringIn), "stringin");
342        assert_eq!(record_type_name(&RecordType::LongIn), "longin");
343    }
344
345    fn test_record() -> RecordInstance {
346        let recs = crate::db::parse_db(
347            r#"
348record(ao, "SIM:AO") {
349    field(VAL, "2.34")
350    field(DESC, "A test output")
351    field(EGU, "V")
352    field(MDEL, "0.5")
353}"#,
354        )
355        .expect("parse");
356        recs.get("SIM:AO").expect("record present").clone()
357    }
358
359    #[test]
360    fn computed_fields_resolve() {
361        let r = test_record();
362        assert_eq!(field_value(&r, "RTYP"), Some(ScalarValue::Str("ao".into())));
363        assert_eq!(
364            field_value(&r, "NAME"),
365            Some(ScalarValue::Str("SIM:AO".into()))
366        );
367        assert_eq!(
368            field_value(&r, "DTYP"),
369            Some(ScalarValue::Str("Soft Channel".into()))
370        );
371        assert_eq!(field_value(&r, "VAL"), Some(ScalarValue::F64(2.34)));
372    }
373
374    #[test]
375    fn raw_db_fields_take_precedence_over_defaults() {
376        let r = test_record();
377        assert_eq!(
378            field_value(&r, "DESC"),
379            Some(ScalarValue::Str("A test output".into()))
380        );
381        assert_eq!(field_value(&r, "EGU"), Some(ScalarValue::Str("V".into())));
382        assert_eq!(field_value(&r, "MDEL"), Some(ScalarValue::F64(0.5)));
383    }
384
385    #[test]
386    fn dbcommon_defaults_fill_absent_fields() {
387        let r = test_record();
388        assert_eq!(
389            field_value(&r, "SCAN"),
390            Some(ScalarValue::Str("Passive".into()))
391        );
392        assert_eq!(field_value(&r, "PHAS"), Some(ScalarValue::I32(0)));
393        assert_eq!(field_value(&r, "ADEL"), Some(ScalarValue::F64(0.0)));
394        assert_eq!(field_value(&r, "NOTAFIELD"), None);
395    }
396
397    fn test_source() -> RecordFieldSource {
398        let recs = crate::db::parse_db(
399            r#"
400record(ao, "SIM:AO") {
401    field(VAL, "2.34")
402    field(DESC, "A test output")
403    field(MDEL, "0.5")
404}"#,
405        )
406        .expect("parse");
407        let store = SimplePvStore::new(recs, std::collections::HashMap::new(), Vec::new(), false);
408        RecordFieldSource::new(Arc::new(store))
409    }
410
411    #[tokio::test]
412    async fn claims_field_pvs_read_only() {
413        let src = test_source();
414        let info = src.claim("SIM:AO.RTYP").await.expect("claimed");
415        assert!(!info.writable);
416        match src.get("SIM:AO.RTYP").await.expect("payload") {
417            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
418            other => panic!("expected scalar, got {other:?}"),
419        }
420    }
421
422    #[tokio::test]
423    async fn does_not_claim_non_field_names() {
424        let src = test_source();
425        assert!(src.claim("SIM:AO").await.is_none()); // base PV: builtin's job
426        assert!(src.claim("SIM:AO.NOTAFIELD").await.is_none());
427        assert!(src.claim("SIM:MISSING.RTYP").await.is_none());
428        assert!(src.claim("SIM:AO.MDEL$").await.is_none()); // $ on non-string
429    }
430
431    #[tokio::test]
432    async fn put_is_rejected() {
433        let src = test_source();
434        let err = src
435            .put("SIM:AO.DESC", &DecodedValue::Int32(1))
436            .await
437            .expect_err("put must fail");
438        assert!(err.contains("read-only"), "unexpected error: {err}");
439    }
440
441    #[tokio::test]
442    async fn long_string_serves_utf8_bytes() {
443        let src = test_source();
444        match src.get("SIM:AO.DESC$").await.expect("payload") {
445            NtPayload::ScalarArray(arr) => {
446                let ScalarArrayValue::I8(bytes) = arr.value else {
447                    panic!("expected Int8 array, got {:?}", arr.value);
448                };
449                let expected: Vec<i8> = "A test output".bytes().map(|b| b as i8).collect();
450                assert_eq!(bytes, expected);
451            }
452            other => panic!("expected scalar array, got {other:?}"),
453        }
454    }
455
456    #[tokio::test]
457    async fn subscribe_delivers_initial_snapshot() {
458        let src = test_source();
459        let mut rx = src.subscribe("SIM:AO.RTYP").await.expect("subscribed");
460        match rx.recv().await.expect("initial value") {
461            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::Str("ao".into())),
462            other => panic!("expected scalar, got {other:?}"),
463        }
464        // Channel stays open (sender retained) — no immediate close.
465        assert!(rx.try_recv().is_err());
466    }
467}