Skip to main content

spvirit_server/
simple_store.rs

1//! A simple in-memory [`Source`] implementation backed by `RecordInstance`.
2//!
3//! Used by [`PvaServer`](crate::pva_server::PvaServer) to serve PVs without
4//! requiring an external database.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8
9use tokio::sync::{RwLock, mpsc};
10use tracing::debug;
11
12use std::future::Future;
13use std::pin::Pin;
14
15use spvirit_codec::spvd_decode::{DecodedValue, FieldDesc, FieldType, StructureDesc, TypeCode};
16use spvirit_types::{NtPayload, ScalarArrayValue, ScalarValue};
17
18use crate::apply::{
19    apply_alarm_update, apply_control_update, apply_display_update, apply_scalar_array_put,
20    apply_value_update,
21};
22use crate::monitor::MonitorRegistry;
23use crate::pvstore::{PvInfo, Source};
24use crate::types::{RecordData, RecordInstance};
25
26/// Callback invoked after a PUT value is applied to a record.
27pub type OnPutCallback = Arc<dyn Fn(&str, &DecodedValue) + Send + Sync>;
28
29/// Callback invoked by the scan scheduler; returns the new value for the PV.
30pub type ScanCallback = Arc<dyn Fn(&str) -> ScalarValue + Send + Sync>;
31
32/// Callback that computes a derived PV value from its input values.
33pub type LinkCallback = Arc<dyn Fn(&[ScalarValue]) -> ScalarValue + Send + Sync>;
34
35/// Pre-apply PUT validator: `Err(msg)` rejects the PUT (error on the wire).
36pub(crate) type PutValidator = Arc<dyn Fn(&str, &DecodedValue) -> Result<(), String> + Send + Sync>;
37
38/// A link from one or more input PVs to a computed output PV.
39pub(crate) struct LinkDef {
40    pub output: String,
41    pub inputs: Vec<String>,
42    pub compute: LinkCallback,
43}
44
45struct PvEntry {
46    record: RecordInstance,
47    subscribers: Vec<mpsc::Sender<NtPayload>>,
48    /// Value carried by the last update posted to subscribers/monitors —
49    /// the reference point for the MDEL monitor deadband. `None` until the
50    /// first post (so the first change always posts).
51    last_posted: Option<f64>,
52}
53
54/// A simple in-memory PV store.
55pub struct SimplePvStore {
56    pvs: RwLock<HashMap<String, PvEntry>>,
57    on_put: HashMap<String, OnPutCallback>,
58    links: Vec<LinkDef>,
59    compute_alarms: bool,
60    registry: RwLock<Option<Arc<MonitorRegistry>>>,
61    validators: RwLock<HashMap<String, PutValidator>>,
62}
63
64impl SimplePvStore {
65    pub(crate) fn new(
66        records: HashMap<String, RecordInstance>,
67        on_put: HashMap<String, OnPutCallback>,
68        links: Vec<LinkDef>,
69        compute_alarms: bool,
70    ) -> Self {
71        let pvs = records
72            .into_iter()
73            .map(|(name, mut record)| {
74                record.stamp_missing_timestamps();
75                let last_posted = initial_posted(&record);
76                (
77                    name,
78                    PvEntry {
79                        record,
80                        subscribers: Vec::new(),
81                        last_posted,
82                    },
83                )
84            })
85            .collect();
86        Self {
87            pvs: RwLock::new(pvs),
88            on_put,
89            links,
90            compute_alarms,
91            registry: RwLock::new(None),
92            validators: RwLock::new(HashMap::new()),
93        }
94    }
95
96    /// Attach the [`MonitorRegistry`] so that `set_value` can push updates
97    /// to PVAccess monitor clients.  Called automatically by [`PvaServer::run`].
98    pub async fn set_registry(&self, registry: Arc<MonitorRegistry>) {
99        *self.registry.write().await = Some(registry);
100    }
101
102    /// Register a pre-apply PUT validator for a PV.
103    pub(crate) async fn set_validator(&self, name: String, v: PutValidator) {
104        self.validators.write().await.insert(name, v);
105    }
106
107    /// Insert or replace a PV record at runtime.
108    pub async fn insert(&self, name: String, mut record: RecordInstance) {
109        record.stamp_missing_timestamps();
110        let mut pvs = self.pvs.write().await;
111        let last_posted = initial_posted(&record);
112        pvs.insert(
113            name,
114            PvEntry {
115                record,
116                subscribers: Vec::new(),
117                last_posted,
118            },
119        );
120    }
121
122    /// Remove a PV record at runtime. Returns `true` if a record was removed,
123    /// `false` if no record with that name existed. Dropping the entry drops
124    /// its subscriber senders, which closes any active monitor channels for
125    /// that PV.
126    pub async fn remove(&self, name: &str) -> bool {
127        self.pvs.write().await.remove(name).is_some()
128    }
129
130    /// Read the current [`ScalarValue`] of a PV.
131    pub async fn get_value(&self, name: &str) -> Option<ScalarValue> {
132        let pvs = self.pvs.read().await;
133        pvs.get(name).map(|e| e.record.current_value())
134    }
135
136    /// Read a clone of the full [`RecordInstance`] backing a PV.
137    ///
138    /// Used by [`RecordFieldSource`](crate::record_fields::RecordFieldSource)
139    /// to serve `<name>.<FIELD>` channels.
140    pub async fn get_record(&self, name: &str) -> Option<RecordInstance> {
141        let pvs = self.pvs.read().await;
142        pvs.get(name).map(|e| e.record.clone())
143    }
144
145    /// Read the full [`NtPayload`] of a PV.
146    pub async fn get_nt(&self, name: &str) -> Option<NtPayload> {
147        let pvs = self.pvs.read().await;
148        pvs.get(name).map(|e| e.record.to_ntpayload())
149    }
150
151    /// Write a [`ScalarValue`] to a PV (bypasses on_put).
152    pub async fn set_value(&self, name: &str, value: ScalarValue) -> bool {
153        if self.set_value_inner(name, value).await {
154            self.evaluate_links(name).await;
155            true
156        } else {
157            false
158        }
159    }
160
161    /// Write a [`ScalarArrayValue`] to an array PV (bypasses on_put).
162    pub async fn set_array_value(&self, name: &str, value: ScalarArrayValue) -> bool {
163        if self.set_array_value_inner(name, value).await {
164            self.evaluate_links(name).await;
165            true
166        } else {
167            false
168        }
169    }
170
171    /// Write a full [`NtPayload`] to a PV (bypasses on_put).
172    pub async fn put_nt(&self, name: &str, payload: NtPayload) -> bool {
173        if self.put_nt_inner(name, payload).await {
174            self.evaluate_links(name).await;
175            true
176        } else {
177            false
178        }
179    }
180
181    /// Explicitly set a record's alarm fields (severity/status/message),
182    /// independent of its value. Unlike [`SimplePvStore::set_value`], alarm
183    /// transitions always post — there is no MDEL deadband gating and no
184    /// link evaluation (alarm changes don't propagate links). Returns
185    /// `false` if the alarm state is unchanged (idempotent) or the record
186    /// doesn't support alarm fields (`Table`/`NdArray`/`Generic`) or doesn't
187    /// exist.
188    pub async fn set_alarm(&self, name: &str, severity: i32, status: i32, message: &str) -> bool {
189        let payload = {
190            let mut pvs = self.pvs.write().await;
191            let Some(entry) = pvs.get_mut(name) else {
192                return false;
193            };
194            let alarm = if let Some(nt) = entry.record.nt_scalar_mut() {
195                (
196                    &mut nt.alarm_severity,
197                    &mut nt.alarm_status,
198                    &mut nt.alarm_message,
199                )
200            } else {
201                match &mut entry.record.data {
202                    RecordData::NtEnum { nt, .. } => (
203                        &mut nt.alarm.severity,
204                        &mut nt.alarm.status,
205                        &mut nt.alarm.message,
206                    ),
207                    RecordData::Waveform { nt, .. }
208                    | RecordData::Aai { nt, .. }
209                    | RecordData::Aao { nt, .. }
210                    | RecordData::SubArray { nt, .. } => (
211                        &mut nt.alarm.severity,
212                        &mut nt.alarm.status,
213                        &mut nt.alarm.message,
214                    ),
215                    _ => return false,
216                }
217            };
218            let (sev, sta, msg) = alarm;
219            let changed = *sev != severity || *sta != status || msg.as_str() != message;
220            if !changed {
221                return false;
222            }
223            *sev = severity;
224            *sta = status;
225            *msg = message.to_string();
226
227            let payload = entry.record.to_ntpayload();
228            entry
229                .subscribers
230                .retain(|tx| tx.try_send(payload.clone()).is_ok());
231            payload
232        };
233
234        let reg = self.registry.read().await;
235        if let Some(registry) = reg.as_ref() {
236            registry.notify_monitors(name, &payload).await;
237        }
238        true
239    }
240
241    /// Core write logic — updates the value, notifies subscribers and monitors,
242    /// but does **not** trigger link evaluation (to avoid recursion).
243    async fn set_value_inner(&self, name: &str, value: ScalarValue) -> bool {
244        let payload = {
245            let mut pvs = self.pvs.write().await;
246            if let Some(entry) = pvs.get_mut(name) {
247                let prev_severity = entry.record.to_ntscalar().alarm_severity;
248                let changed = entry.record.set_scalar_value(value, self.compute_alarms);
249                if changed {
250                    if !should_post_update(entry, prev_severity) {
251                        // Changed, but within the MDEL monitor deadband:
252                        // the record holds the new value (GETs see it), just
253                        // no update is posted to subscribers/monitors.
254                        return true;
255                    }
256                    let payload = entry.record.to_ntpayload();
257                    entry
258                        .subscribers
259                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
260                    Some(payload)
261                } else {
262                    None
263                }
264            } else {
265                return false;
266            }
267        };
268
269        if let Some(payload) = payload {
270            // Notify PVAccess monitor clients (if the registry is attached).
271            let reg = self.registry.read().await;
272            if let Some(registry) = reg.as_ref() {
273                registry.notify_monitors(name, &payload).await;
274            }
275            true
276        } else {
277            false
278        }
279    }
280
281    /// Core array write logic — updates the value, notifies subscribers and monitors,
282    /// but does **not** trigger link evaluation (to avoid recursion).
283    async fn set_array_value_inner(&self, name: &str, value: ScalarArrayValue) -> bool {
284        let payload = {
285            let mut pvs = self.pvs.write().await;
286            if let Some(entry) = pvs.get_mut(name) {
287                let changed = entry.record.set_array_value(value);
288                if changed {
289                    let payload = entry.record.to_ntpayload();
290                    entry
291                        .subscribers
292                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
293                    Some(payload)
294                } else {
295                    None
296                }
297            } else {
298                return false;
299            }
300        };
301
302        if let Some(payload) = payload {
303            // Notify PVAccess monitor clients (if the registry is attached).
304            let reg = self.registry.read().await;
305            if let Some(registry) = reg.as_ref() {
306                registry.notify_monitors(name, &payload).await;
307            }
308            true
309        } else {
310            false
311        }
312    }
313
314    /// Core NtPayload write logic — updates the payload, notifies subscribers
315    /// and monitors, but does **not** trigger link evaluation.
316    async fn put_nt_inner(&self, name: &str, payload: NtPayload) -> bool {
317        let payload = {
318            let mut pvs = self.pvs.write().await;
319            if let Some(entry) = pvs.get_mut(name) {
320                let changed = entry.record.set_nt_payload(payload);
321                if changed {
322                    let payload = entry.record.to_ntpayload();
323                    entry
324                        .subscribers
325                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
326                    Some(payload)
327                } else {
328                    None
329                }
330            } else {
331                return false;
332            }
333        };
334
335        if let Some(payload) = payload {
336            // Notify PVAccess monitor clients (if the registry is attached).
337            let reg = self.registry.read().await;
338            if let Some(registry) = reg.as_ref() {
339                registry.notify_monitors(name, &payload).await;
340            }
341            true
342        } else {
343            false
344        }
345    }
346
347    /// Walk every link whose inputs include `changed_pv`, compute the output,
348    /// and propagate (BFS with cycle detection).
349    async fn evaluate_links(&self, changed_pv: &str) {
350        if self.links.is_empty() {
351            return;
352        }
353        let mut queue = vec![changed_pv.to_string()];
354        let mut visited = HashSet::new();
355
356        while let Some(pv) = queue.pop() {
357            if !visited.insert(pv.clone()) {
358                debug!("Circular link detected for PV '{}', skipping", pv);
359                continue;
360            }
361            for link in &self.links {
362                if !link.inputs.iter().any(|i| i == &pv) {
363                    continue;
364                }
365                // Gather current values of all inputs.
366                let values = {
367                    let pvs = self.pvs.read().await;
368                    link.inputs
369                        .iter()
370                        .map(|n| {
371                            pvs.get(n)
372                                .map(|e| e.record.current_value())
373                                .unwrap_or(ScalarValue::F64(0.0))
374                        })
375                        .collect::<Vec<_>>()
376                };
377                let new_val = (link.compute)(&values);
378                if self.set_value_inner(&link.output, new_val).await {
379                    queue.push(link.output.clone());
380                }
381            }
382        }
383    }
384
385    /// List all PV names.
386    pub async fn pv_names(&self) -> Vec<String> {
387        let pvs = self.pvs.read().await;
388        pvs.keys().cloned().collect()
389    }
390}
391
392impl Source for SimplePvStore {
393    fn claim(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<PvInfo>> + Send + '_>> {
394        let name = name.to_string();
395        Box::pin(async move {
396            let pvs = self.pvs.read().await;
397            let entry = pvs.get(&name)?;
398            let descriptor = descriptor_for_payload(&entry.record.to_ntpayload());
399            Some(PvInfo {
400                descriptor,
401                writable: entry.record.writable(),
402            })
403        })
404    }
405
406    fn get(&self, name: &str) -> Pin<Box<dyn Future<Output = Option<NtPayload>> + Send + '_>> {
407        let name = name.to_string();
408        Box::pin(async move {
409            let pvs = self.pvs.read().await;
410            pvs.get(&name).map(|e| e.record.to_ntpayload())
411        })
412    }
413
414    fn put(
415        &self,
416        name: &str,
417        value: &DecodedValue,
418    ) -> Pin<Box<dyn Future<Output = Result<Vec<(String, NtPayload)>, String>> + Send + '_>> {
419        let name = name.to_string();
420        let value = value.clone();
421        Box::pin(async move {
422            // Clone the validator out inside a tight scope so the read guard
423            // drops before the user callback runs — otherwise temporary
424            // lifetime extension holds the lock across the call, blocking
425            // concurrent set_validator for the duration of every PUT.
426            let validator = {
427                let guard = self.validators.read().await;
428                guard.get(&name).cloned()
429            };
430            if let Some(v) = validator {
431                v(&name, &value)?;
432            }
433
434            let result = {
435                let mut pvs = self.pvs.write().await;
436                let entry = pvs
437                    .get_mut(&name)
438                    .ok_or_else(|| format!("PV '{}' not found", name))?;
439
440                if !entry.record.writable() {
441                    return Err(format!("PV '{}' is not writable", name));
442                }
443
444                let prev_severity = entry.record.to_ntscalar().alarm_severity;
445                let changed = apply_put_to_record(&mut entry.record, &value, self.compute_alarms);
446                if !changed {
447                    return Ok(vec![]);
448                }
449
450                if should_post_update(entry, prev_severity) {
451                    let payload = entry.record.to_ntpayload();
452                    entry
453                        .subscribers
454                        .retain(|tx| tx.try_send(payload.clone()).is_ok());
455                    Some((name.clone(), payload))
456                } else {
457                    // Within the MDEL monitor deadband — value applied, no post.
458                    None
459                }
460            }; // pvs lock dropped
461
462            // Fire on_put callback (non-blocking).
463            if let Some(cb) = self.on_put.get(&name) {
464                let cb = cb.clone();
465                let n = name.clone();
466                let v = value.clone();
467                tokio::spawn(async move { cb(&n, &v) });
468            }
469
470            // Propagate linked PV updates.
471            self.evaluate_links(&name).await;
472
473            Ok(result.into_iter().collect())
474        })
475    }
476
477    fn subscribe(
478        &self,
479        name: &str,
480    ) -> Pin<Box<dyn Future<Output = Option<mpsc::Receiver<NtPayload>>> + Send + '_>> {
481        let name = name.to_string();
482        Box::pin(async move {
483            let mut pvs = self.pvs.write().await;
484            let entry = pvs.get_mut(&name)?;
485            let (tx, rx) = mpsc::channel(64);
486            entry.subscribers.push(tx);
487            Some(rx)
488        })
489    }
490
491    fn names(&self) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + '_>> {
492        Box::pin(async move {
493            let pvs = self.pvs.read().await;
494            pvs.keys().cloned().collect()
495        })
496    }
497}
498
499// ── Helpers ──────────────────────────────────────────────────────────────
500
501/// Numeric view of a scalar value, for deadband arithmetic.
502fn scalar_as_f64(v: &ScalarValue) -> Option<f64> {
503    Some(match v {
504        ScalarValue::I8(x) => *x as f64,
505        ScalarValue::I16(x) => *x as f64,
506        ScalarValue::I32(x) => *x as f64,
507        ScalarValue::I64(x) => *x as f64,
508        ScalarValue::U8(x) => *x as f64,
509        ScalarValue::U16(x) => *x as f64,
510        ScalarValue::U32(x) => *x as f64,
511        ScalarValue::U64(x) => *x as f64,
512        ScalarValue::F32(x) => *x as f64,
513        ScalarValue::F64(x) => *x,
514        ScalarValue::Bool(_) | ScalarValue::Str(_) => return None,
515    })
516}
517
518/// Initial MDEL deadband reference: the record's starting value (EPICS
519/// initialises MLST from the initial VAL, so the first small change is
520/// already subject to the deadband).
521fn initial_posted(record: &RecordInstance) -> Option<f64> {
522    match record.to_ntpayload() {
523        NtPayload::Scalar(nt) => scalar_as_f64(&nt.value),
524        _ => None,
525    }
526}
527
528/// MDEL monitor-deadband gate, called after a record changed.
529///
530/// Returns `true` when the update must be posted to subscribers/monitors
531/// (and records it as the new deadband reference point). An update is
532/// suppressed only when the record is a numeric scalar with MDEL > 0, the
533/// alarm severity did not change, and the value moved less than MDEL from
534/// the last *posted* value — EPICS monitor-deadband semantics.
535fn should_post_update(entry: &mut PvEntry, prev_severity: i32) -> bool {
536    let new_f = match entry.record.to_ntpayload() {
537        NtPayload::Scalar(nt) => match scalar_as_f64(&nt.value) {
538            Some(f) => f,
539            None => return true,
540        },
541        _ => return true,
542    };
543    let mdel = crate::record_fields::mdel_of(&entry.record);
544    let severity_changed = entry.record.to_ntscalar().alarm_severity != prev_severity;
545    let within_deadband = mdel > 0.0
546        && !severity_changed
547        && entry
548            .last_posted
549            .is_some_and(|last| (new_f - last).abs() < mdel);
550    if within_deadband {
551        return false;
552    }
553    entry.last_posted = Some(new_f);
554    true
555}
556
557/// Apply a decoded PUT value to a RecordInstance, returning whether it changed.
558fn apply_put_to_record(
559    record: &mut RecordInstance,
560    value: &DecodedValue,
561    compute_alarms: bool,
562) -> bool {
563    let fields = match value {
564        DecodedValue::Structure(f) => f,
565        other => {
566            // Bare scalar — wrap as value field.
567            return apply_put_to_record(
568                record,
569                &DecodedValue::Structure(vec![("value".to_string(), other.clone())]),
570                compute_alarms,
571            );
572        }
573    };
574
575    let mut changed = false;
576
577    match &mut record.data {
578        RecordData::Ai { nt, .. }
579        | RecordData::Ao { nt, .. }
580        | RecordData::Bi { nt, .. }
581        | RecordData::Bo { nt, .. }
582        | RecordData::StringIn { nt, .. }
583        | RecordData::StringOut { nt, .. } => {
584            for (name, val) in fields {
585                match name.as_str() {
586                    "value" => {
587                        changed |= apply_value_update(nt, val, compute_alarms);
588                    }
589                    "alarm" => {
590                        changed |= apply_alarm_update(nt, val);
591                    }
592                    "display" => {
593                        changed |= apply_display_update(nt, val);
594                    }
595                    "control" => {
596                        changed |= apply_control_update(nt, val);
597                    }
598                    _ => {}
599                }
600            }
601        }
602        RecordData::Waveform { nt, nord, .. }
603        | RecordData::Aai { nt, nord, .. }
604        | RecordData::Aao { nt, nord, .. }
605        | RecordData::SubArray { nt, nord, .. } => {
606            changed = apply_scalar_array_put(nt, nord, value);
607        }
608        RecordData::NtTable { .. } | RecordData::NtNdArray { .. } => {
609            // Table/NdArray PUT not supported via high-level API yet.
610            debug!("PUT to NtTable/NtNdArray not yet supported in SimplePvStore");
611        }
612        RecordData::NtEnum { nt, .. } => {
613            // Accept index updates for NtEnum PVs.
614            for (name, val) in fields {
615                if name == "value" {
616                    let idx = match val {
617                        DecodedValue::Int32(v) => Some(*v),
618                        DecodedValue::Int64(v) => Some(*v as i32),
619                        DecodedValue::Int16(v) => Some(*v as i32),
620                        DecodedValue::Int8(v) => Some(*v as i32),
621                        DecodedValue::Float64(v) => Some(*v as i32),
622                        _ => None,
623                    };
624                    if let Some(idx) = idx {
625                        if idx < 0 || (idx as usize) >= nt.choices.len() {
626                            // out-of-range index — reject, keep value
627                        } else if nt.index != idx {
628                            nt.index = idx;
629                            changed = true;
630                        }
631                    }
632                }
633            }
634        }
635        RecordData::Generic { .. } => {
636            debug!("PUT to Generic not yet supported in SimplePvStore");
637        }
638    }
639
640    changed
641}
642
643// ── NtPayload → StructureDesc ────────────────────────────────────────────
644
645pub fn descriptor_for_payload(payload: &NtPayload) -> StructureDesc {
646    match payload {
647        NtPayload::Scalar(nt) => nt_scalar_desc(&nt.value),
648        NtPayload::ScalarArray(arr) => nt_scalar_array_desc(&arr.value),
649        // Table / NdArray / Enum / Generic: the codec knows how to build the
650        // full structure descriptor. An empty descriptor here makes GET and
651        // MONITOR init hand clients an empty structure, so every read of
652        // these PV types decodes as {}.
653        _ => spvirit_codec::spvd_encode::nt_payload_desc(payload),
654    }
655}
656
657fn value_type_code(sv: &ScalarValue) -> TypeCode {
658    match sv {
659        ScalarValue::Bool(_) => TypeCode::Boolean,
660        ScalarValue::I8(_) => TypeCode::Int8,
661        ScalarValue::I16(_) => TypeCode::Int16,
662        ScalarValue::I32(_) => TypeCode::Int32,
663        ScalarValue::I64(_) => TypeCode::Int64,
664        ScalarValue::U8(_) => TypeCode::UInt8,
665        ScalarValue::U16(_) => TypeCode::UInt16,
666        ScalarValue::U32(_) => TypeCode::UInt32,
667        ScalarValue::U64(_) => TypeCode::UInt64,
668        ScalarValue::F32(_) => TypeCode::Float32,
669        ScalarValue::F64(_) => TypeCode::Float64,
670        ScalarValue::Str(_) => TypeCode::String,
671    }
672}
673
674fn array_type_code(sav: &ScalarArrayValue) -> TypeCode {
675    match sav {
676        ScalarArrayValue::Bool(_) => TypeCode::Boolean,
677        ScalarArrayValue::I8(_) => TypeCode::Int8,
678        ScalarArrayValue::I16(_) => TypeCode::Int16,
679        ScalarArrayValue::I32(_) => TypeCode::Int32,
680        ScalarArrayValue::I64(_) => TypeCode::Int64,
681        ScalarArrayValue::U8(_) => TypeCode::UInt8,
682        ScalarArrayValue::U16(_) => TypeCode::UInt16,
683        ScalarArrayValue::U32(_) => TypeCode::UInt32,
684        ScalarArrayValue::U64(_) => TypeCode::UInt64,
685        ScalarArrayValue::F32(_) => TypeCode::Float32,
686        ScalarArrayValue::F64(_) => TypeCode::Float64,
687        ScalarArrayValue::Str(_) => TypeCode::String,
688    }
689}
690
691fn nt_scalar_desc(sv: &ScalarValue) -> StructureDesc {
692    let tc = value_type_code(sv);
693    StructureDesc {
694        struct_id: Some("epics:nt/NTScalar:1.0".to_string()),
695        fields: vec![
696            FieldDesc {
697                name: "value".to_string(),
698                field_type: FieldType::Scalar(tc),
699            },
700            FieldDesc {
701                name: "alarm".to_string(),
702                field_type: FieldType::Structure(alarm_desc()),
703            },
704            FieldDesc {
705                name: "timeStamp".to_string(),
706                field_type: FieldType::Structure(timestamp_desc()),
707            },
708            FieldDesc {
709                name: "display".to_string(),
710                field_type: FieldType::Structure(display_desc()),
711            },
712            FieldDesc {
713                name: "control".to_string(),
714                field_type: FieldType::Structure(control_desc()),
715            },
716            FieldDesc {
717                name: "valueAlarm".to_string(),
718                field_type: FieldType::Structure(value_alarm_desc()),
719            },
720        ],
721    }
722}
723
724fn nt_scalar_array_desc(sav: &ScalarArrayValue) -> StructureDesc {
725    let tc = array_type_code(sav);
726    StructureDesc {
727        struct_id: Some("epics:nt/NTScalarArray:1.0".to_string()),
728        fields: vec![
729            FieldDesc {
730                name: "value".to_string(),
731                field_type: FieldType::ScalarArray(tc),
732            },
733            FieldDesc {
734                name: "alarm".to_string(),
735                field_type: FieldType::Structure(alarm_desc()),
736            },
737            FieldDesc {
738                name: "timeStamp".to_string(),
739                field_type: FieldType::Structure(timestamp_desc()),
740            },
741            FieldDesc {
742                name: "display".to_string(),
743                field_type: FieldType::Structure(display_desc()),
744            },
745            FieldDesc {
746                name: "control".to_string(),
747                field_type: FieldType::Structure(control_desc()),
748            },
749        ],
750    }
751}
752
753fn alarm_desc() -> StructureDesc {
754    StructureDesc {
755        struct_id: Some("alarm_t".to_string()),
756        fields: vec![
757            FieldDesc {
758                name: "severity".to_string(),
759                field_type: FieldType::Scalar(TypeCode::Int32),
760            },
761            FieldDesc {
762                name: "status".to_string(),
763                field_type: FieldType::Scalar(TypeCode::Int32),
764            },
765            FieldDesc {
766                name: "message".to_string(),
767                field_type: FieldType::String,
768            },
769        ],
770    }
771}
772
773fn timestamp_desc() -> StructureDesc {
774    StructureDesc {
775        struct_id: Some("time_t".to_string()),
776        fields: vec![
777            FieldDesc {
778                name: "secondsPastEpoch".to_string(),
779                field_type: FieldType::Scalar(TypeCode::Int64),
780            },
781            FieldDesc {
782                name: "nanoseconds".to_string(),
783                field_type: FieldType::Scalar(TypeCode::Int32),
784            },
785            FieldDesc {
786                name: "userTag".to_string(),
787                field_type: FieldType::Scalar(TypeCode::Int32),
788            },
789        ],
790    }
791}
792
793fn display_desc() -> StructureDesc {
794    StructureDesc {
795        struct_id: Some("display_t".to_string()),
796        fields: vec![
797            FieldDesc {
798                name: "limitLow".to_string(),
799                field_type: FieldType::Scalar(TypeCode::Float64),
800            },
801            FieldDesc {
802                name: "limitHigh".to_string(),
803                field_type: FieldType::Scalar(TypeCode::Float64),
804            },
805            FieldDesc {
806                name: "description".to_string(),
807                field_type: FieldType::String,
808            },
809            FieldDesc {
810                name: "units".to_string(),
811                field_type: FieldType::String,
812            },
813            FieldDesc {
814                name: "precision".to_string(),
815                field_type: FieldType::Scalar(TypeCode::Int32),
816            },
817            FieldDesc {
818                name: "form".to_string(),
819                field_type: FieldType::Structure(StructureDesc {
820                    struct_id: Some("enum_t".to_string()),
821                    fields: vec![
822                        FieldDesc {
823                            name: "index".to_string(),
824                            field_type: FieldType::Scalar(TypeCode::Int32),
825                        },
826                        FieldDesc {
827                            name: "choices".to_string(),
828                            field_type: FieldType::StringArray,
829                        },
830                    ],
831                }),
832            },
833        ],
834    }
835}
836
837fn control_desc() -> StructureDesc {
838    StructureDesc {
839        struct_id: Some("control_t".to_string()),
840        fields: vec![
841            FieldDesc {
842                name: "limitLow".to_string(),
843                field_type: FieldType::Scalar(TypeCode::Float64),
844            },
845            FieldDesc {
846                name: "limitHigh".to_string(),
847                field_type: FieldType::Scalar(TypeCode::Float64),
848            },
849            FieldDesc {
850                name: "minStep".to_string(),
851                field_type: FieldType::Scalar(TypeCode::Float64),
852            },
853        ],
854    }
855}
856
857fn value_alarm_desc() -> StructureDesc {
858    StructureDesc {
859        struct_id: Some("valueAlarm_t".to_string()),
860        fields: vec![
861            FieldDesc {
862                name: "active".to_string(),
863                field_type: FieldType::Scalar(TypeCode::Boolean),
864            },
865            FieldDesc {
866                name: "lowAlarmLimit".to_string(),
867                field_type: FieldType::Scalar(TypeCode::Float64),
868            },
869            FieldDesc {
870                name: "lowWarningLimit".to_string(),
871                field_type: FieldType::Scalar(TypeCode::Float64),
872            },
873            FieldDesc {
874                name: "highWarningLimit".to_string(),
875                field_type: FieldType::Scalar(TypeCode::Float64),
876            },
877            FieldDesc {
878                name: "highAlarmLimit".to_string(),
879                field_type: FieldType::Scalar(TypeCode::Float64),
880            },
881            FieldDesc {
882                name: "lowAlarmSeverity".to_string(),
883                field_type: FieldType::Scalar(TypeCode::Int32),
884            },
885            FieldDesc {
886                name: "lowWarningSeverity".to_string(),
887                field_type: FieldType::Scalar(TypeCode::Int32),
888            },
889            FieldDesc {
890                name: "highWarningSeverity".to_string(),
891                field_type: FieldType::Scalar(TypeCode::Int32),
892            },
893            FieldDesc {
894                name: "highAlarmSeverity".to_string(),
895                field_type: FieldType::Scalar(TypeCode::Int32),
896            },
897            FieldDesc {
898                name: "hysteresis".to_string(),
899                field_type: FieldType::Scalar(TypeCode::UInt8),
900            },
901        ],
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use crate::types::{DbCommonState, RecordType};
909    use spvirit_types::{
910        NdCodec, NdDimension, NtNdArray, NtPayload, NtScalar, NtScalarArray, NtTable,
911        NtTableColumn, ScalarArrayValue, ScalarValue,
912    };
913
914    fn make_ai(name: &str, val: f64) -> RecordInstance {
915        RecordInstance {
916            name: name.to_string(),
917            record_type: RecordType::Ai,
918            common: DbCommonState::default(),
919            data: RecordData::Ai {
920                nt: NtScalar::from_value(ScalarValue::F64(val)),
921                inp: None,
922                siml: None,
923                siol: None,
924                simm: false,
925            },
926            raw_fields: HashMap::new(),
927        }
928    }
929
930    fn make_mbbo(name: &str, choices: Vec<String>, initial: i32) -> RecordInstance {
931        RecordInstance {
932            name: name.to_string(),
933            record_type: RecordType::Mbbo,
934            common: DbCommonState::default(),
935            data: RecordData::NtEnum {
936                nt: spvirit_types::NtEnum::new(initial, choices),
937                inp: None,
938                out: None,
939                omsl: crate::types::OutputMode::Supervisory,
940            },
941            raw_fields: HashMap::new(),
942        }
943    }
944
945    fn make_ao(name: &str, val: f64) -> RecordInstance {
946        RecordInstance {
947            name: name.to_string(),
948            record_type: RecordType::Ao,
949            common: DbCommonState::default(),
950            data: RecordData::Ao {
951                nt: NtScalar::from_value(ScalarValue::F64(val)),
952                out: None,
953                dol: None,
954                omsl: crate::types::OutputMode::Supervisory,
955                drvl: None,
956                drvh: None,
957                oroc: None,
958                siml: None,
959                siol: None,
960                simm: false,
961            },
962            raw_fields: HashMap::new(),
963        }
964    }
965
966    #[tokio::test]
967    async fn mdel_deadband_suppresses_small_monitor_updates() {
968        let recs = crate::db::parse_db(
969            r#"
970record(ao, "DB:AO") {
971    field(VAL, "0.0")
972    field(MDEL, "0.5")
973}"#,
974        )
975        .expect("parse");
976        let store = SimplePvStore::new(recs, HashMap::new(), Vec::new(), false);
977        let mut rx = Source::subscribe(&store, "DB:AO")
978            .await
979            .expect("subscribed");
980
981        // |Δ| = 0.2 < MDEL 0.5 → value updates but no monitor post.
982        assert!(store.set_value("DB:AO", ScalarValue::F64(0.2)).await);
983        // |Δ| = 0.9 ≥ MDEL 0.5 → posted.
984        assert!(store.set_value("DB:AO", ScalarValue::F64(0.9)).await);
985
986        match rx.recv().await.expect("posted update") {
987            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(0.9)),
988            other => panic!("expected scalar, got {other:?}"),
989        }
990        // The suppressed 0.2 update must not be queued behind it.
991        assert!(rx.try_recv().is_err());
992        // GETs always see the latest value regardless of the deadband.
993        assert_eq!(store.get_value("DB:AO").await, Some(ScalarValue::F64(0.9)));
994    }
995
996    fn make_waveform(name: &str, value: ScalarArrayValue) -> RecordInstance {
997        let nelm = value.len();
998        RecordInstance {
999            name: name.to_string(),
1000            record_type: RecordType::Waveform,
1001            common: DbCommonState::default(),
1002            data: RecordData::Waveform {
1003                nt: NtScalarArray::from_value(value),
1004                inp: None,
1005                ftvl: "DOUBLE".to_string(),
1006                nelm,
1007                nord: nelm,
1008            },
1009            raw_fields: HashMap::new(),
1010        }
1011    }
1012
1013    fn make_nt_table(name: &str) -> RecordInstance {
1014        RecordInstance {
1015            name: name.to_string(),
1016            record_type: RecordType::NtTable,
1017            common: DbCommonState::default(),
1018            data: RecordData::NtTable {
1019                nt: NtTable {
1020                    labels: vec!["X".to_string(), "Y".to_string()],
1021                    columns: vec![
1022                        NtTableColumn {
1023                            name: "x".to_string(),
1024                            values: ScalarArrayValue::F64(vec![1.0, 2.0]),
1025                        },
1026                        NtTableColumn {
1027                            name: "y".to_string(),
1028                            values: ScalarArrayValue::F64(vec![10.0, 20.0]),
1029                        },
1030                    ],
1031                    descriptor: Some("table".to_string()),
1032                    alarm: None,
1033                    time_stamp: None,
1034                },
1035                inp: None,
1036                out: None,
1037                omsl: crate::types::OutputMode::Supervisory,
1038            },
1039            raw_fields: HashMap::new(),
1040        }
1041    }
1042
1043    fn make_nt_ndarray(name: &str) -> RecordInstance {
1044        RecordInstance {
1045            name: name.to_string(),
1046            record_type: RecordType::NtNdArray,
1047            common: DbCommonState::default(),
1048            data: RecordData::NtNdArray {
1049                nt: NtNdArray {
1050                    value: ScalarArrayValue::U8(vec![0; 4]),
1051                    codec: NdCodec {
1052                        name: "none".to_string(),
1053                        parameters: HashMap::new(),
1054                    },
1055                    compressed_size: 4,
1056                    uncompressed_size: 4,
1057                    dimension: vec![NdDimension {
1058                        size: 2,
1059                        offset: 0,
1060                        full_size: 2,
1061                        binning: 1,
1062                        reverse: false,
1063                    }],
1064                    unique_id: 1,
1065                    data_time_stamp: Default::default(),
1066                    attribute: vec![],
1067                    descriptor: Some("ndarray".to_string()),
1068                    alarm: None,
1069                    time_stamp: None,
1070                    display: None,
1071                },
1072                inp: None,
1073                out: None,
1074                omsl: crate::types::OutputMode::Supervisory,
1075            },
1076            raw_fields: HashMap::new(),
1077        }
1078    }
1079
1080    #[tokio::test]
1081    async fn store_stamps_initial_timestamps_on_static_records() {
1082        // Records that are never updated after creation (e.g. a static
1083        // NTTable) must still carry a valid timestamp — clients like the
1084        // EPICS Archiver Appliance reject epoch-0 events.
1085        let mut records = HashMap::new();
1086        records.insert("TEST:TBL".into(), make_nt_table("TEST:TBL"));
1087        records.insert("TEST:NDA".into(), make_nt_ndarray("TEST:NDA"));
1088        records.insert(
1089            "TEST:ENUM".into(),
1090            make_mbbo("TEST:ENUM", vec!["A".into(), "B".into()], 0),
1091        );
1092        records.insert(
1093            "TEST:WF".into(),
1094            make_waveform("TEST:WF", ScalarArrayValue::F64(vec![0.0])),
1095        );
1096        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1097        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1098
1099        match store.get_nt("TEST:TBL").await.unwrap() {
1100            NtPayload::Table(nt) => {
1101                assert!(nt.time_stamp.expect("table stamped").seconds_past_epoch > 0)
1102            }
1103            _ => panic!("expected table"),
1104        }
1105        match store.get_nt("TEST:NDA").await.unwrap() {
1106            NtPayload::NdArray(nt) => {
1107                assert!(nt.time_stamp.expect("ndarray stamped").seconds_past_epoch > 0);
1108                assert!(nt.data_time_stamp.seconds_past_epoch > 0);
1109            }
1110            _ => panic!("expected ndarray"),
1111        }
1112        match store.get_nt("TEST:ENUM").await.unwrap() {
1113            NtPayload::Enum(nt) => assert!(nt.time_stamp.seconds_past_epoch > 0),
1114            _ => panic!("expected enum"),
1115        }
1116        match store.get_nt("TEST:WF").await.unwrap() {
1117            NtPayload::ScalarArray(nt) => assert!(nt.time_stamp.seconds_past_epoch > 0),
1118            _ => panic!("expected scalar array"),
1119        }
1120        match store.get_nt("TEST:AI").await.unwrap() {
1121            NtPayload::Scalar(nt) => {
1122                assert!(nt.time_stamp.expect("scalar stamped").seconds_past_epoch > 0)
1123            }
1124            _ => panic!("expected scalar"),
1125        }
1126    }
1127
1128    #[tokio::test]
1129    async fn has_pv_returns_true_for_existing() {
1130        let mut records = HashMap::new();
1131        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1132        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1133        assert!(store.claim("TEST:AI").await.is_some());
1134        assert!(store.claim("MISSING").await.is_none());
1135    }
1136
1137    #[tokio::test]
1138    async fn get_snapshot_returns_payload() {
1139        let mut records = HashMap::new();
1140        records.insert("TEST:AI".into(), make_ai("TEST:AI", 42.0));
1141        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1142        let snap = store.get("TEST:AI").await.unwrap();
1143        match snap {
1144            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(42.0)),
1145            _ => panic!("expected scalar"),
1146        }
1147    }
1148
1149    #[tokio::test]
1150    async fn put_value_updates_writable_record() {
1151        let mut records = HashMap::new();
1152        records.insert("TEST:AO".into(), make_ao("TEST:AO", 0.0));
1153        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1154
1155        let val = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(99.5))]);
1156        let result = store.put("TEST:AO", &val).await.unwrap();
1157        assert_eq!(result.len(), 1);
1158        assert_eq!(result[0].0, "TEST:AO");
1159
1160        let snap = store.get("TEST:AO").await.unwrap();
1161        match snap {
1162            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(99.5)),
1163            _ => panic!("expected scalar"),
1164        }
1165    }
1166
1167    #[tokio::test]
1168    async fn put_wire_rejects_out_of_range_enum_index() {
1169        let mut records = HashMap::new();
1170        records.insert(
1171            "E".into(),
1172            make_mbbo("E", vec!["A".into(), "B".into(), "C".into()], 0),
1173        );
1174        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1175
1176        // Out-of-range index — must be a no-op (Ok, no changed PVs), index unchanged.
1177        let result = Source::put(&store, "E", &DecodedValue::Int32(7))
1178            .await
1179            .unwrap();
1180        assert!(result.is_empty());
1181        assert_eq!(store.get_value("E").await.unwrap(), ScalarValue::I32(0));
1182
1183        // In-range index — applied.
1184        let result = Source::put(&store, "E", &DecodedValue::Int32(2))
1185            .await
1186            .unwrap();
1187        assert_eq!(result.len(), 1);
1188        assert_eq!(store.get_value("E").await.unwrap(), ScalarValue::I32(2));
1189    }
1190
1191    #[tokio::test]
1192    async fn put_value_rejects_readonly() {
1193        let mut records = HashMap::new();
1194        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1195        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1196
1197        let val = DecodedValue::Float64(5.0);
1198        let err = store.put("TEST:AI", &val).await.unwrap_err();
1199        assert!(err.contains("not writable"));
1200    }
1201
1202    #[tokio::test]
1203    async fn set_value_bypasses_writable_check() {
1204        let mut records = HashMap::new();
1205        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1206        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1207        assert!(store.set_value("TEST:AI", ScalarValue::F64(10.0)).await);
1208        let val = store.get_value("TEST:AI").await.unwrap();
1209        assert_eq!(val, ScalarValue::F64(10.0));
1210    }
1211
1212    #[tokio::test]
1213    async fn set_array_value_updates_all_scalar_array_types() {
1214        let cases: Vec<ScalarArrayValue> = vec![
1215            ScalarArrayValue::Bool(vec![false, true]),
1216            ScalarArrayValue::I8(vec![1, 2]),
1217            ScalarArrayValue::I16(vec![1, 2]),
1218            ScalarArrayValue::I32(vec![1, 2]),
1219            ScalarArrayValue::I64(vec![1, 2]),
1220            ScalarArrayValue::U8(vec![1, 2]),
1221            ScalarArrayValue::U16(vec![1, 2]),
1222            ScalarArrayValue::U32(vec![1, 2]),
1223            ScalarArrayValue::U64(vec![1, 2]),
1224            ScalarArrayValue::F32(vec![1.0, 2.0]),
1225            ScalarArrayValue::F64(vec![1.0, 2.0]),
1226            ScalarArrayValue::Str(vec!["a".to_string(), "b".to_string()]),
1227        ];
1228
1229        for (idx, updated) in cases.into_iter().enumerate() {
1230            let pv = format!("TEST:WF:{idx}");
1231            let mut records = HashMap::new();
1232            records.insert(pv.clone(), make_waveform(&pv, updated.clone()));
1233            let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1234
1235            assert!(!store.set_array_value(&pv, updated.clone()).await);
1236
1237            let second = match updated {
1238                ScalarArrayValue::Bool(_) => ScalarArrayValue::Bool(vec![true, false]),
1239                ScalarArrayValue::I8(_) => ScalarArrayValue::I8(vec![3, 4]),
1240                ScalarArrayValue::I16(_) => ScalarArrayValue::I16(vec![3, 4]),
1241                ScalarArrayValue::I32(_) => ScalarArrayValue::I32(vec![3, 4]),
1242                ScalarArrayValue::I64(_) => ScalarArrayValue::I64(vec![3, 4]),
1243                ScalarArrayValue::U8(_) => ScalarArrayValue::U8(vec![3, 4]),
1244                ScalarArrayValue::U16(_) => ScalarArrayValue::U16(vec![3, 4]),
1245                ScalarArrayValue::U32(_) => ScalarArrayValue::U32(vec![3, 4]),
1246                ScalarArrayValue::U64(_) => ScalarArrayValue::U64(vec![3, 4]),
1247                ScalarArrayValue::F32(_) => ScalarArrayValue::F32(vec![3.0, 4.0]),
1248                ScalarArrayValue::F64(_) => ScalarArrayValue::F64(vec![3.0, 4.0]),
1249                ScalarArrayValue::Str(_) => {
1250                    ScalarArrayValue::Str(vec!["x".to_string(), "y".to_string()])
1251                }
1252            };
1253
1254            assert!(store.set_array_value(&pv, second.clone()).await);
1255            let snap = store.get(&pv).await.unwrap();
1256            match snap {
1257                NtPayload::ScalarArray(nt) => assert_eq!(nt.value, second),
1258                _ => panic!("expected scalar array"),
1259            }
1260        }
1261    }
1262
1263    #[tokio::test]
1264    async fn get_nt_returns_full_payload() {
1265        let mut records = HashMap::new();
1266        records.insert("TEST:AI".into(), make_ai("TEST:AI", 12.5));
1267        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1268
1269        let nt = store.get_nt("TEST:AI").await.unwrap();
1270        match nt {
1271            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(12.5)),
1272            _ => panic!("expected scalar payload"),
1273        }
1274    }
1275
1276    #[tokio::test]
1277    async fn put_nt_updates_scalar_array_table_and_ndarray() {
1278        let mut records = HashMap::new();
1279        records.insert("TEST:AI".into(), make_ai("TEST:AI", 1.0));
1280        records.insert(
1281            "TEST:WF".into(),
1282            make_waveform("TEST:WF", ScalarArrayValue::F64(vec![0.0, 0.0])),
1283        );
1284        records.insert("TEST:TBL".into(), make_nt_table("TEST:TBL"));
1285        records.insert("TEST:NDA".into(), make_nt_ndarray("TEST:NDA"));
1286        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1287
1288        assert!(
1289            store
1290                .put_nt(
1291                    "TEST:AI",
1292                    NtPayload::Scalar(NtScalar::from_value(ScalarValue::F64(5.0))),
1293                )
1294                .await
1295        );
1296        assert!(
1297            store
1298                .put_nt(
1299                    "TEST:WF",
1300                    NtPayload::ScalarArray(NtScalarArray::from_value(ScalarArrayValue::F64(vec![
1301                        3.0, 4.0
1302                    ],))),
1303                )
1304                .await
1305        );
1306
1307        let table = NtTable {
1308            labels: vec!["X".to_string(), "Y".to_string()],
1309            columns: vec![
1310                NtTableColumn {
1311                    name: "x".to_string(),
1312                    values: ScalarArrayValue::F64(vec![2.0, 3.0]),
1313                },
1314                NtTableColumn {
1315                    name: "y".to_string(),
1316                    values: ScalarArrayValue::F64(vec![20.0, 30.0]),
1317                },
1318            ],
1319            descriptor: Some("updated table".to_string()),
1320            alarm: None,
1321            time_stamp: None,
1322        };
1323        assert!(
1324            store
1325                .put_nt("TEST:TBL", NtPayload::Table(table.clone()))
1326                .await
1327        );
1328
1329        let ndarray = NtNdArray {
1330            value: ScalarArrayValue::U8(vec![1, 2, 3, 4]),
1331            codec: NdCodec {
1332                name: "none".to_string(),
1333                parameters: HashMap::new(),
1334            },
1335            compressed_size: 4,
1336            uncompressed_size: 4,
1337            dimension: vec![NdDimension {
1338                size: 4,
1339                offset: 0,
1340                full_size: 4,
1341                binning: 1,
1342                reverse: false,
1343            }],
1344            unique_id: 2,
1345            data_time_stamp: Default::default(),
1346            attribute: vec![],
1347            descriptor: Some("updated ndarray".to_string()),
1348            alarm: None,
1349            time_stamp: None,
1350            display: None,
1351        };
1352        assert!(
1353            store
1354                .put_nt("TEST:NDA", NtPayload::NdArray(ndarray.clone()))
1355                .await
1356        );
1357
1358        assert!(
1359            !store
1360                .put_nt(
1361                    "TEST:AI",
1362                    NtPayload::ScalarArray(NtScalarArray::from_value(ScalarArrayValue::F64(vec![
1363                        1.0
1364                    ]))),
1365                )
1366                .await
1367        );
1368
1369        // The caller supplied no timestamps, so the store stamps the update
1370        // time — compare everything else verbatim.
1371        match store.get_nt("TEST:TBL").await.unwrap() {
1372            NtPayload::Table(mut nt) => {
1373                let ts = nt.time_stamp.take().expect("table put must be stamped");
1374                assert!(ts.seconds_past_epoch > 0);
1375                assert_eq!(nt, table);
1376            }
1377            _ => panic!("expected table payload"),
1378        }
1379        match store.get_nt("TEST:NDA").await.unwrap() {
1380            NtPayload::NdArray(mut nt) => {
1381                let ts = nt.time_stamp.take().expect("ndarray put must be stamped");
1382                assert!(ts.seconds_past_epoch > 0);
1383                assert!(nt.data_time_stamp.seconds_past_epoch > 0);
1384                nt.data_time_stamp = Default::default();
1385                assert_eq!(nt, ndarray);
1386            }
1387            _ => panic!("expected ndarray payload"),
1388        }
1389    }
1390
1391    #[tokio::test]
1392    async fn descriptor_matches_value_type() {
1393        let mut records = HashMap::new();
1394        records.insert("TEST:AI".into(), make_ai("TEST:AI", 0.0));
1395        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1396        let info = store.claim("TEST:AI").await.unwrap();
1397        assert_eq!(
1398            info.descriptor.struct_id.as_deref(),
1399            Some("epics:nt/NTScalar:1.0")
1400        );
1401        let desc = info.descriptor;
1402        let value_field = desc.field("value").unwrap();
1403        assert!(matches!(
1404            value_field.field_type,
1405            FieldType::Scalar(TypeCode::Float64)
1406        ));
1407    }
1408
1409    #[tokio::test]
1410    async fn subscribe_receives_updates() {
1411        let mut records = HashMap::new();
1412        records.insert("TEST:AO".into(), make_ao("TEST:AO", 0.0));
1413        let store = SimplePvStore::new(records, HashMap::new(), vec![], false);
1414
1415        let mut rx = Source::subscribe(&store, "TEST:AO").await.unwrap();
1416
1417        let val = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(7.7))]);
1418        store.put("TEST:AO", &val).await.unwrap();
1419
1420        let update = rx.recv().await.unwrap();
1421        match update {
1422            NtPayload::Scalar(nt) => assert_eq!(nt.value, ScalarValue::F64(7.7)),
1423            _ => panic!("expected scalar"),
1424        }
1425    }
1426
1427    #[tokio::test]
1428    async fn on_put_callback_is_invoked() {
1429        use std::sync::atomic::{AtomicBool, Ordering};
1430
1431        let called = Arc::new(AtomicBool::new(false));
1432        let called2 = called.clone();
1433
1434        let mut records = HashMap::new();
1435        records.insert("CB:AO".into(), make_ao("CB:AO", 0.0));
1436
1437        let mut on_put = HashMap::new();
1438        let cb: OnPutCallback = Arc::new(move |_name, _val| {
1439            called2.store(true, Ordering::SeqCst);
1440        });
1441        on_put.insert("CB:AO".into(), cb);
1442
1443        let store = SimplePvStore::new(records, on_put, vec![], false);
1444        let val = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(1.0))]);
1445        store.put("CB:AO", &val).await.unwrap();
1446
1447        // Give the spawned task time to run.
1448        tokio::task::yield_now().await;
1449        tokio::task::yield_now().await;
1450
1451        assert!(called.load(Ordering::SeqCst));
1452    }
1453
1454    #[tokio::test]
1455    async fn validator_rejects_put_before_apply() {
1456        let mut records = std::collections::HashMap::new();
1457        records.insert(
1458            "V".to_string(),
1459            crate::pva_server::make_output_record(
1460                "V",
1461                crate::types::RecordType::Ao,
1462                ScalarValue::F64(1.0),
1463            ),
1464        );
1465        let store =
1466            SimplePvStore::new(records, std::collections::HashMap::new(), Vec::new(), false);
1467        store
1468            .set_validator(
1469                "V".to_string(),
1470                std::sync::Arc::new(|_name, _val| Err("nope".to_string())),
1471            )
1472            .await;
1473
1474        let dv = DecodedValue::Float64(2.0);
1475        let res = Source::put(&store, "V", &dv).await;
1476        assert_eq!(res, Err("nope".to_string()));
1477        // value unchanged — validator ran BEFORE apply
1478        assert_eq!(store.get_value("V").await, Some(ScalarValue::F64(1.0)));
1479    }
1480
1481    #[tokio::test]
1482    async fn remove_deletes_record_and_is_idempotent() {
1483        let mut records = std::collections::HashMap::new();
1484        records.insert(
1485            "T:GONE".to_string(),
1486            crate::pva_server::make_scalar_record("T:GONE", RecordType::Ai, ScalarValue::F64(1.0)),
1487        );
1488        let store = SimplePvStore::new(records, Default::default(), Vec::new(), false);
1489
1490        assert!(store.get_value("T:GONE").await.is_some());
1491        assert!(store.remove("T:GONE").await, "first remove returns true");
1492        assert!(store.get_value("T:GONE").await.is_none(), "record is gone");
1493        assert!(!store.remove("T:GONE").await, "second remove returns false");
1494        assert!(store.claim("T:GONE").await.is_none(), "claim no longer matches");
1495    }
1496
1497    #[tokio::test]
1498    async fn validator_allows_structure_wrapped_put_through() {
1499        // Real puts to scalar records arrive wrapped as a Structure with a
1500        // "value" field (see apply_put_to_record's bare-scalar-wrapping).
1501        // The validator itself only sees the raw DecodedValue as given to
1502        // `put`; this test documents that a validator returning Ok lets a
1503        // structure-wrapped put proceed and apply normally.
1504        let mut records = std::collections::HashMap::new();
1505        records.insert("W".to_string(), make_ao("W", 1.0));
1506        let store =
1507            SimplePvStore::new(records, std::collections::HashMap::new(), Vec::new(), false);
1508        store
1509            .set_validator("W".to_string(), std::sync::Arc::new(|_name, _val| Ok(())))
1510            .await;
1511
1512        let dv = DecodedValue::Structure(vec![("value".to_string(), DecodedValue::Float64(5.0))]);
1513        let res = Source::put(&store, "W", &dv).await;
1514        assert!(res.is_ok());
1515        assert_eq!(store.get_value("W").await, Some(ScalarValue::F64(5.0)));
1516    }
1517}