Skip to main content

spvirit_server/
pv.rs

1//! Typed PV handles — the ergonomic front door to [`SimplePvStore`].
2//!
3//! A [`Pv<T>`] is created *pending* (it owns a record template plus attached
4//! callbacks) and becomes *bound* to a store when passed to
5//! `PvaServer::serve(...)`. Handles are cheap clones; all clones observe and
6//! drive the same record.
7//!
8//! # Hello world
9//!
10//! ```rust,ignore
11//! use spvirit_server::{AnyPv, Pv, PvaServer};
12//!
13//! let temp = Pv::ai("SIM:TEMP", 22.5).units("C").prec(2);
14//! let sp = Pv::ao("SIM:SETPOINT", 25.0)
15//!     .on_put(|_pv, v: f64| if v.is_finite() { Ok(()) } else { Err("NaN".into()) });
16//!
17//! let server = PvaServer::serve([AnyPv::from(temp.clone()), AnyPv::from(sp)])
18//!     .start()
19//!     .await;
20//! temp.set(23.1).await?;
21//! ```
22//!
23//! # Bulk creation
24//!
25//! Handles are ordinary values, so a whole bank of PVs can be built with an
26//! iterator and handed to `serve` as a `Vec<AnyPv>`:
27//!
28//! ```rust,ignore
29//! use spvirit_server::{AnyPv, Pv, PvaServer};
30//!
31//! let channels: Vec<Pv<f64>> = (0..8)
32//!     .map(|i| Pv::ai(format!("SIM:CH{i}"), 0.0).units("C"))
33//!     .collect();
34//! let pvs: Vec<AnyPv> = channels.iter().cloned().map(AnyPv::from).collect();
35//!
36//! let server = PvaServer::serve(pvs).start().await;
37//! channels[0].set(21.3).await?;
38//! ```
39
40use std::marker::PhantomData;
41use std::sync::{Arc, Mutex};
42
43use spvirit_codec::spvd_decode::DecodedValue;
44use spvirit_types::{NtPayload, ScalarArrayValue, ScalarValue};
45
46use crate::pva_server::{make_array_record, make_output_record, make_scalar_record};
47use crate::simple_store::SimplePvStore;
48use crate::types::{RecordInstance, RecordType};
49
50/// Errors from typed PV handle operations.
51#[derive(Debug, Clone, PartialEq)]
52pub enum PvError {
53    /// The handle has not been bound to a server/store yet.
54    Unbound,
55    /// No record with this name exists in the store.
56    NotFound(String),
57    /// The record's value type does not match the handle's `T`.
58    TypeMismatch {
59        expected: &'static str,
60        actual: String,
61    },
62    /// A PUT was rejected by an `on_put` callback.
63    PutRejected(String),
64}
65
66impl std::fmt::Display for PvError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            PvError::Unbound => write!(f, "PV handle is not bound to a server yet"),
70            PvError::NotFound(n) => write!(f, "PV '{n}' not found"),
71            PvError::TypeMismatch { expected, actual } => {
72                write!(
73                    f,
74                    "PV value type mismatch: expected {expected}, record holds {actual}"
75                )
76            }
77            PvError::PutRejected(msg) => write!(f, "PUT rejected: {msg}"),
78        }
79    }
80}
81
82impl std::error::Error for PvError {}
83
84/// Scalar types that can back a typed [`Pv<T>`] handle.
85pub trait PvScalar: Sized + Send + Sync + 'static {
86    /// Human-readable type name, used in [`PvError::TypeMismatch`].
87    const TYPE_NAME: &'static str;
88    fn into_scalar(self) -> ScalarValue;
89    fn from_scalar(v: ScalarValue) -> Option<Self>;
90
91    /// Convert a decoded wire PUT value directly to `Self`.
92    ///
93    /// The default goes through [`crate::convert::decoded_to_scalar_value`],
94    /// but that helper checks "is this truthy/falsy" (bool) before it checks
95    /// numeric types, so *any* nonzero numeric [`DecodedValue`] resolves to
96    /// `ScalarValue::Bool` first — which then fails `from_scalar` for `f64`
97    /// and `i32`. Those impls override this method with a type-directed
98    /// decoder (`decoded_to_f64` / `decoded_to_i32`) so ordinary numeric PUTs
99    /// aren't spuriously rejected.
100    fn from_decoded(dv: &DecodedValue) -> Option<Self> {
101        Self::from_scalar(crate::convert::decoded_to_scalar_value(dv))
102    }
103}
104
105impl PvScalar for f64 {
106    const TYPE_NAME: &'static str = "f64";
107    fn into_scalar(self) -> ScalarValue {
108        ScalarValue::F64(self)
109    }
110    fn from_scalar(v: ScalarValue) -> Option<Self> {
111        match v {
112            ScalarValue::F64(x) => Some(x),
113            ScalarValue::F32(x) => Some(x as f64),
114            _ => None,
115        }
116    }
117    fn from_decoded(dv: &DecodedValue) -> Option<Self> {
118        crate::convert::decoded_to_f64(dv)
119    }
120}
121
122impl PvScalar for bool {
123    const TYPE_NAME: &'static str = "bool";
124    fn into_scalar(self) -> ScalarValue {
125        ScalarValue::Bool(self)
126    }
127    fn from_scalar(v: ScalarValue) -> Option<Self> {
128        match v {
129            ScalarValue::Bool(b) => Some(b),
130            _ => None,
131        }
132    }
133    fn from_decoded(dv: &DecodedValue) -> Option<Self> {
134        crate::convert::decoded_to_bool(dv)
135    }
136}
137
138impl PvScalar for i32 {
139    const TYPE_NAME: &'static str = "i32";
140    fn into_scalar(self) -> ScalarValue {
141        ScalarValue::I32(self)
142    }
143    fn from_scalar(v: ScalarValue) -> Option<Self> {
144        match v {
145            ScalarValue::I32(x) => Some(x),
146            ScalarValue::I16(x) => Some(x as i32),
147            ScalarValue::I8(x) => Some(x as i32),
148            _ => None,
149        }
150    }
151    fn from_decoded(dv: &DecodedValue) -> Option<Self> {
152        crate::convert::decoded_to_i32(dv)
153    }
154}
155
156impl PvScalar for String {
157    const TYPE_NAME: &'static str = "String";
158    fn into_scalar(self) -> ScalarValue {
159        ScalarValue::Str(self)
160    }
161    fn from_scalar(v: ScalarValue) -> Option<Self> {
162        match v {
163            ScalarValue::Str(s) => Some(s),
164            _ => None,
165        }
166    }
167    fn from_decoded(dv: &DecodedValue) -> Option<Self> {
168        crate::convert::decoded_to_string(dv)
169    }
170}
171
172impl PvScalar for ScalarValue {
173    const TYPE_NAME: &'static str = "scalar";
174    fn into_scalar(self) -> ScalarValue {
175        self
176    }
177    fn from_scalar(v: ScalarValue) -> Option<Self> {
178        Some(v)
179    }
180    /// Faithful 1:1 structural mapping — deliberately NOT the generic
181    /// `decoded_to_scalar_value`, whose truthy-check-first order turns any
182    /// nonzero numeric into `Bool` (see the trait doc above). Callers that
183    /// need a specific wire type re-coerce the returned variant themselves.
184    fn from_decoded(dv: &DecodedValue) -> Option<Self> {
185        Some(match dv {
186            DecodedValue::Boolean(b) => ScalarValue::Bool(*b),
187            DecodedValue::Int8(n) => ScalarValue::I8(*n),
188            DecodedValue::Int16(n) => ScalarValue::I16(*n),
189            DecodedValue::Int32(n) => ScalarValue::I32(*n),
190            DecodedValue::Int64(n) => ScalarValue::I64(*n),
191            DecodedValue::UInt8(n) => ScalarValue::U8(*n),
192            DecodedValue::UInt16(n) => ScalarValue::U16(*n),
193            DecodedValue::UInt32(n) => ScalarValue::U32(*n),
194            DecodedValue::UInt64(n) => ScalarValue::U64(*n),
195            DecodedValue::Float32(f) => ScalarValue::F32(*f),
196            DecodedValue::Float64(f) => ScalarValue::F64(*f),
197            DecodedValue::String(s) => ScalarValue::Str(s.clone()),
198            _ => return None,
199        })
200    }
201}
202
203pub(crate) struct PendingDef {
204    pub(crate) record: RecordInstance,
205    pub(crate) validator: Option<crate::simple_store::PutValidator>,
206    pub(crate) scan: Option<(std::time::Duration, crate::simple_store::ScanCallback)>,
207    pub(crate) calc: Option<(Vec<String>, crate::simple_store::LinkCallback)>,
208}
209
210pub(crate) enum PvState {
211    Pending(PendingDef),
212    Bound(Arc<SimplePvStore>),
213}
214
215pub(crate) struct PvShared {
216    pub(crate) name: String,
217    pub(crate) state: Mutex<PvState>,
218}
219
220/// Typed handle to a PV record. Cheap to clone; all clones share state.
221pub struct Pv<T: PvScalar> {
222    pub(crate) shared: Arc<PvShared>,
223    _marker: PhantomData<fn() -> T>,
224}
225
226impl<T: PvScalar> Clone for Pv<T> {
227    fn clone(&self) -> Self {
228        Self {
229            shared: self.shared.clone(),
230            _marker: PhantomData,
231        }
232    }
233}
234
235impl<T: PvScalar> Pv<T> {
236    fn from_record(record: RecordInstance) -> Self {
237        Self {
238            shared: Arc::new(PvShared {
239                name: record.name.clone(),
240                state: Mutex::new(PvState::Pending(PendingDef {
241                    record,
242                    validator: None,
243                    scan: None,
244                    calc: None,
245                })),
246            }),
247            _marker: PhantomData,
248        }
249    }
250
251    pub fn name(&self) -> &str {
252        &self.shared.name
253    }
254
255    /// Clone of the pending record template; `None` once bound. Test-only —
256    /// `ServeBuilder` reads the pending record via `AnyPv::take_record`.
257    #[cfg(test)]
258    fn pending_record(&self) -> Option<RecordInstance> {
259        match &*self.shared.state.lock().unwrap() {
260            PvState::Pending(def) => Some(def.record.clone()),
261            PvState::Bound(_) => None,
262        }
263    }
264
265    /// Mutate the pending record template. Warns and no-ops if already bound.
266    fn with_record(self, f: impl FnOnce(&mut RecordInstance)) -> Self {
267        {
268            let mut state = self.shared.state.lock().unwrap();
269            match &mut *state {
270                PvState::Pending(def) => f(&mut def.record),
271                PvState::Bound(_) => {
272                    tracing::warn!("Pv '{}': option ignored, already bound", self.shared.name)
273                }
274            }
275        }
276        self
277    }
278
279    pub fn units(self, units: impl Into<String>) -> Self {
280        let u = units.into();
281        self.with_record(|r| {
282            if let Some(nt) = r.nt_scalar_mut() {
283                nt.units = u;
284            }
285        })
286    }
287
288    pub fn prec(self, prec: i32) -> Self {
289        self.with_record(|r| {
290            if let Some(nt) = r.nt_scalar_mut() {
291                nt.display_precision = prec;
292            }
293        })
294    }
295
296    pub fn desc(self, desc: impl Into<String>) -> Self {
297        let d = desc.into();
298        self.with_record(|r| {
299            r.common.desc = d.clone();
300            if let Some(nt) = r.nt_scalar_mut() {
301                nt.display_description = d;
302            }
303        })
304    }
305
306    /// Archive deadband (parsed/exposed via field access; PVA monitors use MDEL).
307    pub fn adel(self, deadband: f64) -> Self {
308        self.with_record(|r| {
309            r.raw_fields.insert("ADEL".into(), trim_float(deadband));
310        })
311    }
312
313    /// Monitor deadband — suppresses monitor posts for changes smaller than this.
314    pub fn mdel(self, deadband: f64) -> Self {
315        self.with_record(|r| {
316            r.raw_fields.insert("MDEL".into(), trim_float(deadband));
317        })
318    }
319
320    pub fn drive_limits(self, low: f64, high: f64) -> Self {
321        self.with_record(|r| {
322            if let Some(nt) = r.nt_scalar_mut() {
323                nt.control_low = low;
324                nt.control_high = high;
325            }
326        })
327    }
328
329    pub fn alarm_limits(self, lolo: f64, low: f64, high: f64, hihi: f64) -> Self {
330        self.with_record(|r| {
331            if let Some(nt) = r.nt_scalar_mut() {
332                nt.value_alarm_active = true;
333                nt.value_alarm_low_alarm_limit = lolo;
334                nt.value_alarm_low_warning_limit = low;
335                nt.value_alarm_high_warning_limit = high;
336                nt.value_alarm_high_alarm_limit = hihi;
337            }
338        })
339    }
340
341    /// Attach a PUT handler. `Err(msg)` rejects the PUT on the wire; `Ok(())`
342    /// accepts it. Called with a bound handle to this PV and the typed value.
343    pub fn on_put<F>(self, f: F) -> Self
344    where
345        F: Fn(&Pv<T>, T) -> Result<(), String> + Send + Sync + 'static,
346    {
347        let handle = self.clone();
348        let validator: crate::simple_store::PutValidator = Arc::new(move |_name, dv| {
349            // Scalar puts may arrive wrapped as a Structure with a "value"
350            // field (mirrors the bare-scalar wrapping in
351            // simple_store::apply_put_to_record) — unwrap it the same way
352            // before converting, or a wrapped put would fail the typed
353            // conversion and be spuriously rejected.
354            let scalar_dv = unwrap_value_field(dv);
355            let typed = T::from_decoded(scalar_dv)
356                .ok_or_else(|| format!("expected {}, got {scalar_dv:?}", T::TYPE_NAME))?;
357            f(&handle, typed)
358        });
359        {
360            let mut state = self.shared.state.lock().unwrap();
361            match &mut *state {
362                PvState::Pending(def) => def.validator = Some(validator),
363                PvState::Bound(_) => {
364                    tracing::warn!("Pv '{}': on_put ignored, already bound", self.shared.name)
365                }
366            }
367        }
368        self
369    }
370
371    /// Periodically compute and post a new value for this PV.
372    pub fn scan<F>(self, period: std::time::Duration, f: F) -> Self
373    where
374        F: Fn(&Pv<T>) -> T + Send + Sync + 'static,
375    {
376        let handle = self.clone();
377        let cb: crate::simple_store::ScanCallback = Arc::new(move |_name| f(&handle).into_scalar());
378        {
379            let mut state = self.shared.state.lock().unwrap();
380            if let PvState::Pending(def) = &mut *state {
381                def.scan = Some((period, cb));
382            } else {
383                tracing::warn!("Pv '{}': scan ignored, already bound", self.shared.name);
384            }
385        }
386        self
387    }
388}
389
390impl Pv<f64> {
391    /// A derived (read-only) PV recomputed whenever any input changes.
392    pub fn calc<F>(name: impl Into<String>, inputs: &[&Pv<f64>], f: F) -> Self
393    where
394        F: Fn(&[f64]) -> f64 + Send + Sync + 'static,
395    {
396        let name = name.into();
397        let input_names: Vec<String> = inputs.iter().map(|p| p.shared.name.clone()).collect();
398        let initial = ScalarValue::F64(0.0);
399        let pv = Self::from_record(make_scalar_record(&name, RecordType::Ai, initial));
400        let compute: crate::simple_store::LinkCallback = Arc::new(move |values| {
401            let floats: Vec<f64> = values
402                .iter()
403                .map(|v| f64::from_scalar(v.clone()).unwrap_or(0.0))
404                .collect();
405            ScalarValue::F64(f(&floats))
406        });
407        if let PvState::Pending(def) = &mut *pv.shared.state.lock().unwrap() {
408            def.calc = Some((input_names, compute));
409        }
410        pv
411    }
412}
413
414/// Mirrors `apply_put_to_record`'s bare-scalar wrapping: if `dv` is a
415/// `Structure`, pull out its "value" field; otherwise treat `dv` itself as
416/// the scalar value.
417fn unwrap_value_field(dv: &DecodedValue) -> &DecodedValue {
418    match dv {
419        DecodedValue::Structure(fields) => fields
420            .iter()
421            .find(|(name, _)| name == "value")
422            .map(|(_, v)| v)
423            .unwrap_or(dv),
424        other => other,
425    }
426}
427
428/// Format a float like EPICS .db files do (no trailing ".0" for integers).
429fn trim_float(v: f64) -> String {
430    if v.fract() == 0.0 && v.abs() < 1e15 {
431        format!("{}", v as i64)
432    } else {
433        format!("{v}")
434    }
435}
436
437impl Pv<f64> {
438    pub fn ai(name: impl Into<String>, initial: f64) -> Self {
439        let name = name.into();
440        Self::from_record(make_scalar_record(
441            &name,
442            RecordType::Ai,
443            ScalarValue::F64(initial),
444        ))
445    }
446    pub fn ao(name: impl Into<String>, initial: f64) -> Self {
447        let name = name.into();
448        Self::from_record(make_output_record(
449            &name,
450            RecordType::Ao,
451            ScalarValue::F64(initial),
452        ))
453    }
454}
455
456impl Pv<bool> {
457    pub fn bi(name: impl Into<String>, initial: bool) -> Self {
458        let name = name.into();
459        Self::from_record(make_scalar_record(
460            &name,
461            RecordType::Bi,
462            ScalarValue::Bool(initial),
463        ))
464    }
465    pub fn bo(name: impl Into<String>, initial: bool) -> Self {
466        let name = name.into();
467        Self::from_record(make_output_record(
468            &name,
469            RecordType::Bo,
470            ScalarValue::Bool(initial),
471        ))
472    }
473}
474
475impl Pv<String> {
476    pub fn string_in(name: impl Into<String>, initial: impl Into<String>) -> Self {
477        let name = name.into();
478        Self::from_record(make_scalar_record(
479            &name,
480            RecordType::StringIn,
481            ScalarValue::Str(initial.into()),
482        ))
483    }
484    pub fn string_out(name: impl Into<String>, initial: impl Into<String>) -> Self {
485        let name = name.into();
486        Self::from_record(make_output_record(
487            &name,
488            RecordType::StringOut,
489            ScalarValue::Str(initial.into()),
490        ))
491    }
492}
493
494impl Pv<i32> {
495    /// `longin` — 32-bit integer input record (read-only over the wire).
496    pub fn longin(name: impl Into<String>, initial: i32) -> Self {
497        let name = name.into();
498        Self::from_record(make_scalar_record(
499            &name,
500            RecordType::LongIn,
501            ScalarValue::I32(initial),
502        ))
503    }
504    /// `longout` — 32-bit integer output record (writable).
505    pub fn longout(name: impl Into<String>, initial: i32) -> Self {
506        let name = name.into();
507        Self::from_record(make_output_record(
508            &name,
509            RecordType::LongOut,
510            ScalarValue::I32(initial),
511        ))
512    }
513    /// `mbbi` — multi-bit binary input (enum, read-only). Value = choice index.
514    pub fn mbbi(name: impl Into<String>, choices: Vec<String>, initial: i32) -> Self {
515        Self::from_enum_record(name.into(), choices, initial, RecordType::Mbbi)
516    }
517    /// `mbbo` — multi-bit binary output (enum, writable). Value = choice index.
518    pub fn mbbo(name: impl Into<String>, choices: Vec<String>, initial: i32) -> Self {
519        Self::from_enum_record(name.into(), choices, initial, RecordType::Mbbo)
520    }
521    fn from_enum_record(name: String, choices: Vec<String>, initial: i32, rt: RecordType) -> Self {
522        // Mirror PvaServerBuilder::mbbi's record shape (pva_server.rs:360-378).
523        let data = crate::types::RecordData::NtEnum {
524            nt: spvirit_types::NtEnum::new(initial, choices),
525            inp: None,
526            out: None,
527            omsl: crate::types::OutputMode::Supervisory,
528        };
529        Self::from_record(RecordInstance {
530            name: name.clone(),
531            record_type: rt,
532            common: crate::types::DbCommonState::default(),
533            data,
534            raw_fields: std::collections::HashMap::new(),
535        })
536    }
537}
538
539/// Family record type for a dynamically typed scalar: the record *shape*
540/// (RTYP, writability) comes from the value family, while the `NtScalar`
541/// payload's `ScalarValue` variant carries the precise wire type.
542pub(crate) fn scalar_family_record_type(v: &ScalarValue, writable: bool) -> RecordType {
543    match (v, writable) {
544        (ScalarValue::F32(_) | ScalarValue::F64(_), false) => RecordType::Ai,
545        (ScalarValue::F32(_) | ScalarValue::F64(_), true) => RecordType::Ao,
546        (ScalarValue::Bool(_), false) => RecordType::Bi,
547        (ScalarValue::Bool(_), true) => RecordType::Bo,
548        (ScalarValue::Str(_), false) => RecordType::StringIn,
549        (ScalarValue::Str(_), true) => RecordType::StringOut,
550        (_, false) => RecordType::LongIn,
551        (_, true) => RecordType::LongOut,
552    }
553}
554
555impl Pv<ScalarValue> {
556    /// Dynamically typed scalar record, read-only over the wire.
557    ///
558    /// The wire value type is whatever `ScalarValue` variant `initial`
559    /// holds — this is the route to any of the twelve NTScalar types
560    /// (`boolean`, `byte`, `short`, `int`, `long`, `ubyte`, `ushort`,
561    /// `uint`, `ulong`, `float`, `double`, `string`), including the eight
562    /// (`byte`/`short`/`ubyte`/`ushort`/`uint`/`ulong`, plus explicit
563    /// `float`/`long`) that the fixed-type constructors (`Pv::ai`, `bi`,
564    /// `longin`, `string_in`, ...) cannot produce. See
565    /// `Pv::<ScalarValue>::scalar_out` for the writable flavor.
566    ///
567    /// ```
568    /// use spvirit_server::Pv;
569    /// use spvirit_types::ScalarValue;
570    ///
571    /// let status = Pv::<ScalarValue>::scalar_in("SIM:STATUS", ScalarValue::U8(0));
572    /// ```
573    pub fn scalar_in(name: impl Into<String>, initial: ScalarValue) -> Self {
574        let name = name.into();
575        let rt = scalar_family_record_type(&initial, false);
576        Self::from_record(make_scalar_record(&name, rt, initial))
577    }
578    /// Dynamically typed scalar record, writable over the wire.
579    ///
580    /// Same type coverage as [`Pv::<ScalarValue>::scalar_in`] — the wire
581    /// value type is whatever `ScalarValue` variant `initial` holds, across
582    /// all twelve NTScalar types.
583    ///
584    /// ```
585    /// use spvirit_server::Pv;
586    /// use spvirit_types::ScalarValue;
587    ///
588    /// let gain = Pv::<ScalarValue>::scalar_out("SIM:GAIN", ScalarValue::U16(1));
589    /// ```
590    pub fn scalar_out(name: impl Into<String>, initial: ScalarValue) -> Self {
591        let name = name.into();
592        let rt = scalar_family_record_type(&initial, true);
593        Self::from_record(make_output_record(&name, rt, initial))
594    }
595}
596
597impl<T: PvScalar> Pv<T> {
598    fn store(&self) -> Result<Arc<SimplePvStore>, PvError> {
599        match &*self.shared.state.lock().unwrap() {
600            PvState::Bound(store) => Ok(store.clone()),
601            PvState::Pending(_) => Err(PvError::Unbound),
602        }
603    }
604
605    /// Write a value through the full posting pipeline (timestamp, alarms,
606    /// MDEL gating, monitors, links).
607    pub async fn set(&self, value: T) -> Result<(), PvError> {
608        let store = self.store()?;
609        if store
610            .set_value(&self.shared.name, value.into_scalar())
611            .await
612        {
613            Ok(())
614        } else if store.get_value(&self.shared.name).await.is_some() {
615            // Record exists — the write was a no-op (value unchanged). Records
616            // CAN now be removed at runtime (`SimplePvStore::remove`), so this
617            // is a genuine (benign) TOCTOU: if the record were removed between
618            // the failed set and this check we would fall through to the
619            // `NotFound` arm, which is the correct outcome.
620            Ok(())
621        } else {
622            Err(PvError::NotFound(self.shared.name.clone()))
623        }
624    }
625
626    /// Explicitly set the record's alarm severity/status/message, independent
627    /// of its value. Alarm transitions always post (no MDEL gating, no link
628    /// evaluation). A no-op re-set (unchanged alarm) is `Ok(())`.
629    pub async fn set_alarm(
630        &self,
631        severity: i32,
632        status: i32,
633        message: &str,
634    ) -> Result<(), PvError> {
635        let store = self.store()?;
636        if store
637            .set_alarm(&self.shared.name, severity, status, message)
638            .await
639        {
640            Ok(())
641        } else if store.get_value(&self.shared.name).await.is_some() {
642            Ok(())
643        } else {
644            Err(PvError::NotFound(self.shared.name.clone()))
645        }
646    }
647
648    /// Read the current value, typed.
649    pub async fn get(&self) -> Result<T, PvError> {
650        let store = self.store()?;
651        let v = store
652            .get_value(&self.shared.name)
653            .await
654            .ok_or_else(|| PvError::NotFound(self.shared.name.clone()))?;
655        let actual = format!("{v:?}");
656        T::from_scalar(v).ok_or(PvError::TypeMismatch {
657            expected: T::TYPE_NAME,
658            actual,
659        })
660    }
661
662    /// Mint a bound handle to an existing record (e.g. loaded from a `.db`).
663    pub(crate) async fn attach(store: &Arc<SimplePvStore>, name: &str) -> Result<Self, PvError> {
664        // `get_value` alone isn't a safe type sniff: for array-backed
665        // records it returns `ScalarValue::I32(len)` (see
666        // RecordInstance::current_value / types.rs), so `Pv::<i32>::attach`
667        // on a waveform/aai/aao record would wrongly "match" i32. Check the
668        // record's actual payload kind first and refuse array/table/ndarray
669        // payloads outright; only Scalar and Enum records are valid `Pv<T>`
670        // targets (enum records attach as i32 by design, see Task 2).
671        match store.get_nt(name).await {
672            None => return Err(PvError::NotFound(name.to_string())),
673            Some(NtPayload::Scalar(_)) | Some(NtPayload::Enum(_)) => {}
674            Some(other) => {
675                return Err(PvError::TypeMismatch {
676                    expected: T::TYPE_NAME,
677                    actual: nt_payload_kind(&other).to_string(),
678                });
679            }
680        }
681        let v = store
682            .get_value(name)
683            .await
684            .ok_or_else(|| PvError::NotFound(name.to_string()))?;
685        let actual = format!("{v:?}");
686        if T::from_scalar(v).is_none() {
687            return Err(PvError::TypeMismatch {
688                expected: T::TYPE_NAME,
689                actual,
690            });
691        }
692        Ok(Self {
693            shared: Arc::new(PvShared {
694                name: name.to_string(),
695                state: Mutex::new(PvState::Bound(store.clone())),
696            }),
697            _marker: PhantomData,
698        })
699    }
700}
701
702/// Short label for an `NtPayload` variant, used in `PvError::TypeMismatch`.
703fn nt_payload_kind(p: &NtPayload) -> &'static str {
704    match p {
705        NtPayload::Scalar(_) => "Scalar",
706        NtPayload::ScalarArray(_) => "ScalarArray",
707        NtPayload::Table(_) => "Table",
708        NtPayload::NdArray(_) => "NdArray",
709        NtPayload::Enum(_) => "Enum",
710        NtPayload::Generic { .. } => "Generic",
711    }
712}
713
714/// Handle to an array-backed record (`waveform`/`aai`/`aao`). Unlike `Pv<T>`
715/// this is untyped over the element kind — values are `ScalarArrayValue`.
716/// Cheap to clone; all clones share state.
717pub struct PvArray {
718    pub(crate) shared: Arc<PvShared>,
719}
720
721impl Clone for PvArray {
722    fn clone(&self) -> Self {
723        Self {
724            shared: self.shared.clone(),
725        }
726    }
727}
728
729impl PvArray {
730    fn from_record(record: RecordInstance) -> Self {
731        Self {
732            shared: Arc::new(PvShared {
733                name: record.name.clone(),
734                state: Mutex::new(PvState::Pending(PendingDef {
735                    record,
736                    validator: None,
737                    scan: None,
738                    calc: None,
739                })),
740            }),
741        }
742    }
743
744    /// `waveform` — array record, writable over the wire.
745    pub fn waveform(name: impl Into<String>, data: ScalarArrayValue) -> Self {
746        let name = name.into();
747        Self::from_record(make_array_record(&name, RecordType::Waveform, data))
748    }
749
750    /// `aai` — analog array input, read-only over the wire.
751    pub fn aai(name: impl Into<String>, data: ScalarArrayValue) -> Self {
752        let name = name.into();
753        Self::from_record(make_array_record(&name, RecordType::Aai, data))
754    }
755
756    /// `aao` — analog array output, writable over the wire.
757    pub fn aao(name: impl Into<String>, data: ScalarArrayValue) -> Self {
758        let name = name.into();
759        Self::from_record(make_array_record(&name, RecordType::Aao, data))
760    }
761
762    pub fn name(&self) -> &str {
763        &self.shared.name
764    }
765
766    /// Clone of the pending record template; `None` once bound. Test-only —
767    /// `ServeBuilder` reads the pending record via `AnyPv::take_record`.
768    #[cfg(test)]
769    fn pending_record(&self) -> Option<RecordInstance> {
770        match &*self.shared.state.lock().unwrap() {
771            PvState::Pending(def) => Some(def.record.clone()),
772            PvState::Bound(_) => None,
773        }
774    }
775
776    fn store(&self) -> Result<Arc<SimplePvStore>, PvError> {
777        match &*self.shared.state.lock().unwrap() {
778            PvState::Bound(store) => Ok(store.clone()),
779            PvState::Pending(_) => Err(PvError::Unbound),
780        }
781    }
782
783    /// Write an array value through the full posting pipeline.
784    pub async fn set(&self, data: ScalarArrayValue) -> Result<(), PvError> {
785        let store = self.store()?;
786        if store.set_array_value(&self.shared.name, data).await {
787            Ok(())
788        } else {
789            match store.get_nt(&self.shared.name).await {
790                // Record exists and is array-backed — the write was a no-op
791                // or a truncated/rejected update; either way this mirrors
792                // `Pv::set`'s benign-TOCTOU existence check.
793                Some(NtPayload::ScalarArray(_)) => Ok(()),
794                Some(other) => Err(PvError::TypeMismatch {
795                    expected: "array",
796                    actual: nt_payload_kind(&other).to_string(),
797                }),
798                None => Err(PvError::NotFound(self.shared.name.clone())),
799            }
800        }
801    }
802
803    /// Explicitly set the record's alarm severity/status/message, independent
804    /// of its value. Alarm transitions always post (no MDEL gating, no link
805    /// evaluation). A no-op re-set (unchanged alarm) is `Ok(())`.
806    pub async fn set_alarm(
807        &self,
808        severity: i32,
809        status: i32,
810        message: &str,
811    ) -> Result<(), PvError> {
812        let store = self.store()?;
813        if store
814            .set_alarm(&self.shared.name, severity, status, message)
815            .await
816        {
817            Ok(())
818        } else if store.get_nt(&self.shared.name).await.is_some() {
819            Ok(())
820        } else {
821            Err(PvError::NotFound(self.shared.name.clone()))
822        }
823    }
824
825    /// Read the current array value.
826    pub async fn get(&self) -> Result<ScalarArrayValue, PvError> {
827        let store = self.store()?;
828        match store.get_nt(&self.shared.name).await {
829            Some(NtPayload::ScalarArray(nt)) => Ok(nt.value),
830            Some(other) => Err(PvError::TypeMismatch {
831                expected: "array",
832                actual: nt_payload_kind(&other).to_string(),
833            }),
834            None => Err(PvError::NotFound(self.shared.name.clone())),
835        }
836    }
837
838    /// Mint a bound handle to an existing array-backed record.
839    pub(crate) async fn attach(store: &Arc<SimplePvStore>, name: &str) -> Result<Self, PvError> {
840        match store.get_nt(name).await {
841            Some(NtPayload::ScalarArray(_)) => Ok(Self {
842                shared: Arc::new(PvShared {
843                    name: name.to_string(),
844                    state: Mutex::new(PvState::Bound(store.clone())),
845                }),
846            }),
847            Some(other) => Err(PvError::TypeMismatch {
848                expected: "array",
849                actual: nt_payload_kind(&other).to_string(),
850            }),
851            None => Err(PvError::NotFound(name.to_string())),
852        }
853    }
854}
855
856impl From<PvArray> for AnyPv {
857    fn from(pv: PvArray) -> Self {
858        Self { shared: pv.shared }
859    }
860}
861
862/// Type-erased PV, as accepted by `PvaServer::serve` / `.pvs(...)`.
863pub struct AnyPv {
864    pub(crate) shared: Arc<PvShared>,
865}
866
867impl<T: PvScalar> From<Pv<T>> for AnyPv {
868    fn from(pv: Pv<T>) -> Self {
869        Self { shared: pv.shared }
870    }
871}
872
873impl AnyPv {
874    /// Clone the pending record template. Returns `None` if already bound.
875    pub(crate) fn take_record(&self) -> Option<RecordInstance> {
876        let state = self.shared.state.lock().unwrap();
877        match &*state {
878            PvState::Pending(def) => Some(def.record.clone()),
879            PvState::Bound(_) => None,
880        }
881    }
882
883    /// Flip the handle (and every clone sharing this state) to bound.
884    pub(crate) fn bind(&self, store: &Arc<SimplePvStore>) {
885        *self.shared.state.lock().unwrap() = PvState::Bound(store.clone());
886    }
887
888    pub fn name(&self) -> &str {
889        &self.shared.name
890    }
891
892    /// Take the pending PUT validator, if any. `None` once bound.
893    pub(crate) fn take_validator(&self) -> Option<crate::simple_store::PutValidator> {
894        let mut state = self.shared.state.lock().unwrap();
895        match &mut *state {
896            PvState::Pending(def) => def.validator.take(),
897            PvState::Bound(_) => None,
898        }
899    }
900
901    /// Take the pending scan definition, if any. `None` once bound.
902    pub(crate) fn take_scan(
903        &self,
904    ) -> Option<(std::time::Duration, crate::simple_store::ScanCallback)> {
905        let mut state = self.shared.state.lock().unwrap();
906        match &mut *state {
907            PvState::Pending(def) => def.scan.take(),
908            PvState::Bound(_) => None,
909        }
910    }
911
912    /// Take the pending calc definition, if any. `None` once bound.
913    pub(crate) fn take_calc(&self) -> Option<(Vec<String>, crate::simple_store::LinkCallback)> {
914        let mut state = self.shared.state.lock().unwrap();
915        match &mut *state {
916            PvState::Pending(def) => def.calc.take(),
917            PvState::Bound(_) => None,
918        }
919    }
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use spvirit_types::NtScalar;
926
927    #[test]
928    fn pvscalar_roundtrip_f64() {
929        assert_eq!(f64::from_scalar(ScalarValue::F64(1.5)), Some(1.5));
930        assert!(matches!(1.5f64.into_scalar(), ScalarValue::F64(x) if x == 1.5));
931    }
932
933    #[test]
934    fn pvscalar_f64_accepts_f32_widening() {
935        assert_eq!(f64::from_scalar(ScalarValue::F32(2.0)), Some(2.0));
936    }
937
938    #[test]
939    fn pvscalar_rejects_wrong_variant() {
940        assert_eq!(f64::from_scalar(ScalarValue::Str("x".into())), None);
941        assert_eq!(bool::from_scalar(ScalarValue::F64(1.0)), None);
942        assert_eq!(i32::from_scalar(ScalarValue::Str("1".into())), None);
943        assert_eq!(String::from_scalar(ScalarValue::Bool(true)), None);
944    }
945
946    #[test]
947    fn pverror_display() {
948        let e = PvError::TypeMismatch {
949            expected: "f64",
950            actual: "Str".into(),
951        };
952        assert!(e.to_string().contains("f64"));
953        assert!(PvError::Unbound.to_string().contains("not bound"));
954    }
955
956    #[test]
957    fn ai_constructor_builds_record_template() {
958        let pv = Pv::ai("SIM:TEMP", 22.5).units("C").prec(2).desc("Temp");
959        let rec = pv.pending_record().expect("still pending");
960        assert_eq!(rec.name, "SIM:TEMP");
961        let nt = rec.to_ntscalar();
962        assert_eq!(nt.value, ScalarValue::F64(22.5));
963        assert_eq!(nt.units, "C");
964        assert_eq!(nt.display_precision, 2);
965        assert_eq!(rec.common.desc, "Temp");
966        assert!(!rec.writable(), "ai is read-only over the wire");
967        assert_eq!(pv.name(), "SIM:TEMP");
968    }
969
970    #[test]
971    fn ao_is_writable_with_drive_limits() {
972        let pv = Pv::ao("SIM:SP", 25.0).drive_limits(0.0, 100.0);
973        let rec = pv.pending_record().unwrap();
974        assert!(rec.writable());
975        let nt = rec.to_ntscalar();
976        assert_eq!(nt.control_low, 0.0);
977        assert_eq!(nt.control_high, 100.0);
978    }
979
980    #[test]
981    fn mdel_adel_go_to_raw_fields() {
982        let pv = Pv::ai("SIM:X", 0.0).mdel(0.5).adel(1.0);
983        let rec = pv.pending_record().unwrap();
984        assert_eq!(rec.raw_fields.get("MDEL").map(String::as_str), Some("0.5"));
985        assert_eq!(rec.raw_fields.get("ADEL").map(String::as_str), Some("1"));
986    }
987
988    #[test]
989    fn alarm_limits_set_value_alarm_block() {
990        let pv = Pv::ao("SIM:A", 0.0).alarm_limits(-10.0, -5.0, 5.0, 10.0);
991        let nt = pv.pending_record().unwrap().to_ntscalar();
992        assert_eq!(nt.value_alarm_low_alarm_limit, -10.0);
993        assert_eq!(nt.value_alarm_low_warning_limit, -5.0);
994        assert_eq!(nt.value_alarm_high_warning_limit, 5.0);
995        assert_eq!(nt.value_alarm_high_alarm_limit, 10.0);
996        assert!(nt.value_alarm_active);
997    }
998
999    #[test]
1000    fn bool_and_string_constructors() {
1001        assert!(Pv::bo("B", true).pending_record().unwrap().writable());
1002        assert!(!Pv::bi("B2", false).pending_record().unwrap().writable());
1003        let s = Pv::string_in("S", "hello").pending_record().unwrap();
1004        assert_eq!(s.to_ntscalar().value, ScalarValue::Str("hello".into()));
1005    }
1006
1007    #[test]
1008    fn longin_longout_constructors() {
1009        let li = Pv::longin("L:IN", 42);
1010        let rec = li.pending_record().unwrap();
1011        assert_eq!(rec.record_type, crate::types::RecordType::LongIn);
1012        assert_eq!(rec.to_ntscalar().value, ScalarValue::I32(42));
1013        assert!(!rec.writable());
1014
1015        let lo = Pv::longout("L:OUT", 7).drive_limits(0.0, 1000.0);
1016        let rec = lo.pending_record().unwrap();
1017        assert_eq!(rec.record_type, crate::types::RecordType::LongOut);
1018        assert!(rec.writable());
1019        assert_eq!(rec.to_ntscalar().control_high, 1000.0);
1020    }
1021
1022    #[tokio::test]
1023    async fn longout_set_get_roundtrip() {
1024        let store = empty_store();
1025        let pv = Pv::longout("L:RT", 1);
1026        let any: AnyPv = pv.clone().into();
1027        let rec = any.take_record().unwrap();
1028        store.insert(rec.name.clone(), rec).await;
1029        any.bind(&store);
1030        pv.set(99).await.unwrap();
1031        assert_eq!(pv.get().await, Ok(99));
1032    }
1033
1034    #[test]
1035    fn mbbi_mbbo_constructors() {
1036        let m = Pv::mbbi("M:I", vec!["Off".into(), "On".into(), "Auto".into()], 1);
1037        let rec = m.pending_record().unwrap();
1038        assert_eq!(rec.record_type, crate::types::RecordType::Mbbi);
1039        assert_eq!(rec.current_value(), ScalarValue::I32(1));
1040
1041        let o = Pv::mbbo("M:O", vec!["A".into(), "B".into()], 0);
1042        assert!(o.pending_record().unwrap().writable());
1043    }
1044
1045    #[tokio::test]
1046    async fn mbbo_set_get_index_with_bounds() {
1047        let store = empty_store();
1048        let pv = Pv::mbbo("M:RT", vec!["Stop".into(), "Run".into(), "Fault".into()], 0);
1049        let any: AnyPv = pv.clone().into();
1050        let rec = any.take_record().unwrap();
1051        store.insert(rec.name.clone(), rec).await;
1052        any.bind(&store);
1053
1054        pv.set(2).await.unwrap();
1055        assert_eq!(pv.get().await, Ok(2));
1056        // out-of-range index is rejected (value unchanged); set() maps the
1057        // store's `false` to Ok-if-exists, so verify via get()
1058        let _ = pv.set(7).await;
1059        assert_eq!(pv.get().await, Ok(2));
1060    }
1061
1062    #[test]
1063    fn set_scalar_value_on_raw_nt_enum_record() {
1064        let mut rec = Pv::mbbo("M:RAW", vec!["Off".into(), "On".into(), "Auto".into()], 0)
1065            .pending_record()
1066            .unwrap();
1067        // In-range change succeeds.
1068        assert!(rec.set_scalar_value(ScalarValue::I32(2), true));
1069        assert_eq!(rec.current_value(), ScalarValue::I32(2));
1070        // Same-index is a no-op.
1071        assert!(!rec.set_scalar_value(ScalarValue::I32(2), true));
1072        // Out-of-range index is rejected; value unchanged.
1073        assert!(!rec.set_scalar_value(ScalarValue::I32(7), true));
1074        assert_eq!(rec.current_value(), ScalarValue::I32(2));
1075        assert!(!rec.set_scalar_value(ScalarValue::I32(-1), true));
1076        assert_eq!(rec.current_value(), ScalarValue::I32(2));
1077    }
1078
1079    #[test]
1080    fn set_scalar_value_stamps_timestamp() {
1081        let mut rec = Pv::ao("A:TS", 1.0).pending_record().unwrap();
1082        assert!(rec.set_scalar_value(ScalarValue::F64(2.0), false));
1083        match rec.to_ntpayload() {
1084            NtPayload::Scalar(nt) => {
1085                let ts = nt.time_stamp.expect("scalar update must store a timestamp");
1086                assert!(ts.seconds_past_epoch > 0);
1087            }
1088            other => panic!("expected scalar payload, got {other:?}"),
1089        }
1090        // Unchanged value: no post, timestamp keeps the last update time.
1091        assert!(!rec.set_scalar_value(ScalarValue::F64(2.0), false));
1092    }
1093
1094    #[test]
1095    fn set_scalar_value_stamps_enum_timestamp() {
1096        let mut rec = Pv::mbbo("M:TS", vec!["Off".into(), "On".into()], 0)
1097            .pending_record()
1098            .unwrap();
1099        assert!(rec.set_scalar_value(ScalarValue::I32(1), false));
1100        match rec.to_ntpayload() {
1101            NtPayload::Enum(nt) => {
1102                assert!(nt.time_stamp.seconds_past_epoch > 0);
1103            }
1104            other => panic!("expected enum payload, got {other:?}"),
1105        }
1106    }
1107
1108    #[test]
1109    fn set_nt_payload_stamps_missing_timestamp_but_keeps_explicit_one() {
1110        let mut rec = Pv::ao("A:NT", 1.0).pending_record().unwrap();
1111
1112        // No caller timestamp: server stamps the update time.
1113        let nt = NtScalar::from_value(ScalarValue::F64(2.0));
1114        assert!(rec.set_nt_payload(NtPayload::Scalar(nt)));
1115        match rec.to_ntpayload() {
1116            NtPayload::Scalar(nt) => {
1117                let ts = nt.time_stamp.expect("put_nt must store a timestamp");
1118                assert!(ts.seconds_past_epoch > 0);
1119            }
1120            other => panic!("expected scalar payload, got {other:?}"),
1121        }
1122
1123        // Explicit caller timestamp is preserved verbatim.
1124        let nt = NtScalar::from_value(ScalarValue::F64(3.0)).with_timestamp(1_700_000_000, 42);
1125        assert!(rec.set_nt_payload(NtPayload::Scalar(nt)));
1126        match rec.to_ntpayload() {
1127            NtPayload::Scalar(nt) => {
1128                let ts = nt.time_stamp.unwrap();
1129                assert_eq!(ts.seconds_past_epoch, 1_700_000_000);
1130                assert_eq!(ts.nanoseconds, 42);
1131            }
1132            other => panic!("expected scalar payload, got {other:?}"),
1133        }
1134    }
1135
1136    #[tokio::test]
1137    async fn pv_array_roundtrip_and_serve() {
1138        let wf = PvArray::waveform("W:1", ScalarArrayValue::F64(vec![1.0, 2.0, 3.0]));
1139        let server = crate::pva_server::PvaServer::serve([AnyPv::from(wf.clone())])
1140            .build()
1141            .await;
1142        assert_eq!(
1143            wf.get().await,
1144            Ok(ScalarArrayValue::F64(vec![1.0, 2.0, 3.0]))
1145        );
1146        wf.set(ScalarArrayValue::F64(vec![4.0, 5.0])).await.unwrap();
1147        match wf.get().await.unwrap() {
1148            ScalarArrayValue::F64(v) => assert_eq!(v, vec![4.0, 5.0]),
1149            other => panic!("wrong kind: {other:?}"),
1150        }
1151        // typed attach via the server
1152        let h = server.array_pv("W:1").await.unwrap();
1153        assert!(matches!(h.get().await.unwrap(), ScalarArrayValue::F64(_)));
1154        // scalar attach to an array record must type-mismatch
1155        assert!(matches!(
1156            server.pv::<f64>("W:1").await,
1157            Err(PvError::TypeMismatch { .. })
1158        ));
1159        // array attach to a scalar record must type-mismatch
1160        let t = Pv::ai("W:S", 1.0);
1161        let s2 = crate::pva_server::PvaServer::serve([AnyPv::from(t)])
1162            .build()
1163            .await;
1164        assert!(matches!(
1165            s2.array_pv("W:S").await,
1166            Err(PvError::TypeMismatch { .. })
1167        ));
1168    }
1169
1170    #[test]
1171    fn aai_read_only_aao_writable() {
1172        let a = PvArray::aai("W:AI", ScalarArrayValue::I32(vec![1]));
1173        assert!(a.pending_record().is_some());
1174        assert!(!AnyPv::from(a).take_record().unwrap().writable());
1175        let b = PvArray::aao("W:AO", ScalarArrayValue::I32(vec![1]));
1176        assert!(AnyPv::from(b).take_record().unwrap().writable());
1177    }
1178
1179    #[tokio::test]
1180    async fn scalar_attach_rejects_array_backed_record() {
1181        // Regression: Pv::attach used to sniff via get_value, which returns
1182        // I32(len) for array records — so Pv::<i32>::attach would WRONGLY
1183        // succeed on a waveform/aai/aao record. The payload-kind guard must
1184        // reject it as TypeMismatch instead.
1185        let store = empty_store();
1186        let wf = PvArray::waveform("W:GUARD", ScalarArrayValue::I32(vec![1, 2, 3]));
1187        let any: AnyPv = wf.into();
1188        let rec = any.take_record().unwrap();
1189        store.insert(rec.name.clone(), rec).await;
1190
1191        let bad = Pv::<i32>::attach(&store, "W:GUARD").await;
1192        assert!(matches!(bad, Err(PvError::TypeMismatch { .. })));
1193    }
1194
1195    fn empty_store() -> Arc<SimplePvStore> {
1196        Arc::new(SimplePvStore::new(
1197            std::collections::HashMap::new(),
1198            std::collections::HashMap::new(),
1199            Vec::new(),
1200            false,
1201        ))
1202    }
1203
1204    #[tokio::test]
1205    async fn set_get_before_bind_errors() {
1206        let pv = Pv::ai("SIM:X", 1.0);
1207        assert_eq!(pv.set(2.0).await, Err(PvError::Unbound));
1208        assert_eq!(pv.get().await, Err(PvError::Unbound));
1209    }
1210
1211    #[tokio::test]
1212    async fn bind_then_set_get_roundtrip() {
1213        let store = empty_store();
1214        let pv = Pv::ai("SIM:X", 1.0).units("mm");
1215        let any: AnyPv = pv.clone().into();
1216        let rec = any.take_record().expect("pending record");
1217        store.insert(rec.name.clone(), rec).await;
1218        any.bind(&store);
1219
1220        assert_eq!(pv.get().await, Ok(1.0));
1221        pv.set(2.5).await.unwrap();
1222        assert_eq!(pv.get().await, Ok(2.5));
1223        // clone sees the same record
1224        assert_eq!(pv.clone().get().await, Ok(2.5));
1225    }
1226
1227    #[tokio::test]
1228    async fn attach_mints_typed_handle_and_checks_type() {
1229        let store = empty_store();
1230        let src = Pv::ai("SIM:Y", 3.0);
1231        let any: AnyPv = src.into();
1232        let rec = any.take_record().unwrap();
1233        store.insert(rec.name.clone(), rec).await;
1234
1235        let h: Pv<f64> = Pv::attach(&store, "SIM:Y").await.unwrap();
1236        assert_eq!(h.get().await, Ok(3.0));
1237
1238        let bad = Pv::<bool>::attach(&store, "SIM:Y").await;
1239        assert!(matches!(bad, Err(PvError::TypeMismatch { .. })));
1240        let missing = Pv::<f64>::attach(&store, "NOPE").await;
1241        assert!(matches!(missing, Err(PvError::NotFound(ref n)) if n == "NOPE"));
1242    }
1243
1244    #[tokio::test]
1245    async fn set_same_value_is_ok_not_not_found() {
1246        let store = empty_store();
1247        let pv = Pv::ai("SIM:Z", 1.0);
1248        let any: AnyPv = pv.clone().into();
1249        let rec = any.take_record().unwrap();
1250        store.insert(rec.name.clone(), rec).await;
1251        any.bind(&store);
1252
1253        assert_eq!(pv.set(2.5).await, Ok(()));
1254        // Second write of the same value is a no-op, not NotFound.
1255        assert_eq!(pv.set(2.5).await, Ok(()));
1256        assert_eq!(pv.get().await, Ok(2.5));
1257    }
1258
1259    #[tokio::test]
1260    async fn set_alarm_posts_and_reads_back() {
1261        let store = empty_store();
1262        let pv = Pv::ai("A:1", 1.0);
1263        let any: AnyPv = pv.clone().into();
1264        let rec = any.take_record().unwrap();
1265        store.insert(rec.name.clone(), rec).await;
1266        any.bind(&store);
1267
1268        // subscribe like a monitor client
1269        let mut rx = crate::pvstore::Source::subscribe(&*store, "A:1")
1270            .await
1271            .unwrap();
1272
1273        pv.set_alarm(2, 3, "sensor dead").await.unwrap();
1274        let rec = store.get_record("A:1").await.unwrap();
1275        let nt = rec.to_ntscalar();
1276        assert_eq!(nt.alarm_severity, 2);
1277        assert_eq!(nt.alarm_status, 3);
1278        assert_eq!(nt.alarm_message, "sensor dead");
1279        // a payload was posted to the subscriber
1280        let posted = rx.try_recv().expect("alarm change must post");
1281        drop(posted);
1282        // idempotent re-set posts nothing
1283        pv.set_alarm(2, 3, "sensor dead").await.unwrap();
1284        assert!(rx.try_recv().is_err());
1285        // unbound / missing paths
1286        let ghost = Pv::ai("A:GHOST", 0.0);
1287        assert_eq!(ghost.set_alarm(1, 0, "x").await, Err(PvError::Unbound));
1288
1289        let missing: Pv<f64> = Pv::attach(&store, "A:1").await.unwrap();
1290        // sanity: attach roundtrip still works after alarm writes
1291        assert_eq!(missing.get().await, Ok(1.0));
1292    }
1293
1294    #[tokio::test]
1295    async fn set_alarm_missing_record_is_not_found() {
1296        let store = empty_store();
1297        let pv = Pv::ai("A:MISSING", 0.0);
1298        let any: AnyPv = pv.clone().into();
1299        any.bind(&store);
1300        assert_eq!(
1301            pv.set_alarm(1, 1, "x").await,
1302            Err(PvError::NotFound("A:MISSING".into()))
1303        );
1304    }
1305
1306    #[tokio::test]
1307    async fn set_alarm_on_enum_and_array_records() {
1308        let store = empty_store();
1309
1310        let mbbo = Pv::mbbo("M:ALM", vec!["Stop".into(), "Run".into()], 0);
1311        let any: AnyPv = mbbo.clone().into();
1312        let rec = any.take_record().unwrap();
1313        store.insert(rec.name.clone(), rec).await;
1314        any.bind(&store);
1315
1316        let wf = PvArray::waveform("W:ALM", ScalarArrayValue::F64(vec![1.0, 2.0]));
1317        let any: AnyPv = wf.clone().into();
1318        let rec = any.take_record().unwrap();
1319        store.insert(rec.name.clone(), rec).await;
1320        any.bind(&store);
1321
1322        assert!(store.set_alarm("M:ALM", 2, 5, "enum fault").await);
1323        assert!(store.set_alarm("W:ALM", 1, 4, "array fault").await);
1324
1325        match store.get_nt("M:ALM").await.unwrap() {
1326            NtPayload::Enum(nt) => {
1327                assert_eq!(nt.alarm.severity, 2);
1328                assert_eq!(nt.alarm.status, 5);
1329                assert_eq!(nt.alarm.message, "enum fault");
1330            }
1331            other => panic!("expected Enum, got {other:?}"),
1332        }
1333        match store.get_nt("W:ALM").await.unwrap() {
1334            NtPayload::ScalarArray(nt) => {
1335                assert_eq!(nt.alarm.severity, 1);
1336                assert_eq!(nt.alarm.status, 4);
1337                assert_eq!(nt.alarm.message, "array fault");
1338            }
1339            other => panic!("expected ScalarArray, got {other:?}"),
1340        }
1341
1342        // idempotent re-set posts nothing / returns false
1343        assert!(!store.set_alarm("M:ALM", 2, 5, "enum fault").await);
1344    }
1345
1346    #[tokio::test]
1347    async fn on_put_callback_travels_to_store() {
1348        let rejected = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1349        let r2 = rejected.clone();
1350        let pv = Pv::ao("SIM:SP", 1.0).on_put(move |_pv, v: f64| {
1351            if v > 100.0 {
1352                r2.store(true, std::sync::atomic::Ordering::SeqCst);
1353                Err("over limit".into())
1354            } else {
1355                Ok(())
1356            }
1357        });
1358        let any: AnyPv = pv.clone().into();
1359        assert!(any.take_validator().is_some());
1360    }
1361
1362    #[tokio::test]
1363    async fn on_put_wrapper_unwraps_structure_wrapped_scalar_put() {
1364        // Real puts to scalar records arrive as a Structure with a "value"
1365        // field (see apply_put_to_record's bare-scalar wrapping in
1366        // simple_store.rs). The typed on_put wrapper must unwrap that the
1367        // same way before calling convert::decoded_to_scalar_value, or a
1368        // wrapped put would fail the typed conversion and spuriously reject.
1369        let store = empty_store();
1370        let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
1371        let seen2 = seen.clone();
1372        let pv = Pv::ao("SIM:WRAP", 1.0).on_put(move |_pv, v: f64| {
1373            *seen2.lock().unwrap() = Some(v);
1374            Ok(())
1375        });
1376        let any: AnyPv = pv.clone().into();
1377        let rec = any.take_record().unwrap();
1378        store.insert(rec.name.clone(), rec).await;
1379        let validator = any.take_validator().expect("validator attached");
1380        any.bind(&store);
1381
1382        let dv = spvirit_codec::spvd_decode::DecodedValue::Structure(vec![(
1383            "value".to_string(),
1384            spvirit_codec::spvd_decode::DecodedValue::Float64(42.0),
1385        )]);
1386        let res = validator("SIM:WRAP", &dv);
1387        assert_eq!(res, Ok(()));
1388        assert_eq!(*seen.lock().unwrap(), Some(42.0));
1389    }
1390
1391    #[test]
1392    fn scalar_value_handle_constructors() {
1393        let p = Pv::<ScalarValue>::scalar_out("S:U16", ScalarValue::U16(7));
1394        let rec = p.pending_record().unwrap();
1395        assert_eq!(rec.record_type, crate::types::RecordType::LongOut);
1396        assert_eq!(rec.current_value(), ScalarValue::U16(7));
1397        assert!(rec.writable());
1398
1399        let q = Pv::<ScalarValue>::scalar_in("S:F32", ScalarValue::F32(1.5));
1400        let rec = q.pending_record().unwrap();
1401        assert_eq!(rec.record_type, crate::types::RecordType::Ai);
1402        assert!(!rec.writable());
1403
1404        let s = Pv::<ScalarValue>::scalar_in("S:STR", ScalarValue::Str("x".into()));
1405        assert_eq!(
1406            s.pending_record().unwrap().record_type,
1407            crate::types::RecordType::StringIn
1408        );
1409
1410        let b = Pv::<ScalarValue>::scalar_out("S:B", ScalarValue::Bool(true));
1411        assert_eq!(
1412            b.pending_record().unwrap().record_type,
1413            crate::types::RecordType::Bo
1414        );
1415    }
1416
1417    #[tokio::test]
1418    async fn scalar_value_handle_set_get_preserves_variant() {
1419        let store = empty_store();
1420        let pv = Pv::<ScalarValue>::scalar_out("S:U64", ScalarValue::U64(1));
1421        let any: AnyPv = pv.clone().into();
1422        let rec = any.take_record().unwrap();
1423        store.insert(rec.name.clone(), rec).await;
1424        any.bind(&store);
1425
1426        pv.set(ScalarValue::U64(u64::MAX)).await.unwrap();
1427        assert_eq!(pv.get().await, Ok(ScalarValue::U64(u64::MAX)));
1428    }
1429
1430    #[test]
1431    fn set_scalar_value_same_variant_u64_is_exact() {
1432        let mut rec = make_output_record("S:U64", RecordType::LongOut, ScalarValue::U64(1));
1433        let changed = rec.set_scalar_value(ScalarValue::U64(u64::MAX), true);
1434        assert!(changed);
1435        assert_eq!(rec.current_value(), ScalarValue::U64(u64::MAX));
1436    }
1437
1438    #[test]
1439    fn set_scalar_value_same_variant_f32_is_exact() {
1440        let mut rec = make_output_record("S:F32", RecordType::LongOut, ScalarValue::F32(1.5));
1441        let changed = rec.set_scalar_value(ScalarValue::F32(2.5), true);
1442        assert!(changed);
1443        assert_eq!(rec.current_value(), ScalarValue::F32(2.5));
1444    }
1445
1446    #[test]
1447    fn set_scalar_value_same_variant_i64_is_exact() {
1448        let mut rec = make_output_record("S:I64", RecordType::LongOut, ScalarValue::I64(1));
1449        let changed = rec.set_scalar_value(ScalarValue::I64(i64::MIN), true);
1450        assert!(changed);
1451        assert_eq!(rec.current_value(), ScalarValue::I64(i64::MIN));
1452    }
1453
1454    #[test]
1455    fn set_scalar_value_cross_variant_preserves_target_variant_from_i32() {
1456        let mut rec = make_output_record("S:U16", RecordType::LongOut, ScalarValue::U16(5));
1457        let changed = rec.set_scalar_value(ScalarValue::I32(42), true);
1458        assert!(changed);
1459        assert_eq!(rec.current_value(), ScalarValue::U16(42));
1460    }
1461
1462    #[test]
1463    fn set_scalar_value_cross_variant_preserves_target_variant_from_f64() {
1464        let mut rec = make_output_record("S:U16", RecordType::LongOut, ScalarValue::U16(5));
1465        let changed = rec.set_scalar_value(ScalarValue::F64(7.0), true);
1466        assert!(changed);
1467        assert_eq!(rec.current_value(), ScalarValue::U16(7));
1468    }
1469
1470    #[test]
1471    fn set_scalar_value_unchanged_u64_returns_false() {
1472        let mut rec = make_output_record("S:U64B", RecordType::LongOut, ScalarValue::U64(5));
1473        let changed = rec.set_scalar_value(ScalarValue::U64(5), true);
1474        assert!(!changed);
1475        assert_eq!(rec.current_value(), ScalarValue::U64(5));
1476    }
1477
1478    #[test]
1479    fn scalar_value_from_decoded_maps_one_to_one() {
1480        assert_eq!(
1481            ScalarValue::from_decoded(&DecodedValue::UInt32(7)),
1482            Some(ScalarValue::U32(7))
1483        );
1484        assert_eq!(
1485            ScalarValue::from_decoded(&DecodedValue::Int8(-3)),
1486            Some(ScalarValue::I8(-3))
1487        );
1488        assert_eq!(
1489            ScalarValue::from_decoded(&DecodedValue::Boolean(true)),
1490            Some(ScalarValue::Bool(true))
1491        );
1492        assert_eq!(
1493            ScalarValue::from_decoded(&DecodedValue::String("hi".into())),
1494            Some(ScalarValue::Str("hi".into()))
1495        );
1496        assert!(ScalarValue::from_decoded(&DecodedValue::Null).is_none());
1497    }
1498}