Skip to main content

spvirit/
codec.rs

1//! Python wrappers for the standalone `spvirit-codec` encoder/decoder.
2//!
3//! Exposes introspection types (`FieldDesc`, `StructureDesc`), packet-level
4//! decoding of `pvData` buffers, and encoding helpers usable without any
5//! network IO.
6
7use pyo3::prelude::*;
8use pyo3::types::{PyBytes, PyDict, PyList};
9
10use spvirit_codec::epics_decode::PvaPacket;
11use spvirit_codec::spvd_decode::{
12    DecodedValue, FieldDesc, FieldType, PvdDecoder, StructureDesc, TypeCode,
13    extract_nt_scalar_value, format_compact_value,
14};
15use spvirit_codec::spvd_encode::encode_pv_request as codec_encode_pv_request;
16use spvirit_client::put_encode::encode_put_payload as client_encode_put_payload;
17
18use crate::convert::{decoded_to_py, py_to_json};
19use crate::errors::{decode_msg_to_py_err, protocol_msg_to_py_err};
20
21// ─── FieldDesc / StructureDesc wrappers ──────────────────────────────────────
22
23/// A single field description: name and a type string (e.g. "int32",
24/// "double[]", "struct", "struct[]", "union", "any", "string[5]").
25#[pyclass(name = "FieldDesc", frozen, module = "spvirit.codec")]
26#[derive(Clone)]
27pub struct PyFieldDesc {
28    inner: FieldDesc,
29}
30
31impl PyFieldDesc {
32    pub(crate) fn from_inner(inner: FieldDesc) -> Self {
33        Self { inner }
34    }
35
36    #[allow(dead_code)]
37    pub(crate) fn inner(&self) -> &FieldDesc {
38        &self.inner
39    }
40}
41
42#[pymethods]
43impl PyFieldDesc {
44    #[getter]
45    fn name(&self) -> &str {
46        &self.inner.name
47    }
48
49    /// Human-readable type string (same as `FieldType::type_name`).
50    #[getter]
51    fn field_type(&self) -> &'static str {
52        self.inner.field_type.type_name()
53    }
54
55    /// Underlying scalar/array element type code, e.g. `"int32"`, or None
56    /// for non-scalar fields.
57    #[getter]
58    fn type_code(&self) -> Option<&'static str> {
59        match &self.inner.field_type {
60            FieldType::Scalar(tc) | FieldType::ScalarArray(tc) => Some(type_code_name(*tc)),
61            FieldType::String | FieldType::StringArray | FieldType::BoundedString(_) => {
62                Some("string")
63            }
64            _ => None,
65        }
66    }
67
68    /// True if this field represents any kind of array.
69    #[getter]
70    fn is_array(&self) -> bool {
71        matches!(
72            self.inner.field_type,
73            FieldType::ScalarArray(_)
74                | FieldType::StringArray
75                | FieldType::StructureArray(_)
76                | FieldType::UnionArray(_)
77                | FieldType::VariantArray
78        )
79    }
80
81    /// Nested `StructureDesc` when this field is a structure or structure
82    /// array; otherwise None.
83    #[getter]
84    fn struct_desc(&self) -> Option<PyStructureDesc> {
85        match &self.inner.field_type {
86            FieldType::Structure(s) | FieldType::StructureArray(s) => {
87                Some(PyStructureDesc::from_inner(s.clone()))
88            }
89            _ => None,
90        }
91    }
92
93    fn __repr__(&self) -> String {
94        format!(
95            "FieldDesc(name={:?}, field_type={:?})",
96            self.inner.name,
97            self.inner.field_type.type_name()
98        )
99    }
100}
101
102fn type_code_name(tc: TypeCode) -> &'static str {
103    match tc {
104        TypeCode::Null => "null",
105        TypeCode::Boolean => "boolean",
106        TypeCode::Int8 => "int8",
107        TypeCode::Int16 => "int16",
108        TypeCode::Int32 => "int32",
109        TypeCode::Int64 => "int64",
110        TypeCode::UInt8 => "uint8",
111        TypeCode::UInt16 => "uint16",
112        TypeCode::UInt32 => "uint32",
113        TypeCode::UInt64 => "uint64",
114        TypeCode::Float32 => "float32",
115        TypeCode::Float64 => "float64",
116        TypeCode::String => "string",
117        TypeCode::Variant => "any",
118    }
119}
120
121/// Structure type description: optional struct ID plus ordered fields.
122#[pyclass(name = "StructureDesc", frozen, module = "spvirit.codec")]
123#[derive(Clone)]
124pub struct PyStructureDesc {
125    inner: StructureDesc,
126}
127
128impl PyStructureDesc {
129    pub(crate) fn from_inner(inner: StructureDesc) -> Self {
130        Self { inner }
131    }
132
133    pub(crate) fn inner(&self) -> &StructureDesc {
134        &self.inner
135    }
136}
137
138#[pymethods]
139impl PyStructureDesc {
140    #[getter]
141    fn struct_id(&self) -> Option<&str> {
142        self.inner.struct_id.as_deref()
143    }
144
145    #[getter]
146    fn fields(&self) -> Vec<PyFieldDesc> {
147        self.inner
148            .fields
149            .iter()
150            .cloned()
151            .map(PyFieldDesc::from_inner)
152            .collect()
153    }
154
155    /// Look up a field by name, returning None when absent.
156    fn field(&self, name: &str) -> Option<PyFieldDesc> {
157        self.inner
158            .field(name)
159            .cloned()
160            .map(PyFieldDesc::from_inner)
161    }
162
163    fn __len__(&self) -> usize {
164        self.inner.fields.len()
165    }
166
167    fn __contains__(&self, name: &str) -> bool {
168        self.inner.field(name).is_some()
169    }
170
171    fn __repr__(&self) -> String {
172        format!(
173            "StructureDesc(struct_id={:?}, fields={})",
174            self.inner.struct_id,
175            self.inner.fields.len()
176        )
177    }
178
179    /// Multi-line human-readable dump matching `Display for StructureDesc`.
180    fn dump(&self) -> String {
181        format!("{}", self.inner)
182    }
183}
184
185// ─── Free functions ──────────────────────────────────────────────────────────
186
187/// Decode a `StructureDesc` from raw introspection bytes (the PVD field
188/// description as seen on the wire).
189#[pyfunction]
190#[pyo3(signature = (data, is_be=false))]
191pub fn decode_introspection(data: &[u8], is_be: bool) -> PyResult<PyStructureDesc> {
192    let decoder = PvdDecoder::new(is_be);
193    decoder
194        .parse_introspection(data)
195        .map(PyStructureDesc::from_inner)
196        .ok_or_else(|| decode_msg_to_py_err("failed to parse introspection"))
197}
198
199/// Decode a pvData value given the accompanying `StructureDesc`.
200#[pyfunction]
201#[pyo3(signature = (data, desc, is_be=false))]
202pub fn decode_value(
203    py: Python<'_>,
204    data: &[u8],
205    desc: &PyStructureDesc,
206    is_be: bool,
207) -> PyResult<PyObject> {
208    let decoder = PvdDecoder::new(is_be);
209    // The top-level introspection is a structure; decode it as such.
210    let ft = FieldType::Structure(desc.inner().clone());
211    let (decoded, _consumed) = decoder
212        .decode_value(data, &ft)
213        .ok_or_else(|| decode_msg_to_py_err("failed to decode value"))?;
214    Ok(decoded_to_py(py, &decoded))
215}
216
217/// Encode a pvRequest mask selecting specific top-level fields.  Pass an
218/// empty list or None to request all fields.
219#[pyfunction]
220#[pyo3(signature = (fields=None, is_be=false))]
221pub fn encode_pv_request(
222    py: Python<'_>,
223    fields: Option<Vec<String>>,
224    is_be: bool,
225) -> PyResult<PyObject> {
226    let fields = fields.unwrap_or_default();
227    let bytes = if fields.is_empty() {
228        vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
229    } else {
230        let refs: Vec<&str> = fields.iter().map(String::as_str).collect();
231        codec_encode_pv_request(&refs, is_be)
232    };
233    Ok(PyBytes::new(py, &bytes).into_any().unbind())
234}
235
236/// Encode a PUT payload (size-prefixed bitset + field data) for the given
237/// structure description and value dict.  The bitset is built from the
238/// field keys present in `value` so callers only transmit changed fields.
239#[pyfunction]
240#[pyo3(signature = (desc, value, is_be=false))]
241pub fn encode_put_payload(
242    py: Python<'_>,
243    desc: &PyStructureDesc,
244    value: PyObject,
245    is_be: bool,
246) -> PyResult<PyObject> {
247    let json = py_to_json(value.bind(py))?;
248    let bytes = client_encode_put_payload(desc.inner(), &json, is_be)
249        .map_err(protocol_msg_to_py_err)?;
250    Ok(PyBytes::new(py, &bytes).into_any().unbind())
251}
252
253/// Compact single-line representation of a decoded value.  Accepts any
254/// Python object that was produced by [`decode_value`] or by this crate's
255/// GET result conversion; non-NT inputs are round-tripped via their
256/// string form.
257#[pyfunction]
258pub fn format_value(py: Python<'_>, value: PyObject) -> PyResult<String> {
259    let bound = value.bind(py);
260    let decoded = py_to_decoded(bound)?;
261    Ok(format_compact_value(&decoded))
262}
263
264/// Extract the inner `value` field from an NT structure dict.  Returns
265/// None if the input is not an NT structure.
266#[pyfunction]
267pub fn extract_nt_value(py: Python<'_>, value: PyObject) -> PyResult<Option<PyObject>> {
268    let bound = value.bind(py);
269    let decoded = py_to_decoded(bound)?;
270    Ok(extract_nt_scalar_value(&decoded).map(|v| decoded_to_py(py, v)))
271}
272
273/// Best-effort inspection of a full PVA packet (header + payload).
274/// Returns a dict with `command`, `command_name`, `version`, `flags`, and
275/// a `details` sub-dict describing the command-specific payload.
276#[pyfunction]
277pub fn decode_packet<'py>(py: Python<'py>, data: &[u8]) -> PyResult<Bound<'py, PyDict>> {
278    use spvirit_codec::epics_decode::command_name;
279
280    if data.len() < 8 {
281        return Err(decode_msg_to_py_err("packet shorter than 8-byte header"));
282    }
283
284    let mut pkt = PvaPacket::new(data);
285    let out = PyDict::new(py);
286    out.set_item("magic", pkt.header.magic)?;
287    out.set_item("version", pkt.header.version)?;
288    out.set_item("command", pkt.header.command)?;
289    out.set_item("command_name", command_name(pkt.header.command))?;
290    out.set_item("payload_length", pkt.header.payload_length)?;
291
292    let flags = PyDict::new(py);
293    flags.set_item("raw", pkt.header.flags.raw)?;
294    flags.set_item("is_application", pkt.header.flags.is_application)?;
295    flags.set_item("is_control", pkt.header.flags.is_control)?;
296    flags.set_item("is_segmented", pkt.header.flags.is_segmented)?;
297    flags.set_item("is_client", pkt.header.flags.is_client)?;
298    flags.set_item("is_server", pkt.header.flags.is_server)?;
299    flags.set_item("is_msb", pkt.header.flags.is_msb)?;
300    out.set_item("flags", flags)?;
301
302    out.set_item(
303        "payload",
304        PyBytes::new(py, &data[8.min(data.len())..]),
305    )?;
306
307    let details = PyDict::new(py);
308    if let Some(cmd) = pkt.decode_payload() {
309        fill_command_details(py, &cmd, &details)?;
310    }
311    out.set_item("details", details)?;
312    Ok(out)
313}
314
315pub(crate) fn fill_command_details(
316    py: Python<'_>,
317    cmd: &spvirit_codec::epics_decode::PvaPacketCommand,
318    details: &Bound<'_, PyDict>,
319) -> PyResult<()> {
320    use spvirit_codec::epics_decode::PvaPacketCommand as C;
321
322    match cmd {
323        C::Control(p) => {
324            details.set_item("kind", "control")?;
325            details.set_item("command", p.command)?;
326            details.set_item("data", p.data)?;
327        }
328        C::Search(_) => {
329            details.set_item("kind", "search")?;
330        }
331        C::SearchResponse(p) => {
332            details.set_item("kind", "search_response")?;
333            details.set_item("guid", PyBytes::new(py, &p.guid))?;
334            details.set_item("seq", p.seq)?;
335            details.set_item("port", p.port)?;
336            details.set_item("protocol", &p.protocol)?;
337            details.set_item("found", p.found)?;
338            details.set_item("cids", PyList::new(py, p.cids.iter().cloned())?)?;
339        }
340        C::Beacon(_) => {
341            details.set_item("kind", "beacon")?;
342        }
343        C::ConnectionValidation(_) => {
344            details.set_item("kind", "connection_validation")?;
345        }
346        C::ConnectionValidated(_) => {
347            details.set_item("kind", "connection_validated")?;
348        }
349        C::AuthNZ(_) => {
350            details.set_item("kind", "authnz")?;
351        }
352        C::AclChange(_) => {
353            details.set_item("kind", "acl_change")?;
354        }
355        C::Op(p) => {
356            details.set_item("kind", "op")?;
357            details.set_item("command", p.command)?;
358            details.set_item("sid_or_cid", p.sid_or_cid)?;
359            details.set_item("ioid", p.ioid)?;
360            details.set_item("subcmd", p.subcmd)?;
361            details.set_item("is_server", p.is_server)?;
362            if let Some(status) = &p.status {
363                let s = PyDict::new(py);
364                s.set_item("code", status.code)?;
365                s.set_item("message", status.message.as_deref())?;
366                s.set_item("is_error", status.is_error())?;
367                details.set_item("status", s)?;
368            }
369            if let Some(intro) = &p.introspection {
370                details.set_item(
371                    "introspection",
372                    PyStructureDesc::from_inner(intro.clone()),
373                )?;
374            }
375        }
376        C::CreateChannel(p) => {
377            details.set_item("kind", "create_channel")?;
378            details.set_item("is_server", p.is_server)?;
379            details.set_item("cid", p.cid)?;
380            details.set_item("sid", p.sid)?;
381            if !p.channels.is_empty() {
382                let pairs: Vec<(u32, String)> = p.channels.clone();
383                details.set_item("channels", pairs)?;
384            }
385            if let Some(status) = &p.status {
386                let s = PyDict::new(py);
387                s.set_item("code", status.code)?;
388                s.set_item("message", status.message.as_deref())?;
389                s.set_item("is_error", status.is_error())?;
390                details.set_item("status", s)?;
391            }
392        }
393        C::DestroyChannel(p) => {
394            details.set_item("kind", "destroy_channel")?;
395            details.set_item("sid", p.sid)?;
396            details.set_item("cid", p.cid)?;
397        }
398        C::GetField(p) => {
399            details.set_item("kind", "get_field")?;
400            details.set_item("is_server", p.is_server)?;
401            details.set_item("sid", p.sid)?;
402            details.set_item("ioid", p.ioid)?;
403            details.set_item("field_name", p.field_name.as_deref())?;
404        }
405        C::Message(_) => {
406            details.set_item("kind", "message")?;
407        }
408        C::MultipleData(_) => {
409            details.set_item("kind", "multiple_data")?;
410        }
411        C::CancelRequest(_) => {
412            details.set_item("kind", "cancel_request")?;
413        }
414        C::DestroyRequest(_) => {
415            details.set_item("kind", "destroy_request")?;
416        }
417        C::OriginTag(_) => {
418            details.set_item("kind", "origin_tag")?;
419        }
420        C::Echo(data) => {
421            details.set_item("kind", "echo")?;
422            details.set_item("data", PyBytes::new(py, data))?;
423        }
424        C::Unknown(_) => {
425            details.set_item("kind", "unknown")?;
426        }
427    }
428    Ok(())
429}
430
431/// Convert a Python value (dict/list/scalar) back into `DecodedValue` for
432/// the formatter helpers.  Used only for single-shot formatting — avoids
433/// needing a round-trip through serde_json where possible.
434fn py_to_decoded(obj: &Bound<'_, PyAny>) -> PyResult<DecodedValue> {
435    use pyo3::types::{PyBool, PyFloat, PyInt, PyString};
436
437    if obj.is_none() {
438        return Ok(DecodedValue::Null);
439    }
440    if let Ok(b) = obj.downcast::<PyBool>() {
441        return Ok(DecodedValue::Boolean(b.is_true()));
442    }
443    if obj.is_instance_of::<PyInt>() {
444        let v: i64 = obj.extract()?;
445        return Ok(DecodedValue::Int64(v));
446    }
447    if obj.is_instance_of::<PyFloat>() {
448        let v: f64 = obj.extract()?;
449        return Ok(DecodedValue::Float64(v));
450    }
451    if obj.is_instance_of::<PyString>() {
452        let v: String = obj.extract()?;
453        return Ok(DecodedValue::String(v));
454    }
455    if let Ok(b) = obj.downcast::<pyo3::types::PyBytes>() {
456        return Ok(DecodedValue::Raw(b.as_bytes().to_vec()));
457    }
458    if let Ok(list) = obj.downcast::<PyList>() {
459        let mut out = Vec::with_capacity(list.len());
460        for item in list.iter() {
461            out.push(py_to_decoded(&item)?);
462        }
463        return Ok(DecodedValue::Array(out));
464    }
465    if let Ok(dict) = obj.downcast::<PyDict>() {
466        let mut out = Vec::new();
467        for (k, v) in dict.iter() {
468            let key: String = k.extract()?;
469            out.push((key, py_to_decoded(&v)?));
470        }
471        return Ok(DecodedValue::Structure(out));
472    }
473    Err(decode_msg_to_py_err(format!(
474        "cannot convert {} to DecodedValue",
475        obj.get_type().name()?
476    )))
477}
478
479/// Register the `spvirit.codec` submodule.
480pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> {
481    let py = parent.py();
482    let m = PyModule::new(py, "codec")?;
483    m.add_class::<PyFieldDesc>()?;
484    m.add_class::<PyStructureDesc>()?;
485    m.add_function(wrap_pyfunction!(decode_introspection, &m)?)?;
486    m.add_function(wrap_pyfunction!(decode_value, &m)?)?;
487    m.add_function(wrap_pyfunction!(encode_pv_request, &m)?)?;
488    m.add_function(wrap_pyfunction!(encode_put_payload, &m)?)?;
489    m.add_function(wrap_pyfunction!(format_value, &m)?)?;
490    m.add_function(wrap_pyfunction!(extract_nt_value, &m)?)?;
491    m.add_function(wrap_pyfunction!(decode_packet, &m)?)?;
492    parent.add_submodule(&m)?;
493    // Also expose under the dotted name so `import spvirit.codec` works.
494    py.import("sys")?
495        .getattr("modules")?
496        .set_item("spvirit.codec", &m)?;
497    Ok(())
498}