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