Skip to main content

spvirit/
client.rs

1//! Python client wrappers — sync-only for phase 1.
2
3use std::net::SocketAddr;
4use std::ops::ControlFlow;
5use std::time::Duration;
6
7use pyo3::prelude::*;
8use pyo3::types::{PyDict, PyList};
9
10use spvirit_client::pva_client::PvaClient;
11use spvirit_client::search::{build_auto_broadcast_targets, discover_servers};
12
13
14use crate::convert::{decoded_to_py, py_to_json};
15use crate::errors::to_py_err;
16use crate::runtime::RUNTIME;
17
18// ─── GetResult ───────────────────────────────────────────────────────────────
19
20#[pyclass(name = "GetResult")]
21pub struct PyGetResult {
22    #[pyo3(get)]
23    pub pv_name: String,
24    value: PyObject,
25    #[pyo3(get)]
26    pub raw_pva: Vec<u8>,
27    #[pyo3(get)]
28    pub raw_pvd: Vec<u8>,
29}
30
31impl PyGetResult {
32    pub(crate) fn new(pv_name: String, value: PyObject, raw_pva: Vec<u8>, raw_pvd: Vec<u8>) -> Self {
33        Self {
34            pv_name,
35            value,
36            raw_pva,
37            raw_pvd,
38        }
39    }
40}
41
42#[pymethods]
43impl PyGetResult {
44    #[getter]
45    fn value(&self, py: Python<'_>) -> PyObject {
46        self.value.clone_ref(py)
47    }
48
49    fn __repr__(&self) -> String {
50        format!("GetResult(pv_name={:?})", self.pv_name)
51    }
52}
53
54// ─── MonitorEvent ────────────────────────────────────────────────────────────
55
56#[pyclass(name = "MonitorEvent")]
57pub struct PyMonitorEvent {
58    #[pyo3(get)]
59    pub pv_name: String,
60    value: PyObject,
61}
62
63#[pymethods]
64impl PyMonitorEvent {
65    #[getter]
66    fn value(&self, py: Python<'_>) -> PyObject {
67        self.value.clone_ref(py)
68    }
69
70    fn __repr__(&self) -> String {
71        format!("MonitorEvent(pv_name={:?})", self.pv_name)
72    }
73}
74
75// ─── DiscoveredServer ────────────────────────────────────────────────────────
76
77#[pyclass(name = "DiscoveredServer")]
78#[derive(Clone)]
79pub struct PyDiscoveredServer {
80    #[pyo3(get)]
81    pub guid: Vec<u8>,
82    #[pyo3(get)]
83    pub tcp_addr: String,
84}
85
86#[pymethods]
87impl PyDiscoveredServer {
88    fn __repr__(&self) -> String {
89        format!("DiscoveredServer(tcp_addr={:?})", self.tcp_addr)
90    }
91}
92
93// ─── ClientBuilder ───────────────────────────────────────────────────────────
94
95#[pyclass(name = "ClientBuilder")]
96pub struct PyClientBuilder {
97    udp_port: u16,
98    tcp_port: u16,
99    timeout_secs: f64,
100    no_broadcast: bool,
101    name_servers: Vec<String>,
102    authnz_user: Option<String>,
103    authnz_host: Option<String>,
104    server_addr: Option<String>,
105    search_addr: Option<String>,
106    bind_addr: Option<String>,
107    debug: bool,
108}
109
110#[pymethods]
111impl PyClientBuilder {
112    #[new]
113    fn new() -> Self {
114        Self {
115            udp_port: 5076,
116            tcp_port: 5075,
117            timeout_secs: 5.0,
118            no_broadcast: false,
119            name_servers: Vec::new(),
120            authnz_user: None,
121            authnz_host: None,
122            server_addr: None,
123            search_addr: None,
124            bind_addr: None,
125            debug: false,
126        }
127    }
128
129    fn port(mut slf: PyRefMut<'_, Self>, port: u16) -> PyRefMut<'_, Self> {
130        slf.tcp_port = port;
131        slf
132    }
133
134    fn udp_port(mut slf: PyRefMut<'_, Self>, port: u16) -> PyRefMut<'_, Self> {
135        slf.udp_port = port;
136        slf
137    }
138
139    fn timeout(mut slf: PyRefMut<'_, Self>, secs: f64) -> PyRefMut<'_, Self> {
140        slf.timeout_secs = secs;
141        slf
142    }
143
144    fn no_broadcast(mut slf: PyRefMut<'_, Self>, enabled: bool) -> PyRefMut<'_, Self> {
145        slf.no_broadcast = enabled;
146        slf
147    }
148
149    fn name_server(mut slf: PyRefMut<'_, Self>, addr: String) -> PyResult<PyRefMut<'_, Self>> {
150        let _: SocketAddr = addr
151            .parse()
152            .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid address: {e}")))?;
153        slf.name_servers.push(addr);
154        Ok(slf)
155    }
156
157    fn authnz_user(mut slf: PyRefMut<'_, Self>, user: String) -> PyRefMut<'_, Self> {
158        slf.authnz_user = Some(user);
159        slf
160    }
161
162    fn authnz_host(mut slf: PyRefMut<'_, Self>, host: String) -> PyRefMut<'_, Self> {
163        slf.authnz_host = Some(host);
164        slf
165    }
166
167    fn server_addr(mut slf: PyRefMut<'_, Self>, addr: String) -> PyResult<PyRefMut<'_, Self>> {
168        let _: SocketAddr = addr
169            .parse()
170            .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid address: {e}")))?;
171        slf.server_addr = Some(addr);
172        Ok(slf)
173    }
174
175    fn search_addr(mut slf: PyRefMut<'_, Self>, addr: String) -> PyRefMut<'_, Self> {
176        slf.search_addr = Some(addr);
177        slf
178    }
179
180    fn bind_addr(mut slf: PyRefMut<'_, Self>, addr: String) -> PyRefMut<'_, Self> {
181        slf.bind_addr = Some(addr);
182        slf
183    }
184
185    fn debug(mut slf: PyRefMut<'_, Self>, enabled: bool) -> PyRefMut<'_, Self> {
186        slf.debug = enabled;
187        slf
188    }
189
190    fn build(&self) -> PyResult<PyClient> {
191        let mut b = PvaClient::builder()
192            .port(self.tcp_port)
193            .udp_port(self.udp_port)
194            .timeout(Duration::from_secs_f64(self.timeout_secs));
195        if self.no_broadcast {
196            b = b.no_broadcast();
197        }
198        for ns in &self.name_servers {
199            let addr: SocketAddr = ns.parse().map_err(|e| {
200                pyo3::exceptions::PyValueError::new_err(format!("invalid address: {e}"))
201            })?;
202            b = b.name_server(addr);
203        }
204        if let Some(ref user) = self.authnz_user {
205            b = b.authnz_user(user);
206        }
207        if let Some(ref host) = self.authnz_host {
208            b = b.authnz_host(host);
209        }
210        if let Some(ref addr) = self.server_addr {
211            let sa: SocketAddr = addr.parse().map_err(|e| {
212                pyo3::exceptions::PyValueError::new_err(format!("invalid address: {e}"))
213            })?;
214            b = b.server_addr(sa);
215        }
216        if let Some(ref addr) = self.search_addr {
217            let ip: std::net::IpAddr = addr.parse().map_err(|e| {
218                pyo3::exceptions::PyValueError::new_err(format!("invalid IP: {e}"))
219            })?;
220            b = b.search_addr(ip);
221        }
222        if let Some(ref addr) = self.bind_addr {
223            let ip: std::net::IpAddr = addr.parse().map_err(|e| {
224                pyo3::exceptions::PyValueError::new_err(format!("invalid IP: {e}"))
225            })?;
226            b = b.bind_addr(ip);
227        }
228        if self.debug {
229            b = b.debug();
230        }
231        Ok(PyClient {
232            inner: b.build(),
233        })
234    }
235}
236
237// ─── Client ──────────────────────────────────────────────────────────────────
238
239#[pyclass(name = "Client")]
240pub struct PyClient {
241    inner: PvaClient,
242}
243
244#[pymethods]
245impl PyClient {
246    #[new]
247    fn new() -> Self {
248        Self {
249            inner: PvaClient::builder().build(),
250        }
251    }
252
253    /// Create a builder for fine-grained configuration.
254    #[staticmethod]
255    fn builder() -> PyClientBuilder {
256        PyClientBuilder::new()
257    }
258
259    /// Fetch the current value of a PV (blocking).
260    ///
261    /// If `fields` is provided, the pvRequest restricts the returned
262    /// structure to those dotted paths (e.g. `["value", "alarm.severity"]`).
263    #[pyo3(signature = (pv_name, fields=None))]
264    fn get(
265        &self,
266        py: Python<'_>,
267        pv_name: String,
268        fields: Option<Vec<String>>,
269    ) -> PyResult<PyGetResult> {
270        let client = self.inner.clone();
271        let result = py
272            .allow_threads(|| {
273                RUNTIME.block_on(async {
274                    match fields {
275                        None => client.pvget(&pv_name).await,
276                        Some(ref f) => {
277                            let refs: Vec<&str> = f.iter().map(String::as_str).collect();
278                            client.pvget_fields(&pv_name, &refs).await
279                        }
280                    }
281                })
282            })
283            .map_err(to_py_err)?;
284        let value = decoded_to_py(py, &result.value);
285        Ok(PyGetResult {
286            pv_name: result.pv_name,
287            value,
288            raw_pva: result.raw_pva,
289            raw_pvd: result.raw_pvd,
290        })
291    }
292
293    /// Write a value to a PV (blocking).
294    ///
295    /// `fields` selects which pvRequest fields are targeted. Defaults to
296    /// `["value"]` when omitted.
297    #[pyo3(signature = (pv_name, value, fields=None))]
298    fn put(
299        &self,
300        py: Python<'_>,
301        pv_name: String,
302        value: PyObject,
303        fields: Option<Vec<String>>,
304    ) -> PyResult<()> {
305        let json_val = py_to_json(value.bind(py))?;
306        let client = self.inner.clone();
307        py.allow_threads(|| {
308            RUNTIME.block_on(async {
309                match fields {
310                    None => client.pvput(&pv_name, json_val).await,
311                    Some(ref f) => {
312                        let refs: Vec<&str> = f.iter().map(String::as_str).collect();
313                        client.pvput_fields(&pv_name, json_val, &refs).await
314                    }
315                }
316            })
317        })
318        .map_err(to_py_err)
319    }
320
321    /// Subscribe to a PV and call `callback(value_dict)` for each update.
322    ///
323    /// Blocks until the callback returns `False` or raises an exception.
324    /// `fields` restricts the subscription to the given dotted paths.
325    #[pyo3(signature = (pv_name, callback, fields=None))]
326    fn monitor(
327        &self,
328        _py: Python<'_>,
329        pv_name: String,
330        callback: PyObject,
331        fields: Option<Vec<String>>,
332    ) -> PyResult<()> {
333        let client = self.inner.clone();
334        let fields = fields.unwrap_or_default();
335        // We need the GIL inside the callback, so we cannot use allow_threads
336        // for the entire operation. Instead we spawn on the runtime and
337        // use Python::with_gil inside the callback.
338        let result = RUNTIME.block_on(async {
339            let refs: Vec<&str> = fields.iter().map(String::as_str).collect();
340            client
341                .pvmonitor_fields(&pv_name, &refs, |decoded| {
342                    let keep_going = Python::with_gil(|py| {
343                        let py_val = decoded_to_py(py, decoded);
344                        match callback.call1(py, (py_val,)) {
345                            Ok(ret) => {
346                                // If callback returns False, stop
347                                ret.extract::<bool>(py).unwrap_or(true)
348                            }
349                            Err(_) => false,
350                        }
351                    });
352                    if keep_going {
353                        ControlFlow::Continue(())
354                    } else {
355                        ControlFlow::Break(())
356                    }
357                })
358                .await
359        });
360        // Release the GIL while we were waiting
361        result.map_err(to_py_err)
362    }
363
364    /// Retrieve introspection (field description) for a PV.
365    fn info(&self, py: Python<'_>, pv_name: String) -> PyResult<PyObject> {
366        let client = self.inner.clone();
367        let desc = py
368            .allow_threads(|| RUNTIME.block_on(client.pvinfo(&pv_name)))
369            .map_err(to_py_err)?;
370        // Return as a dict: {struct_id, fields: [{name, field_type}, ...]}
371        let dict = PyDict::new(py);
372        dict.set_item("struct_id", &desc.struct_id)?;
373        let fields: Vec<PyObject> = desc
374            .fields
375            .iter()
376            .map(|f| {
377                let fd = PyDict::new(py);
378                fd.set_item("name", &f.name).expect("set");
379                fd.set_item("field_type", format!("{:?}", f.field_type))
380                    .expect("set");
381                fd.into_any().unbind()
382            })
383            .collect();
384        dict.set_item("fields", PyList::new(py, &fields)?)?;
385        Ok(dict.into_any().unbind())
386    }
387
388    /// List PV names from a specific server.
389    fn pvlist(&self, py: Python<'_>, server_addr: String) -> PyResult<Vec<String>> {
390        let addr: SocketAddr = server_addr
391            .parse()
392            .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid address: {e}")))?;
393        let client = self.inner.clone();
394        py.allow_threads(|| RUNTIME.block_on(client.pvlist(addr)))
395            .map_err(to_py_err)
396    }
397}
398
399// ─── discover_servers ────────────────────────────────────────────────────────
400
401/// Discover PVA servers on the network via UDP beacon search.
402#[pyfunction]
403#[pyo3(signature = (udp_port=5076, timeout=2.0, debug=false))]
404pub fn py_discover_servers(
405    py: Python<'_>,
406    udp_port: u16,
407    timeout: f64,
408    debug: bool,
409) -> PyResult<Vec<PyDiscoveredServer>> {
410    let targets = build_auto_broadcast_targets();
411    let dur = Duration::from_secs_f64(timeout);
412    let servers = py
413        .allow_threads(|| RUNTIME.block_on(discover_servers(udp_port, dur, &targets, debug)))
414        .map_err(to_py_err)?;
415    Ok(servers
416        .into_iter()
417        .map(|s| PyDiscoveredServer {
418            guid: s.guid.to_vec(),
419            tcp_addr: s.tcp_addr.to_string(),
420        })
421        .collect())
422}