Skip to main content

spvirit_client/
pva_client.rs

1//! High-level PVAccess client — one-liner get, put, monitor, info.
2//!
3//! # Example
4//!
5//! ```rust,ignore
6//! use spvirit_client::PvaClient;
7//!
8//! let client = PvaClient::builder().build();
9//! let result = client.pvget("MY:PV").await?;
10//! client.pvput("MY:PV", 42.0).await?;
11//! ```
12
13use std::net::SocketAddr;
14use std::ops::ControlFlow;
15use std::sync::atomic::{AtomicU32, Ordering};
16use std::time::Duration;
17
18use serde_json::Value;
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::tcp::OwnedWriteHalf;
21use tokio::task::JoinHandle;
22use tokio::time::{Instant, interval};
23
24use spvirit_codec::epics_decode::{PvaPacket, PvaPacketCommand};
25use spvirit_codec::spvd_decode::{DecodedValue, PvdDecoder, StructureDesc};
26use spvirit_codec::spvd_encode::encode_pv_request;
27use spvirit_codec::spvirit_encode::{
28    encode_control_message, encode_get_field_request, encode_monitor_request, encode_put_request,
29};
30
31use crate::client::{ChannelConn, ensure_status_ok, establish_channel, pvget as low_level_pvget};
32use crate::put_encode::encode_put_payload;
33use crate::search::resolve_pv_server;
34use crate::transport::{read_packet, read_until};
35use crate::types::{PvGetError, PvGetResult, PvOptions};
36
37/// PVA protocol version used in headers.
38const PVA_VERSION: u8 = 2;
39/// QoS / subcommand flag: INIT.
40const QOS_INIT: u8 = 0x08;
41
42static NEXT_IOID: AtomicU32 = AtomicU32::new(1);
43fn alloc_ioid() -> u32 {
44    NEXT_IOID.fetch_add(1, Ordering::Relaxed)
45}
46
47/// Build the pvRequest body for a GET / PUT / MONITOR INIT.
48///
49/// Returns the canonical "all fields" pvRequest (`field()`) when `fields` is
50/// empty, otherwise delegates to [`encode_pv_request`] which supports dotted
51/// nested paths like `"alarm.severity"`.
52fn build_pv_request(fields: &[&str], is_be: bool) -> Vec<u8> {
53    if fields.is_empty() {
54        // Empty pvRequest \u2014 server returns full descriptor / all fields.
55        vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
56    } else {
57        encode_pv_request(fields, is_be)
58    }
59}
60
61// ─── PvaClientBuilder ────────────────────────────────────────────────────────
62
63/// Builder for [`PvaClient`].
64///
65/// ```rust,ignore
66/// let client = PvaClient::builder()
67///     .timeout(Duration::from_secs(10))
68///     .port(5075)
69///     .build();
70/// ```
71pub struct PvaClientBuilder {
72    udp_port: u16,
73    tcp_port: u16,
74    timeout: Duration,
75    no_broadcast: bool,
76    name_servers: Vec<SocketAddr>,
77    authnz_user: Option<String>,
78    authnz_host: Option<String>,
79    server_addr: Option<SocketAddr>,
80    search_addr: Option<std::net::IpAddr>,
81    bind_addr: Option<std::net::IpAddr>,
82    debug: bool,
83}
84
85impl PvaClientBuilder {
86    fn new() -> Self {
87        Self {
88            udp_port: 5076,
89            tcp_port: 5075,
90            timeout: Duration::from_secs(5),
91            no_broadcast: false,
92            name_servers: Vec::new(),
93            authnz_user: None,
94            authnz_host: None,
95            server_addr: None,
96            search_addr: None,
97            bind_addr: None,
98            debug: false,
99        }
100    }
101
102    /// Set the TCP port (default 5075).
103    pub fn port(mut self, port: u16) -> Self {
104        self.tcp_port = port;
105        self
106    }
107
108    /// Set the UDP search port (default 5076).
109    pub fn udp_port(mut self, port: u16) -> Self {
110        self.udp_port = port;
111        self
112    }
113
114    /// Set the operation timeout (default 5 s).
115    pub fn timeout(mut self, timeout: Duration) -> Self {
116        self.timeout = timeout;
117        self
118    }
119
120    /// Disable UDP broadcast search (use name servers only).
121    pub fn no_broadcast(mut self) -> Self {
122        self.no_broadcast = true;
123        self
124    }
125
126    /// Add a PVA name-server address for TCP search.
127    pub fn name_server(mut self, addr: SocketAddr) -> Self {
128        self.name_servers.push(addr);
129        self
130    }
131
132    /// Override the authentication user.
133    pub fn authnz_user(mut self, user: impl Into<String>) -> Self {
134        self.authnz_user = Some(user.into());
135        self
136    }
137
138    /// Override the authentication host.
139    pub fn authnz_host(mut self, host: impl Into<String>) -> Self {
140        self.authnz_host = Some(host.into());
141        self
142    }
143
144    /// Set an explicit server address, bypassing UDP search.
145    pub fn server_addr(mut self, addr: SocketAddr) -> Self {
146        self.server_addr = Some(addr);
147        self
148    }
149
150    /// Set the search target IP address.
151    pub fn search_addr(mut self, addr: std::net::IpAddr) -> Self {
152        self.search_addr = Some(addr);
153        self
154    }
155
156    /// Set the local bind IP for UDP search.
157    pub fn bind_addr(mut self, addr: std::net::IpAddr) -> Self {
158        self.bind_addr = Some(addr);
159        self
160    }
161
162    /// Enable debug logging.
163    pub fn debug(mut self) -> Self {
164        self.debug = true;
165        self
166    }
167
168    /// Build the [`PvaClient`].
169    pub fn build(self) -> PvaClient {
170        PvaClient {
171            udp_port: self.udp_port,
172            tcp_port: self.tcp_port,
173            timeout: self.timeout,
174            no_broadcast: self.no_broadcast,
175            name_servers: self.name_servers,
176            authnz_user: self.authnz_user,
177            authnz_host: self.authnz_host,
178            server_addr: self.server_addr,
179            search_addr: self.search_addr,
180            bind_addr: self.bind_addr,
181            debug: self.debug,
182        }
183    }
184}
185
186// ─── PvaClient ───────────────────────────────────────────────────────────────
187
188/// High-level PVAccess client.
189///
190/// Provides `pvget`, `pvput`, `pvmonitor`, and `pvinfo` methods that hide
191/// the underlying protocol handshake.
192///
193/// ```rust,ignore
194/// let client = PvaClient::builder().build();
195/// let val = client.pvget("MY:PV").await?;
196/// ```
197#[derive(Clone, Debug)]
198pub struct PvaClient {
199    udp_port: u16,
200    tcp_port: u16,
201    timeout: Duration,
202    no_broadcast: bool,
203    name_servers: Vec<SocketAddr>,
204    authnz_user: Option<String>,
205    authnz_host: Option<String>,
206    server_addr: Option<SocketAddr>,
207    search_addr: Option<std::net::IpAddr>,
208    bind_addr: Option<std::net::IpAddr>,
209    debug: bool,
210}
211
212impl PvaClient {
213    /// Create a builder for configuring a [`PvaClient`].
214    pub fn builder() -> PvaClientBuilder {
215        PvaClientBuilder::new()
216    }
217
218    /// Build [`PvOptions`] for a given PV name, inheriting client-level settings.
219    fn opts(&self, pv_name: &str) -> PvOptions {
220        let mut o = PvOptions::new(pv_name.to_string());
221        o.udp_port = self.udp_port;
222        o.tcp_port = self.tcp_port;
223        o.timeout = self.timeout;
224        o.no_broadcast = self.no_broadcast;
225        o.name_servers.clone_from(&self.name_servers);
226        o.authnz_user.clone_from(&self.authnz_user);
227        o.authnz_host.clone_from(&self.authnz_host);
228        o.server_addr = self.server_addr;
229        o.search_addr = self.search_addr;
230        o.bind_addr = self.bind_addr;
231        o.debug = self.debug;
232        o
233    }
234
235    /// Resolve a PV server and establish a channel, returning the raw connection.
236    async fn open_channel(&self, pv_name: &str) -> Result<ChannelConn, PvGetError> {
237        let opts = self.opts(pv_name);
238        let target = resolve_pv_server(&opts).await?;
239        establish_channel(target, &opts).await
240    }
241
242    // ─── pvget ───────────────────────────────────────────────────────────
243
244    /// Fetch the current value of a PV.
245    pub async fn pvget(&self, pv_name: &str) -> Result<PvGetResult, PvGetError> {
246        let opts = self.opts(pv_name);
247        low_level_pvget(&opts).await
248    }
249
250    /// Fetch a PV with field filtering (equivalent to `pvget -r "field(value,alarm)"`).
251    pub async fn pvget_fields(
252        &self,
253        pv_name: &str,
254        fields: &[&str],
255    ) -> Result<PvGetResult, PvGetError> {
256        let opts = self.opts(pv_name);
257        crate::client::pvget_fields(&opts, fields).await
258    }
259
260    // ─── pvput ───────────────────────────────────────────────────────────
261
262    /// Write a value to a PV.
263    ///
264    /// Accepts anything convertible to `serde_json::Value`:
265    /// ```rust,ignore
266    /// client.pvput("MY:PV", 42.0).await?;
267    /// client.pvput("MY:PV", "hello").await?;
268    /// client.pvput("MY:PV", serde_json::json!({"value": 1.5})).await?;
269    /// ```
270    pub async fn pvput(&self, pv_name: &str, value: impl Into<Value>) -> Result<(), PvGetError> {
271        // Default: PUT only the `value` field (the universal PVA convention).
272        // Use [`pvput_fields`](Self::pvput_fields) for richer selections.
273        self.pvput_fields(pv_name, value, &["value"]).await
274    }
275
276    /// Write to a PV with explicit field selection (dotted paths).
277    ///
278    /// `fields` is forwarded as the PUT pvRequest. An empty slice is treated
279    /// as "all fields" (server returns full descriptor on INIT).
280    pub async fn pvput_fields(
281        &self,
282        pv_name: &str,
283        value: impl Into<Value>,
284        fields: &[&str],
285    ) -> Result<(), PvGetError> {
286        let json_val = value.into();
287        let ChannelConn {
288            mut stream,
289            sid,
290            version: _,
291            is_be,
292            ..
293        } = self.open_channel(pv_name).await?;
294
295        let ioid = alloc_ioid();
296
297        // PUT INIT — pvRequest from caller-supplied field paths.
298        let pv_request = build_pv_request(fields, is_be);
299        let init = encode_put_request(sid, ioid, QOS_INIT, &pv_request, PVA_VERSION, is_be);
300        stream.write_all(&init).await?;
301
302        // Read INIT response — extract introspection
303        let init_bytes = read_until(&mut stream, self.timeout, |cmd| {
304            matches!(cmd, PvaPacketCommand::Op(op) if op.command == 11 && (op.subcmd & 0x08) != 0)
305        })
306        .await?;
307
308        let desc = decode_init_introspection(&init_bytes, "PUT")?;
309
310        // Encode and send the value
311        let payload = encode_put_payload(&desc, &json_val, is_be)
312            .map_err(|e| PvGetError::Protocol(format!("put encode: {e}")))?;
313        let req = encode_put_request(sid, ioid, 0x00, &payload, PVA_VERSION, is_be);
314        stream.write_all(&req).await?;
315
316        // Read PUT response — verify status
317        let resp_bytes = read_until(
318            &mut stream,
319            self.timeout,
320            |cmd| matches!(cmd, PvaPacketCommand::Op(op) if op.command == 11 && op.subcmd == 0x00),
321        )
322        .await?;
323        ensure_status_ok(&resp_bytes, is_be, "PUT")?;
324
325        Ok(())
326    }
327
328    // ─── open_put_channel ────────────────────────────────────────────────
329
330    /// Open a persistent channel for high-rate PUT streaming.
331    ///
332    /// Resolves the PV, establishes a channel, and completes the PUT INIT
333    /// handshake. The returned [`PvaChannel`] is ready for immediate
334    /// [`put`](PvaChannel::put) calls.
335    pub async fn open_put_channel(&self, pv_name: &str) -> Result<PvaChannel, PvGetError> {
336        self.open_put_channel_fields(pv_name, &["value"]).await
337    }
338
339    /// Open a persistent PUT channel with explicit field selection.
340    ///
341    /// An empty `fields` slice requests all fields from the server.
342    pub async fn open_put_channel_fields(
343        &self,
344        pv_name: &str,
345        fields: &[&str],
346    ) -> Result<PvaChannel, PvGetError> {
347        let ChannelConn {
348            mut stream,
349            sid,
350            version,
351            is_be,
352            ..
353        } = self.open_channel(pv_name).await?;
354
355        let ioid = alloc_ioid();
356
357        // PUT INIT
358        let pv_request = build_pv_request(fields, is_be);
359        let init = encode_put_request(sid, ioid, QOS_INIT, &pv_request, PVA_VERSION, is_be);
360        stream.write_all(&init).await?;
361
362        let init_bytes = read_until(&mut stream, self.timeout, |cmd| {
363            matches!(cmd, PvaPacketCommand::Op(op) if op.command == 11 && (op.subcmd & 0x08) != 0)
364        })
365        .await?;
366
367        let desc = decode_init_introspection(&init_bytes, "PUT")?;
368
369        // Split stream; background reader logs PUT errors
370        let (mut reader, writer) = stream.into_split();
371        let reader_is_be = is_be;
372        let reader_handle = tokio::spawn(async move {
373            loop {
374                let mut header = [0u8; 8];
375                if reader.read_exact(&mut header).await.is_err() {
376                    break;
377                }
378                let hdr = spvirit_codec::epics_decode::PvaHeader::new(&header);
379                let len = if hdr.flags.is_control {
380                    0usize
381                } else {
382                    hdr.payload_length as usize
383                };
384                let mut payload = vec![0u8; len];
385                if len > 0 && reader.read_exact(&mut payload).await.is_err() {
386                    break;
387                }
388                if hdr.command == 11 && !hdr.flags.is_control && len >= 5 {
389                    if let Some(st) =
390                        spvirit_codec::epics_decode::decode_status(&payload[5..], reader_is_be).0
391                    {
392                        if st.code != 0 {
393                            let msg = st.message.unwrap_or_else(|| format!("code={}", st.code));
394                            eprintln!("PvaChannel put error: {msg}");
395                        }
396                    }
397                }
398            }
399        });
400
401        Ok(PvaChannel {
402            writer,
403            sid,
404            ioid,
405            version,
406            is_be,
407            put_desc: desc,
408            echo_token: 1,
409            last_echo: Instant::now(),
410            _reader_handle: reader_handle,
411        })
412    }
413
414    // ─── pvmonitor ───────────────────────────────────────────────────────
415
416    /// Subscribe to a PV and receive live updates via a callback.
417    ///
418    /// The callback returns [`ControlFlow::Continue`] to keep listening or
419    /// [`ControlFlow::Break`] to stop the subscription.
420    ///
421    /// ```rust,ignore
422    /// use std::ops::ControlFlow;
423    ///
424    /// client.pvmonitor("MY:PV", |value| {
425    ///     println!("{value:?}");
426    ///     ControlFlow::Continue(())
427    /// }).await?;
428    /// ```
429    pub async fn pvmonitor<F>(&self, pv_name: &str, callback: F) -> Result<(), PvGetError>
430    where
431        F: FnMut(&DecodedValue) -> ControlFlow<()>,
432    {
433        // Default: subscribe to the entire structure. Use
434        // [`pvmonitor_fields`](Self::pvmonitor_fields) for filtered subscriptions.
435        self.pvmonitor_fields(pv_name, &[], callback).await
436    }
437
438    /// Subscribe to a PV with explicit field selection (dotted paths).
439    ///
440    /// `fields` is the MONITOR pvRequest. Each entry may be a top-level
441    /// field (`"value"`) or a dotted nested path (`"alarm.severity"`). An
442    /// empty slice requests all fields.
443    pub async fn pvmonitor_fields<F>(
444        &self,
445        pv_name: &str,
446        fields: &[&str],
447        mut callback: F,
448    ) -> Result<(), PvGetError>
449    where
450        F: FnMut(&DecodedValue) -> ControlFlow<()>,
451    {
452        let ChannelConn {
453            mut stream,
454            sid,
455            version: _,
456            is_be,
457            ..
458        } = self.open_channel(pv_name).await?;
459
460        let ioid = alloc_ioid();
461        let decoder = PvdDecoder::new(is_be);
462
463        // MONITOR INIT — pvRequest from caller-supplied field paths.
464        let pv_request = build_pv_request(fields, is_be);
465        let init = encode_monitor_request(sid, ioid, QOS_INIT, &pv_request, PVA_VERSION, is_be);
466        stream.write_all(&init).await?;
467
468        // Read INIT response — extract introspection
469        let init_bytes = read_until(&mut stream, self.timeout, |cmd| {
470            matches!(cmd, PvaPacketCommand::Op(op) if op.command == 13 && (op.subcmd & 0x08) != 0)
471        })
472        .await?;
473
474        let field_desc = decode_init_introspection(&init_bytes, "MONITOR")?;
475
476        // Start subscription (non-pipeline: START 0x04 + GET 0x40 = 0x44)
477        let start = encode_monitor_request(sid, ioid, 0x44, &[], PVA_VERSION, is_be);
478        stream.write_all(&start).await?;
479
480        // Event loop — with echo keepalive and timeout resilience
481        let mut echo_interval = interval(Duration::from_secs(10));
482        let mut echo_token: u32 = 1;
483
484        loop {
485            tokio::select! {
486                _ = echo_interval.tick() => {
487                    let msg = encode_control_message(false, is_be, PVA_VERSION, 3, echo_token);
488                    echo_token = echo_token.wrapping_add(1);
489                    let _ = stream.write_all(&msg).await;
490                }
491                res = read_packet(&mut stream, self.timeout) => {
492                    let bytes = match res {
493                        Ok(b) => b,
494                        Err(PvGetError::Timeout(_)) => continue,
495                        Err(e) => return Err(e),
496                    };
497                    let mut pkt = PvaPacket::new(&bytes);
498                    if let Some(PvaPacketCommand::Op(op)) = pkt.decode_payload() {
499                        if op.command == 13 && op.ioid == ioid && op.subcmd == 0x00 {
500                            let payload = &bytes[8..]; // skip header
501                            let pos = 5; // skip ioid(4) + subcmd(1)
502                            if let Some((decoded, _)) =
503                                decoder.decode_structure_with_bitset(&payload[pos..], &field_desc)
504                            {
505                                if callback(&decoded).is_break() {
506                                    return Ok(());
507                                }
508                            }
509                        }
510                    }
511                }
512            }
513        }
514    }
515
516    // ─── pvinfo ──────────────────────────────────────────────────────────
517
518    /// Retrieve the field/structure description (introspection) for a PV.
519    pub async fn pvinfo(&self, pv_name: &str) -> Result<StructureDesc, PvGetError> {
520        let result = self.pvinfo_full(pv_name).await?;
521        Ok(result.0)
522    }
523
524    /// Retrieve introspection and server address for a PV.
525    pub async fn pvinfo_full(
526        &self,
527        pv_name: &str,
528    ) -> Result<(StructureDesc, SocketAddr), PvGetError> {
529        let ChannelConn {
530            mut stream,
531            sid,
532            version: _,
533            is_be,
534            server_addr,
535        } = self.open_channel(pv_name).await?;
536
537        let ioid = alloc_ioid();
538        let msg = encode_get_field_request(sid, ioid, None, PVA_VERSION, is_be);
539        stream.write_all(&msg).await?;
540
541        let resp_bytes = read_until(
542            &mut stream,
543            self.timeout,
544            |cmd| matches!(cmd, PvaPacketCommand::GetField(_)),
545        )
546        .await?;
547
548        let mut pkt = PvaPacket::new(&resp_bytes);
549        let cmd = pkt
550            .decode_payload()
551            .ok_or_else(|| PvGetError::Decode("GET_FIELD response decode failed".to_string()))?;
552        match cmd {
553            PvaPacketCommand::GetField(payload) => {
554                if let Some(ref st) = payload.status {
555                    if st.is_error() {
556                        let msg = st
557                            .message
558                            .clone()
559                            .unwrap_or_else(|| format!("code={}", st.code));
560                        return Err(PvGetError::Protocol(format!("GET_FIELD error: {msg}")));
561                    }
562                }
563                let desc = payload
564                    .introspection
565                    .ok_or_else(|| PvGetError::Decode("missing GET_FIELD introspection".to_string()))?;
566                Ok((desc, server_addr))
567            }
568            _ => Err(PvGetError::Protocol(
569                "unexpected GET_FIELD response".to_string(),
570            )),
571        }
572    }
573
574    // ─── pvlist ──────────────────────────────────────────────────────────
575
576    /// List PV names served by a specific server (via `__pvlist` GET).
577    pub async fn pvlist(&self, server_addr: SocketAddr) -> Result<Vec<String>, PvGetError> {
578        let opts = self.opts("__pvlist");
579        crate::pvlist::pvlist(&opts, server_addr).await
580    }
581
582    /// List PV names with automatic fallback through all strategies.
583    ///
584    /// Tries: `__pvlist` → GET_FIELD (opt-in) → Server RPC → Server GET.
585    pub async fn pvlist_with_fallback(
586        &self,
587        server_addr: SocketAddr,
588    ) -> Result<(Vec<String>, crate::pvlist::PvListSource), PvGetError> {
589        let opts = self.opts("__pvlist");
590        crate::pvlist::pvlist_with_fallback(&opts, server_addr).await
591    }
592}
593
594// ─── PvaChannel ──────────────────────────────────────────────────────────────
595
596/// A persistent PVA channel for high-rate streaming PUT operations.
597///
598/// Created via [`PvaClient::open_put_channel`], this keeps the TCP connection
599/// open and reuses the PUT introspection for repeated writes without
600/// per-operation handshake overhead.
601///
602/// # Example
603///
604/// ```rust,ignore
605/// let client = PvaClient::builder().build();
606/// let mut channel = client.open_put_channel("MY:PV").await?;
607/// for value in 0..100 {
608///     channel.put(value as f64).await?;
609/// }
610/// ```
611pub struct PvaChannel {
612    writer: OwnedWriteHalf,
613    sid: u32,
614    ioid: u32,
615    version: u8,
616    is_be: bool,
617    put_desc: StructureDesc,
618    echo_token: u32,
619    last_echo: Instant,
620    _reader_handle: JoinHandle<()>,
621}
622
623impl PvaChannel {
624    /// Write a value over the persistent channel.
625    ///
626    /// Automatically sends echo keepalive pings when more than 10 seconds
627    /// have elapsed since the last one.
628    pub async fn put(&mut self, value: impl Into<Value>) -> Result<(), PvGetError> {
629        // Echo keepalive
630        if self.last_echo.elapsed() >= Duration::from_secs(10) {
631            let msg = encode_control_message(false, self.is_be, self.version, 3, self.echo_token);
632            self.echo_token = self.echo_token.wrapping_add(1);
633            let _ = self.writer.write_all(&msg).await;
634            self.last_echo = Instant::now();
635        }
636
637        let json_val = value.into();
638        let payload = encode_put_payload(&self.put_desc, &json_val, self.is_be)
639            .map_err(|e| PvGetError::Protocol(format!("put encode: {e}")))?;
640        let req = encode_put_request(
641            self.sid,
642            self.ioid,
643            0x00,
644            &payload,
645            self.version,
646            self.is_be,
647        );
648        self.writer.write_all(&req).await?;
649        Ok(())
650    }
651
652    /// Returns the PUT introspection for this channel.
653    pub fn introspection(&self) -> &StructureDesc {
654        &self.put_desc
655    }
656}
657
658impl Drop for PvaChannel {
659    fn drop(&mut self) {
660        self._reader_handle.abort();
661    }
662}
663
664// ─── Standalone convenience functions ────────────────────────────────────────
665
666/// Write a value to a PV (one-shot).
667///
668/// ```rust,ignore
669/// use spvirit_client::{pvput, PvOptions};
670///
671/// pvput(&PvOptions::new("MY:PV".into()), 42.0).await?;
672/// ```
673pub async fn pvput(opts: &PvOptions, value: impl Into<Value>) -> Result<(), PvGetError> {
674    let client = client_from_opts(opts);
675    client.pvput(&opts.pv_name, value).await
676}
677
678/// Subscribe to a PV and receive live updates (one-shot).
679///
680/// The callback returns [`ControlFlow::Continue`] to keep listening or
681/// [`ControlFlow::Break`] to stop. Subscribes to the full structure;
682/// see [`pvmonitor_fields`] for filtered subscriptions.
683pub async fn pvmonitor<F>(opts: &PvOptions, callback: F) -> Result<(), PvGetError>
684where
685    F: FnMut(&DecodedValue) -> ControlFlow<()>,
686{
687    let client = client_from_opts(opts);
688    client.pvmonitor(&opts.pv_name, callback).await
689}
690
691/// Subscribe to a PV with explicit field selection (dotted paths).
692pub async fn pvmonitor_fields<F>(
693    opts: &PvOptions,
694    fields: &[&str],
695    callback: F,
696) -> Result<(), PvGetError>
697where
698    F: FnMut(&DecodedValue) -> ControlFlow<()>,
699{
700    let client = client_from_opts(opts);
701    client.pvmonitor_fields(&opts.pv_name, fields, callback).await
702}
703
704/// Write a value to a PV with explicit field selection (one-shot).
705pub async fn pvput_fields(
706    opts: &PvOptions,
707    value: impl Into<Value>,
708    fields: &[&str],
709) -> Result<(), PvGetError> {
710    let client = client_from_opts(opts);
711    client.pvput_fields(&opts.pv_name, value, fields).await
712}
713
714/// Retrieve the field/structure description for a PV (one-shot).
715pub async fn pvinfo(opts: &PvOptions) -> Result<StructureDesc, PvGetError> {
716    let client = client_from_opts(opts);
717    client.pvinfo(&opts.pv_name).await
718}
719
720// ─── Internal helpers ────────────────────────────────────────────────────────
721
722/// Build a PvaClient inheriting configuration from PvOptions.
723pub fn client_from_opts(opts: &PvOptions) -> PvaClient {
724    let mut b = PvaClient::builder()
725        .port(opts.tcp_port)
726        .udp_port(opts.udp_port)
727        .timeout(opts.timeout);
728    if opts.no_broadcast {
729        b = b.no_broadcast();
730    }
731    for ns in &opts.name_servers {
732        b = b.name_server(*ns);
733    }
734    if let Some(ref u) = opts.authnz_user {
735        b = b.authnz_user(u.clone());
736    }
737    if let Some(ref h) = opts.authnz_host {
738        b = b.authnz_host(h.clone());
739    }
740    if let Some(addr) = opts.server_addr {
741        b = b.server_addr(addr);
742    }
743    if let Some(addr) = opts.search_addr {
744        b = b.search_addr(addr);
745    }
746    if let Some(addr) = opts.bind_addr {
747        b = b.bind_addr(addr);
748    }
749    if opts.debug {
750        b = b.debug();
751    }
752    b.build()
753}
754
755/// Decode an INIT response to extract the introspection StructureDesc.
756pub fn decode_init_introspection(raw: &[u8], label: &str) -> Result<StructureDesc, PvGetError> {
757    let mut pkt = PvaPacket::new(raw);
758    let cmd = pkt
759        .decode_payload()
760        .ok_or_else(|| PvGetError::Decode(format!("{label} init response decode failed")))?;
761
762    match cmd {
763        PvaPacketCommand::Op(op) => {
764            if let Some(ref st) = op.status {
765                if st.is_error() {
766                    let msg = st
767                        .message
768                        .clone()
769                        .unwrap_or_else(|| format!("code={}", st.code));
770                    return Err(PvGetError::Protocol(format!("{label} init error: {msg}")));
771                }
772            }
773            op.introspection
774                .ok_or_else(|| PvGetError::Decode(format!("missing {label} introspection")))
775        }
776        _ => Err(PvGetError::Protocol(format!(
777            "unexpected {label} init response"
778        ))),
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[test]
787    fn builder_defaults() {
788        let c = PvaClient::builder().build();
789        assert_eq!(c.tcp_port, 5075);
790        assert_eq!(c.udp_port, 5076);
791        assert_eq!(c.timeout, Duration::from_secs(5));
792        assert!(!c.no_broadcast);
793        assert!(c.name_servers.is_empty());
794    }
795
796    #[test]
797    fn builder_overrides() {
798        let c = PvaClient::builder()
799            .port(9075)
800            .udp_port(9076)
801            .timeout(Duration::from_secs(10))
802            .no_broadcast()
803            .name_server("127.0.0.1:5075".parse().unwrap())
804            .authnz_user("testuser")
805            .authnz_host("testhost")
806            .build();
807        assert_eq!(c.tcp_port, 9075);
808        assert_eq!(c.udp_port, 9076);
809        assert_eq!(c.timeout, Duration::from_secs(10));
810        assert!(c.no_broadcast);
811        assert_eq!(c.name_servers.len(), 1);
812        assert_eq!(c.authnz_user.as_deref(), Some("testuser"));
813        assert_eq!(c.authnz_host.as_deref(), Some("testhost"));
814    }
815
816    #[test]
817    fn opts_inherits_client_config() {
818        let c = PvaClient::builder()
819            .port(9075)
820            .udp_port(9076)
821            .timeout(Duration::from_secs(10))
822            .no_broadcast()
823            .build();
824        let o = c.opts("TEST:PV");
825        assert_eq!(o.pv_name, "TEST:PV");
826        assert_eq!(o.tcp_port, 9075);
827        assert_eq!(o.udp_port, 9076);
828        assert_eq!(o.timeout, Duration::from_secs(10));
829        assert!(o.no_broadcast);
830    }
831
832    #[test]
833    fn client_from_opts_roundtrip() {
834        let mut opts = PvOptions::new("X:Y".into());
835        opts.tcp_port = 8075;
836        opts.udp_port = 8076;
837        opts.timeout = Duration::from_secs(3);
838        opts.no_broadcast = true;
839        let c = client_from_opts(&opts);
840        assert_eq!(c.tcp_port, 8075);
841        assert_eq!(c.udp_port, 8076);
842        assert!(c.no_broadcast);
843    }
844
845    #[test]
846    fn pv_get_options_alias_works() {
847        // PvGetOptions is a type alias for PvOptions — verify it compiles and works
848        let opts: crate::types::PvGetOptions = PvOptions::new("ALIAS:TEST".into());
849        assert_eq!(opts.pv_name, "ALIAS:TEST");
850    }
851}