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).
12fn 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    pub fn set_scalar_value(&mut self, value: ScalarValue, compute_alarms: bool) -> bool {
431        if let RecordData::NtEnum { nt, .. } = &mut self.data {
432            let idx = match &value {
433                ScalarValue::I32(i) => *i,
434                ScalarValue::I16(i) => *i as i32,
435                ScalarValue::I8(i) => *i as i32,
436                ScalarValue::I64(i) => *i as i32,
437                _ => return false,
438            };
439            if idx < 0 || (idx as usize) >= nt.choices.len() {
440                return false; // out-of-range index — reject, keep value
441            }
442            if nt.index == idx {
443                return false;
444            }
445            nt.index = idx;
446            // NtEnum encodes its stored time_stamp verbatim — stamp the
447            // update time or clients see epoch 0 forever.
448            nt.time_stamp = now_nt_timestamp();
449            return true;
450        }
451
452        let nt = match &mut self.data {
453            RecordData::Ai { nt, .. }
454            | RecordData::Ao { nt, .. }
455            | RecordData::Bi { nt, .. }
456            | RecordData::Bo { nt, .. }
457            | RecordData::StringIn { nt, .. }
458            | RecordData::StringOut { nt, .. } => nt,
459            _ => return false,
460        };
461
462        let changed = match (&mut nt.value, value) {
463            // Same-variant fast path: exact compare-and-assign for every
464            // variant, including the 8 non-native numeric ones. This must
465            // come before the native↔native arms below and the f64-based
466            // catch-all so 64-bit integers (and other precision-sensitive
467            // types) never round-trip through f64.
468            (ScalarValue::I8(current), ScalarValue::I8(v)) => {
469                if *current == v {
470                    false
471                } else {
472                    *current = v;
473                    true
474                }
475            }
476            (ScalarValue::I16(current), ScalarValue::I16(v)) => {
477                if *current == v {
478                    false
479                } else {
480                    *current = v;
481                    true
482                }
483            }
484            (ScalarValue::I64(current), ScalarValue::I64(v)) => {
485                if *current == v {
486                    false
487                } else {
488                    *current = v;
489                    true
490                }
491            }
492            (ScalarValue::U8(current), ScalarValue::U8(v)) => {
493                if *current == v {
494                    false
495                } else {
496                    *current = v;
497                    true
498                }
499            }
500            (ScalarValue::U16(current), ScalarValue::U16(v)) => {
501                if *current == v {
502                    false
503                } else {
504                    *current = v;
505                    true
506                }
507            }
508            (ScalarValue::U32(current), ScalarValue::U32(v)) => {
509                if *current == v {
510                    false
511                } else {
512                    *current = v;
513                    true
514                }
515            }
516            (ScalarValue::U64(current), ScalarValue::U64(v)) => {
517                if *current == v {
518                    false
519                } else {
520                    *current = v;
521                    true
522                }
523            }
524            (ScalarValue::F32(current), ScalarValue::F32(v)) => {
525                if (*current - v).abs() < f32::EPSILON {
526                    false
527                } else {
528                    *current = v;
529                    true
530                }
531            }
532            (ScalarValue::Bool(current), ScalarValue::Bool(v)) => {
533                if *current == v {
534                    false
535                } else {
536                    *current = v;
537                    true
538                }
539            }
540            (ScalarValue::I32(current), ScalarValue::I32(v)) => {
541                if *current == v {
542                    false
543                } else {
544                    *current = v;
545                    true
546                }
547            }
548            (ScalarValue::F64(current), ScalarValue::F64(v)) => {
549                if (*current - v).abs() < f64::EPSILON {
550                    false
551                } else {
552                    *current = v;
553                    true
554                }
555            }
556            (ScalarValue::Str(current), ScalarValue::Str(v)) => {
557                if *current == v {
558                    false
559                } else {
560                    *current = v;
561                    true
562                }
563            }
564            (ScalarValue::Bool(current), ScalarValue::I32(v)) => {
565                let next = v != 0;
566                if *current == next {
567                    false
568                } else {
569                    *current = next;
570                    true
571                }
572            }
573            (ScalarValue::Bool(current), ScalarValue::F64(v)) => {
574                let next = v != 0.0;
575                if *current == next {
576                    false
577                } else {
578                    *current = next;
579                    true
580                }
581            }
582            (ScalarValue::I32(current), ScalarValue::Bool(v)) => {
583                let next = if v { 1 } else { 0 };
584                if *current == next {
585                    false
586                } else {
587                    *current = next;
588                    true
589                }
590            }
591            (ScalarValue::I32(current), ScalarValue::F64(v)) => {
592                let next = v as i32;
593                if *current == next {
594                    false
595                } else {
596                    *current = next;
597                    true
598                }
599            }
600            (ScalarValue::F64(current), ScalarValue::Bool(v)) => {
601                let next = if v { 1.0 } else { 0.0 };
602                if (*current - next).abs() < f64::EPSILON {
603                    false
604                } else {
605                    *current = next;
606                    true
607                }
608            }
609            (ScalarValue::F64(current), ScalarValue::I32(v)) => {
610                let next = v as f64;
611                if (*current - next).abs() < f64::EPSILON {
612                    false
613                } else {
614                    *current = next;
615                    true
616                }
617            }
618            (ScalarValue::Str(current), ScalarValue::Bool(v)) => {
619                let next = if v { "1" } else { "0" }.to_string();
620                if *current == next {
621                    false
622                } else {
623                    *current = next;
624                    true
625                }
626            }
627            (ScalarValue::Str(current), ScalarValue::I32(v)) => {
628                let next = v.to_string();
629                if *current == next {
630                    false
631                } else {
632                    *current = next;
633                    true
634                }
635            }
636            (ScalarValue::Str(current), ScalarValue::F64(v)) => {
637                let next = v.to_string();
638                if *current == next {
639                    false
640                } else {
641                    *current = next;
642                    true
643                }
644            }
645            (ScalarValue::Bool(current), ScalarValue::Str(v)) => {
646                let next = parse_bool_like(&v).unwrap_or(*current);
647                if *current == next {
648                    false
649                } else {
650                    *current = next;
651                    true
652                }
653            }
654            (ScalarValue::I32(current), ScalarValue::Str(v)) => {
655                let next = v.parse::<i32>().unwrap_or(*current);
656                if *current == next {
657                    false
658                } else {
659                    *current = next;
660                    true
661                }
662            }
663            (ScalarValue::F64(current), ScalarValue::Str(v)) => {
664                let next = v.parse::<f64>().unwrap_or(*current);
665                if (*current - next).abs() < f64::EPSILON {
666                    false
667                } else {
668                    *current = next;
669                    true
670                }
671            }
672            // Handle all remaining numeric ScalarValue variants by coercing to
673            // the target's type via f64.
674            (target, other) => {
675                let as_f64 = match &other {
676                    ScalarValue::I8(v) => *v as f64,
677                    ScalarValue::I16(v) => *v as f64,
678                    ScalarValue::I64(v) => *v as f64,
679                    ScalarValue::U8(v) => *v as f64,
680                    ScalarValue::U16(v) => *v as f64,
681                    ScalarValue::U32(v) => *v as f64,
682                    ScalarValue::U64(v) => *v as f64,
683                    ScalarValue::F32(v) => *v as f64,
684                    // I32/F64/Bool as the *incoming* value only reach this
685                    // catch-all when the *target* is one of the new
686                    // non-native numeric variants (native-target combos of
687                    // these are already consumed by the explicit arms
688                    // above), e.g. a U16 record receiving an I32 or F64 put.
689                    ScalarValue::I32(v) => *v as f64,
690                    ScalarValue::F64(v) => *v,
691                    ScalarValue::Bool(v) => {
692                        if *v {
693                            1.0
694                        } else {
695                            0.0
696                        }
697                    }
698                    _ => return false,
699                };
700                // Integer-to-integer conversions route through i128 instead
701                // of f64 so 64-bit magnitudes (I64/U64) survive the
702                // coercion intact — an f64 round-trip would round them.
703                // `None` when `other` is a float (F32/F64); those targets
704                // fall back to the `as_f64` path below.
705                let as_i128: Option<i128> = match &other {
706                    ScalarValue::I8(v) => Some(*v as i128),
707                    ScalarValue::I16(v) => Some(*v as i128),
708                    ScalarValue::I32(v) => Some(*v as i128),
709                    ScalarValue::I64(v) => Some(*v as i128),
710                    ScalarValue::U8(v) => Some(*v as i128),
711                    ScalarValue::U16(v) => Some(*v as i128),
712                    ScalarValue::U32(v) => Some(*v as i128),
713                    ScalarValue::U64(v) => Some(*v as i128),
714                    ScalarValue::Bool(v) => Some(if *v { 1 } else { 0 }),
715                    _ => None,
716                };
717                match target {
718                    ScalarValue::Bool(current) => {
719                        let next = as_f64 != 0.0;
720                        if *current == next {
721                            false
722                        } else {
723                            *current = next;
724                            true
725                        }
726                    }
727                    ScalarValue::I32(current) => {
728                        let next = as_f64 as i32;
729                        if *current == next {
730                            false
731                        } else {
732                            *current = next;
733                            true
734                        }
735                    }
736                    ScalarValue::F64(current) => {
737                        if (*current - as_f64).abs() < f64::EPSILON {
738                            false
739                        } else {
740                            *current = as_f64;
741                            true
742                        }
743                    }
744                    ScalarValue::Str(current) => {
745                        let next = as_f64.to_string();
746                        if *current == next {
747                            false
748                        } else {
749                            *current = next;
750                            true
751                        }
752                    }
753                    // Non-native numeric targets: prefer the exact i128
754                    // route for integer-to-integer coercions; fall back to
755                    // the f64 path when `other` is a float (F32). Final
756                    // narrowing uses C-like `as` cast semantics, mirroring
757                    // the `as_f64 as i32` arm above.
758                    ScalarValue::I8(current) => {
759                        let next = as_i128.map(|i| i as i8).unwrap_or(as_f64 as i8);
760                        if *current == next {
761                            false
762                        } else {
763                            *current = next;
764                            true
765                        }
766                    }
767                    ScalarValue::I16(current) => {
768                        let next = as_i128.map(|i| i as i16).unwrap_or(as_f64 as i16);
769                        if *current == next {
770                            false
771                        } else {
772                            *current = next;
773                            true
774                        }
775                    }
776                    ScalarValue::I64(current) => {
777                        let next = as_i128.map(|i| i as i64).unwrap_or(as_f64 as i64);
778                        if *current == next {
779                            false
780                        } else {
781                            *current = next;
782                            true
783                        }
784                    }
785                    ScalarValue::U8(current) => {
786                        let next = as_i128.map(|i| i as u8).unwrap_or(as_f64 as u8);
787                        if *current == next {
788                            false
789                        } else {
790                            *current = next;
791                            true
792                        }
793                    }
794                    ScalarValue::U16(current) => {
795                        let next = as_i128.map(|i| i as u16).unwrap_or(as_f64 as u16);
796                        if *current == next {
797                            false
798                        } else {
799                            *current = next;
800                            true
801                        }
802                    }
803                    ScalarValue::U32(current) => {
804                        let next = as_i128.map(|i| i as u32).unwrap_or(as_f64 as u32);
805                        if *current == next {
806                            false
807                        } else {
808                            *current = next;
809                            true
810                        }
811                    }
812                    ScalarValue::U64(current) => {
813                        let next = as_i128.map(|i| i as u64).unwrap_or(as_f64 as u64);
814                        if *current == next {
815                            false
816                        } else {
817                            *current = next;
818                            true
819                        }
820                    }
821                    ScalarValue::F32(current) => {
822                        let next = as_f64 as f32;
823                        if (*current - next).abs() < f32::EPSILON {
824                            false
825                        } else {
826                            *current = next;
827                            true
828                        }
829                    }
830                }
831            }
832        };
833        if changed {
834            // A `None` time_stamp makes the encoder stamp now() at *encode*
835            // time. The monitor delta path re-encodes the previous and next
836            // snapshots microseconds apart, so secondsPastEpoch compares
837            // equal and is never flagged changed — freezing the seconds on
838            // monitoring clients. Store the update time so encodes are
839            // stable (see spvd_encode::encode_timestamp).
840            nt.time_stamp = Some(now_nt_timestamp());
841            if compute_alarms {
842                nt.update_alarm_from_value();
843            }
844        }
845        changed
846    }
847
848    pub fn set_array_value(&mut self, value: ScalarArrayValue) -> bool {
849        // Unlike NtScalar (where a `None` time_stamp makes the encoder fall
850        // back to now()), array payloads encode their stored time_stamp
851        // verbatim — so stamp the update time here or clients see epoch 0.
852        let ts = now_nt_timestamp();
853        // NtNdArray keeps its dimensions; only the flat pixel data changes.
854        if let RecordData::NtNdArray { nt, .. } = &mut self.data {
855            if nt.value == value {
856                return false;
857            }
858            nt.value = value;
859            nt.time_stamp = Some(ts.clone());
860            nt.data_time_stamp = ts;
861            return true;
862        }
863        let (nt, nord, nelm) = match &mut self.data {
864            RecordData::Waveform { nt, nord, nelm, .. }
865            | RecordData::Aai { nt, nord, nelm, .. }
866            | RecordData::Aao { nt, nord, nelm, .. }
867            | RecordData::SubArray { nt, nord, nelm, .. } => (nt, nord, *nelm),
868            _ => return false,
869        };
870
871        let mut next = value;
872        truncate_scalar_array_to_nelm(&mut next, nelm);
873        if nt.value == next {
874            return false;
875        }
876
877        *nord = next.len();
878        nt.value = next;
879        nt.time_stamp = ts;
880        true
881    }
882
883    pub fn set_nt_payload(&mut self, payload: NtPayload) -> bool {
884        match (&mut self.data, payload) {
885            (
886                RecordData::Ai { nt, .. }
887                | RecordData::Ao { nt, .. }
888                | RecordData::Bi { nt, .. }
889                | RecordData::Bo { nt, .. }
890                | RecordData::StringIn { nt, .. }
891                | RecordData::StringOut { nt, .. },
892                NtPayload::Scalar(mut next),
893            ) => {
894                if *nt == next {
895                    false
896                } else {
897                    // Respect a caller-supplied acquisition time; otherwise
898                    // stamp the update time so monitor deltas stay stable
899                    // (see set_scalar_value).
900                    if next.time_stamp.is_none() {
901                        next.time_stamp = Some(now_nt_timestamp());
902                    }
903                    *nt = next;
904                    true
905                }
906            }
907            (
908                RecordData::Waveform { nt, nord, nelm, .. }
909                | RecordData::Aai { nt, nord, nelm, .. }
910                | RecordData::Aao { nt, nord, nelm, .. }
911                | RecordData::SubArray { nt, nord, nelm, .. },
912                NtPayload::ScalarArray(mut next),
913            ) => {
914                truncate_scalar_array_to_nelm(&mut next.value, *nelm);
915                let next_len = next.value.len();
916                if *nt == next {
917                    false
918                } else {
919                    // Epoch 0 is the untouched default, never a real
920                    // acquisition time — stamp the update time instead.
921                    if next.time_stamp == NtTimeStamp::default() {
922                        next.time_stamp = now_nt_timestamp();
923                    }
924                    *nord = next_len;
925                    *nt = next;
926                    true
927                }
928            }
929            (RecordData::NtTable { nt, .. }, NtPayload::Table(mut next)) => {
930                if next.validate().is_err() || *nt == next {
931                    false
932                } else {
933                    if next.time_stamp.is_none() {
934                        next.time_stamp = Some(now_nt_timestamp());
935                    }
936                    *nt = next;
937                    true
938                }
939            }
940            (RecordData::NtNdArray { nt, .. }, NtPayload::NdArray(mut next)) => {
941                if next.validate().is_err() || *nt == next {
942                    false
943                } else {
944                    let ts = now_nt_timestamp();
945                    if next.time_stamp.is_none() {
946                        next.time_stamp = Some(ts.clone());
947                    }
948                    if next.data_time_stamp == NtTimeStamp::default() {
949                        next.data_time_stamp = ts;
950                    }
951                    *nt = next;
952                    true
953                }
954            }
955            (RecordData::NtEnum { nt, .. }, NtPayload::Enum(mut next)) => {
956                if *nt == next {
957                    false
958                } else {
959                    if next.time_stamp == NtTimeStamp::default() {
960                        next.time_stamp = now_nt_timestamp();
961                    }
962                    *nt = next;
963                    true
964                }
965            }
966            (
967                RecordData::Generic {
968                    struct_id, fields, ..
969                },
970                NtPayload::Generic {
971                    struct_id: next_id,
972                    fields: next_fields,
973                },
974            ) => {
975                if *struct_id == next_id && *fields == next_fields {
976                    false
977                } else {
978                    *struct_id = next_id;
979                    *fields = next_fields;
980                    true
981                }
982            }
983            _ => false,
984        }
985    }
986}
987
988fn truncate_scalar_array_to_nelm(value: &mut ScalarArrayValue, nelm: usize) {
989    match value {
990        ScalarArrayValue::Bool(v) => v.truncate(nelm),
991        ScalarArrayValue::I8(v) => v.truncate(nelm),
992        ScalarArrayValue::I16(v) => v.truncate(nelm),
993        ScalarArrayValue::I32(v) => v.truncate(nelm),
994        ScalarArrayValue::I64(v) => v.truncate(nelm),
995        ScalarArrayValue::U8(v) => v.truncate(nelm),
996        ScalarArrayValue::U16(v) => v.truncate(nelm),
997        ScalarArrayValue::U32(v) => v.truncate(nelm),
998        ScalarArrayValue::U64(v) => v.truncate(nelm),
999        ScalarArrayValue::F32(v) => v.truncate(nelm),
1000        ScalarArrayValue::F64(v) => v.truncate(nelm),
1001        ScalarArrayValue::Str(v) => v.truncate(nelm),
1002    }
1003}
1004
1005fn parse_bool_like(input: &str) -> Option<bool> {
1006    match input.trim().to_ascii_lowercase().as_str() {
1007        "1" | "true" | "yes" | "on" => Some(true),
1008        "0" | "false" | "no" | "off" => Some(false),
1009        _ => None,
1010    }
1011}