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    #[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.field(name).cloned().map(PyFieldDesc::from_inner)
158    }
159
160    fn __len__(&self) -> usize {
161        self.inner.fields.len()
162    }
163
164    fn __contains__(&self, name: &str) -> bool {
165        self.inner.field(name).is_some()
166    }
167
168    fn __repr__(&self) -> String {
169        format!(
170            "StructureDesc(struct_id={:?}, fields={})",
171            self.inner.struct_id,
172            self.inner.fields.len()
173        )
174    }
175
176    /// Multi-line human-readable dump matching `Display for StructureDesc`.
177    fn dump(&self) -> String {
178        format!("{}", self.inner)
179    }
180}
181
182// ─── Free functions ──────────────────────────────────────────────────────────
183
184/// Decode a `StructureDesc` from raw introspection bytes (the PVD field
185/// description as seen on the wire).
186#[pyfunction]
187#[pyo3(signature = (data, is_be=false))]
188pub fn decode_introspection(data: &[u8], is_be: bool) -> PyResult<PyStructureDesc> {
189    let decoder = PvdDecoder::new(is_be);
190    decoder
191        .parse_introspection(data)
192        .map(PyStructureDesc::from_inner)
193        .ok_or_else(|| decode_msg_to_py_err("failed to parse introspection"))
194}
195
196/// Decode a pvData value given the accompanying `StructureDesc`.
197#[pyfunction]
198#[pyo3(signature = (data, desc, is_be=false))]
199pub fn decode_value(
200    py: Python<'_>,
201    data: &[u8],
202    desc: &PyStructureDesc,
203    is_be: bool,
204) -> PyResult<PyObject> {
205    let decoder = PvdDecoder::new(is_be);
206    // The top-level introspection is a structure; decode it as such.
207    let ft = FieldType::Structure(desc.inner().clone());
208    let (decoded, _consumed) = decoder
209        .decode_value(data, &ft)
210        .ok_or_else(|| decode_msg_to_py_err("failed to decode value"))?;
211    Ok(decoded_to_py(py, &decoded))
212}
213
214/// Encode a pvRequest mask selecting specific top-level fields.  Pass an
215/// empty list or None to request all fields.
216#[pyfunction]
217#[pyo3(signature = (fields=None, is_be=false))]
218pub fn encode_pv_request(
219    py: Python<'_>,
220    fields: Option<Vec<String>>,
221    is_be: bool,
222) -> PyResult<PyObject> {
223    let fields = fields.unwrap_or_default();
224    let bytes = if fields.is_empty() {
225        vec![0xfd, 0x02, 0x00, 0x80, 0x00, 0x00]
226    } else {
227        let refs: Vec<&str> = fields.iter().map(String::as_str).collect();
228        codec_encode_pv_request(&refs, is_be)
229    };
230    Ok(PyBytes::new(py, &bytes).into_any().unbind())
231}
232
233/// Encode a PUT payload (size-prefixed bitset + field data) for the given
234/// structure description and value dict.  The bitset is built from the
235/// field keys present in `value` so callers only transmit changed fields.
236#[pyfunction]
237#[pyo3(signature = (desc, value, is_be=false))]
238pub fn encode_put_payload(
239    py: Python<'_>,
240    desc: &PyStructureDesc,
241    value: PyObject,
242    is_be: bool,
243) -> PyResult<PyObject> {
244    let json = py_to_json(value.bind(py))?;
245    let bytes =
246        client_encode_put_payload(desc.inner(), &json, is_be).map_err(protocol_msg_to_py_err)?;
247    Ok(PyBytes::new(py, &bytes).into_any().unbind())
248}
249
250/// Compact single-line representation of a decoded value.  Accepts any
251/// Python object that was produced by [`decode_value`] or by this crate's
252/// GET result conversion; non-NT inputs are round-tripped via their
253/// string form.
254#[pyfunction]
255pub fn format_value(py: Python<'_>, value: PyObject) -> PyResult<String> {
256    let bound = value.bind(py);
257    let decoded = py_to_decoded(bound)?;
258    Ok(format_compact_value(&decoded))
259}
260
261/// Extract the inner `value` field from an NT structure dict.  Returns
262/// None if the input is not an NT structure.
263#[pyfunction]
264pub fn extract_nt_value(py: Python<'_>, value: PyObject) -> PyResult<Option<PyObject>> {
265    let bound = value.bind(py);
266    let decoded = py_to_decoded(bound)?;
267    Ok(extract_nt_scalar_value(&decoded).map(|v| decoded_to_py(py, v)))
268}
269
270/// Best-effort inspection of a full PVA packet (header + payload).
271/// Returns a dict with `command`, `command_name`, `version`, `flags`, and
272/// a `details` sub-dict describing the command-specific payload.
273#[pyfunction]
274pub fn decode_packet<'py>(py: Python<'py>, data: &[u8]) -> PyResult<Bound<'py, PyDict>> {
275    use spvirit_codec::epics_decode::command_name;
276
277    if data.len() < 8 {
278        return Err(decode_msg_to_py_err("packet shorter than 8-byte header"));
279    }
280
281    let mut pkt = PvaPacket::new(data);
282    let out = PyDict::new(py);
283    out.set_item("magic", pkt.header.magic)?;
284    out.set_item("version", pkt.header.version)?;
285    out.set_item("command", pkt.header.command)?;
286    out.set_item("command_name", command_name(pkt.header.command))?;
287    out.set_item("payload_length", pkt.header.payload_length)?;
288
289    let flags = PyDict::new(py);
290    flags.set_item("raw", pkt.header.flags.raw)?;
291    flags.set_item("is_application", pkt.header.flags.is_application)?;
292    flags.set_item("is_control", pkt.header.flags.is_control)?;
293    flags.set_item("is_segmented", pkt.header.flags.is_segmented)?;
294    flags.set_item("is_client", pkt.header.flags.is_client)?;
295    flags.set_item("is_server", pkt.header.flags.is_server)?;
296    flags.set_item("is_msb", pkt.header.flags.is_msb)?;
297    out.set_item("flags", flags)?;
298
299    out.set_item("payload", PyBytes::new(py, &data[8.min(data.len())..]))?;
300
301    let details = PyDict::new(py);
302    if let Some(cmd) = pkt.decode_payload() {
303        fill_command_details(py, &cmd, &details)?;
304    }
305    out.set_item("details", details)?;
306    Ok(out)
307}
308
309pub(crate) fn fill_command_details(
310    py: Python<'_>,
311    cmd: &spvirit_codec::epics_decode::PvaPacketCommand,
312    details: &Bound<'_, PyDict>,
313) -> PyResult<()> {
314    use spvirit_codec::epics_decode::PvaPacketCommand as C;
315
316    match cmd {
317        C::Control(p) => {
318            details.set_item("kind", "control")?;
319            details.set_item("command", p.command)?;
320            details.set_item("data", p.data)?;
321        }
322        C::Search(_) => {
323            details.set_item("kind", "search")?;
324        }
325        C::SearchResponse(p) => {
326            details.set_item("kind", "search_response")?;
327            details.set_item("guid", PyBytes::new(py, &p.guid))?;
328            details.set_item("seq", p.seq)?;
329            details.set_item("port", p.port)?;
330            details.set_item("protocol", &p.protocol)?;
331            details.set_item("found", p.found)?;
332            details.set_item("cids", PyList::new(py, p.cids.iter().cloned())?)?;
333        }
334        C::Beacon(_) => {
335            details.set_item("kind", "beacon")?;
336        }
337        C::ConnectionValidation(_) => {
338            details.set_item("kind", "connection_validation")?;
339        }
340        C::ConnectionValidated(_) => {
341            details.set_item("kind", "connection_validated")?;
342        }
343        C::AuthNZ(_) => {
344            details.set_item("kind", "authnz")?;
345        }
346        C::AclChange(_) => {
347            details.set_item("kind", "acl_change")?;
348        }
349        C::Op(p) => {
350            details.set_item("kind", "op")?;
351            details.set_item("command", p.command)?;
352            details.set_item("sid_or_cid", p.sid_or_cid)?;
353            details.set_item("ioid", p.ioid)?;
354            details.set_item("subcmd", p.subcmd)?;
355            details.set_item("is_server", p.is_server)?;
356            if let Some(status) = &p.status {
357                let s = PyDict::new(py);
358                s.set_item("code", status.code)?;
359                s.set_item("message", status.message.as_deref())?;
360                s.set_item("is_error", status.is_error())?;
361                details.set_item("status", s)?;
362            }
363            if let Some(intro) = &p.introspection {
364                details.set_item("introspection", PyStructureDesc::from_inner(intro.clone()))?;
365            }
366        }
367        C::CreateChannel(p) => {
368            details.set_item("kind", "create_channel")?;
369            details.set_item("is_server", p.is_server)?;
370            details.set_item("cid", p.cid)?;
371            details.set_item("sid", p.sid)?;
372            if !p.channels.is_empty() {
373                let pairs: Vec<(u32, String)> = p.channels.clone();
374                details.set_item("channels", pairs)?;
375            }
376            if let Some(status) = &p.status {
377                let s = PyDict::new(py);
378                s.set_item("code", status.code)?;
379                s.set_item("message", status.message.as_deref())?;
380                s.set_item("is_error", status.is_error())?;
381                details.set_item("status", s)?;
382            }
383        }
384        C::DestroyChannel(p) => {
385            details.set_item("kind", "destroy_channel")?;
386            details.set_item("sid", p.sid)?;
387            details.set_item("cid", p.cid)?;
388        }
389        C::GetField(p) => {
390            details.set_item("kind", "get_field")?;
391            details.set_item("is_server", p.is_server)?;
392            details.set_item("sid", p.sid)?;
393            details.set_item("ioid", p.ioid)?;
394            details.set_item("field_name", p.field_name.as_deref())?;
395        }
396        C::Message(_) => {
397            details.set_item("kind", "message")?;
398        }
399        C::MultipleData(_) => {
400            details.set_item("kind", "multiple_data")?;
401        }
402        C::CancelRequest(_) => {
403            details.set_item("kind", "cancel_request")?;
404        }
405        C::DestroyRequest(_) => {
406            details.set_item("kind", "destroy_request")?;
407        }
408        C::OriginTag(_) => {
409            details.set_item("kind", "origin_tag")?;
410        }
411        C::Echo(data) => {
412            details.set_item("kind", "echo")?;
413            details.set_item("data", PyBytes::new(py, data))?;
414        }
415        C::Unknown(_) => {
416            details.set_item("kind", "unknown")?;
417        }
418    }
419    Ok(())
420}
421
422/// Convert a Python value (dict/list/scalar) back into `DecodedValue` for
423/// the formatter helpers.  Used only for single-shot formatting — avoids
424/// needing a round-trip through serde_json where possible.
425fn py_to_decoded(obj: &Bound<'_, PyAny>) -> PyResult<DecodedValue> {
426    use pyo3::types::{PyBool, PyFloat, PyInt, PyString};
427
428    if obj.is_none() {
429        return Ok(DecodedValue::Null);
430    }
431    if let Ok(b) = obj.downcast::<PyBool>() {
432        return Ok(DecodedValue::Boolean(b.is_true()));
433    }
434    if obj.is_instance_of::<PyInt>() {
435        let v: i64 = obj.extract()?;
436        return Ok(DecodedValue::Int64(v));
437    }
438    if obj.is_instance_of::<PyFloat>() {
439        let v: f64 = obj.extract()?;
440        return Ok(DecodedValue::Float64(v));
441    }
442    if obj.is_instance_of::<PyString>() {
443        let v: String = obj.extract()?;
444        return Ok(DecodedValue::String(v));
445    }
446    if let Ok(b) = obj.downcast::<pyo3::types::PyBytes>() {
447        return Ok(DecodedValue::Raw(b.as_bytes().to_vec()));
448    }
449    if let Ok(list) = obj.downcast::<PyList>() {
450        let mut out = Vec::with_capacity(list.len());
451        for item in list.iter() {
452            out.push(py_to_decoded(&item)?);
453        }
454        return Ok(DecodedValue::Array(out));
455    }
456    if let Ok(dict) = obj.downcast::<PyDict>() {
457        let mut out = Vec::new();
458        for (k, v) in dict.iter() {
459            let key: String = k.extract()?;
460            out.push((key, py_to_decoded(&v)?));
461        }
462        return Ok(DecodedValue::Structure(out));
463    }
464    Err(decode_msg_to_py_err(format!(
465        "cannot convert {} to DecodedValue",
466        obj.get_type().name()?
467    )))
468}
469
470/// Register the `spvirit.codec` submodule.
471pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> {
472    let py = parent.py();
473    let m = PyModule::new(py, "codec")?;
474    m.add_class::<PyFieldDesc>()?;
475    m.add_class::<PyStructureDesc>()?;
476    m.add_function(wrap_pyfunction!(decode_introspection, &m)?)?;
477    m.add_function(wrap_pyfunction!(decode_value, &m)?)?;
478    m.add_function(wrap_pyfunction!(encode_pv_request, &m)?)?;
479    m.add_function(wrap_pyfunction!(encode_put_payload, &m)?)?;
480    m.add_function(wrap_pyfunction!(format_value, &m)?)?;
481    m.add_function(wrap_pyfunction!(extract_nt_value, &m)?)?;
482    m.add_function(wrap_pyfunction!(decode_packet, &m)?)?;
483    parent.add_submodule(&m)?;
484    // Also expose under the dotted name so `import spvirit.codec` works.
485    py.import("sys")?
486        .getattr("modules")?
487        .set_item("spvirit.codec", &m)?;
488    Ok(())
489}