Skip to main content

spvirit_server/
types.rs

1//! Server-specific record and IOC types.
2//!
3//! Shared Normative Type definitions (ScalarValue, NtScalar, NtPayload, etc.)
4//! live in the `spvirit-types` crate and are re-exported here for convenience.
5
6use std::collections::HashMap;
7use std::time::Duration;
8
9pub use spvirit_types::*;
10
11/// Current wall-clock time as an [`NtTimeStamp`] (UNIX epoch).
12pub(crate) fn now_nt_timestamp() -> NtTimeStamp {
13    let now = std::time::SystemTime::now()
14        .duration_since(std::time::UNIX_EPOCH)
15        .unwrap_or_default();
16    NtTimeStamp {
17        seconds_past_epoch: now.as_secs() as i64,
18        nanoseconds: now.subsec_nanos() as i32,
19        user_tag: 0,
20    }
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RecordType {
25    Ai,
26    Ao,
27    Bi,
28    Bo,
29    StringIn,
30    StringOut,
31    Waveform,
32    Aai,
33    Aao,
34    SubArray,
35    NtTable,
36    NtNdArray,
37    Mbbi,
38    Mbbo,
39    Generic,
40    LongIn,
41    LongOut,
42}
43
44impl RecordType {
45    // TODO(follow-up): .db parsing — longin/longout are not yet recognized
46    // here; wiring `.db`-loaded longin/longout records needs db.rs
47    // construction work (out of scope for the handle-API-only gap task).
48    pub fn from_db_name(name: &str) -> Option<Self> {
49        match name.to_ascii_lowercase().as_str() {
50            "ai" => Some(Self::Ai),
51            "ao" => Some(Self::Ao),
52            "bi" => Some(Self::Bi),
53            "bo" => Some(Self::Bo),
54            "stringin" => Some(Self::StringIn),
55            "stringout" => Some(Self::StringOut),
56            "waveform" => Some(Self::Waveform),
57            "aai" => Some(Self::Aai),
58            "aao" => Some(Self::Aao),
59            "subarray" => Some(Self::SubArray),
60            "mbbi" | "ntenum" => Some(Self::Mbbi),
61            "mbbo" => Some(Self::Mbbo),
62            _ => None,
63        }
64    }
65
66    pub fn is_output(&self) -> bool {
67        matches!(
68            self,
69            Self::Ao | Self::Bo | Self::StringOut | Self::Aao | Self::Mbbo | Self::LongOut
70        )
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ScanMode {
76    Passive,
77    Periodic(Duration),
78    Event(String),
79    IoEvent(String),
80}
81
82#[derive(Debug, Clone, PartialEq)]
83pub enum LinkExpr {
84    Constant(ScalarValue),
85    DbLink {
86        target: String,
87        process_passive: bool,
88        maximize_severity: bool,
89    },
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum OutputMode {
94    Supervisory,
95    ClosedLoop,
96}
97
98#[derive(Debug, Clone, PartialEq)]
99pub struct DbCommonState {
100    pub desc: String,
101    pub scan: ScanMode,
102    pub pini: bool,
103    pub phas: i32,
104    pub pact: bool,
105    pub disa: bool,
106    pub sdis: Option<LinkExpr>,
107    pub diss: i32,
108    pub flnk: Option<LinkExpr>,
109}
110
111impl Default for DbCommonState {
112    fn default() -> Self {
113        Self {
114            desc: String::new(),
115            scan: ScanMode::Passive,
116            pini: false,
117            phas: 0,
118            pact: false,
119            disa: false,
120            sdis: None,
121            diss: 0,
122            flnk: None,
123        }
124    }
125}
126
127#[derive(Debug, Clone, PartialEq)]
128pub enum RecordData {
129    Ai {
130        nt: NtScalar,
131        inp: Option<LinkExpr>,
132        siml: Option<LinkExpr>,
133        siol: Option<LinkExpr>,
134        simm: bool,
135    },
136    Ao {
137        nt: NtScalar,
138        out: Option<LinkExpr>,
139        dol: Option<LinkExpr>,
140        omsl: OutputMode,
141        drvl: Option<f64>,
142        drvh: Option<f64>,
143        oroc: Option<f64>,
144        siml: Option<LinkExpr>,
145        siol: Option<LinkExpr>,
146        simm: bool,
147    },
148    Bi {
149        nt: NtScalar,
150        inp: Option<LinkExpr>,
151        znam: String,
152        onam: String,
153        siml: Option<LinkExpr>,
154        siol: Option<LinkExpr>,
155        simm: bool,
156    },
157    Bo {
158        nt: NtScalar,
159        out: Option<LinkExpr>,
160        dol: Option<LinkExpr>,
161        omsl: OutputMode,
162        znam: String,
163        onam: String,
164        siml: Option<LinkExpr>,
165        siol: Option<LinkExpr>,
166        simm: bool,
167    },
168    StringIn {
169        nt: NtScalar,
170        inp: Option<LinkExpr>,
171        siml: Option<LinkExpr>,
172        siol: Option<LinkExpr>,
173        simm: bool,
174    },
175    StringOut {
176        nt: NtScalar,
177        out: Option<LinkExpr>,
178        dol: Option<LinkExpr>,
179        omsl: OutputMode,
180        siml: Option<LinkExpr>,
181        siol: Option<LinkExpr>,
182        simm: bool,
183    },
184    Waveform {
185        nt: NtScalarArray,
186        inp: Option<LinkExpr>,
187        ftvl: String,
188        nelm: usize,
189        nord: usize,
190    },
191    Aai {
192        nt: NtScalarArray,
193        inp: Option<LinkExpr>,
194        ftvl: String,
195        nelm: usize,
196        nord: usize,
197    },
198    Aao {
199        nt: NtScalarArray,
200        out: Option<LinkExpr>,
201        dol: Option<LinkExpr>,
202        omsl: OutputMode,
203        ftvl: String,
204        nelm: usize,
205        nord: usize,
206    },
207    SubArray {
208        nt: NtScalarArray,
209        inp: Option<LinkExpr>,
210        ftvl: String,
211        malm: usize,
212        nelm: usize,
213        nord: usize,
214        indx: usize,
215    },
216    NtTable {
217        nt: NtTable,
218        inp: Option<LinkExpr>,
219        out: Option<LinkExpr>,
220        omsl: OutputMode,
221    },
222    NtNdArray {
223        nt: NtNdArray,
224        inp: Option<LinkExpr>,
225        out: Option<LinkExpr>,
226        omsl: OutputMode,
227    },
228    NtEnum {
229        nt: NtEnum,
230        inp: Option<LinkExpr>,
231        out: Option<LinkExpr>,
232        omsl: OutputMode,
233    },
234    Generic {
235        struct_id: String,
236        fields: Vec<(String, PvValue)>,
237        inp: Option<LinkExpr>,
238        out: Option<LinkExpr>,
239        omsl: OutputMode,
240    },
241}
242
243impl RecordData {
244    pub fn nt(&self) -> &NtScalar {
245        match self {
246            Self::Ai { nt, .. } => nt,
247            Self::Ao { nt, .. } => nt,
248            Self::Bi { nt, .. } => nt,
249            Self::Bo { nt, .. } => nt,
250            Self::StringIn { nt, .. } => nt,
251            Self::StringOut { nt, .. } => nt,
252            _ => panic!("record variant does not expose NtScalar"),
253        }
254    }
255
256    pub fn nt_mut(&mut self) -> &mut NtScalar {
257        match self {
258            Self::Ai { nt, .. } => nt,
259            Self::Ao { nt, .. } => nt,
260            Self::Bi { nt, .. } => nt,
261            Self::Bo { nt, .. } => nt,
262            Self::StringIn { nt, .. } => nt,
263            Self::StringOut { nt, .. } => nt,
264            _ => panic!("record variant does not expose NtScalar"),
265        }
266    }
267
268    pub fn payload(&self) -> NtPayload {
269        match self {
270            Self::Ai { nt, .. }
271            | Self::Ao { nt, .. }
272            | Self::Bi { nt, .. }
273            | Self::Bo { nt, .. }
274            | Self::StringIn { nt, .. }
275            | Self::StringOut { nt, .. } => NtPayload::Scalar(nt.clone()),
276            Self::Waveform { nt, .. }
277            | Self::Aai { nt, .. }
278            | Self::Aao { nt, .. }
279            | Self::SubArray { nt, .. } => NtPayload::ScalarArray(nt.clone()),
280            Self::NtTable { nt, .. } => NtPayload::Table(nt.clone()),
281            Self::NtNdArray { nt, .. } => NtPayload::NdArray(nt.clone()),
282            Self::NtEnum { nt, .. } => NtPayload::Enum(nt.clone()),
283            Self::Generic {
284                struct_id, fields, ..
285            } => NtPayload::Generic {
286                struct_id: struct_id.clone(),
287                fields: fields.clone(),
288            },
289        }
290    }
291}
292
293#[derive(Debug, Clone, PartialEq)]
294pub struct RecordInstance {
295    pub name: String,
296    pub record_type: RecordType,
297    pub common: DbCommonState,
298    pub data: RecordData,
299    pub raw_fields: HashMap<String, String>,
300}
301
302impl RecordInstance {
303    pub fn writable(&self) -> bool {
304        if self.record_type.is_output() {
305            return true;
306        }
307        match &self.data {
308            RecordData::Ai { simm: true, .. } => true,
309            RecordData::Waveform { .. } => true,
310            RecordData::NtTable { .. } => true,
311            RecordData::NtNdArray { .. } => true,
312            RecordData::NtEnum { .. } => true,
313            RecordData::Generic { .. } => true,
314            _ => false,
315        }
316    }
317
318    pub fn to_ntpayload(&self) -> NtPayload {
319        self.data.payload()
320    }
321
322    pub fn to_ntscalar(&self) -> NtScalar {
323        match self.to_ntpayload() {
324            NtPayload::Scalar(nt) => nt,
325            NtPayload::ScalarArray(nt) => {
326                let mut scalar = NtScalar::from_value(ScalarValue::I32(nt.value.len() as i32));
327                scalar.display_description = "Array length".to_string();
328                scalar
329            }
330            NtPayload::Table(nt) => {
331                let mut scalar = NtScalar::from_value(ScalarValue::I32(nt.columns.len() as i32));
332                scalar.display_description = "Table columns".to_string();
333                scalar
334            }
335            NtPayload::NdArray(nt) => {
336                let mut scalar = NtScalar::from_value(ScalarValue::I32(nt.dimension.len() as i32));
337                scalar.display_description = "NDArray dimensions".to_string();
338                scalar
339            }
340            NtPayload::Enum(nt) => {
341                let mut scalar = NtScalar::from_value(ScalarValue::I32(nt.index));
342                scalar.display_description = nt.selected().unwrap_or("").to_string();
343                scalar
344            }
345            NtPayload::Generic { fields, .. } => {
346                let mut scalar = NtScalar::from_value(ScalarValue::I32(fields.len() as i32));
347                scalar.display_description = "Generic structure".to_string();
348                scalar
349            }
350        }
351    }
352    //
353    pub fn nt_mut(&mut self) -> &mut NtScalar {
354        self.data.nt_mut()
355    }
356
357    /// Fallible mutable access to the record's `NtScalar`, for record variants
358    /// that carry one (Ai, Ao, Bi, Bo, StringIn, StringOut). `None` otherwise.
359    pub(crate) fn nt_scalar_mut(&mut self) -> Option<&mut NtScalar> {
360        match &mut self.data {
361            RecordData::Ai { nt, .. }
362            | RecordData::Ao { nt, .. }
363            | RecordData::Bi { nt, .. }
364            | RecordData::Bo { nt, .. }
365            | RecordData::StringIn { nt, .. }
366            | RecordData::StringOut { nt, .. } => Some(nt),
367            _ => None,
368        }
369    }
370
371    pub fn current_value(&self) -> ScalarValue {
372        match self.to_ntpayload() {
373            NtPayload::Scalar(nt) => nt.value,
374            NtPayload::ScalarArray(nt) => ScalarValue::I32(nt.value.len() as i32),
375            NtPayload::Table(nt) => ScalarValue::I32(nt.columns.len() as i32),
376            NtPayload::NdArray(nt) => ScalarValue::I32(nt.dimension.len() as i32),
377            NtPayload::Enum(nt) => ScalarValue::I32(nt.index),
378            NtPayload::Generic { fields, .. } => ScalarValue::I32(fields.len() as i32),
379        }
380    }
381
382    /// Fill in any missing (`None` or epoch-0) timestamps with the current
383    /// time. Called when a record enters a store so that PVs that are never
384    /// updated afterwards (e.g. a static NTTable) still serve a valid
385    /// timestamp — clients like the EPICS Archiver Appliance reject epoch-0
386    /// events.
387    pub(crate) fn stamp_missing_timestamps(&mut self) {
388        let ts = now_nt_timestamp();
389        match &mut self.data {
390            RecordData::Ai { nt, .. }
391            | RecordData::Ao { nt, .. }
392            | RecordData::Bi { nt, .. }
393            | RecordData::Bo { nt, .. }
394            | RecordData::StringIn { nt, .. }
395            | RecordData::StringOut { nt, .. } => {
396                if nt.time_stamp.is_none() {
397                    nt.time_stamp = Some(ts);
398                }
399            }
400            RecordData::Waveform { nt, .. }
401            | RecordData::Aai { nt, .. }
402            | RecordData::Aao { nt, .. }
403            | RecordData::SubArray { nt, .. } => {
404                if nt.time_stamp == NtTimeStamp::default() {
405                    nt.time_stamp = ts;
406                }
407            }
408            RecordData::NtEnum { nt, .. } => {
409                if nt.time_stamp == NtTimeStamp::default() {
410                    nt.time_stamp = ts;
411                }
412            }
413            RecordData::NtTable { nt, .. } => {
414                if nt.time_stamp.is_none() {
415                    nt.time_stamp = Some(ts);
416                }
417            }
418            RecordData::NtNdArray { nt, .. } => {
419                if nt.time_stamp.is_none() {
420                    nt.time_stamp = Some(ts.clone());
421                }
422                if nt.data_time_stamp == NtTimeStamp::default() {
423                    nt.data_time_stamp = ts;
424                }
425            }
426            RecordData::Generic { .. } => {}
427        }
428    }
429
430    /// Overwrite the record's timestamp, whatever it currently holds.
431    ///
432    /// Mirrors [`RecordInstance::stamp_missing_timestamps`]'s arm structure
433    /// but assigns unconditionally. Used by the PUT path, which restamps on
434    /// every accepted write — EPICS Base advances TIME on every record
435    /// process, value change or not.
436    pub fn set_time_stamp(&mut self, ts: NtTimeStamp) {
437        match &mut self.data {
438            RecordData::Ai { nt, .. }
439            | RecordData::Ao { nt, .. }
440            | RecordData::Bi { nt, .. }
441            | RecordData::Bo { nt, .. }
442            | RecordData::StringIn { nt, .. }
443            | RecordData::StringOut { nt, .. } => nt.time_stamp = Some(ts),
444            RecordData::Waveform { nt, .. }
445            | RecordData::Aai { nt, .. }
446            | RecordData::Aao { nt, .. }
447            | RecordData::SubArray { nt, .. } => nt.time_stamp = ts,
448            RecordData::NtEnum { nt, .. } => nt.time_stamp = ts,
449            RecordData::NtTable { nt, .. } => nt.time_stamp = Some(ts),
450            RecordData::NtNdArray { nt, .. } => {
451                nt.time_stamp = Some(ts.clone());
452                nt.data_time_stamp = ts;
453            }
454            RecordData::Generic { .. } => {}
455        }
456    }
457
458    pub fn set_scalar_value(&mut self, value: ScalarValue, compute_alarms: bool) -> bool {
459        if let RecordData::NtEnum { nt, .. } = &mut self.data {
460            let idx = match &value {
461                ScalarValue::I32(i) => *i,
462                ScalarValue::I16(i) => *i as i32,
463                ScalarValue::I8(i) => *i as i32,
464                ScalarValue::I64(i) => *i as i32,
465                _ => return false,
466            };
467            if idx < 0 || (idx as usize) >= nt.choices.len() {
468                return false; // out-of-range index — reject, keep value
469            }
470            if nt.index == idx {
471                return false;
472            }
473            nt.index = idx;
474            // NtEnum encodes its stored time_stamp verbatim — stamp the
475            // update time or clients see epoch 0 forever.
476            nt.time_stamp = now_nt_timestamp();
477            return true;
478        }
479
480        let nt = match &mut self.data {
481            RecordData::Ai { nt, .. }
482            | RecordData::Ao { nt, .. }
483            | RecordData::Bi { nt, .. }
484            | RecordData::Bo { nt, .. }
485            | RecordData::StringIn { nt, .. }
486            | RecordData::StringOut { nt, .. } => nt,
487            _ => return false,
488        };
489
490        let changed = match (&mut nt.value, value) {
491            // Same-variant fast path: exact compare-and-assign for every
492            // variant, including the 8 non-native numeric ones. This must
493            // come before the native↔native arms below and the f64-based
494            // catch-all so 64-bit integers (and other precision-sensitive
495            // types) never round-trip through f64.
496            (ScalarValue::I8(current), ScalarValue::I8(v)) => {
497                if *current == v {
498                    false
499                } else {
500                    *current = v;
501                    true
502                }
503            }
504            (ScalarValue::I16(current), ScalarValue::I16(v)) => {
505                if *current == v {
506                    false
507                } else {
508                    *current = v;
509                    true
510                }
511            }
512            (ScalarValue::I64(current), ScalarValue::I64(v)) => {
513                if *current == v {
514                    false
515                } else {
516                    *current = v;
517                    true
518                }
519            }
520            (ScalarValue::U8(current), ScalarValue::U8(v)) => {
521                if *current == v {
522                    false
523                } else {
524                    *current = v;
525                    true
526                }
527            }
528            (ScalarValue::U16(current), ScalarValue::U16(v)) => {
529                if *current == v {
530                    false
531                } else {
532                    *current = v;
533                    true
534                }
535            }
536            (ScalarValue::U32(current), ScalarValue::U32(v)) => {
537                if *current == v {
538                    false
539                } else {
540                    *current = v;
541                    true
542                }
543            }
544            (ScalarValue::U64(current), ScalarValue::U64(v)) => {
545                if *current == v {
546                    false
547                } else {
548                    *current = v;
549                    true
550                }
551            }
552            (ScalarValue::F32(current), ScalarValue::F32(v)) => {
553                if (*current - v).abs() < f32::EPSILON {
554                    false
555                } else {
556                    *current = v;
557                    true
558                }
559            }
560            (ScalarValue::Bool(current), ScalarValue::Bool(v)) => {
561                if *current == v {
562                    false
563                } else {
564                    *current = v;
565                    true
566                }
567            }
568            (ScalarValue::I32(current), ScalarValue::I32(v)) => {
569                if *current == v {
570                    false
571                } else {
572                    *current = v;
573                    true
574                }
575            }
576            (ScalarValue::F64(current), ScalarValue::F64(v)) => {
577                if (*current - v).abs() < f64::EPSILON {
578                    false
579                } else {
580                    *current = v;
581                    true
582                }
583            }
584            (ScalarValue::Str(current), ScalarValue::Str(v)) => {
585                if *current == v {
586                    false
587                } else {
588                    *current = v;
589                    true
590                }
591            }
592            (ScalarValue::Bool(current), ScalarValue::I32(v)) => {
593                let next = v != 0;
594                if *current == next {
595                    false
596                } else {
597                    *current = next;
598                    true
599                }
600            }
601            (ScalarValue::Bool(current), ScalarValue::F64(v)) => {
602                let next = v != 0.0;
603                if *current == next {
604                    false
605                } else {
606                    *current = next;
607                    true
608                }
609            }
610            (ScalarValue::I32(current), ScalarValue::Bool(v)) => {
611                let next = if v { 1 } else { 0 };
612                if *current == next {
613                    false
614                } else {
615                    *current = next;
616                    true
617                }
618            }
619            (ScalarValue::I32(current), ScalarValue::F64(v)) => {
620                let next = v as i32;
621                if *current == next {
622                    false
623                } else {
624                    *current = next;
625                    true
626                }
627            }
628            (ScalarValue::F64(current), ScalarValue::Bool(v)) => {
629                let next = if v { 1.0 } else { 0.0 };
630                if (*current - next).abs() < f64::EPSILON {
631                    false
632                } else {
633                    *current = next;
634                    true
635                }
636            }
637            (ScalarValue::F64(current), ScalarValue::I32(v)) => {
638                let next = v as f64;
639                if (*current - next).abs() < f64::EPSILON {
640                    false
641                } else {
642                    *current = next;
643                    true
644                }
645            }
646            (ScalarValue::Str(current), ScalarValue::Bool(v)) => {
647                let next = if v { "1" } else { "0" }.to_string();
648                if *current == next {
649                    false
650                } else {
651                    *current = next;
652                    true
653                }
654            }
655            (ScalarValue::Str(current), ScalarValue::I32(v)) => {
656                let next = v.to_string();
657                if *current == next {
658                    false
659                } else {
660                    *current = next;
661                    true
662                }
663            }
664            (ScalarValue::Str(current), ScalarValue::F64(v)) => {
665                let next = v.to_string();
666                if *current == next {
667                    false
668                } else {
669                    *current = next;
670                    true
671                }
672            }
673            (ScalarValue::Bool(current), ScalarValue::Str(v)) => {
674                let next = parse_bool_like(&v).unwrap_or(*current);
675                if *current == next {
676                    false
677                } else {
678                    *current = next;
679                    true
680                }
681            }
682            (ScalarValue::I32(current), ScalarValue::Str(v)) => {
683                let next = v.parse::<i32>().unwrap_or(*current);
684                if *current == next {
685                    false
686                } else {
687                    *current = next;
688                    true
689                }
690            }
691            (ScalarValue::F64(current), ScalarValue::Str(v)) => {
692                let next = v.parse::<f64>().unwrap_or(*current);
693                if (*current - next).abs() < f64::EPSILON {
694                    false
695                } else {
696                    *current = next;
697                    true
698                }
699            }
700            // Handle all remaining numeric ScalarValue variants by coercing to
701            // the target's type via f64.
702            (target, other) => {
703                let as_f64 = match &other {
704                    ScalarValue::I8(v) => *v as f64,
705                    ScalarValue::I16(v) => *v as f64,
706                    ScalarValue::I64(v) => *v as f64,
707                    ScalarValue::U8(v) => *v as f64,
708                    ScalarValue::U16(v) => *v as f64,
709                    ScalarValue::U32(v) => *v as f64,
710                    ScalarValue::U64(v) => *v as f64,
711                    ScalarValue::F32(v) => *v as f64,
712                    // I32/F64/Bool as the *incoming* value only reach this
713                    // catch-all when the *target* is one of the new
714                    // non-native numeric variants (native-target combos of
715                    // these are already consumed by the explicit arms
716                    // above), e.g. a U16 record receiving an I32 or F64 put.
717                    ScalarValue::I32(v) => *v as f64,
718                    ScalarValue::F64(v) => *v,
719                    ScalarValue::Bool(v) => {
720                        if *v {
721                            1.0
722                        } else {
723                            0.0
724                        }
725                    }
726                    _ => return false,
727                };
728                // Integer-to-integer conversions route through i128 instead
729                // of f64 so 64-bit magnitudes (I64/U64) survive the
730                // coercion intact — an f64 round-trip would round them.
731                // `None` when `other` is a float (F32/F64); those targets
732                // fall back to the `as_f64` path below.
733                let as_i128: Option<i128> = match &other {
734                    ScalarValue::I8(v) => Some(*v as i128),
735                    ScalarValue::I16(v) => Some(*v as i128),
736                    ScalarValue::I32(v) => Some(*v as i128),
737                    ScalarValue::I64(v) => Some(*v as i128),
738                    ScalarValue::U8(v) => Some(*v as i128),
739                    ScalarValue::U16(v) => Some(*v as i128),
740                    ScalarValue::U32(v) => Some(*v as i128),
741                    ScalarValue::U64(v) => Some(*v as i128),
742                    ScalarValue::Bool(v) => Some(if *v { 1 } else { 0 }),
743                    _ => None,
744                };
745                match target {
746                    ScalarValue::Bool(current) => {
747                        let next = as_f64 != 0.0;
748                        if *current == next {
749                            false
750                        } else {
751                            *current = next;
752                            true
753                        }
754                    }
755                    ScalarValue::I32(current) => {
756                        let next = as_f64 as i32;
757                        if *current == next {
758                            false
759                        } else {
760                            *current = next;
761                            true
762                        }
763                    }
764                    ScalarValue::F64(current) => {
765                        if (*current - as_f64).abs() < f64::EPSILON {
766                            false
767                        } else {
768                            *current = as_f64;
769                            true
770                        }
771                    }
772                    ScalarValue::Str(current) => {
773                        let next = as_f64.to_string();
774                        if *current == next {
775                            false
776                        } else {
777                            *current = next;
778                            true
779                        }
780                    }
781                    // Non-native numeric targets: prefer the exact i128
782                    // route for integer-to-integer coercions; fall back to
783                    // the f64 path when `other` is a float (F32). Final
784                    // narrowing uses C-like `as` cast semantics, mirroring
785                    // the `as_f64 as i32` arm above.
786                    ScalarValue::I8(current) => {
787                        let next = as_i128.map(|i| i as i8).unwrap_or(as_f64 as i8);
788                        if *current == next {
789                            false
790                        } else {
791                            *current = next;
792                            true
793                        }
794                    }
795                    ScalarValue::I16(current) => {
796                        let next = as_i128.map(|i| i as i16).unwrap_or(as_f64 as i16);
797                        if *current == next {
798                            false
799                        } else {
800                            *current = next;
801                            true
802                        }
803                    }
804                    ScalarValue::I64(current) => {
805                        let next = as_i128.map(|i| i as i64).unwrap_or(as_f64 as i64);
806                        if *current == next {
807                            false
808                        } else {
809                            *current = next;
810                            true
811                        }
812                    }
813                    ScalarValue::U8(current) => {
814                        let next = as_i128.map(|i| i as u8).unwrap_or(as_f64 as u8);
815                        if *current == next {
816                            false
817                        } else {
818                            *current = next;
819                            true
820                        }
821                    }
822                    ScalarValue::U16(current) => {
823                        let next = as_i128.map(|i| i as u16).unwrap_or(as_f64 as u16);
824                        if *current == next {
825                            false
826                        } else {
827                            *current = next;
828                            true
829                        }
830                    }
831                    ScalarValue::U32(current) => {
832                        let next = as_i128.map(|i| i as u32).unwrap_or(as_f64 as u32);
833                        if *current == next {
834                            false
835                        } else {
836                            *current = next;
837                            true
838                        }
839                    }
840                    ScalarValue::U64(current) => {
841                        let next = as_i128.map(|i| i as u64).unwrap_or(as_f64 as u64);
842                        if *current == next {
843                            false
844                        } else {
845                            *current = next;
846                            true
847                        }
848                    }
849                    ScalarValue::F32(current) => {
850                        let next = as_f64 as f32;
851                        if (*current - next).abs() < f32::EPSILON {
852                            false
853                        } else {
854                            *current = next;
855                            true
856                        }
857                    }
858                }
859            }
860        };
861        if changed {
862            // A `None` time_stamp makes the encoder stamp now() at *encode*
863            // time. The monitor delta path re-encodes the previous and next
864            // snapshots microseconds apart, so secondsPastEpoch compares
865            // equal and is never flagged changed — freezing the seconds on
866            // monitoring clients. Store the update time so encodes are
867            // stable (see spvd_encode::encode_timestamp).
868            nt.time_stamp = Some(now_nt_timestamp());
869            if compute_alarms {
870                nt.update_alarm_from_value();
871            }
872        }
873        changed
874    }
875
876    pub fn set_array_value(&mut self, value: ScalarArrayValue) -> bool {
877        // Unlike NtScalar (where a `None` time_stamp makes the encoder fall
878        // back to now()), array payloads encode their stored time_stamp
879        // verbatim — so stamp the update time here or clients see epoch 0.
880        let ts = now_nt_timestamp();
881        // NtNdArray keeps its dimensions; only the flat pixel data changes.
882        if let RecordData::NtNdArray { nt, .. } = &mut self.data {
883            if nt.value == value {
884                return false;
885            }
886            nt.value = value;
887            nt.time_stamp = Some(ts.clone());
888            nt.data_time_stamp = ts;
889            return true;
890        }
891        let (nt, nord, nelm) = match &mut self.data {
892            RecordData::Waveform { nt, nord, nelm, .. }
893            | RecordData::Aai { nt, nord, nelm, .. }
894            | RecordData::Aao { nt, nord, nelm, .. }
895            | RecordData::SubArray { nt, nord, nelm, .. } => (nt, nord, *nelm),
896            _ => return false,
897        };
898
899        let mut next = value;
900        truncate_scalar_array_to_nelm(&mut next, nelm);
901        if nt.value == next {
902            return false;
903        }
904
905        *nord = next.len();
906        nt.value = next;
907        nt.time_stamp = ts;
908        true
909    }
910
911    pub fn set_nt_payload(&mut self, payload: NtPayload) -> bool {
912        match (&mut self.data, payload) {
913            (
914                RecordData::Ai { nt, .. }
915                | RecordData::Ao { nt, .. }
916                | RecordData::Bi { nt, .. }
917                | RecordData::Bo { nt, .. }
918                | RecordData::StringIn { nt, .. }
919                | RecordData::StringOut { nt, .. },
920                NtPayload::Scalar(mut next),
921            ) => {
922                if *nt == next {
923                    false
924                } else {
925                    // Respect a caller-supplied acquisition time; otherwise
926                    // stamp the update time so monitor deltas stay stable
927                    // (see set_scalar_value).
928                    if next.time_stamp.is_none() {
929                        next.time_stamp = Some(now_nt_timestamp());
930                    }
931                    *nt = next;
932                    true
933                }
934            }
935            (
936                RecordData::Waveform { nt, nord, nelm, .. }
937                | RecordData::Aai { nt, nord, nelm, .. }
938                | RecordData::Aao { nt, nord, nelm, .. }
939                | RecordData::SubArray { nt, nord, nelm, .. },
940                NtPayload::ScalarArray(mut next),
941            ) => {
942                truncate_scalar_array_to_nelm(&mut next.value, *nelm);
943                let next_len = next.value.len();
944                if *nt == next {
945                    false
946                } else {
947                    // Epoch 0 is the untouched default, never a real
948                    // acquisition time — stamp the update time instead.
949                    if next.time_stamp == NtTimeStamp::default() {
950                        next.time_stamp = now_nt_timestamp();
951                    }
952                    *nord = next_len;
953                    *nt = next;
954                    true
955                }
956            }
957            (RecordData::NtTable { nt, .. }, NtPayload::Table(mut next)) => {
958                if next.validate().is_err() || *nt == next {
959                    false
960                } else {
961                    if next.time_stamp.is_none() {
962                        next.time_stamp = Some(now_nt_timestamp());
963                    }
964                    *nt = next;
965                    true
966                }
967            }
968            (RecordData::NtNdArray { nt, .. }, NtPayload::NdArray(mut next)) => {
969                if next.validate().is_err() || *nt == next {
970                    false
971                } else {
972                    let ts = now_nt_timestamp();
973                    if next.time_stamp.is_none() {
974                        next.time_stamp = Some(ts.clone());
975                    }
976                    if next.data_time_stamp == NtTimeStamp::default() {
977                        next.data_time_stamp = ts;
978                    }
979                    *nt = next;
980                    true
981                }
982            }
983            (RecordData::NtEnum { nt, .. }, NtPayload::Enum(mut next)) => {
984                if *nt == next {
985                    false
986                } else {
987                    if next.time_stamp == NtTimeStamp::default() {
988                        next.time_stamp = now_nt_timestamp();
989                    }
990                    *nt = next;
991                    true
992                }
993            }
994            (
995                RecordData::Generic {
996                    struct_id, fields, ..
997                },
998                NtPayload::Generic {
999                    struct_id: next_id,
1000                    fields: next_fields,
1001                },
1002            ) => {
1003                if *struct_id == next_id && *fields == next_fields {
1004                    false
1005                } else {
1006                    *struct_id = next_id;
1007                    *fields = next_fields;
1008                    true
1009                }
1010            }
1011            _ => false,
1012        }
1013    }
1014}
1015
1016fn truncate_scalar_array_to_nelm(value: &mut ScalarArrayValue, nelm: usize) {
1017    match value {
1018        ScalarArrayValue::Bool(v) => v.truncate(nelm),
1019        ScalarArrayValue::I8(v) => v.truncate(nelm),
1020        ScalarArrayValue::I16(v) => v.truncate(nelm),
1021        ScalarArrayValue::I32(v) => v.truncate(nelm),
1022        ScalarArrayValue::I64(v) => v.truncate(nelm),
1023        ScalarArrayValue::U8(v) => v.truncate(nelm),
1024        ScalarArrayValue::U16(v) => v.truncate(nelm),
1025        ScalarArrayValue::U32(v) => v.truncate(nelm),
1026        ScalarArrayValue::U64(v) => v.truncate(nelm),
1027        ScalarArrayValue::F32(v) => v.truncate(nelm),
1028        ScalarArrayValue::F64(v) => v.truncate(nelm),
1029        ScalarArrayValue::Str(v) => v.truncate(nelm),
1030    }
1031}
1032
1033fn parse_bool_like(input: &str) -> Option<bool> {
1034    match input.trim().to_ascii_lowercase().as_str() {
1035        "1" | "true" | "yes" | "on" => Some(true),
1036        "0" | "false" | "no" | "off" => Some(false),
1037        _ => None,
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    fn ai_record(val: f64) -> RecordInstance {
1046        RecordInstance {
1047            name: "T".to_string(),
1048            record_type: RecordType::Ai,
1049            common: DbCommonState::default(),
1050            data: RecordData::Ai {
1051                nt: NtScalar::from_value(ScalarValue::F64(val)),
1052                inp: None,
1053                siml: None,
1054                siol: None,
1055                simm: false,
1056            },
1057            raw_fields: std::collections::HashMap::new(),
1058        }
1059    }
1060
1061    #[test]
1062    fn set_time_stamp_overwrites_an_existing_stamp() {
1063        let mut rec = ai_record(1.0);
1064        rec.set_time_stamp(NtTimeStamp {
1065            seconds_past_epoch: 100,
1066            nanoseconds: 0,
1067            user_tag: 0,
1068        });
1069        let replacement = NtTimeStamp {
1070            seconds_past_epoch: 200,
1071            nanoseconds: 7,
1072            user_tag: 3,
1073        };
1074        rec.set_time_stamp(replacement.clone());
1075
1076        let RecordData::Ai { nt, .. } = &rec.data else {
1077            panic!("expected Ai");
1078        };
1079        assert_eq!(nt.time_stamp, Some(replacement));
1080    }
1081
1082    #[test]
1083    fn set_time_stamp_on_generic_record_is_a_no_op() {
1084        let mut rec = RecordInstance {
1085            name: "G".to_string(),
1086            record_type: RecordType::Generic,
1087            common: DbCommonState::default(),
1088            data: RecordData::Generic {
1089                struct_id: "epics:nt/NTScalar:1.0".to_string(),
1090                fields: vec![],
1091                inp: None,
1092                out: None,
1093                omsl: OutputMode::Supervisory,
1094            },
1095            raw_fields: std::collections::HashMap::new(),
1096        };
1097        // Must not panic and must leave the record untouched.
1098        rec.set_time_stamp(now_nt_timestamp());
1099        let RecordData::Generic { fields, .. } = &rec.data else {
1100            panic!("expected Generic");
1101        };
1102        assert!(fields.is_empty());
1103    }
1104}