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