Skip to main content

spvirit_types/
lib.rs

1//! Shared Normative Type (NT) data model types.
2//!
3//! These types represent the PVAccess Normative Types used across the
4//! codec, client tools, server, and packet capture subsystems.
5
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum ScalarValue {
10    Bool(bool),
11    I8(i8),
12    I16(i16),
13    I32(i32),
14    I64(i64),
15    U8(u8),
16    U16(u16),
17    U32(u32),
18    U64(u64),
19    F32(f32),
20    F64(f64),
21    Str(String),
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum ScalarArrayValue {
26    Bool(Vec<bool>),
27    I8(Vec<i8>),
28    I16(Vec<i16>),
29    I32(Vec<i32>),
30    I64(Vec<i64>),
31    U8(Vec<u8>),
32    U16(Vec<u16>),
33    U32(Vec<u32>),
34    U64(Vec<u64>),
35    F32(Vec<f32>),
36    F64(Vec<f64>),
37    Str(Vec<String>),
38}
39
40impl ScalarArrayValue {
41    pub fn len(&self) -> usize {
42        match self {
43            Self::Bool(v) => v.len(),
44            Self::I8(v) => v.len(),
45            Self::I16(v) => v.len(),
46            Self::I32(v) => v.len(),
47            Self::I64(v) => v.len(),
48            Self::U8(v) => v.len(),
49            Self::U16(v) => v.len(),
50            Self::U32(v) => v.len(),
51            Self::U64(v) => v.len(),
52            Self::F32(v) => v.len(),
53            Self::F64(v) => v.len(),
54            Self::Str(v) => v.len(),
55        }
56    }
57
58    pub fn is_empty(&self) -> bool {
59        self.len() == 0
60    }
61
62    pub fn element_size_bytes(&self) -> usize {
63        match self {
64            Self::Bool(_) => 1,
65            Self::I8(_) => 1,
66            Self::I16(_) => 2,
67            Self::I32(_) => 4,
68            Self::I64(_) => 8,
69            Self::U8(_) => 1,
70            Self::U16(_) => 2,
71            Self::U32(_) => 4,
72            Self::U64(_) => 8,
73            Self::F32(_) => 4,
74            Self::F64(_) => 8,
75            Self::Str(v) => v.iter().map(|s| s.len()).sum(),
76        }
77    }
78
79    pub fn type_label(&self) -> &'static str {
80        match self {
81            Self::Bool(_) => "boolean[]",
82            Self::I8(_) => "byte[]",
83            Self::I16(_) => "short[]",
84            Self::I32(_) => "int[]",
85            Self::I64(_) => "long[]",
86            Self::U8(_) => "ubyte[]",
87            Self::U16(_) => "ushort[]",
88            Self::U32(_) => "uint[]",
89            Self::U64(_) => "ulong[]",
90            Self::F32(_) => "float[]",
91            Self::F64(_) => "double[]",
92            Self::Str(_) => "string[]",
93        }
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Default)]
98pub struct NtAlarm {
99    pub severity: i32,
100    pub status: i32,
101    pub message: String,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
105pub struct NtTimeStamp {
106    pub seconds_past_epoch: i64,
107    pub nanoseconds: i32,
108    pub user_tag: i32,
109}
110
111#[derive(Debug, Clone, PartialEq)]
112pub struct NtDisplay {
113    pub limit_low: f64,
114    pub limit_high: f64,
115    pub description: String,
116    pub units: String,
117    pub precision: i32,
118}
119
120impl Default for NtDisplay {
121    fn default() -> Self {
122        Self {
123            limit_low: 0.0,
124            limit_high: 0.0,
125            description: String::new(),
126            units: String::new(),
127            precision: 0,
128        }
129    }
130}
131
132#[derive(Debug, Clone, PartialEq)]
133pub struct NtControl {
134    pub limit_low: f64,
135    pub limit_high: f64,
136    pub min_step: f64,
137}
138
139impl Default for NtControl {
140    fn default() -> Self {
141        Self {
142            limit_low: 0.0,
143            limit_high: 0.0,
144            min_step: 0.0,
145        }
146    }
147}
148
149#[derive(Debug, Clone, PartialEq)]
150pub struct NtScalar {
151    pub value: ScalarValue,
152    pub alarm_severity: i32,
153    pub alarm_status: i32,
154    pub alarm_message: String,
155    pub alarm_low: Option<f64>,
156    pub alarm_high: Option<f64>,
157    pub alarm_lolo: Option<f64>,
158    pub alarm_hihi: Option<f64>,
159    pub display_low: f64,
160    pub display_high: f64,
161    pub display_description: String,
162    pub display_precision: i32,
163    pub display_form_index: i32,
164    pub display_form_choices: Vec<String>,
165    pub control_low: f64,
166    pub control_high: f64,
167    pub control_min_step: f64,
168    pub units: String,
169    pub value_alarm_active: bool,
170    pub value_alarm_low_alarm_limit: f64,
171    pub value_alarm_low_warning_limit: f64,
172    pub value_alarm_high_warning_limit: f64,
173    pub value_alarm_high_alarm_limit: f64,
174    pub value_alarm_low_alarm_severity: i32,
175    pub value_alarm_low_warning_severity: i32,
176    pub value_alarm_high_warning_severity: i32,
177    pub value_alarm_high_alarm_severity: i32,
178    pub value_alarm_hysteresis: u8,
179    /// Optional explicit timestamp for this value. When `Some`, the encoder
180    /// serialises exactly this `timeStamp` (stable across encodes); when
181    /// `None`, the encoder falls back to stamping `SystemTime::now()` at
182    /// encode time. Set this to the time the value was actually acquired /
183    /// updated so that monitor deltas flag `secondsPastEpoch` correctly and
184    /// clients can detect stale data. See `with_timestamp`.
185    pub time_stamp: Option<NtTimeStamp>,
186}
187
188impl NtScalar {
189    pub fn from_value(value: ScalarValue) -> Self {
190        Self {
191            value,
192            alarm_severity: 0,
193            alarm_status: 0,
194            alarm_message: String::new(),
195            alarm_low: None,
196            alarm_high: None,
197            alarm_lolo: None,
198            alarm_hihi: None,
199            display_low: 0.0,
200            display_high: 0.0,
201            display_description: String::new(),
202            display_precision: 0,
203            display_form_index: 0,
204            display_form_choices: default_form_choices(),
205            control_low: 0.0,
206            control_high: 0.0,
207            control_min_step: 0.0,
208            units: String::new(),
209            value_alarm_active: false,
210            value_alarm_low_alarm_limit: 0.0,
211            value_alarm_low_warning_limit: 0.0,
212            value_alarm_high_warning_limit: 0.0,
213            value_alarm_high_alarm_limit: 0.0,
214            value_alarm_low_alarm_severity: 0,
215            value_alarm_low_warning_severity: 0,
216            value_alarm_high_warning_severity: 0,
217            value_alarm_high_alarm_severity: 0,
218            value_alarm_hysteresis: 0,
219            time_stamp: None,
220        }
221    }
222
223    /// Set an explicit `timeStamp` (seconds/nanoseconds past the UNIX epoch)
224    /// for this value. Supplying a stable, per-update timestamp lets monitor
225    /// deltas report `secondsPastEpoch` changes correctly and lets clients
226    /// see the real acquisition time rather than the server's encode time.
227    pub fn with_timestamp(mut self, seconds_past_epoch: i64, nanoseconds: i32) -> Self {
228        self.time_stamp = Some(NtTimeStamp {
229            seconds_past_epoch,
230            nanoseconds,
231            user_tag: 0,
232        });
233        self
234    }
235
236    pub fn with_limits(mut self, low: f64, high: f64) -> Self {
237        self.display_low = low;
238        self.display_high = high;
239        self.control_low = low;
240        self.control_high = high;
241        self
242    }
243
244    pub fn with_units(mut self, units: String) -> Self {
245        self.units = units;
246        self
247    }
248
249    pub fn with_description(mut self, description: String) -> Self {
250        self.display_description = description;
251        self
252    }
253
254    pub fn with_precision(mut self, precision: i32) -> Self {
255        self.display_precision = precision;
256        self
257    }
258
259    pub fn with_alarm_limits(
260        mut self,
261        low: Option<f64>,
262        high: Option<f64>,
263        lolo: Option<f64>,
264        hihi: Option<f64>,
265    ) -> Self {
266        self.alarm_low = low;
267        self.alarm_high = high;
268        self.alarm_lolo = lolo;
269        self.alarm_hihi = hihi;
270        if let Some(v) = low {
271            self.value_alarm_low_warning_limit = v;
272        }
273        if let Some(v) = high {
274            self.value_alarm_high_warning_limit = v;
275        }
276        if let Some(v) = lolo {
277            self.value_alarm_low_alarm_limit = v;
278        }
279        if let Some(v) = hihi {
280            self.value_alarm_high_alarm_limit = v;
281        }
282        self
283    }
284
285    pub fn update_alarm_from_value(&mut self) {
286        let val = match self.value {
287            ScalarValue::F64(v) => v,
288            ScalarValue::F32(v) => v as f64,
289            ScalarValue::I8(v) => v as f64,
290            ScalarValue::I16(v) => v as f64,
291            ScalarValue::I32(v) => v as f64,
292            ScalarValue::I64(v) => v as f64,
293            ScalarValue::U8(v) => v as f64,
294            ScalarValue::U16(v) => v as f64,
295            ScalarValue::U32(v) => v as f64,
296            ScalarValue::U64(v) => v as f64,
297            _ => {
298                self.alarm_severity = 0;
299                self.alarm_status = 0;
300                self.alarm_message.clear();
301                return;
302            }
303        };
304
305        let mut severity = 0;
306        let mut message = String::new();
307
308        if let Some(hihi) = self.alarm_hihi {
309            if val >= hihi {
310                severity = 2;
311                message = "HIHI".to_string();
312            }
313        }
314        if severity == 0 {
315            if let Some(high) = self.alarm_high {
316                if val >= high {
317                    severity = 1;
318                    message = "HIGH".to_string();
319                }
320            }
321        }
322        if severity == 0 {
323            if let Some(lolo) = self.alarm_lolo {
324                if val <= lolo {
325                    severity = 2;
326                    message = "LOLO".to_string();
327                }
328            }
329        }
330        if severity == 0 {
331            if let Some(low) = self.alarm_low {
332                if val <= low {
333                    severity = 1;
334                    message = "LOW".to_string();
335                }
336            }
337        }
338
339        if severity == 0 {
340            self.alarm_severity = 0;
341            self.alarm_status = 0;
342            self.alarm_message.clear();
343        } else {
344            self.alarm_severity = severity;
345            self.alarm_status = 1;
346            self.alarm_message = message;
347        }
348    }
349}
350
351#[derive(Debug, Clone, PartialEq)]
352pub struct NtScalarArray {
353    pub value: ScalarArrayValue,
354    pub alarm: NtAlarm,
355    pub time_stamp: NtTimeStamp,
356    pub display: NtDisplay,
357    pub control: NtControl,
358}
359
360impl NtScalarArray {
361    pub fn from_value(value: ScalarArrayValue) -> Self {
362        Self {
363            value,
364            alarm: NtAlarm::default(),
365            time_stamp: NtTimeStamp::default(),
366            display: NtDisplay::default(),
367            control: NtControl::default(),
368        }
369    }
370}
371
372#[derive(Debug, Clone, PartialEq)]
373pub struct NtTableColumn {
374    pub name: String,
375    pub values: ScalarArrayValue,
376}
377
378#[derive(Debug, Clone, PartialEq)]
379pub struct NtTable {
380    pub labels: Vec<String>,
381    pub columns: Vec<NtTableColumn>,
382    pub descriptor: Option<String>,
383    pub alarm: Option<NtAlarm>,
384    pub time_stamp: Option<NtTimeStamp>,
385}
386
387impl NtTable {
388    pub fn validate(&self) -> Result<(), String> {
389        let mut expected_len: Option<usize> = None;
390        for col in &self.columns {
391            let len = col.values.len();
392            if let Some(expected) = expected_len {
393                if expected != len {
394                    return Err(format!(
395                        "table column '{}' length {} does not match expected {}",
396                        col.name, len, expected
397                    ));
398                }
399            } else {
400                expected_len = Some(len);
401            }
402        }
403        Ok(())
404    }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct NdCodec {
409    pub name: String,
410    pub parameters: HashMap<String, String>,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct NdDimension {
415    pub size: i32,
416    pub offset: i32,
417    pub full_size: i32,
418    pub binning: i32,
419    pub reverse: bool,
420}
421
422#[derive(Debug, Clone, PartialEq)]
423pub struct NtAttribute {
424    pub name: String,
425    pub value: ScalarValue,
426    pub descriptor: String,
427    pub source_type: i32,
428    pub source: String,
429}
430
431#[derive(Debug, Clone, PartialEq)]
432pub struct NtNdArray {
433    pub value: ScalarArrayValue,
434    pub codec: NdCodec,
435    pub compressed_size: i64,
436    pub uncompressed_size: i64,
437    pub dimension: Vec<NdDimension>,
438    pub unique_id: i32,
439    pub data_time_stamp: NtTimeStamp,
440    pub attribute: Vec<NtAttribute>,
441    pub descriptor: Option<String>,
442    pub alarm: Option<NtAlarm>,
443    pub time_stamp: Option<NtTimeStamp>,
444    pub display: Option<NtDisplay>,
445}
446
447impl NtNdArray {
448    /// Empty NTNDArray with no data — used for type introspection before
449    /// any image has been acquired.
450    pub fn empty() -> Self {
451        Self {
452            value: ScalarArrayValue::U8(vec![]),
453            codec: NdCodec {
454                name: String::new(),
455                parameters: Default::default(),
456            },
457            compressed_size: 0,
458            uncompressed_size: 0,
459            dimension: vec![],
460            unique_id: 0,
461            data_time_stamp: NtTimeStamp {
462                seconds_past_epoch: 0,
463                nanoseconds: 0,
464                user_tag: 0,
465            },
466            attribute: vec![],
467            descriptor: None,
468            alarm: None,
469            time_stamp: None,
470            display: None,
471        }
472    }
473
474    pub fn validate(&self) -> Result<(), String> {
475        if self
476            .attribute
477            .iter()
478            .any(|a| a.descriptor.trim().is_empty())
479        {
480            return Err("ntndarray attribute descriptor must be set".to_string());
481        }
482        let element_size = self.value.element_size_bytes().max(1) as i64;
483        let logical_elements = self
484            .dimension
485            .iter()
486            .map(|d| d.size.max(0) as i64)
487            .product::<i64>()
488            .max(0);
489        let expected_uncompressed = logical_elements.saturating_mul(element_size);
490        if self.uncompressed_size > 0 && self.uncompressed_size != expected_uncompressed {
491            return Err(format!(
492                "uncompressed_size {} does not match dimension*element_size {}",
493                self.uncompressed_size, expected_uncompressed
494            ));
495        }
496        if self.compressed_size > 0 && self.compressed_size > self.uncompressed_size {
497            return Err(format!(
498                "compressed_size {} cannot exceed uncompressed_size {}",
499                self.compressed_size, self.uncompressed_size
500            ));
501        }
502        Ok(())
503    }
504}
505
506// ---------------------------------------------------------------------------
507// NTEnum — EPICS Normative Type for enumerated values
508// ---------------------------------------------------------------------------
509
510/// NTEnum normative type — represents an enumerated PV value.
511///
512/// The `index` selects one of the `choices` strings.  The wire layout matches
513/// the C++ `epics:nt/NTEnum:1.0` structure:
514///
515/// ```text
516/// structure "epics:nt/NTEnum:1.0"
517///   enum_t value
518///     int index
519///     string[] choices
520///   alarm_t alarm
521///   time_t timeStamp
522/// ```
523#[derive(Debug, Clone, PartialEq)]
524pub struct NtEnum {
525    pub index: i32,
526    pub choices: Vec<String>,
527    pub alarm: NtAlarm,
528    pub time_stamp: NtTimeStamp,
529}
530
531impl NtEnum {
532    pub fn new(index: i32, choices: Vec<String>) -> Self {
533        Self {
534            index,
535            choices,
536            alarm: NtAlarm::default(),
537            time_stamp: NtTimeStamp::default(),
538        }
539    }
540
541    /// Returns the currently selected choice string, or `None` if the index
542    /// is out of range.
543    pub fn selected(&self) -> Option<&str> {
544        if self.index >= 0 {
545            self.choices.get(self.index as usize).map(|s| s.as_str())
546        } else {
547            None
548        }
549    }
550}
551
552// ---------------------------------------------------------------------------
553// PvValue — recursive value tree for arbitrary PVA structures
554// ---------------------------------------------------------------------------
555
556/// A recursive value type that can represent any PVA structure without
557/// depending on the codec crate.  Used by [`NtPayload::Generic`] to carry
558/// group-PV composite values and other non-normative structures.
559#[derive(Debug, Clone, PartialEq)]
560pub enum PvValue {
561    Scalar(ScalarValue),
562    ScalarArray(ScalarArrayValue),
563    Structure {
564        struct_id: String,
565        fields: Vec<(String, PvValue)>,
566    },
567}
568
569#[derive(Debug, Clone, PartialEq)]
570pub enum NtPayload {
571    Scalar(NtScalar),
572    ScalarArray(NtScalarArray),
573    Table(NtTable),
574    NdArray(NtNdArray),
575    Enum(NtEnum),
576    /// Arbitrary PVA structure — used for group PVs and non-normative types.
577    Generic {
578        struct_id: String,
579        fields: Vec<(String, PvValue)>,
580    },
581}
582
583pub(crate) fn default_form_choices() -> Vec<String> {
584    vec![
585        "Default".to_string(),
586        "String".to_string(),
587        "Binary".to_string(),
588        "Decimal".to_string(),
589        "Hex".to_string(),
590        "Exponential".to_string(),
591        "Engineering".to_string(),
592    ]
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn from_value_has_no_timestamp_by_default() {
601        let nt = NtScalar::from_value(ScalarValue::F64(1.0));
602        assert_eq!(nt.time_stamp, None);
603    }
604
605    #[test]
606    fn with_timestamp_sets_stored_timestamp() {
607        let nt = NtScalar::from_value(ScalarValue::F64(1.0)).with_timestamp(1_700_000_000, 250);
608        assert_eq!(
609            nt.time_stamp,
610            Some(NtTimeStamp {
611                seconds_past_epoch: 1_700_000_000,
612                nanoseconds: 250,
613                user_tag: 0,
614            })
615        );
616    }
617}