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
172pub(crate) struct PendingDef {
173    pub(crate) record: RecordInstance,
174    pub(crate) validator: Option<crate::simple_store::PutValidator>,
175    pub(crate) scan: Option<(std::time::Duration, crate::simple_store::ScanCallback)>,
176    pub(crate) calc: Option<(Vec<String>, crate::simple_store::LinkCallback)>,
177}
178
179pub(crate) enum PvState {
180    Pending(PendingDef),
181    Bound(Arc<SimplePvStore>),
182}
183
184pub(crate) struct PvShared {
185    pub(crate) name: String,
186    pub(crate) state: Mutex<PvState>,
187}
188
189/// Typed handle to a PV record. Cheap to clone; all clones share state.
190pub struct Pv<T: PvScalar> {
191    pub(crate) shared: Arc<PvShared>,
192    _marker: PhantomData<fn() -> T>,
193}
194
195impl<T: PvScalar> Clone for Pv<T> {
196    fn clone(&self) -> Self {
197        Self {
198            shared: self.shared.clone(),
199            _marker: PhantomData,
200        }
201    }
202}
203
204impl<T: PvScalar> Pv<T> {
205    fn from_record(record: RecordInstance) -> Self {
206        Self {
207            shared: Arc::new(PvShared {
208                name: record.name.clone(),
209                state: Mutex::new(PvState::Pending(PendingDef {
210                    record,
211                    validator: None,
212                    scan: None,
213                    calc: None,
214                })),
215            }),
216            _marker: PhantomData,
217        }
218    }
219
220    pub fn name(&self) -> &str {
221        &self.shared.name
222    }
223
224    /// Clone of the pending record template; `None` once bound. Test-only —
225    /// `ServeBuilder` reads the pending record via `AnyPv::take_record`.
226    #[cfg(test)]
227    fn pending_record(&self) -> Option<RecordInstance> {
228        match &*self.shared.state.lock().unwrap() {
229            PvState::Pending(def) => Some(def.record.clone()),
230            PvState::Bound(_) => None,
231        }
232    }
233
234    /// Mutate the pending record template. Warns and no-ops if already bound.
235    fn with_record(self, f: impl FnOnce(&mut RecordInstance)) -> Self {
236        {
237            let mut state = self.shared.state.lock().unwrap();
238            match &mut *state {
239                PvState::Pending(def) => f(&mut def.record),
240                PvState::Bound(_) => {
241                    tracing::warn!("Pv '{}': option ignored, already bound", self.shared.name)
242                }
243            }
244        }
245        self
246    }
247
248    pub fn units(self, units: impl Into<String>) -> Self {
249        let u = units.into();
250        self.with_record(|r| {
251            if let Some(nt) = r.nt_scalar_mut() {
252                nt.units = u;
253            }
254        })
255    }
256
257    pub fn prec(self, prec: i32) -> Self {
258        self.with_record(|r| {
259            if let Some(nt) = r.nt_scalar_mut() {
260                nt.display_precision = prec;
261            }
262        })
263    }
264
265    pub fn desc(self, desc: impl Into<String>) -> Self {
266        let d = desc.into();
267        self.with_record(|r| {
268            r.common.desc = d.clone();
269            if let Some(nt) = r.nt_scalar_mut() {
270                nt.display_description = d;
271            }
272        })
273    }
274
275    /// Archive deadband (parsed/exposed via field access; PVA monitors use MDEL).
276    pub fn adel(self, deadband: f64) -> Self {
277        self.with_record(|r| {
278            r.raw_fields.insert("ADEL".into(), trim_float(deadband));
279        })
280    }
281
282    /// Monitor deadband — suppresses monitor posts for changes smaller than this.
283    pub fn mdel(self, deadband: f64) -> Self {
284        self.with_record(|r| {
285            r.raw_fields.insert("MDEL".into(), trim_float(deadband));
286        })
287    }
288
289    pub fn drive_limits(self, low: f64, high: f64) -> Self {
290        self.with_record(|r| {
291            if let Some(nt) = r.nt_scalar_mut() {
292                nt.control_low = low;
293                nt.control_high = high;
294            }
295        })
296    }
297
298    pub fn alarm_limits(self, lolo: f64, low: f64, high: f64, hihi: f64) -> Self {
299        self.with_record(|r| {
300            if let Some(nt) = r.nt_scalar_mut() {
301                nt.value_alarm_active = true;
302                nt.value_alarm_low_alarm_limit = lolo;
303                nt.value_alarm_low_warning_limit = low;
304                nt.value_alarm_high_warning_limit = high;
305                nt.value_alarm_high_alarm_limit = hihi;
306            }
307        })
308    }
309
310    /// Attach a PUT handler. `Err(msg)` rejects the PUT on the wire; `Ok(())`
311    /// accepts it. Called with a bound handle to this PV and the typed value.
312    pub fn on_put<F>(self, f: F) -> Self
313    where
314        F: Fn(&Pv<T>, T) -> Result<(), String> + Send + Sync + 'static,
315    {
316        let handle = self.clone();
317        let validator: crate::simple_store::PutValidator = Arc::new(move |_name, dv| {
318            // Scalar puts may arrive wrapped as a Structure with a "value"
319            // field (mirrors the bare-scalar wrapping in
320            // simple_store::apply_put_to_record) — unwrap it the same way
321            // before converting, or a wrapped put would fail the typed
322            // conversion and be spuriously rejected.
323            let scalar_dv = unwrap_value_field(dv);
324            let typed = T::from_decoded(scalar_dv)
325                .ok_or_else(|| format!("expected {}, got {scalar_dv:?}", T::TYPE_NAME))?;
326            f(&handle, typed)
327        });
328        {
329            let mut state = self.shared.state.lock().unwrap();
330            match &mut *state {
331                PvState::Pending(def) => def.validator = Some(validator),
332                PvState::Bound(_) => {
333                    tracing::warn!("Pv '{}': on_put ignored, already bound", self.shared.name)
334                }
335            }
336        }
337        self
338    }
339
340    /// Periodically compute and post a new value for this PV.
341    pub fn scan<F>(self, period: std::time::Duration, f: F) -> Self
342    where
343        F: Fn(&Pv<T>) -> T + Send + Sync + 'static,
344    {
345        let handle = self.clone();
346        let cb: crate::simple_store::ScanCallback = Arc::new(move |_name| f(&handle).into_scalar());
347        {
348            let mut state = self.shared.state.lock().unwrap();
349            if let PvState::Pending(def) = &mut *state {
350                def.scan = Some((period, cb));
351            } else {
352                tracing::warn!("Pv '{}': scan ignored, already bound", self.shared.name);
353            }
354        }
355        self
356    }
357}
358
359impl Pv<f64> {
360    /// A derived (read-only) PV recomputed whenever any input changes.
361    pub fn calc<F>(name: impl Into<String>, inputs: &[&Pv<f64>], f: F) -> Self
362    where
363        F: Fn(&[f64]) -> f64 + Send + Sync + 'static,
364    {
365        let name = name.into();
366        let input_names: Vec<String> = inputs.iter().map(|p| p.shared.name.clone()).collect();
367        let initial = ScalarValue::F64(0.0);
368        let pv = Self::from_record(make_scalar_record(&name, RecordType::Ai, initial));
369        let compute: crate::simple_store::LinkCallback = Arc::new(move |values| {
370            let floats: Vec<f64> = values
371                .iter()
372                .map(|v| f64::from_scalar(v.clone()).unwrap_or(0.0))
373                .collect();
374            ScalarValue::F64(f(&floats))
375        });
376        if let PvState::Pending(def) = &mut *pv.shared.state.lock().unwrap() {
377            def.calc = Some((input_names, compute));
378        }
379        pv
380    }
381}
382
383/// Mirrors `apply_put_to_record`'s bare-scalar wrapping: if `dv` is a
384/// `Structure`, pull out its "value" field; otherwise treat `dv` itself as
385/// the scalar value.
386fn unwrap_value_field(dv: &DecodedValue) -> &DecodedValue {
387    match dv {
388        DecodedValue::Structure(fields) => fields
389            .iter()
390            .find(|(name, _)| name == "value")
391            .map(|(_, v)| v)
392            .unwrap_or(dv),
393        other => other,
394    }
395}
396
397/// Format a float like EPICS .db files do (no trailing ".0" for integers).
398fn trim_float(v: f64) -> String {
399    if v.fract() == 0.0 && v.abs() < 1e15 {
400        format!("{}", v as i64)
401    } else {
402        format!("{v}")
403    }
404}
405
406impl Pv<f64> {
407    pub fn ai(name: impl Into<String>, initial: f64) -> Self {
408        let name = name.into();
409        Self::from_record(make_scalar_record(
410            &name,
411            RecordType::Ai,
412            ScalarValue::F64(initial),
413        ))
414    }
415    pub fn ao(name: impl Into<String>, initial: f64) -> Self {
416        let name = name.into();
417        Self::from_record(make_output_record(
418            &name,
419            RecordType::Ao,
420            ScalarValue::F64(initial),
421        ))
422    }
423}
424
425impl Pv<bool> {
426    pub fn bi(name: impl Into<String>, initial: bool) -> Self {
427        let name = name.into();
428        Self::from_record(make_scalar_record(
429            &name,
430            RecordType::Bi,
431            ScalarValue::Bool(initial),
432        ))
433    }
434    pub fn bo(name: impl Into<String>, initial: bool) -> Self {
435        let name = name.into();
436        Self::from_record(make_output_record(
437            &name,
438            RecordType::Bo,
439            ScalarValue::Bool(initial),
440        ))
441    }
442}
443
444impl Pv<String> {
445    pub fn string_in(name: impl Into<String>, initial: impl Into<String>) -> Self {
446        let name = name.into();
447        Self::from_record(make_scalar_record(
448            &name,
449            RecordType::StringIn,
450            ScalarValue::Str(initial.into()),
451        ))
452    }
453    pub fn string_out(name: impl Into<String>, initial: impl Into<String>) -> Self {
454        let name = name.into();
455        Self::from_record(make_output_record(
456            &name,
457            RecordType::StringOut,
458            ScalarValue::Str(initial.into()),
459        ))
460    }
461}
462
463impl Pv<i32> {
464    /// `longin` — 32-bit integer input record (read-only over the wire).
465    pub fn longin(name: impl Into<String>, initial: i32) -> Self {
466        let name = name.into();
467        Self::from_record(make_scalar_record(
468            &name,
469            RecordType::LongIn,
470            ScalarValue::I32(initial),
471        ))
472    }
473    /// `longout` — 32-bit integer output record (writable).
474    pub fn longout(name: impl Into<String>, initial: i32) -> Self {
475        let name = name.into();
476        Self::from_record(make_output_record(
477            &name,
478            RecordType::LongOut,
479            ScalarValue::I32(initial),
480        ))
481    }
482    /// `mbbi` — multi-bit binary input (enum, read-only). Value = choice index.
483    pub fn mbbi(name: impl Into<String>, choices: Vec<String>, initial: i32) -> Self {
484        Self::from_enum_record(name.into(), choices, initial, RecordType::Mbbi)
485    }
486    /// `mbbo` — multi-bit binary output (enum, writable). Value = choice index.
487    pub fn mbbo(name: impl Into<String>, choices: Vec<String>, initial: i32) -> Self {
488        Self::from_enum_record(name.into(), choices, initial, RecordType::Mbbo)
489    }
490    fn from_enum_record(name: String, choices: Vec<String>, initial: i32, rt: RecordType) -> Self {
491        // Mirror PvaServerBuilder::mbbi's record shape (pva_server.rs:360-378).
492        let data = crate::types::RecordData::NtEnum {
493            nt: spvirit_types::NtEnum::new(initial, choices),
494            inp: None,
495            out: None,
496            omsl: crate::types::OutputMode::Supervisory,
497        };
498        Self::from_record(RecordInstance {
499            name: name.clone(),
500            record_type: rt,
501            common: crate::types::DbCommonState::default(),
502            data,
503            raw_fields: std::collections::HashMap::new(),
504        })
505    }
506}
507
508impl<T: PvScalar> Pv<T> {
509    fn store(&self) -> Result<Arc<SimplePvStore>, PvError> {
510        match &*self.shared.state.lock().unwrap() {
511            PvState::Bound(store) => Ok(store.clone()),
512            PvState::Pending(_) => Err(PvError::Unbound),
513        }
514    }
515
516    /// Write a value through the full posting pipeline (timestamp, alarms,
517    /// MDEL gating, monitors, links).
518    pub async fn set(&self, value: T) -> Result<(), PvError> {
519        let store = self.store()?;
520        if store
521            .set_value(&self.shared.name, value.into_scalar())
522            .await
523        {
524            Ok(())
525        } else if store.get_value(&self.shared.name).await.is_some() {
526            // Record exists — the write was a no-op (value unchanged). This
527            // existence check is a benign TOCTOU: records are never removed
528            // from SimplePvStore, so a `Some` here can't go stale by the time
529            // we return `Ok`.
530            Ok(())
531        } else {
532            Err(PvError::NotFound(self.shared.name.clone()))
533        }
534    }
535
536    /// Explicitly set the record's alarm severity/status/message, independent
537    /// of its value. Alarm transitions always post (no MDEL gating, no link
538    /// evaluation). A no-op re-set (unchanged alarm) is `Ok(())`.
539    pub async fn set_alarm(
540        &self,
541        severity: i32,
542        status: i32,
543        message: &str,
544    ) -> Result<(), PvError> {
545        let store = self.store()?;
546        if store
547            .set_alarm(&self.shared.name, severity, status, message)
548            .await
549        {
550            Ok(())
551        } else if store.get_value(&self.shared.name).await.is_some() {
552            Ok(())
553        } else {
554            Err(PvError::NotFound(self.shared.name.clone()))
555        }
556    }
557
558    /// Read the current value, typed.
559    pub async fn get(&self) -> Result<T, PvError> {
560        let store = self.store()?;
561        let v = store
562            .get_value(&self.shared.name)
563            .await
564            .ok_or_else(|| PvError::NotFound(self.shared.name.clone()))?;
565        let actual = format!("{v:?}");
566        T::from_scalar(v).ok_or(PvError::TypeMismatch {
567            expected: T::TYPE_NAME,
568            actual,
569        })
570    }
571
572    /// Mint a bound handle to an existing record (e.g. loaded from a `.db`).
573    pub(crate) async fn attach(store: &Arc<SimplePvStore>, name: &str) -> Result<Self, PvError> {
574        // `get_value` alone isn't a safe type sniff: for array-backed
575        // records it returns `ScalarValue::I32(len)` (see
576        // RecordInstance::current_value / types.rs), so `Pv::<i32>::attach`
577        // on a waveform/aai/aao record would wrongly "match" i32. Check the
578        // record's actual payload kind first and refuse array/table/ndarray
579        // payloads outright; only Scalar and Enum records are valid `Pv<T>`
580        // targets (enum records attach as i32 by design, see Task 2).
581        match store.get_nt(name).await {
582            None => return Err(PvError::NotFound(name.to_string())),
583            Some(NtPayload::Scalar(_)) | Some(NtPayload::Enum(_)) => {}
584            Some(other) => {
585                return Err(PvError::TypeMismatch {
586                    expected: T::TYPE_NAME,
587                    actual: nt_payload_kind(&other).to_string(),
588                });
589            }
590        }
591        let v = store
592            .get_value(name)
593            .await
594            .ok_or_else(|| PvError::NotFound(name.to_string()))?;
595        let actual = format!("{v:?}");
596        if T::from_scalar(v).is_none() {
597            return Err(PvError::TypeMismatch {
598                expected: T::TYPE_NAME,
599                actual,
600            });
601        }
602        Ok(Self {
603            shared: Arc::new(PvShared {
604                name: name.to_string(),
605                state: Mutex::new(PvState::Bound(store.clone())),
606            }),
607            _marker: PhantomData,
608        })
609    }
610}
611
612/// Short label for an `NtPayload` variant, used in `PvError::TypeMismatch`.
613fn nt_payload_kind(p: &NtPayload) -> &'static str {
614    match p {
615        NtPayload::Scalar(_) => "Scalar",
616        NtPayload::ScalarArray(_) => "ScalarArray",
617        NtPayload::Table(_) => "Table",
618        NtPayload::NdArray(_) => "NdArray",
619        NtPayload::Enum(_) => "Enum",
620        NtPayload::Generic { .. } => "Generic",
621    }
622}
623
624/// Handle to an array-backed record (`waveform`/`aai`/`aao`). Unlike `Pv<T>`
625/// this is untyped over the element kind — values are `ScalarArrayValue`.
626/// Cheap to clone; all clones share state.
627pub struct PvArray {
628    pub(crate) shared: Arc<PvShared>,
629}
630
631impl Clone for PvArray {
632    fn clone(&self) -> Self {
633        Self {
634            shared: self.shared.clone(),
635        }
636    }
637}
638
639impl PvArray {
640    fn from_record(record: RecordInstance) -> Self {
641        Self {
642            shared: Arc::new(PvShared {
643                name: record.name.clone(),
644                state: Mutex::new(PvState::Pending(PendingDef {
645                    record,
646                    validator: None,
647                    scan: None,
648                    calc: None,
649                })),
650            }),
651        }
652    }
653
654    /// `waveform` — array record, writable over the wire.
655    pub fn waveform(name: impl Into<String>, data: ScalarArrayValue) -> Self {
656        let name = name.into();
657        Self::from_record(make_array_record(&name, RecordType::Waveform, data))
658    }
659
660    /// `aai` — analog array input, read-only over the wire.
661    pub fn aai(name: impl Into<String>, data: ScalarArrayValue) -> Self {
662        let name = name.into();
663        Self::from_record(make_array_record(&name, RecordType::Aai, data))
664    }
665
666    /// `aao` — analog array output, writable over the wire.
667    pub fn aao(name: impl Into<String>, data: ScalarArrayValue) -> Self {
668        let name = name.into();
669        Self::from_record(make_array_record(&name, RecordType::Aao, data))
670    }
671
672    pub fn name(&self) -> &str {
673        &self.shared.name
674    }
675
676    /// Clone of the pending record template; `None` once bound. Test-only —
677    /// `ServeBuilder` reads the pending record via `AnyPv::take_record`.
678    #[cfg(test)]
679    fn pending_record(&self) -> Option<RecordInstance> {
680        match &*self.shared.state.lock().unwrap() {
681            PvState::Pending(def) => Some(def.record.clone()),
682            PvState::Bound(_) => None,
683        }
684    }
685
686    fn store(&self) -> Result<Arc<SimplePvStore>, PvError> {
687        match &*self.shared.state.lock().unwrap() {
688            PvState::Bound(store) => Ok(store.clone()),
689            PvState::Pending(_) => Err(PvError::Unbound),
690        }
691    }
692
693    /// Write an array value through the full posting pipeline.
694    pub async fn set(&self, data: ScalarArrayValue) -> Result<(), PvError> {
695        let store = self.store()?;
696        if store.set_array_value(&self.shared.name, data).await {
697            Ok(())
698        } else {
699            match store.get_nt(&self.shared.name).await {
700                // Record exists and is array-backed — the write was a no-op
701                // or a truncated/rejected update; either way this mirrors
702                // `Pv::set`'s benign-TOCTOU existence check.
703                Some(NtPayload::ScalarArray(_)) => Ok(()),
704                Some(other) => Err(PvError::TypeMismatch {
705                    expected: "array",
706                    actual: nt_payload_kind(&other).to_string(),
707                }),
708                None => Err(PvError::NotFound(self.shared.name.clone())),
709            }
710        }
711    }
712
713    /// Explicitly set the record's alarm severity/status/message, independent
714    /// of its value. Alarm transitions always post (no MDEL gating, no link
715    /// evaluation). A no-op re-set (unchanged alarm) is `Ok(())`.
716    pub async fn set_alarm(
717        &self,
718        severity: i32,
719        status: i32,
720        message: &str,
721    ) -> Result<(), PvError> {
722        let store = self.store()?;
723        if store
724            .set_alarm(&self.shared.name, severity, status, message)
725            .await
726        {
727            Ok(())
728        } else if store.get_nt(&self.shared.name).await.is_some() {
729            Ok(())
730        } else {
731            Err(PvError::NotFound(self.shared.name.clone()))
732        }
733    }
734
735    /// Read the current array value.
736    pub async fn get(&self) -> Result<ScalarArrayValue, PvError> {
737        let store = self.store()?;
738        match store.get_nt(&self.shared.name).await {
739            Some(NtPayload::ScalarArray(nt)) => Ok(nt.value),
740            Some(other) => Err(PvError::TypeMismatch {
741                expected: "array",
742                actual: nt_payload_kind(&other).to_string(),
743            }),
744            None => Err(PvError::NotFound(self.shared.name.clone())),
745        }
746    }
747
748    /// Mint a bound handle to an existing array-backed record.
749    pub(crate) async fn attach(store: &Arc<SimplePvStore>, name: &str) -> Result<Self, PvError> {
750        match store.get_nt(name).await {
751            Some(NtPayload::ScalarArray(_)) => Ok(Self {
752                shared: Arc::new(PvShared {
753                    name: name.to_string(),
754                    state: Mutex::new(PvState::Bound(store.clone())),
755                }),
756            }),
757            Some(other) => Err(PvError::TypeMismatch {
758                expected: "array",
759                actual: nt_payload_kind(&other).to_string(),
760            }),
761            None => Err(PvError::NotFound(name.to_string())),
762        }
763    }
764}
765
766impl From<PvArray> for AnyPv {
767    fn from(pv: PvArray) -> Self {
768        Self { shared: pv.shared }
769    }
770}
771
772/// Type-erased PV, as accepted by `PvaServer::serve` / `.pvs(...)`.
773pub struct AnyPv {
774    pub(crate) shared: Arc<PvShared>,
775}
776
777impl<T: PvScalar> From<Pv<T>> for AnyPv {
778    fn from(pv: Pv<T>) -> Self {
779        Self { shared: pv.shared }
780    }
781}
782
783impl AnyPv {
784    /// Clone the pending record template. Returns `None` if already bound.
785    pub(crate) fn take_record(&self) -> Option<RecordInstance> {
786        let state = self.shared.state.lock().unwrap();
787        match &*state {
788            PvState::Pending(def) => Some(def.record.clone()),
789            PvState::Bound(_) => None,
790        }
791    }
792
793    /// Flip the handle (and every clone sharing this state) to bound.
794    pub(crate) fn bind(&self, store: &Arc<SimplePvStore>) {
795        *self.shared.state.lock().unwrap() = PvState::Bound(store.clone());
796    }
797
798    pub fn name(&self) -> &str {
799        &self.shared.name
800    }
801
802    /// Take the pending PUT validator, if any. `None` once bound.
803    pub(crate) fn take_validator(&self) -> Option<crate::simple_store::PutValidator> {
804        let mut state = self.shared.state.lock().unwrap();
805        match &mut *state {
806            PvState::Pending(def) => def.validator.take(),
807            PvState::Bound(_) => None,
808        }
809    }
810
811    /// Take the pending scan definition, if any. `None` once bound.
812    pub(crate) fn take_scan(
813        &self,
814    ) -> Option<(std::time::Duration, crate::simple_store::ScanCallback)> {
815        let mut state = self.shared.state.lock().unwrap();
816        match &mut *state {
817            PvState::Pending(def) => def.scan.take(),
818            PvState::Bound(_) => None,
819        }
820    }
821
822    /// Take the pending calc definition, if any. `None` once bound.
823    pub(crate) fn take_calc(&self) -> Option<(Vec<String>, crate::simple_store::LinkCallback)> {
824        let mut state = self.shared.state.lock().unwrap();
825        match &mut *state {
826            PvState::Pending(def) => def.calc.take(),
827            PvState::Bound(_) => None,
828        }
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835
836    #[test]
837    fn pvscalar_roundtrip_f64() {
838        assert_eq!(f64::from_scalar(ScalarValue::F64(1.5)), Some(1.5));
839        assert!(matches!(1.5f64.into_scalar(), ScalarValue::F64(x) if x == 1.5));
840    }
841
842    #[test]
843    fn pvscalar_f64_accepts_f32_widening() {
844        assert_eq!(f64::from_scalar(ScalarValue::F32(2.0)), Some(2.0));
845    }
846
847    #[test]
848    fn pvscalar_rejects_wrong_variant() {
849        assert_eq!(f64::from_scalar(ScalarValue::Str("x".into())), None);
850        assert_eq!(bool::from_scalar(ScalarValue::F64(1.0)), None);
851        assert_eq!(i32::from_scalar(ScalarValue::Str("1".into())), None);
852        assert_eq!(String::from_scalar(ScalarValue::Bool(true)), None);
853    }
854
855    #[test]
856    fn pverror_display() {
857        let e = PvError::TypeMismatch {
858            expected: "f64",
859            actual: "Str".into(),
860        };
861        assert!(e.to_string().contains("f64"));
862        assert!(PvError::Unbound.to_string().contains("not bound"));
863    }
864
865    #[test]
866    fn ai_constructor_builds_record_template() {
867        let pv = Pv::ai("SIM:TEMP", 22.5).units("C").prec(2).desc("Temp");
868        let rec = pv.pending_record().expect("still pending");
869        assert_eq!(rec.name, "SIM:TEMP");
870        let nt = rec.to_ntscalar();
871        assert_eq!(nt.value, ScalarValue::F64(22.5));
872        assert_eq!(nt.units, "C");
873        assert_eq!(nt.display_precision, 2);
874        assert_eq!(rec.common.desc, "Temp");
875        assert!(!rec.writable(), "ai is read-only over the wire");
876        assert_eq!(pv.name(), "SIM:TEMP");
877    }
878
879    #[test]
880    fn ao_is_writable_with_drive_limits() {
881        let pv = Pv::ao("SIM:SP", 25.0).drive_limits(0.0, 100.0);
882        let rec = pv.pending_record().unwrap();
883        assert!(rec.writable());
884        let nt = rec.to_ntscalar();
885        assert_eq!(nt.control_low, 0.0);
886        assert_eq!(nt.control_high, 100.0);
887    }
888
889    #[test]
890    fn mdel_adel_go_to_raw_fields() {
891        let pv = Pv::ai("SIM:X", 0.0).mdel(0.5).adel(1.0);
892        let rec = pv.pending_record().unwrap();
893        assert_eq!(rec.raw_fields.get("MDEL").map(String::as_str), Some("0.5"));
894        assert_eq!(rec.raw_fields.get("ADEL").map(String::as_str), Some("1"));
895    }
896
897    #[test]
898    fn alarm_limits_set_value_alarm_block() {
899        let pv = Pv::ao("SIM:A", 0.0).alarm_limits(-10.0, -5.0, 5.0, 10.0);
900        let nt = pv.pending_record().unwrap().to_ntscalar();
901        assert_eq!(nt.value_alarm_low_alarm_limit, -10.0);
902        assert_eq!(nt.value_alarm_low_warning_limit, -5.0);
903        assert_eq!(nt.value_alarm_high_warning_limit, 5.0);
904        assert_eq!(nt.value_alarm_high_alarm_limit, 10.0);
905        assert!(nt.value_alarm_active);
906    }
907
908    #[test]
909    fn bool_and_string_constructors() {
910        assert!(Pv::bo("B", true).pending_record().unwrap().writable());
911        assert!(!Pv::bi("B2", false).pending_record().unwrap().writable());
912        let s = Pv::string_in("S", "hello").pending_record().unwrap();
913        assert_eq!(s.to_ntscalar().value, ScalarValue::Str("hello".into()));
914    }
915
916    #[test]
917    fn longin_longout_constructors() {
918        let li = Pv::longin("L:IN", 42);
919        let rec = li.pending_record().unwrap();
920        assert_eq!(rec.record_type, crate::types::RecordType::LongIn);
921        assert_eq!(rec.to_ntscalar().value, ScalarValue::I32(42));
922        assert!(!rec.writable());
923
924        let lo = Pv::longout("L:OUT", 7).drive_limits(0.0, 1000.0);
925        let rec = lo.pending_record().unwrap();
926        assert_eq!(rec.record_type, crate::types::RecordType::LongOut);
927        assert!(rec.writable());
928        assert_eq!(rec.to_ntscalar().control_high, 1000.0);
929    }
930
931    #[tokio::test]
932    async fn longout_set_get_roundtrip() {
933        let store = empty_store();
934        let pv = Pv::longout("L:RT", 1);
935        let any: AnyPv = pv.clone().into();
936        let rec = any.take_record().unwrap();
937        store.insert(rec.name.clone(), rec).await;
938        any.bind(&store);
939        pv.set(99).await.unwrap();
940        assert_eq!(pv.get().await, Ok(99));
941    }
942
943    #[test]
944    fn mbbi_mbbo_constructors() {
945        let m = Pv::mbbi("M:I", vec!["Off".into(), "On".into(), "Auto".into()], 1);
946        let rec = m.pending_record().unwrap();
947        assert_eq!(rec.record_type, crate::types::RecordType::Mbbi);
948        assert_eq!(rec.current_value(), ScalarValue::I32(1));
949
950        let o = Pv::mbbo("M:O", vec!["A".into(), "B".into()], 0);
951        assert!(o.pending_record().unwrap().writable());
952    }
953
954    #[tokio::test]
955    async fn mbbo_set_get_index_with_bounds() {
956        let store = empty_store();
957        let pv = Pv::mbbo("M:RT", vec!["Stop".into(), "Run".into(), "Fault".into()], 0);
958        let any: AnyPv = pv.clone().into();
959        let rec = any.take_record().unwrap();
960        store.insert(rec.name.clone(), rec).await;
961        any.bind(&store);
962
963        pv.set(2).await.unwrap();
964        assert_eq!(pv.get().await, Ok(2));
965        // out-of-range index is rejected (value unchanged); set() maps the
966        // store's `false` to Ok-if-exists, so verify via get()
967        let _ = pv.set(7).await;
968        assert_eq!(pv.get().await, Ok(2));
969    }
970
971    #[test]
972    fn set_scalar_value_on_raw_nt_enum_record() {
973        let mut rec = Pv::mbbo("M:RAW", vec!["Off".into(), "On".into(), "Auto".into()], 0)
974            .pending_record()
975            .unwrap();
976        // In-range change succeeds.
977        assert!(rec.set_scalar_value(ScalarValue::I32(2), true));
978        assert_eq!(rec.current_value(), ScalarValue::I32(2));
979        // Same-index is a no-op.
980        assert!(!rec.set_scalar_value(ScalarValue::I32(2), true));
981        // Out-of-range index is rejected; value unchanged.
982        assert!(!rec.set_scalar_value(ScalarValue::I32(7), true));
983        assert_eq!(rec.current_value(), ScalarValue::I32(2));
984        assert!(!rec.set_scalar_value(ScalarValue::I32(-1), true));
985        assert_eq!(rec.current_value(), ScalarValue::I32(2));
986    }
987
988    #[tokio::test]
989    async fn pv_array_roundtrip_and_serve() {
990        let wf = PvArray::waveform("W:1", ScalarArrayValue::F64(vec![1.0, 2.0, 3.0]));
991        let server = crate::pva_server::PvaServer::serve([AnyPv::from(wf.clone())])
992            .build()
993            .await;
994        assert_eq!(
995            wf.get().await,
996            Ok(ScalarArrayValue::F64(vec![1.0, 2.0, 3.0]))
997        );
998        wf.set(ScalarArrayValue::F64(vec![4.0, 5.0])).await.unwrap();
999        match wf.get().await.unwrap() {
1000            ScalarArrayValue::F64(v) => assert_eq!(v, vec![4.0, 5.0]),
1001            other => panic!("wrong kind: {other:?}"),
1002        }
1003        // typed attach via the server
1004        let h = server.array_pv("W:1").await.unwrap();
1005        assert!(matches!(h.get().await.unwrap(), ScalarArrayValue::F64(_)));
1006        // scalar attach to an array record must type-mismatch
1007        assert!(matches!(
1008            server.pv::<f64>("W:1").await,
1009            Err(PvError::TypeMismatch { .. })
1010        ));
1011        // array attach to a scalar record must type-mismatch
1012        let t = Pv::ai("W:S", 1.0);
1013        let s2 = crate::pva_server::PvaServer::serve([AnyPv::from(t)])
1014            .build()
1015            .await;
1016        assert!(matches!(
1017            s2.array_pv("W:S").await,
1018            Err(PvError::TypeMismatch { .. })
1019        ));
1020    }
1021
1022    #[test]
1023    fn aai_read_only_aao_writable() {
1024        let a = PvArray::aai("W:AI", ScalarArrayValue::I32(vec![1]));
1025        assert!(a.pending_record().is_some());
1026        assert!(!AnyPv::from(a).take_record().unwrap().writable());
1027        let b = PvArray::aao("W:AO", ScalarArrayValue::I32(vec![1]));
1028        assert!(AnyPv::from(b).take_record().unwrap().writable());
1029    }
1030
1031    #[tokio::test]
1032    async fn scalar_attach_rejects_array_backed_record() {
1033        // Regression: Pv::attach used to sniff via get_value, which returns
1034        // I32(len) for array records — so Pv::<i32>::attach would WRONGLY
1035        // succeed on a waveform/aai/aao record. The payload-kind guard must
1036        // reject it as TypeMismatch instead.
1037        let store = empty_store();
1038        let wf = PvArray::waveform("W:GUARD", ScalarArrayValue::I32(vec![1, 2, 3]));
1039        let any: AnyPv = wf.into();
1040        let rec = any.take_record().unwrap();
1041        store.insert(rec.name.clone(), rec).await;
1042
1043        let bad = Pv::<i32>::attach(&store, "W:GUARD").await;
1044        assert!(matches!(bad, Err(PvError::TypeMismatch { .. })));
1045    }
1046
1047    fn empty_store() -> Arc<SimplePvStore> {
1048        Arc::new(SimplePvStore::new(
1049            std::collections::HashMap::new(),
1050            std::collections::HashMap::new(),
1051            Vec::new(),
1052            false,
1053        ))
1054    }
1055
1056    #[tokio::test]
1057    async fn set_get_before_bind_errors() {
1058        let pv = Pv::ai("SIM:X", 1.0);
1059        assert_eq!(pv.set(2.0).await, Err(PvError::Unbound));
1060        assert_eq!(pv.get().await, Err(PvError::Unbound));
1061    }
1062
1063    #[tokio::test]
1064    async fn bind_then_set_get_roundtrip() {
1065        let store = empty_store();
1066        let pv = Pv::ai("SIM:X", 1.0).units("mm");
1067        let any: AnyPv = pv.clone().into();
1068        let rec = any.take_record().expect("pending record");
1069        store.insert(rec.name.clone(), rec).await;
1070        any.bind(&store);
1071
1072        assert_eq!(pv.get().await, Ok(1.0));
1073        pv.set(2.5).await.unwrap();
1074        assert_eq!(pv.get().await, Ok(2.5));
1075        // clone sees the same record
1076        assert_eq!(pv.clone().get().await, Ok(2.5));
1077    }
1078
1079    #[tokio::test]
1080    async fn attach_mints_typed_handle_and_checks_type() {
1081        let store = empty_store();
1082        let src = Pv::ai("SIM:Y", 3.0);
1083        let any: AnyPv = src.into();
1084        let rec = any.take_record().unwrap();
1085        store.insert(rec.name.clone(), rec).await;
1086
1087        let h: Pv<f64> = Pv::attach(&store, "SIM:Y").await.unwrap();
1088        assert_eq!(h.get().await, Ok(3.0));
1089
1090        let bad = Pv::<bool>::attach(&store, "SIM:Y").await;
1091        assert!(matches!(bad, Err(PvError::TypeMismatch { .. })));
1092        let missing = Pv::<f64>::attach(&store, "NOPE").await;
1093        assert!(matches!(missing, Err(PvError::NotFound(ref n)) if n == "NOPE"));
1094    }
1095
1096    #[tokio::test]
1097    async fn set_same_value_is_ok_not_not_found() {
1098        let store = empty_store();
1099        let pv = Pv::ai("SIM:Z", 1.0);
1100        let any: AnyPv = pv.clone().into();
1101        let rec = any.take_record().unwrap();
1102        store.insert(rec.name.clone(), rec).await;
1103        any.bind(&store);
1104
1105        assert_eq!(pv.set(2.5).await, Ok(()));
1106        // Second write of the same value is a no-op, not NotFound.
1107        assert_eq!(pv.set(2.5).await, Ok(()));
1108        assert_eq!(pv.get().await, Ok(2.5));
1109    }
1110
1111    #[tokio::test]
1112    async fn set_alarm_posts_and_reads_back() {
1113        let store = empty_store();
1114        let pv = Pv::ai("A:1", 1.0);
1115        let any: AnyPv = pv.clone().into();
1116        let rec = any.take_record().unwrap();
1117        store.insert(rec.name.clone(), rec).await;
1118        any.bind(&store);
1119
1120        // subscribe like a monitor client
1121        let mut rx = crate::pvstore::Source::subscribe(&*store, "A:1")
1122            .await
1123            .unwrap();
1124
1125        pv.set_alarm(2, 3, "sensor dead").await.unwrap();
1126        let rec = store.get_record("A:1").await.unwrap();
1127        let nt = rec.to_ntscalar();
1128        assert_eq!(nt.alarm_severity, 2);
1129        assert_eq!(nt.alarm_status, 3);
1130        assert_eq!(nt.alarm_message, "sensor dead");
1131        // a payload was posted to the subscriber
1132        let posted = rx.try_recv().expect("alarm change must post");
1133        drop(posted);
1134        // idempotent re-set posts nothing
1135        pv.set_alarm(2, 3, "sensor dead").await.unwrap();
1136        assert!(rx.try_recv().is_err());
1137        // unbound / missing paths
1138        let ghost = Pv::ai("A:GHOST", 0.0);
1139        assert_eq!(ghost.set_alarm(1, 0, "x").await, Err(PvError::Unbound));
1140
1141        let missing: Pv<f64> = Pv::attach(&store, "A:1").await.unwrap();
1142        // sanity: attach roundtrip still works after alarm writes
1143        assert_eq!(missing.get().await, Ok(1.0));
1144    }
1145
1146    #[tokio::test]
1147    async fn set_alarm_missing_record_is_not_found() {
1148        let store = empty_store();
1149        let pv = Pv::ai("A:MISSING", 0.0);
1150        let any: AnyPv = pv.clone().into();
1151        any.bind(&store);
1152        assert_eq!(
1153            pv.set_alarm(1, 1, "x").await,
1154            Err(PvError::NotFound("A:MISSING".into()))
1155        );
1156    }
1157
1158    #[tokio::test]
1159    async fn set_alarm_on_enum_and_array_records() {
1160        let store = empty_store();
1161
1162        let mbbo = Pv::mbbo("M:ALM", vec!["Stop".into(), "Run".into()], 0);
1163        let any: AnyPv = mbbo.clone().into();
1164        let rec = any.take_record().unwrap();
1165        store.insert(rec.name.clone(), rec).await;
1166        any.bind(&store);
1167
1168        let wf = PvArray::waveform("W:ALM", ScalarArrayValue::F64(vec![1.0, 2.0]));
1169        let any: AnyPv = wf.clone().into();
1170        let rec = any.take_record().unwrap();
1171        store.insert(rec.name.clone(), rec).await;
1172        any.bind(&store);
1173
1174        assert!(store.set_alarm("M:ALM", 2, 5, "enum fault").await);
1175        assert!(store.set_alarm("W:ALM", 1, 4, "array fault").await);
1176
1177        match store.get_nt("M:ALM").await.unwrap() {
1178            NtPayload::Enum(nt) => {
1179                assert_eq!(nt.alarm.severity, 2);
1180                assert_eq!(nt.alarm.status, 5);
1181                assert_eq!(nt.alarm.message, "enum fault");
1182            }
1183            other => panic!("expected Enum, got {other:?}"),
1184        }
1185        match store.get_nt("W:ALM").await.unwrap() {
1186            NtPayload::ScalarArray(nt) => {
1187                assert_eq!(nt.alarm.severity, 1);
1188                assert_eq!(nt.alarm.status, 4);
1189                assert_eq!(nt.alarm.message, "array fault");
1190            }
1191            other => panic!("expected ScalarArray, got {other:?}"),
1192        }
1193
1194        // idempotent re-set posts nothing / returns false
1195        assert!(!store.set_alarm("M:ALM", 2, 5, "enum fault").await);
1196    }
1197
1198    #[tokio::test]
1199    async fn on_put_callback_travels_to_store() {
1200        let rejected = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1201        let r2 = rejected.clone();
1202        let pv = Pv::ao("SIM:SP", 1.0).on_put(move |_pv, v: f64| {
1203            if v > 100.0 {
1204                r2.store(true, std::sync::atomic::Ordering::SeqCst);
1205                Err("over limit".into())
1206            } else {
1207                Ok(())
1208            }
1209        });
1210        let any: AnyPv = pv.clone().into();
1211        assert!(any.take_validator().is_some());
1212    }
1213
1214    #[tokio::test]
1215    async fn on_put_wrapper_unwraps_structure_wrapped_scalar_put() {
1216        // Real puts to scalar records arrive as a Structure with a "value"
1217        // field (see apply_put_to_record's bare-scalar wrapping in
1218        // simple_store.rs). The typed on_put wrapper must unwrap that the
1219        // same way before calling convert::decoded_to_scalar_value, or a
1220        // wrapped put would fail the typed conversion and spuriously reject.
1221        let store = empty_store();
1222        let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
1223        let seen2 = seen.clone();
1224        let pv = Pv::ao("SIM:WRAP", 1.0).on_put(move |_pv, v: f64| {
1225            *seen2.lock().unwrap() = Some(v);
1226            Ok(())
1227        });
1228        let any: AnyPv = pv.clone().into();
1229        let rec = any.take_record().unwrap();
1230        store.insert(rec.name.clone(), rec).await;
1231        let validator = any.take_validator().expect("validator attached");
1232        any.bind(&store);
1233
1234        let dv = spvirit_codec::spvd_decode::DecodedValue::Structure(vec![(
1235            "value".to_string(),
1236            spvirit_codec::spvd_decode::DecodedValue::Float64(42.0),
1237        )]);
1238        let res = validator("SIM:WRAP", &dv);
1239        assert_eq!(res, Ok(()));
1240        assert_eq!(*seen.lock().unwrap(), Some(42.0));
1241    }
1242}