Skip to main content

spvirit/
channel.rs

1//! Reusable PVA channel exposed to Python.
2//!
3//! A `Channel` holds a single established TCP connection to a PVA server
4//! for a single PV name and lets callers perform repeated `get` / `put` /
5//! `monitor` / `introspect` operations over it.  Provides both sync and
6//! async (`_async`) variants.
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::time::Duration;
11
12use pyo3::prelude::*;
13use tokio::io::AsyncWriteExt;
14use tokio::sync::Mutex;
15
16use spvirit_client::client::{
17    ChannelConn, encode_get_request, encode_monitor_request, encode_put_request, ensure_status_ok,
18    establish_channel,
19};
20use spvirit_client::pva_client::decode_init_introspection;
21use spvirit_client::transport::{read_packet, read_until};
22use spvirit_client::types::{PvGetError, PvGetOptions};
23use spvirit_codec::epics_decode::{PvaPacket, PvaPacketCommand};
24use spvirit_codec::spvd_decode::PvdDecoder;
25use spvirit_codec::spvd_encode::encode_pv_request;
26use spvirit_codec::spvirit_encode::encode_control_message;
27
28use crate::codec::PyStructureDesc;
29use crate::convert::{decoded_to_py, py_to_json};
30use crate::errors::to_py_err;
31use crate::runtime::{block_on_py, future_into_py};
32
33const PVA_VERSION: u8 = 2;
34const QOS_INIT: u8 = 0x08;
35
36/// Build the pvRequest body for a channel operation.  An empty `fields`
37/// slice yields the canonical "all fields" pvRequest bytes; otherwise we
38/// delegate to [`encode_pv_request`] (which supports dotted nested paths).
39fn build_pv_request(fields: &[String], is_be: bool) -> Vec<u8> {
40    if fields.is_empty() {
41        vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
42    } else {
43        let refs: Vec<&str> = fields.iter().map(String::as_str).collect();
44        encode_pv_request(&refs, is_be)
45    }
46}
47
48/// Normalise a user-supplied `fields` argument into a `Vec<String>`.
49///
50/// Accepts `None` -> empty, a single string (treated as a one-entry list;
51/// this preserves backwards compatibility with the previous `field: str`
52/// kwarg), or an iterable of strings.
53fn normalize_fields(py: Python<'_>, fields: Option<PyObject>) -> PyResult<Vec<String>> {
54    let Some(obj) = fields else {
55        return Ok(Vec::new());
56    };
57    let bound = obj.bind(py);
58    if let Ok(s) = bound.extract::<String>() {
59        return Ok(vec![s]);
60    }
61    bound.extract::<Vec<String>>()
62}
63
64/// Shared mutable state for a `Channel`.  Option-ized so `close()` can
65/// drop the TCP stream while leaving the Python handle alive.
66struct ChannelState {
67    conn: Option<ChannelConn>,
68    pv_name: String,
69    timeout: Duration,
70    next_ioid: u32,
71}
72
73impl ChannelState {
74    fn alloc_ioid(&mut self) -> u32 {
75        let v = self.next_ioid;
76        self.next_ioid = self.next_ioid.wrapping_add(1).max(1);
77        v
78    }
79
80    fn conn_mut(&mut self) -> Result<&mut ChannelConn, PvGetError> {
81        self.conn
82            .as_mut()
83            .ok_or_else(|| PvGetError::Protocol("channel is closed".to_string()))
84    }
85}
86
87#[pyclass(name = "Channel", module = "spvirit.lowlevel")]
88pub struct PyChannel {
89    state: Arc<Mutex<ChannelState>>,
90}
91
92fn parse_addr(addr: &str) -> PyResult<SocketAddr> {
93    addr.parse()
94        .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid address: {e}")))
95}
96
97async fn do_connect(
98    pv_name: String,
99    server_addr: SocketAddr,
100    timeout: Duration,
101) -> Result<ChannelState, PvGetError> {
102    let mut opts = PvGetOptions::new(pv_name.clone());
103    opts.timeout = timeout;
104    opts.server_addr = Some(server_addr);
105    let conn = establish_channel(server_addr, &opts).await?;
106    Ok(ChannelState {
107        conn: Some(conn),
108        pv_name,
109        timeout,
110        next_ioid: 1,
111    })
112}
113
114async fn run_get(
115    state: Arc<Mutex<ChannelState>>,
116    fields: Vec<String>,
117) -> Result<
118    (
119        String,
120        spvirit_codec::spvd_decode::DecodedValue,
121        Vec<u8>,
122        Vec<u8>,
123    ),
124    PvGetError,
125> {
126    let mut guard = state.lock().await;
127    let timeout = guard.timeout;
128    let ioid = guard.alloc_ioid();
129    let pv_name = guard.pv_name.clone();
130    let conn = guard.conn_mut()?;
131    let is_be = conn.is_be;
132    let version = conn.version;
133    let sid = conn.sid;
134
135    let pv_request = if fields.is_empty() {
136        vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
137    } else {
138        build_pv_request(&fields, is_be)
139    };
140
141    let get_init = encode_get_request(sid, ioid, QOS_INIT, &pv_request, version, is_be);
142    conn.stream.write_all(&get_init).await?;
143
144    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
145        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 10 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
146    })
147    .await?;
148    let desc = decode_init_introspection(&init_resp, "GET")?;
149
150    let get_data = encode_get_request(sid, ioid, 0x00, &[], version, is_be);
151    conn.stream.write_all(&get_data).await?;
152
153    let data_resp = read_until(&mut conn.stream, timeout, |cmd| {
154        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 10 && op.ioid == ioid && op.subcmd == 0x00)
155    })
156    .await?;
157    let mut pkt = PvaPacket::new(&data_resp);
158    let cmd = pkt
159        .decode_payload()
160        .ok_or_else(|| PvGetError::Protocol("get data decode failed".to_string()))?;
161    match cmd {
162        PvaPacketCommand::Op(mut op) => {
163            op.decode_with_field_desc(&desc, is_be);
164            let value = op
165                .decoded_value
166                .ok_or_else(|| PvGetError::Decode("no decoded value".to_string()))?;
167            Ok((pv_name, value, data_resp, op.body))
168        }
169        _ => Err(PvGetError::Protocol(
170            "unexpected get data response".to_string(),
171        )),
172    }
173}
174
175async fn run_put(
176    state: Arc<Mutex<ChannelState>>,
177    json_val: serde_json::Value,
178    fields: Vec<String>,
179) -> Result<(), PvGetError> {
180    let mut guard = state.lock().await;
181    let timeout = guard.timeout;
182    let ioid = guard.alloc_ioid();
183    let conn = guard.conn_mut()?;
184    let is_be = conn.is_be;
185    let sid = conn.sid;
186
187    let pv_request = build_pv_request(&fields, is_be);
188    let init = encode_put_request(sid, ioid, QOS_INIT, &pv_request, PVA_VERSION, is_be);
189    conn.stream.write_all(&init).await?;
190
191    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
192        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 11 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
193    })
194    .await?;
195    let desc = decode_init_introspection(&init_resp, "PUT")?;
196
197    let payload = spvirit_client::put_encode::encode_put_payload(&desc, &json_val, is_be)
198        .map_err(|e| PvGetError::Protocol(format!("put encode: {e}")))?;
199    let req = encode_put_request(sid, ioid, 0x00, &payload, PVA_VERSION, is_be);
200    conn.stream.write_all(&req).await?;
201
202    let resp = read_until(&mut conn.stream, timeout, |cmd| {
203        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 11 && op.ioid == ioid && op.subcmd == 0x00)
204    })
205    .await?;
206    ensure_status_ok(&resp, is_be, "PUT")?;
207    Ok(())
208}
209
210async fn run_introspect(
211    state: Arc<Mutex<ChannelState>>,
212) -> Result<spvirit_codec::spvd_decode::StructureDesc, PvGetError> {
213    // Cheapest way: do GET INIT only and return the introspection.
214    let mut guard = state.lock().await;
215    let timeout = guard.timeout;
216    let ioid = guard.alloc_ioid();
217    let conn = guard.conn_mut()?;
218    let is_be = conn.is_be;
219    let version = conn.version;
220    let sid = conn.sid;
221
222    let pv_request = vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00];
223    let init = encode_get_request(sid, ioid, QOS_INIT, &pv_request, version, is_be);
224    conn.stream.write_all(&init).await?;
225    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
226        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 10 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
227    })
228    .await?;
229    decode_init_introspection(&init_resp, "GET")
230}
231
232/// Monitor loop running against the persistent channel.  Invokes
233/// `callback(decoded_value_py)` inside the GIL; stops when the callback
234/// returns a falsy value or raises.
235async fn run_monitor(
236    state: Arc<Mutex<ChannelState>>,
237    callback: PyObject,
238    fields: Vec<String>,
239) -> Result<(), PvGetError> {
240    let mut guard = state.lock().await;
241    let timeout = guard.timeout;
242    let ioid = guard.alloc_ioid();
243    let conn = guard.conn_mut()?;
244    let is_be = conn.is_be;
245    let sid = conn.sid;
246
247    let decoder = PvdDecoder::new(is_be);
248    let pv_request = build_pv_request(&fields, is_be);
249    let init = encode_monitor_request(sid, ioid, QOS_INIT, &pv_request, PVA_VERSION, is_be);
250    conn.stream.write_all(&init).await?;
251
252    let init_resp = read_until(&mut conn.stream, timeout, |cmd| {
253        matches!(cmd, PvaPacketCommand::Op(op) if op.command == 13 && op.ioid == ioid && (op.subcmd & 0x08) != 0)
254    })
255    .await?;
256    let field_desc = decode_init_introspection(&init_resp, "MONITOR")?;
257
258    let start = encode_monitor_request(sid, ioid, 0x44, &[], PVA_VERSION, is_be);
259    conn.stream.write_all(&start).await?;
260
261    let mut echo_interval = tokio::time::interval(Duration::from_secs(10));
262    echo_interval.tick().await; // skip immediate tick
263    let mut echo_token: u32 = 1;
264
265    loop {
266        tokio::select! {
267            _ = echo_interval.tick() => {
268                let msg = encode_control_message(false, is_be, PVA_VERSION, 3, echo_token);
269                echo_token = echo_token.wrapping_add(1);
270                let _ = conn.stream.write_all(&msg).await;
271            }
272            res = read_packet(&mut conn.stream, timeout) => {
273                let bytes = match res {
274                    Ok(b) => b,
275                    Err(PvGetError::Timeout(_)) => continue,
276                    Err(e) => return Err(e),
277                };
278                let mut pkt = PvaPacket::new(&bytes);
279                if let Some(PvaPacketCommand::Op(op)) = pkt.decode_payload() {
280                    if op.command == 13 && op.ioid == ioid && op.subcmd == 0x00 {
281                        let payload = &bytes[8..];
282                        let pos = 5;
283                        if let Some((decoded, _)) =
284                            decoder.decode_structure_with_bitset(&payload[pos..], &field_desc)
285                        {
286                            let keep_going = Python::with_gil(|py| {
287                                let v = decoded_to_py(py, &decoded);
288                                match callback.call1(py, (v,)) {
289                                    Ok(ret) => ret.extract::<bool>(py).unwrap_or(true),
290                                    Err(_) => false,
291                                }
292                            });
293                            if !keep_going {
294                                return Ok(());
295                            }
296                        }
297                    }
298                }
299            }
300        }
301    }
302}
303
304#[pymethods]
305impl PyChannel {
306    /// Connect to `server_addr` and establish a channel for `pv_name`.
307    #[staticmethod]
308    #[pyo3(signature = (pv_name, server_addr, timeout=5.0))]
309    fn connect(
310        py: Python<'_>,
311        pv_name: String,
312        server_addr: String,
313        timeout: f64,
314    ) -> PyResult<PyChannel> {
315        let sa = parse_addr(&server_addr)?;
316        let dur = Duration::from_secs_f64(timeout);
317        let state = block_on_py(py, do_connect(pv_name, sa, dur)).map_err(to_py_err)?;
318        Ok(PyChannel {
319            state: Arc::new(Mutex::new(state)),
320        })
321    }
322
323    /// Async variant of [`connect`].
324    #[staticmethod]
325    #[pyo3(signature = (pv_name, server_addr, timeout=5.0))]
326    fn connect_async<'py>(
327        py: Python<'py>,
328        pv_name: String,
329        server_addr: String,
330        timeout: f64,
331    ) -> PyResult<Bound<'py, PyAny>> {
332        let sa = parse_addr(&server_addr)?;
333        let dur = Duration::from_secs_f64(timeout);
334        future_into_py(py, async move {
335            let state = do_connect(pv_name, sa, dur).await.map_err(to_py_err)?;
336            Ok(PyChannel {
337                state: Arc::new(Mutex::new(state)),
338            })
339        })
340    }
341
342    #[getter]
343    fn pv_name(&self, py: Python<'_>) -> String {
344        py.allow_threads(|| {
345            let guard = self.state.blocking_lock();
346            guard.pv_name.clone()
347        })
348    }
349
350    #[getter]
351    fn is_open(&self, py: Python<'_>) -> bool {
352        py.allow_threads(|| {
353            let guard = self.state.blocking_lock();
354            guard.conn.is_some()
355        })
356    }
357
358    #[getter]
359    fn server_addr(&self, py: Python<'_>) -> Option<String> {
360        py.allow_threads(|| {
361            let guard = self.state.blocking_lock();
362            guard.conn.as_ref().map(|c| c.server_addr.to_string())
363        })
364    }
365
366    #[getter]
367    fn sid(&self, py: Python<'_>) -> Option<u32> {
368        py.allow_threads(|| {
369            let guard = self.state.blocking_lock();
370            guard.conn.as_ref().map(|c| c.sid)
371        })
372    }
373
374    /// Fetch the current value (blocking).  Returns a dict mirroring the
375    /// NT structure.
376    #[pyo3(signature = (fields=None))]
377    fn get(
378        &self,
379        py: Python<'_>,
380        fields: Option<Vec<String>>,
381    ) -> PyResult<crate::client::PyGetResult> {
382        let state = self.state.clone();
383        let fields = fields.unwrap_or_default();
384        let (pv_name, value, raw_pva, raw_pvd) =
385            block_on_py(py, run_get(state, fields)).map_err(to_py_err)?;
386        let py_val = decoded_to_py(py, &value);
387        Ok(crate::client::PyGetResult::new(
388            pv_name, py_val, raw_pva, raw_pvd,
389        ))
390    }
391
392    #[pyo3(signature = (fields=None))]
393    fn get_async<'py>(
394        &self,
395        py: Python<'py>,
396        fields: Option<Vec<String>>,
397    ) -> PyResult<Bound<'py, PyAny>> {
398        let state = self.state.clone();
399        let fields = fields.unwrap_or_default();
400        future_into_py(py, async move {
401            let (pv_name, value, raw_pva, raw_pvd) =
402                run_get(state, fields).await.map_err(to_py_err)?;
403            Python::with_gil(|py| {
404                let py_val = decoded_to_py(py, &value);
405                Ok(Py::new(
406                    py,
407                    crate::client::PyGetResult::new(pv_name, py_val, raw_pva, raw_pvd),
408                )?)
409            })
410        })
411    }
412
413    /// Write a value (blocking).
414    ///
415    /// `fields` selects which pvRequest fields are targeted. Defaults to
416    /// `["value"]` when omitted. A single string is still accepted for
417    /// backwards compatibility with earlier versions of this binding.
418    #[pyo3(signature = (value, fields=None))]
419    fn put(&self, py: Python<'_>, value: PyObject, fields: Option<PyObject>) -> PyResult<()> {
420        let state = self.state.clone();
421        let json = py_to_json(value.bind(py))?;
422        let fields = normalize_fields(py, fields)?;
423        block_on_py(py, run_put(state, json, fields)).map_err(to_py_err)
424    }
425
426    #[pyo3(signature = (value, fields=None))]
427    fn put_async<'py>(
428        &self,
429        py: Python<'py>,
430        value: PyObject,
431        fields: Option<PyObject>,
432    ) -> PyResult<Bound<'py, PyAny>> {
433        let state = self.state.clone();
434        let json = py_to_json(value.bind(py))?;
435        let fields = normalize_fields(py, fields)?;
436        future_into_py(py, async move {
437            run_put(state, json, fields).await.map_err(to_py_err)?;
438            Ok(())
439        })
440    }
441
442    /// Retrieve the PV's structure description (INIT exchange only).
443    fn introspect(&self, py: Python<'_>) -> PyResult<PyStructureDesc> {
444        let state = self.state.clone();
445        let desc = block_on_py(py, run_introspect(state)).map_err(to_py_err)?;
446        Ok(PyStructureDesc::from_inner(desc))
447    }
448
449    fn introspect_async<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
450        let state = self.state.clone();
451        future_into_py(py, async move {
452            let desc = run_introspect(state).await.map_err(to_py_err)?;
453            Python::with_gil(|py| Py::new(py, PyStructureDesc::from_inner(desc)))
454        })
455    }
456
457    /// Subscribe and block until `callback(value)` returns False or
458    /// raises an exception.
459    ///
460    /// `fields` restricts the subscription to the given dotted paths.
461    #[pyo3(signature = (callback, fields=None))]
462    fn monitor(
463        &self,
464        py: Python<'_>,
465        callback: PyObject,
466        fields: Option<Vec<String>>,
467    ) -> PyResult<()> {
468        let state = self.state.clone();
469        let fields = fields.unwrap_or_default();
470        let _ = py;
471        crate::runtime::RUNTIME
472            .block_on(run_monitor(state, callback, fields))
473            .map_err(to_py_err)
474    }
475
476    /// Close the underlying TCP stream.  Subsequent operations raise
477    /// ProtocolError.
478    fn close(&self, py: Python<'_>) -> PyResult<()> {
479        py.allow_threads(|| {
480            let mut guard = self.state.blocking_lock();
481            guard.conn.take();
482        });
483        Ok(())
484    }
485
486    fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
487        slf
488    }
489
490    #[pyo3(signature = (_exc_type=None, _exc=None, _tb=None))]
491    fn __exit__(
492        &self,
493        py: Python<'_>,
494        _exc_type: Option<PyObject>,
495        _exc: Option<PyObject>,
496        _tb: Option<PyObject>,
497    ) -> PyResult<bool> {
498        self.close(py)?;
499        Ok(false)
500    }
501
502    fn __repr__(&self, py: Python<'_>) -> String {
503        py.allow_threads(|| {
504            let guard = self.state.blocking_lock();
505            let addr = guard
506                .conn
507                .as_ref()
508                .map(|c| c.server_addr.to_string())
509                .unwrap_or_else(|| "<closed>".to_string());
510            format!("Channel(pv_name={:?}, server={})", guard.pv_name, addr)
511        })
512    }
513
514    /// Read the next PVA frame off the wire and return a `Packet`.
515    #[pyo3(signature = (timeout=None))]
516    fn read_packet(
517        &self,
518        py: Python<'_>,
519        timeout: Option<f64>,
520    ) -> PyResult<crate::packet::PyPacket> {
521        let state = self.state.clone();
522        let override_timeout = timeout.map(Duration::from_secs_f64);
523        let bytes = block_on_py(py, async move {
524            let mut guard = state.lock().await;
525            let t = override_timeout.unwrap_or(guard.timeout);
526            let conn = guard.conn_mut()?;
527            read_packet(&mut conn.stream, t).await
528        })
529        .map_err(to_py_err)?;
530        Ok(crate::packet::PyPacket::from_bytes(bytes))
531    }
532
533    #[pyo3(signature = (timeout=None))]
534    fn read_packet_async<'py>(
535        &self,
536        py: Python<'py>,
537        timeout: Option<f64>,
538    ) -> PyResult<Bound<'py, PyAny>> {
539        let state = self.state.clone();
540        let override_timeout = timeout.map(Duration::from_secs_f64);
541        future_into_py(py, async move {
542            let bytes = {
543                let mut guard = state.lock().await;
544                let t = override_timeout.unwrap_or(guard.timeout);
545                let conn = guard.conn_mut().map_err(to_py_err)?;
546                read_packet(&mut conn.stream, t).await.map_err(to_py_err)?
547            };
548            Python::with_gil(|py| Py::new(py, crate::packet::PyPacket::from_bytes(bytes)))
549        })
550    }
551
552    /// Read frames until `predicate(packet)` returns truthy, then return
553    /// that packet.  `predicate` may be any Python callable.
554    #[pyo3(signature = (predicate, timeout=None, max_frames=None))]
555    fn read_until(
556        &self,
557        py: Python<'_>,
558        predicate: PyObject,
559        timeout: Option<f64>,
560        max_frames: Option<usize>,
561    ) -> PyResult<crate::packet::PyPacket> {
562        let state = self.state.clone();
563        let override_timeout = timeout.map(Duration::from_secs_f64);
564        let max = max_frames.unwrap_or(usize::MAX);
565        let mut seen = 0usize;
566        loop {
567            if seen >= max {
568                return Err(pyo3::exceptions::PyRuntimeError::new_err(
569                    "read_until: max_frames reached",
570                ));
571            }
572            seen += 1;
573            let bytes = {
574                let state = state.clone();
575                block_on_py(py, async move {
576                    let mut guard = state.lock().await;
577                    let t = override_timeout.unwrap_or(guard.timeout);
578                    let conn = guard.conn_mut()?;
579                    read_packet(&mut conn.stream, t).await
580                })
581                .map_err(to_py_err)?
582            };
583            let pkt = crate::packet::PyPacket::from_bytes(bytes);
584            let (matched, pkt_back) = Python::with_gil(|py| -> PyResult<(bool, _)> {
585                let pkt_obj = Py::new(py, pkt)?;
586                let result = predicate.call1(py, (pkt_obj.clone_ref(py),))?;
587                let matched = result.extract::<bool>(py).unwrap_or(false);
588                Ok((matched, pkt_obj))
589            })?;
590            if matched {
591                // Move the inner PyPacket back out.
592                let pkt: crate::packet::PyPacket = Python::with_gil(|py| -> PyResult<_> {
593                    let bound = pkt_back.bind(py);
594                    let data: Vec<u8> = bound.borrow().raw().to_vec();
595                    Ok(crate::packet::PyPacket::from_bytes(data))
596                })?;
597                return Ok(pkt);
598            }
599        }
600    }
601}
602
603/// Register the `spvirit.lowlevel` submodule and add `Channel`.
604pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> {
605    let py = parent.py();
606    let m = PyModule::new(py, "lowlevel")?;
607    m.add_class::<PyChannel>()?;
608    m.add_class::<crate::packet::PyPacket>()?;
609    crate::discovery::register(&m)?;
610    parent.add_submodule(&m)?;
611    py.import("sys")?
612        .getattr("modules")?
613        .set_item("spvirit.lowlevel", &m)?;
614    Ok(())
615}