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