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