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_client::put_encode::encode_put_payload as client_encode_put_payload;
11use spvirit_codec::epics_decode::PvaPacket;
12use spvirit_codec::spvd_decode::{
13    DecodedValue, FieldDesc, FieldType, PvdDecoder, StructureDesc, TypeCode,
14    extract_nt_scalar_value, format_compact_value,
15};
16use spvirit_codec::spvd_encode::encode_pv_request as codec_encode_pv_request;
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    /// Field name.
45    #[getter]
46    fn name(&self) -> &str {
47        &self.inner.name
48    }
49
50    /// Human-readable type string (same as `FieldType::type_name`).
51    #[getter]
52    fn field_type(&self) -> &'static str {
53        self.inner.field_type.type_name()
54    }
55
56    /// Underlying scalar/array element type code, e.g. `"int32"`, or None
57    /// for non-scalar fields.
58    #[getter]
59    fn type_code(&self) -> Option<&'static str> {
60        match &self.inner.field_type {
61            FieldType::Scalar(tc) | FieldType::ScalarArray(tc) => Some(type_code_name(*tc)),
62            FieldType::String | FieldType::StringArray | FieldType::BoundedString(_) => {
63                Some("string")
64            }
65            _ => None,
66        }
67    }
68
69    /// True if this field represents any kind of array.
70    #[getter]
71    fn is_array(&self) -> bool {
72        matches!(
73            self.inner.field_type,
74            FieldType::ScalarArray(_)
75                | FieldType::StringArray
76                | FieldType::StructureArray(_)
77                | FieldType::UnionArray(_)
78                | FieldType::VariantArray
79        )
80    }
81
82    /// Nested `StructureDesc` when this field is a structure or structure
83    /// array; otherwise None.
84    #[getter]
85    fn struct_desc(&self) -> Option<PyStructureDesc> {
86        match &self.inner.field_type {
87            FieldType::Structure(s) | FieldType::StructureArray(s) => {
88                Some(PyStructureDesc::from_inner(s.clone()))
89            }
90            _ => None,
91        }
92    }
93
94    fn __repr__(&self) -> String {
95        format!(
96            "FieldDesc(name={:?}, field_type={:?})",
97            self.inner.name,
98            self.inner.field_type.type_name()
99        )
100    }
101}
102
103fn type_code_name(tc: TypeCode) -> &'static str {
104    match tc {
105        TypeCode::Null => "null",
106        TypeCode::Boolean => "boolean",
107        TypeCode::Int8 => "int8",
108        TypeCode::Int16 => "int16",
109        TypeCode::Int32 => "int32",
110        TypeCode::Int64 => "int64",
111        TypeCode::UInt8 => "uint8",
112        TypeCode::UInt16 => "uint16",
113        TypeCode::UInt32 => "uint32",
114        TypeCode::UInt64 => "uint64",
115        TypeCode::Float32 => "float32",
116        TypeCode::Float64 => "float64",
117        TypeCode::String => "string",
118        TypeCode::Variant => "any",
119    }
120}
121
122/// Structure type description: optional struct ID plus ordered fields.
123#[pyclass(name = "StructureDesc", frozen, module = "spvirit.codec")]
124#[derive(Clone)]
125pub struct PyStructureDesc {
126    inner: StructureDesc,
127}
128
129impl PyStructureDesc {
130    pub(crate) fn from_inner(inner: StructureDesc) -> Self {
131        Self { inner }
132    }
133
134    pub(crate) fn inner(&self) -> &StructureDesc {
135        &self.inner
136    }
137}
138
139#[pymethods]
140impl PyStructureDesc {
141    /// Structure type ID (e.g. `"epics:nt/NTScalar:1.0"`), or None.
142    #[getter]
143    fn struct_id(&self) -> Option<&str> {
144        self.inner.struct_id.as_deref()
145    }
146
147    /// Ordered list of `FieldDesc` entries for this structure.
148    #[getter]
149    fn fields(&self) -> Vec<PyFieldDesc> {
150        self.inner
151            .fields
152            .iter()
153            .cloned()
154            .map(PyFieldDesc::from_inner)
155            .collect()
156    }
157
158    /// Look up a field by name, returning None when absent.
159    fn field(&self, name: &str) -> Option<PyFieldDesc> {
160        self.inner.field(name).cloned().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 =
249        client_encode_put_payload(desc.inner(), &json, is_be).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("payload", PyBytes::new(py, &data[8.min(data.len())..]))?;
303
304    let details = PyDict::new(py);
305    if let Some(cmd) = pkt.decode_payload() {
306        fill_command_details(py, &cmd, &details)?;
307    }
308    out.set_item("details", details)?;
309    Ok(out)
310}
311
312pub(crate) fn fill_command_details(
313    py: Python<'_>,
314    cmd: &spvirit_codec::epics_decode::PvaPacketCommand,
315    details: &Bound<'_, PyDict>,
316) -> PyResult<()> {
317    use spvirit_codec::epics_decode::PvaPacketCommand as C;
318
319    match cmd {
320        C::Control(p) => {
321            details.set_item("kind", "control")?;
322            details.set_item("command", p.command)?;
323            details.set_item("data", p.data)?;
324        }
325        C::Search(_) => {
326            details.set_item("kind", "search")?;
327        }
328        C::SearchResponse(p) => {
329            details.set_item("kind", "search_response")?;
330            details.set_item("guid", PyBytes::new(py, &p.guid))?;
331            details.set_item("seq", p.seq)?;
332            details.set_item("port", p.port)?;
333            details.set_item("protocol", &p.protocol)?;
334            details.set_item("found", p.found)?;
335            details.set_item("cids", PyList::new(py, p.cids.iter().cloned())?)?;
336        }
337        C::Beacon(_) => {
338            details.set_item("kind", "beacon")?;
339        }
340        C::ConnectionValidation(_) => {
341            details.set_item("kind", "connection_validation")?;
342        }
343        C::ConnectionValidated(_) => {
344            details.set_item("kind", "connection_validated")?;
345        }
346        C::AuthNZ(_) => {
347            details.set_item("kind", "authnz")?;
348        }
349        C::AclChange(_) => {
350            details.set_item("kind", "acl_change")?;
351        }
352        C::Op(p) => {
353            details.set_item("kind", "op")?;
354            details.set_item("command", p.command)?;
355            details.set_item("sid_or_cid", p.sid_or_cid)?;
356            details.set_item("ioid", p.ioid)?;
357            details.set_item("subcmd", p.subcmd)?;
358            details.set_item("is_server", p.is_server)?;
359            if let Some(status) = &p.status {
360                let s = PyDict::new(py);
361                s.set_item("code", status.code)?;
362                s.set_item("message", status.message.as_deref())?;
363                s.set_item("is_error", status.is_error())?;
364                details.set_item("status", s)?;
365            }
366            if let Some(intro) = &p.introspection {
367                details.set_item("introspection", PyStructureDesc::from_inner(intro.clone()))?;
368            }
369        }
370        C::CreateChannel(p) => {
371            details.set_item("kind", "create_channel")?;
372            details.set_item("is_server", p.is_server)?;
373            details.set_item("cid", p.cid)?;
374            details.set_item("sid", p.sid)?;
375            if !p.channels.is_empty() {
376                let pairs: Vec<(u32, String)> = p.channels.clone();
377                details.set_item("channels", pairs)?;
378            }
379            if let Some(status) = &p.status {
380                let s = PyDict::new(py);
381                s.set_item("code", status.code)?;
382                s.set_item("message", status.message.as_deref())?;
383                s.set_item("is_error", status.is_error())?;
384                details.set_item("status", s)?;
385            }
386        }
387        C::DestroyChannel(p) => {
388            details.set_item("kind", "destroy_channel")?;
389            details.set_item("sid", p.sid)?;
390            details.set_item("cid", p.cid)?;
391        }
392        C::GetField(p) => {
393            details.set_item("kind", "get_field")?;
394            details.set_item("is_server", p.is_server)?;
395            details.set_item("sid", p.sid)?;
396            details.set_item("ioid", p.ioid)?;
397            details.set_item("field_name", p.field_name.as_deref())?;
398        }
399        C::Message(_) => {
400            details.set_item("kind", "message")?;
401        }
402        C::MultipleData(_) => {
403            details.set_item("kind", "multiple_data")?;
404        }
405        C::CancelRequest(_) => {
406            details.set_item("kind", "cancel_request")?;
407        }
408        C::DestroyRequest(_) => {
409            details.set_item("kind", "destroy_request")?;
410        }
411        C::OriginTag(_) => {
412            details.set_item("kind", "origin_tag")?;
413        }
414        C::Echo(data) => {
415            details.set_item("kind", "echo")?;
416            details.set_item("data", PyBytes::new(py, data))?;
417        }
418        C::Unknown(_) => {
419            details.set_item("kind", "unknown")?;
420        }
421    }
422    Ok(())
423}
424
425/// Convert a Python value (dict/list/scalar) back into `DecodedValue` for
426/// the formatter helpers.  Used only for single-shot formatting — avoids
427/// needing a round-trip through serde_json where possible.
428fn py_to_decoded(obj: &Bound<'_, PyAny>) -> PyResult<DecodedValue> {
429    use pyo3::types::{PyBool, PyFloat, PyInt, PyString};
430
431    if obj.is_none() {
432        return Ok(DecodedValue::Null);
433    }
434    if let Ok(b) = obj.downcast::<PyBool>() {
435        return Ok(DecodedValue::Boolean(b.is_true()));
436    }
437    if obj.is_instance_of::<PyInt>() {
438        let v: i64 = obj.extract()?;
439        return Ok(DecodedValue::Int64(v));
440    }
441    if obj.is_instance_of::<PyFloat>() {
442        let v: f64 = obj.extract()?;
443        return Ok(DecodedValue::Float64(v));
444    }
445    if obj.is_instance_of::<PyString>() {
446        let v: String = obj.extract()?;
447        return Ok(DecodedValue::String(v));
448    }
449    if let Ok(b) = obj.downcast::<pyo3::types::PyBytes>() {
450        return Ok(DecodedValue::Raw(b.as_bytes().to_vec()));
451    }
452    if let Ok(list) = obj.downcast::<PyList>() {
453        let mut out = Vec::with_capacity(list.len());
454        for item in list.iter() {
455            out.push(py_to_decoded(&item)?);
456        }
457        return Ok(DecodedValue::Array(out));
458    }
459    if let Ok(dict) = obj.downcast::<PyDict>() {
460        let mut out = Vec::new();
461        for (k, v) in dict.iter() {
462            let key: String = k.extract()?;
463            out.push((key, py_to_decoded(&v)?));
464        }
465        return Ok(DecodedValue::Structure(out));
466    }
467    Err(decode_msg_to_py_err(format!(
468        "cannot convert {} to DecodedValue",
469        obj.get_type().name()?
470    )))
471}
472
473/// Register the `spvirit.codec` submodule.
474pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> {
475    let py = parent.py();
476    let m = PyModule::new(py, "codec")?;
477    m.add_class::<PyFieldDesc>()?;
478    m.add_class::<PyStructureDesc>()?;
479    m.add_function(wrap_pyfunction!(decode_introspection, &m)?)?;
480    m.add_function(wrap_pyfunction!(decode_value, &m)?)?;
481    m.add_function(wrap_pyfunction!(encode_pv_request, &m)?)?;
482    m.add_function(wrap_pyfunction!(encode_put_payload, &m)?)?;
483    m.add_function(wrap_pyfunction!(format_value, &m)?)?;
484    m.add_function(wrap_pyfunction!(extract_nt_value, &m)?)?;
485    m.add_function(wrap_pyfunction!(decode_packet, &m)?)?;
486    parent.add_submodule(&m)?;
487    // Also expose under the dotted name so `import spvirit.codec` works.
488    py.import("sys")?
489        .getattr("modules")?
490        .set_item("spvirit.codec", &m)?;
491    Ok(())
492}