From db0529c0535da9d7be562f420228f8c08ea15397 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 24 Mar 2026 11:08:54 -0400 Subject: [PATCH 01/26] Feat: update to twinleaf-rust 1.7.0 and use rpc list cacheing --- python/twinleaf/__init__.py | 19 ++-- rust/Cargo.lock | 167 ++++++++++++++++++++++++++++++++++-- rust/Cargo.toml | 2 +- rust/src/lib.rs | 22 +++-- 4 files changed, 185 insertions(+), 25 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index a620233..99fe8ab 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -93,21 +93,18 @@ def survey(local_self): return cls def _instantiate_rpcs(self): - n = int.from_bytes(self._rpc("rpc.listinfo", b""), "little") cls = self._get_obj_survey(self) setattr(self, 'settings', cls()) - for i in range(n): - res = self._rpc("rpc.listinfo", i.to_bytes(2, "little")) - meta = int.from_bytes(res[0:2], "little") - name = res[2:].decode() - mname, *prefix = reversed(name.split(".")) + rpc_list = self._rpc_list() + for (name, meta) in rpc_list: parent = self.settings - survey_prefix = "" - if prefix and (prefix[-1] == "rpc"): - prefix[-1] = "_rpc" - for token in reversed(prefix): - survey_prefix += "." + token + + *prefix, mname = name.split('.') + if prefix and (prefix[0] == "rpc"): + prefix[0] = "_rpc" + + for token in prefix: if not hasattr(parent, token): cls = self._get_obj_survey(token) setattr(parent, token, cls()) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ebfd936..07f6440 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -20,6 +20,15 @@ version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "cfg-if" version = "1.0.3" @@ -119,12 +128,60 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.3", + "objc2", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.15.5" @@ -169,6 +226,15 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + [[package]] name = "log" version = "0.4.27" @@ -208,7 +274,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -269,6 +335,45 @@ dependencies = [ "syn", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.3", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.3", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -369,6 +474,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -416,13 +532,33 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.16", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -455,16 +591,20 @@ dependencies = [ [[package]] name = "twinleaf" -version = "1.4.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc53566f02b812538dbb85381a696ebadd0101ee9ed36a28f73218e45ec5f9d" +checksum = "5d2ee4f8c52f6ccca5415f95c76c6f4535442263193f3f5fa1517d6f07889986" dependencies = [ "crc", "crossbeam", + "dirs-next", + "glob", "mio", "mio-serial", "num_enum", - "winapi", + "objc2", + "objc2-foundation", + "windows-sys 0.61.2", ] [[package]] @@ -482,7 +622,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c01d12e3a56a4432a8b436f293c25f4808bdf9e9f9f98f9260bba1f1bc5a1f26" dependencies = [ - "thiserror", + "thiserror 2.0.16", ] [[package]] @@ -525,6 +665,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.59.0" @@ -534,6 +680,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 8ae1817..161ecac 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,5 +10,5 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.26", features = ["extension-module"] } -twinleaf = { version = "1.4" } +twinleaf = { version = "1.7" } crossbeam = "0.8" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5865346..80c0453 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,12 +1,13 @@ use ::twinleaf::tio::*; +use ::twinleaf::device::*; use ::twinleaf::*; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyDict}; +use pyo3::types::{PyBytes, PyDict, PyList}; #[pyclass(name = "DataIterator", subclass)] struct PyIter { - port: data::Device, + port: device::Device, n: Option, stream: String, columns: Vec, @@ -84,7 +85,7 @@ impl PyIter { struct PyDevice { proxy: proxy::Interface, route: proto::DeviceRoute, - rpc: proxy::Port, + rpc: device::RpcClient, } #[pymethods] @@ -103,17 +104,24 @@ impl PyDevice { proto::DeviceRoute::root() }; let proxy = proxy::Interface::new(&root); - let rpc = proxy.device_rpc(route.clone()).unwrap(); + let rpc = RpcClient::open(&proxy, route.clone()).unwrap(); Ok(PyDevice { proxy, route, rpc }) } fn _rpc<'py>(&self, py: Python<'py>, name: &str, req: &[u8]) -> PyResult> { - match self.rpc.raw_rpc(name, req) { + match self.rpc.raw_rpc(&self.route, name, req) { Ok(ret) => Ok(PyBytes::new(py, &ret[..])), _ => Err(PyRuntimeError::new_err(format!("RPC '{}' failed", name))), } } + fn _rpc_list<'py>(&self, py: Python<'py>) -> PyResult> { + match self.rpc.rpc_list(&self.route) { + Ok(ret) => Ok(PyList::new(py, ret.vec)?), + Err(e) => Err(PyRuntimeError::new_err(format!("{:?}", e))), + } + } + #[pyo3(signature = (n=None, stream=None, columns=None))] fn _samples<'py>( &self, @@ -123,7 +131,7 @@ impl PyDevice { columns: Option>, ) -> PyResult { Ok(PyIter { - port: data::Device::new(self.proxy.device_full(self.route.clone()).unwrap()), + port: device::Device::new(self.proxy.device_full(self.route.clone()).unwrap()), n: n, stream: stream.unwrap_or_default(), columns: columns.unwrap_or_default(), @@ -131,7 +139,7 @@ impl PyDevice { } fn _get_metadata<'py>(&self, py: Python<'py>) -> PyResult> { - let mut device = data::Device::new(self.proxy.device_full(self.route.clone()).unwrap()); + let mut device = device::Device::new(self.proxy.device_full(self.route.clone()).unwrap()); let meta = match device.get_metadata() { Ok(meta) => meta, Err(_) => return Err(PyRuntimeError::new_err("Failed to get metadata")), From 04245aaa071320a7f34eed409431427d52f52807 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 24 Mar 2026 11:09:09 -0400 Subject: [PATCH 02/26] Fix: Don't override existing rpc with new survey --- python/twinleaf/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 99fe8ab..e706d31 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -111,7 +111,10 @@ def _instantiate_rpcs(self): parent = getattr(parent, token) cls = self._get_rpc_obj(name, meta) - setattr(parent, mname, cls()) + rpc = cls() + if hasattr(parent, mname): + rpc.__dict__ |= getattr(parent, mname).__dict__ + setattr(parent, mname, rpc) def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] = []): samples = list(self._samples(n, stream=stream, columns=columns)) From dc321145506ed03b7d5b6ad9af536301cdd6d48d Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 24 Mar 2026 14:54:31 -0400 Subject: [PATCH 03/26] Chore: clean up namespace --- python/twinleaf/__init__.py | 28 +++++++++++++++++----------- python/twinleaf/itl.py | 26 +++++++++++++------------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index e706d31..9112440 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -1,8 +1,6 @@ -import twinleaf._twinleaf -import struct -from .itl import * +from twinleaf._twinleaf import Device -class Device(_twinleaf.Device): +class Device(Device): def __new__(cls, url=None, route=None, announce=False, instantiate=True): device = super().__new__(cls, url, route) return device @@ -31,15 +29,23 @@ def _rpc_int(self, name: str, size: int, signed: bool, value: int | None = None) fstr = ' float: fstr = ' Date: Wed, 25 Mar 2026 23:24:23 -0400 Subject: [PATCH 04/26] Chore: Underscore _twinleaf._Device to avoid potentially weird namespacing --- python/twinleaf/__init__.py | 8 ++------ rust/src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 9112440..0b439e0 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -1,6 +1,6 @@ -from twinleaf._twinleaf import Device +from twinleaf._twinleaf import _Device -class Device(Device): +class Device(_Device): def __new__(cls, url=None, route=None, announce=False, instantiate=True): device = super().__new__(cls, url, route) return device @@ -223,7 +223,3 @@ def _interact(self): repl.interact( banner = "", exitmsg = "") - -#__doc__ = twinleaf.__doc__ -#if hasattr(twinleaf, "__all__"): -# __all__ = twinleaf.__all__ diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 80c0453..5f2e915 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -81,7 +81,7 @@ impl PyIter { } } -#[pyclass(name = "Device", subclass)] +#[pyclass(name = "_Device", subclass)] struct PyDevice { proxy: proxy::Interface, route: proto::DeviceRoute, From 3bfb715fdfd6005b1561925ee063d5f9359096e7 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 00:13:01 -0400 Subject: [PATCH 05/26] Feat (rust): PyRpc (_twinleaf._Rpc) and PyRegistry (_twinleaf._RpcRegistry) to interface with RpcDescriptor and RpcRegistry --- python/twinleaf/__init__.py | 4 +- rust/src/lib.rs | 95 +++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 0b439e0..83b64e7 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -1,6 +1,6 @@ -from twinleaf._twinleaf import _Device +from twinleaf import _twinleaf -class Device(_Device): +class Device(_twinleaf._Device): def __new__(cls, url=None, route=None, announce=False, instantiate=True): device = super().__new__(cls, url, route) return device diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5f2e915..1325554 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,11 +1,10 @@ use ::twinleaf::tio::*; -use ::twinleaf::device::*; use ::twinleaf::*; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; -#[pyclass(name = "DataIterator", subclass)] +#[pyclass(name = "_DataIterator", subclass)] struct PyIter { port: device::Device, n: Option, @@ -81,6 +80,87 @@ impl PyIter { } } +#[pyclass(name = "_Rpc")] +#[derive(Clone)] +struct PyRpc { + inner: device::RpcDescriptor, +} + +#[pymethods] +impl PyRpc { + #[getter] + fn name(&self) -> String { + self.inner.full_name.clone() + } + + #[getter] + fn readable(&self) -> bool { + self.inner.readable + } + + #[getter] + fn writable(&self) -> bool { + self.inner.writable + } + + #[getter] + fn size_bytes(&self) -> Option { + self.inner.size_bytes() + } + + #[getter] + fn type_str(&self) -> String { + self.inner.type_str() + } + + fn __repr__(&self) -> String { + format!( + "Rpc(name='{}', type='{}', {})", + self.inner.full_name, + self.inner.type_str(), + self.inner.perm_str() + ) + } +} + +#[pyclass(name = "_RpcRegistry")] +struct PyRegistry { + inner: device::RpcRegistry, +} + +#[pymethods] +impl PyRegistry { + fn children_of(&self, prefix: &str) -> Vec { + self.inner.children_of(prefix) + } + + fn find(&self, name: &str) -> Option { + self.inner.find(name).map(|desc| PyRpc { inner: desc.clone() }) + } + + fn suggest(&self, query: &str) -> Vec { + self.inner.suggest(query) + } + + fn search(&self, query: &str) -> Vec { + self.inner.search(query) + } + + #[getter] + fn hash(&self) -> Option { + self.inner.hash + } + + fn __repr__(&self) -> String { + let count = self.inner.search("").len(); + if let Some(h) = self.inner.hash { + format!("Registry({} RPCs, hash=0x{:08x})", count, h) + } else { + format!("Registry({} RPCs)", count) + } + } +} + #[pyclass(name = "_Device", subclass)] struct PyDevice { proxy: proxy::Interface, @@ -104,7 +184,7 @@ impl PyDevice { proto::DeviceRoute::root() }; let proxy = proxy::Interface::new(&root); - let rpc = RpcClient::open(&proxy, route.clone()).unwrap(); + let rpc = device::RpcClient::open(&proxy, route.clone()).unwrap(); Ok(PyDevice { proxy, route, rpc }) } @@ -122,6 +202,13 @@ impl PyDevice { } } + fn _rpc_registry(&self) -> PyResult { + match self.rpc.rpc_list(&self.route) { + Ok(list) => Ok(PyRegistry { inner: device::RpcRegistry::from(&list) }), + Err(e) => Err(PyRuntimeError::new_err(format!("{:?}", e))), + } + } + #[pyo3(signature = (n=None, stream=None, columns=None))] fn _samples<'py>( &self, @@ -187,5 +274,7 @@ impl PyDevice { #[pymodule] fn _twinleaf(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } From 1c7c7cb4840f940c761bc8f04af53ae2b756248e Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 00:14:07 -0400 Subject: [PATCH 06/26] Chore: Fix struct logic in _rpc_int and _rpc_float --- python/twinleaf/__init__.py | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 83b64e7..6defd8a 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -12,37 +12,25 @@ def __init__(self, url=None, route=None, announce=False, instantiate=True): self._instantiate_samples(announce) def _rpc_int(self, name: str, size: int, signed: bool, value: int | None = None) -> int: - # print(name) - if signed: - match size: - case 1: - fstr = ' float: + import struct fstr = ' Date: Thu, 26 Mar 2026 00:15:04 -0400 Subject: [PATCH 07/26] Feat: Factory methods w/ base classes for metaprogramming --- python/twinleaf/__init__.py | 175 +++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 73 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 6defd8a..343fd31 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -35,80 +35,23 @@ def _rpc_float(self, name: str, size: int, value: float | None = None) -> float: del struct return val - def _get_rpc_obj(self, name: str, meta: int): - data_type = (meta & 0xF) - data_size = (meta >> 4) & 0xF - if (meta & 0x8000) == 0: - def rpc_method(local_self, arg: bytes = b'') -> bytes: - return self._rpc(name, arg) - elif data_size == 0: - def rpc_method(local_self) -> None: - return self._rpc(name, b'') - elif data_type in (0, 1): - signed = (data_type) == 1 - if (meta & 0x0200) == 0: - def rpc_method(local_self) -> int: - return self._rpc_int(name, data_size, signed) - else: - def rpc_method(local_self, arg: int | None = None) -> int: - return self._rpc_int(name, data_size, signed, arg) - elif data_type == 2: - if (meta & 0x0200) == 0: - def rpc_method(local_self) -> float: - return self._rpc_float(name, data_size) - else: - def rpc_method(local_self, arg: float | None = None) -> float: - return self._rpc_float(name, data_size, arg) - elif data_type == 3: - if (meta & 0x0200) == 0: - def rpc_method(local_self) -> str: - return self._rpc(name, b'').decode() - else: - def rpc_method(local_self, arg: str | None = None) -> str: - return self._rpc(name, arg.encode()).decode() - cls = type('rpc',(), {'__name__':name, '__call__':rpc_method, '_data_type':data_type, '_data_size':data_size}) - return cls - - def _get_obj_survey(self, name: str): - def survey(local_self): - survey = {} - for name, attr in local_self.__dict__.items(): - if callable(attr): - if hasattr(attr, '_data_type'): - # don't call actions like reset, stop, etc. - if attr._data_type > 0 or attr._data_size > 0: - survey[attr.__name__] = attr() - else: - if attr.__class__.__name__ == 'survey': - subsurvey = attr() - survey = {**survey, **subsurvey} - return survey - cls = type('survey',(), {'__name__':name, '__call__':survey}) - return cls - def _instantiate_rpcs(self): - cls = self._get_obj_survey(self) - setattr(self, 'settings', cls()) - - rpc_list = self._rpc_list() - for (name, meta) in rpc_list: - parent = self.settings - - *prefix, mname = name.split('.') - if prefix and (prefix[0] == "rpc"): - prefix[0] = "_rpc" - - for token in prefix: - if not hasattr(parent, token): - cls = self._get_obj_survey(token) - setattr(parent, token, cls()) - parent = getattr(parent, token) - - cls = self._get_rpc_obj(name, meta) - rpc = cls() - if hasattr(parent, mname): - rpc.__dict__ |= getattr(parent, mname).__dict__ - setattr(parent, mname, rpc) + self._registry = self._rpc_registry() + self.settings = RpcSurvey('settings') + self._instantiate_rpcs_recursive(self.settings) + + def _instantiate_rpcs_recursive(self, parent, prefix=''): + for child_name in self._registry.children_of(prefix): + full_path = f'{prefix}.{child_name}' if prefix else child_name + rpc = self._registry.find(full_path) + attr_name = '_rpc' if child_name == 'rpc' else child_name + + if rpc is not None: + child = Rpc(rpc, self) + else: + child = RpcSurvey(attr_name) + setattr(parent, attr_name, child) + self._instantiate_rpcs_recursive(child, full_path) def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] = []): samples = list(self._samples(n, stream=stream, columns=columns)) @@ -211,3 +154,89 @@ def _interact(self): repl.interact( banner = "", exitmsg = "") + +type _rpc_type = int | float | str | bytes | None +class _RpcNode: + def __init__(self, name, device: Device): + self.__name__ = name + self._device = device + + def survey(self) -> dict[str, _rpc_type]: + results = {} + for name, attr in self.__dict__.items(): + if isinstance(attr, _RpcNode): + # Check if it's an RPC that should be read + if isinstance(attr, _RpcBase): + if attr._readable and attr._data_type is not None: + results[attr.__name__] = attr() + + # Recursively survey children (works for both Rpc and Survey) + results |= attr.survey() + return results + +class _RpcBase(_RpcNode): + def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): + super().__init__(pyrpc.name, device) + self._size_bytes = pyrpc.size_bytes + self._readable = pyrpc.readable + self._writable = pyrpc.writable + match pyrpc.type_str: + case _ if _.startswith('i'): self._data_type, self._signed = int, True + case _ if _.startswith('u'): self._data_type, self._signed = int, False + case _ if _.startswith('f'): self._data_type = float + case _ if _.startswith('s'): self._data_type = str + case '' if self._size_bytes == 0: self._data_type = None + case other: self._data_type = bytes + + def _call_with_arg(self, arg=None) -> _rpc_type: + match self._data_type: + case _ if _ is int: + return self._device._rpc_int(self.__name__, self._size_bytes, self._signed, arg) + case _ if _ is float: + return self._device._rpc_float(self.__name__, self._size_bytes, arg) + case _ if _ is str: + return self._device._rpc(self.__name__, arg.encode()).decode() + case _ if _ is bytes: + return self._device._rpc(self.__name__, arg) + case None: + return self._device._rpc(self.__name__, b'') + case other: + raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") + + def _call(self) -> _rpc_type: + match self._data_type: + case _ if _ is int: + return self._device._rpc_int(self.__name__, self._size_bytes, self._signed) + case _ if _ is float: + return self._device._rpc_float(self.__name__, self._size_bytes) + case _ if _ is str: + return self._device._rpc(self.__name__, b'').decode() + case _ if _ is bytes | None: + return self._device._rpc(self.__name__, b'') + case other: + raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") + +class _RpcSurveyBase(_RpcNode): + def __init__(self, name: str, device: Device): + super().__init__(name, device) + + def __call__(self): + return self.survey() + +def _Rpc(pyrpc: _twinleaf._Rpc, device: Device) -> _RpcNode: + if pyrpc.writable: + def __call__(self, arg=None) -> _rpc_type: + if arg is None: + return self._call() + else: + return self._call_with_arg(arg) + else: + def __call__(self) -> _rpc_type: + return self._call() + + cls = type('Rpc', (_RpcBase,), {'__call__': __call__}) + return cls(pyrpc, device) + +def _RpcSurvey(name: str) -> _RpcNode: + cls = type('Survey', (_RpcSurveyBase,), {}) + return cls(name, device) From ff00ab31a315a56e3e087f065ad588ceb9fc66a0 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 00:19:38 -0400 Subject: [PATCH 08/26] Comments for RPC functions/classes --- python/twinleaf/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 343fd31..4de9375 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -1,6 +1,7 @@ from twinleaf import _twinleaf class Device(_twinleaf._Device): + """ Primary TIO interface with sensor object """ def __new__(cls, url=None, route=None, announce=False, instantiate=True): device = super().__new__(cls, url, route) return device @@ -12,6 +13,7 @@ def __init__(self, url=None, route=None, announce=False, instantiate=True): self._instantiate_samples(announce) def _rpc_int(self, name: str, size: int, signed: bool, value: int | None = None) -> int: + """ Use struct to send int-typed RPCs """ import struct match size, signed: case 1, True: fstr = ' float: + """ Use struct to send float-typed RPCs """ import struct fstr = ' float: return val def _instantiate_rpcs(self): + """ Set up Device.samples, then recursively instantiate RPCs """ self._registry = self._rpc_registry() self.settings = RpcSurvey('settings') self._instantiate_rpcs_recursive(self.settings) def _instantiate_rpcs_recursive(self, parent, prefix=''): + """ Get children from registry, setattr them, then recurse """ for child_name in self._registry.children_of(prefix): full_path = f'{prefix}.{child_name}' if prefix else child_name rpc = self._registry.find(full_path) @@ -157,11 +162,13 @@ def _interact(self): type _rpc_type = int | float | str | bytes | None class _RpcNode: + """ Base class for RPCs and surveys in the device tree """ def __init__(self, name, device: Device): self.__name__ = name self._device = device def survey(self) -> dict[str, _rpc_type]: + """ Recursively collect all readable RPC values in this subtree """ results = {} for name, attr in self.__dict__.items(): if isinstance(attr, _RpcNode): @@ -175,6 +182,7 @@ def survey(self) -> dict[str, _rpc_type]: return results class _RpcBase(_RpcNode): + """ Internal base class for RPCs """ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): super().__init__(pyrpc.name, device) self._size_bytes = pyrpc.size_bytes @@ -217,6 +225,7 @@ def _call(self) -> _rpc_type: raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") class _RpcSurveyBase(_RpcNode): + """ Internal class for RPC surveys """ def __init__(self, name: str, device: Device): super().__init__(name, device) @@ -224,6 +233,7 @@ def __call__(self): return self.survey() def _Rpc(pyrpc: _twinleaf._Rpc, device: Device) -> _RpcNode: + """ Factory function that creates an RPC with appropriate __call__ signature """ if pyrpc.writable: def __call__(self, arg=None) -> _rpc_type: if arg is None: @@ -238,5 +248,6 @@ def __call__(self) -> _rpc_type: return cls(pyrpc, device) def _RpcSurvey(name: str) -> _RpcNode: + """ Factory function that creates an RPC survey """ cls = type('Survey', (_RpcSurveyBase,), {}) return cls(name, device) From 5ce9b6161762507a02ad51d45e178e775f51a2a5 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 00:42:51 -0400 Subject: [PATCH 09/26] Fix: Underscore factory methods and give _RpcSurvey device --- python/twinleaf/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 4de9375..cc34edf 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -41,7 +41,7 @@ def _rpc_float(self, name: str, size: int, value: float | None = None) -> float: def _instantiate_rpcs(self): """ Set up Device.samples, then recursively instantiate RPCs """ self._registry = self._rpc_registry() - self.settings = RpcSurvey('settings') + self.settings = _RpcSurvey('settings') self._instantiate_rpcs_recursive(self.settings) def _instantiate_rpcs_recursive(self, parent, prefix=''): @@ -52,9 +52,9 @@ def _instantiate_rpcs_recursive(self, parent, prefix=''): attr_name = '_rpc' if child_name == 'rpc' else child_name if rpc is not None: - child = Rpc(rpc, self) + child = _Rpc(rpc, self) else: - child = RpcSurvey(attr_name) + child = _RpcSurvey(attr_name, self) setattr(parent, attr_name, child) self._instantiate_rpcs_recursive(child, full_path) @@ -247,7 +247,7 @@ def __call__(self) -> _rpc_type: cls = type('Rpc', (_RpcBase,), {'__call__': __call__}) return cls(pyrpc, device) -def _RpcSurvey(name: str) -> _RpcNode: +def _RpcSurvey(name: str, device: Device) -> _RpcNode: """ Factory function that creates an RPC survey """ cls = type('Survey', (_RpcSurveyBase,), {}) return cls(name, device) From ed0caf8f69b2ef552b1e3674a7f78baff60c431a Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 00:44:32 -0400 Subject: [PATCH 10/26] Feat: Factory methods for samples --- python/twinleaf/__init__.py | 57 +++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index cc34edf..82295b9 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -88,20 +88,6 @@ def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] = [], t dataRows.insert(0,columnNames) return dataRows - def _get_obj_samples_dict(self, name: str, stream: str = "", columns: list[str] = [], *args, **kwargs): - def samples_method(local_self, *args, **kwargs): - # print(f"Sampling {name} from stream {stream} with columns {columns}") - return self._samples_dict(stream=stream, columns=columns, *args, **kwargs) - cls = type('samplesDict'+name,(), {'__name__':name, '__call__':samples_method}) - return cls - - def _get_obj_samples_list(self, name: str, stream: str = "", columns: list[str] = [], *args, **kwargs): - def samples_method(local_self, *args, **kwargs): - # print(f"Sampling {name} from stream {stream} with columns {columns}") - return self._samples_list(stream=stream, columns=columns, *args, **kwargs) - cls = type('samplesList'+name,(), {'__name__':name, '__call__':samples_method}) - return cls - def _instantiate_samples(self, announce: bool = False): metadata = self._get_metadata() dev_meta = metadata['device'] @@ -112,9 +98,8 @@ def _instantiate_samples(self, announce: bool = False): for column_name in value['columns'].keys(): streams_flattened.append(stream+"."+column_name) - # All samples - cls = self._get_obj_samples_dict("samples", stream="", columns=[]) - setattr(self, 'samples', cls()) + # All samples + self.samples = _SamplesDict(self, "samples", stream="", columns=[])) for stream_column in streams_flattened: mname, *prefix, stream = reversed(stream_column.split(".")) @@ -122,25 +107,22 @@ def _instantiate_samples(self, announce: bool = False): if not hasattr(parent, stream): # All samples for this stream - cls = self._get_obj_samples_list(stream, stream=stream, columns=[]) - setattr(parent, stream, cls()) + setattr(parent, stream, _SamplesList(self, stream, stream=stream, columns=[])) parent = getattr(parent, stream) stream_prefix = "" for token in reversed(prefix): - + stream_prefix += "." + token if not hasattr(parent, token): #wildcard columns - cls = self._get_obj_samples_list(token, stream=stream, columns=[stream_prefix[1:]+".*"]) - setattr(parent, token, cls()) + setattr(parent, token, _SamplesList(self, token, stream=stream, columns=[stream_prefix[1:]+".*"])) parent = getattr(parent, token) # specific stream samples stream, column_name = stream_column.split(".",1) - cls = self._get_obj_samples_list(mname, stream=stream, columns=[column_name]) - setattr(parent, mname, cls()) + setattr(parent, mname, _SamplesList(self, mname, stream=stream, columns=[column_name])) def _interact(self): imported_objects = {} @@ -251,3 +233,30 @@ def _RpcSurvey(name: str, device: Device) -> _RpcNode: """ Factory function that creates an RPC survey """ cls = type('Survey', (_RpcSurveyBase,), {}) return cls(name, device) + +# Samples classes +class _SamplesBase: + """ Base class for sample objects """ + def __init__(self, device: Device, name: str, stream: str, columns: list[str]): + self._device = device + self.__name__ = name + self._stream = stream + self._columns = columns + +class _SamplesDictBase(_SamplesBase): + """ Returns samples as dict keyed by stream_id """ + def __call__(self, n: int = 1, **kwargs): + return self._device._samples_dict(n, self._stream, self._columns, **kwargs) + +class _SamplesListBase(_SamplesBase): + """ Returns samples as list for single stream """ + def __call__(self, n: int = 1, **kwargs): + return self._device._samples_list(n, self._stream, self._columns, **kwargs) + +def _SamplesDict(device: Device, name: str, stream: str = "", columns: list[str] = []): + """ Factory function that creates a sample dict """ + return _SamplesDictBase(device, name, stream, columns) + +def _SamplesList(device: Device, name: str, stream: str = "", columns: list[str] = []): + """ Factory function that creates a sample list """ + return _SamplesListBase(device, name, stream, columns) From 75e143e460aed056d53946bde06c67d026b58371 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 00:49:21 -0400 Subject: [PATCH 11/26] Chore: clean up _samples_list and _instantiate_samples code --- python/twinleaf/__init__.py | 45 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 82295b9..6cca0e8 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -60,7 +60,7 @@ def _instantiate_rpcs_recursive(self, parent, prefix=''): def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] = []): samples = list(self._samples(n, stream=stream, columns=columns)) - # bin into streams + # Bin into streams streams = {} for line in samples: stream_id = line.pop("stream", None) @@ -72,57 +72,56 @@ def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] = []): streams[stream_id][key].append(value) return streams - def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] = [], timeColumn = True, titleRow = True): + def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] = [], time_column = True, title_row = True): streams = self._samples_dict(n, stream, columns) # Convert to list with rows of data. Not super happy about how inefficient this is. if len(streams.items()) > 1: raise NotImplementedError("Stream concatenation not yet implemented for two different streams") stream = list(streams.values())[0] stream.pop('stream') - if not timeColumn: + if not time_column: stream.pop('time') - dataColumns = [column for column in stream.values() ] - dataRows = [list(row) for row in zip(*dataColumns)] - if titleRow: - columnNames = list(stream.keys()); - dataRows.insert(0,columnNames) - return dataRows - - def _instantiate_samples(self, announce: bool = False): + data_columns = [column for column in stream.values() ] + data_rows = [list(row) for row in zip(*data_columns)] + if title_row: + column_names = list(stream.keys()); + data_rows.insert(0,column_names) + return data_rows + + def _instantiate_samples(self, announce: bool=False): metadata = self._get_metadata() - dev_meta = metadata['device'] if announce: + dev_meta = metadata['device'] print(f"{dev_meta['name']} ({dev_meta['serial_number']}) [{dev_meta['firmware_hash']}]") + streams_flattened = [] for stream, value in metadata['streams'].items(): for column_name in value['columns'].keys(): streams_flattened.append(stream+"."+column_name) # All samples - self.samples = _SamplesDict(self, "samples", stream="", columns=[])) + self.samples = _SamplesDict(self, "samples", stream="", columns=[]) for stream_column in streams_flattened: - mname, *prefix, stream = reversed(stream_column.split(".")) + stream, *prefix, mname = stream_column.split(".") parent = self.samples + # All samples for this stream if not hasattr(parent, stream): - # All samples for this stream - setattr(parent, stream, _SamplesList(self, stream, stream=stream, columns=[])) + setattr(parent, stream, _SamplesList(self, name=stream, stream=stream, columns=[])) parent = getattr(parent, stream) + # Wildcard columns within stream stream_prefix = "" - for token in reversed(prefix): - + for token in prefix: stream_prefix += "." + token if not hasattr(parent, token): - #wildcard columns - setattr(parent, token, _SamplesList(self, token, stream=stream, columns=[stream_prefix[1:]+".*"])) + setattr(parent, token, _SamplesList(self, token, stream, columns=[stream_prefix[1:]+".*"])) parent = getattr(parent, token) - # specific stream samples + # Specific stream samples stream, column_name = stream_column.split(".",1) - - setattr(parent, mname, _SamplesList(self, mname, stream=stream, columns=[column_name])) + setattr(parent, mname, _SamplesList(self, mname, stream, columns=[column_name])) def _interact(self): imported_objects = {} From 489fbd42625aa2cc51c87d17bc0c7d57e3229890 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 01:14:57 -0400 Subject: [PATCH 12/26] Feat: 64-bit ints --- python/twinleaf/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 6cca0e8..035b986 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -19,9 +19,11 @@ def _rpc_int(self, name: str, size: int, signed: bool, value: int | None = None) case 1, True: fstr = ' Date: Thu, 26 Mar 2026 01:15:25 -0400 Subject: [PATCH 13/26] Chore: Add missing self argument to _RpcSurvey and remove unnecessary del struct --- python/twinleaf/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 035b986..5d27b30 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -27,7 +27,6 @@ def _rpc_int(self, name: str, size: int, signed: bool, value: int | None = None) payload = b'' if value is None else struct.pack(fstr, value) rep = self._rpc(name, payload) val = struct.unpack(fstr, rep)[0] - del struct return val def _rpc_float(self, name: str, size: int, value: float | None = None) -> float: @@ -37,13 +36,12 @@ def _rpc_float(self, name: str, size: int, value: float | None = None) -> float: payload = b'' if value is None else struct.pack(fstr, value) rep = self._rpc(name, payload) val = struct.unpack(fstr, rep)[0] - del struct return val def _instantiate_rpcs(self): """ Set up Device.samples, then recursively instantiate RPCs """ self._registry = self._rpc_registry() - self.settings = _RpcSurvey('settings') + self.settings = _RpcSurvey('settings', self) self._instantiate_rpcs_recursive(self.settings) def _instantiate_rpcs_recursive(self, parent, prefix=''): From 72a8306d6d5e09847a470d58e5c9b3d20924403b Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 01:15:51 -0400 Subject: [PATCH 14/26] Fix: Remove mutable defaults and fix bytes | None pattern guard --- python/twinleaf/__init__.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 5d27b30..4f5eb17 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -58,7 +58,8 @@ def _instantiate_rpcs_recursive(self, parent, prefix=''): setattr(parent, attr_name, child) self._instantiate_rpcs_recursive(child, full_path) - def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] = []): + def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] | None=None): + if columns is None: columns = [] # Avoid mutable default samples = list(self._samples(n, stream=stream, columns=columns)) # Bin into streams streams = {} @@ -72,7 +73,8 @@ def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] = []): streams[stream_id][key].append(value) return streams - def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] = [], time_column = True, title_row = True): + def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] | None=None, time_column = True, title_row = True): + if columns is None: columns = [] # Avoid mutable default streams = self._samples_dict(n, stream, columns) # Convert to list with rows of data. Not super happy about how inefficient this is. if len(streams.items()) > 1: @@ -84,7 +86,7 @@ def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] = [], t data_columns = [column for column in stream.values() ] data_rows = [list(row) for row in zip(*data_columns)] if title_row: - column_names = list(stream.keys()); + column_names = list(stream.keys()) data_rows.insert(0,column_names) return data_rows @@ -200,7 +202,7 @@ def _call(self) -> _rpc_type: return self._device._rpc_float(self.__name__, self._size_bytes) case _ if _ is str: return self._device._rpc(self.__name__, b'').decode() - case _ if _ is bytes | None: + case _ if _ is bytes or _ is None: return self._device._rpc(self.__name__, b'') case other: raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") @@ -252,10 +254,10 @@ class _SamplesListBase(_SamplesBase): def __call__(self, n: int = 1, **kwargs): return self._device._samples_list(n, self._stream, self._columns, **kwargs) -def _SamplesDict(device: Device, name: str, stream: str = "", columns: list[str] = []): +def _SamplesDict(device: Device, name: str, stream: str = "", columns: list[str] | None=None): """ Factory function that creates a sample dict """ - return _SamplesDictBase(device, name, stream, columns) + return _SamplesDictBase(device, name, stream, columns if columns is not None else []) -def _SamplesList(device: Device, name: str, stream: str = "", columns: list[str] = []): +def _SamplesList(device: Device, name: str, stream: str = "", columns: list[str] | None=None): """ Factory function that creates a sample list """ - return _SamplesListBase(device, name, stream, columns) + return _SamplesListBase(device, name, stream, columns if columns is not None else []) From 0a995a50738f3417ce6679f6d0f2a52ea2250b77 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 10:12:28 -0400 Subject: [PATCH 15/26] Fix: Change _ in cases to t so guards actually work --- python/twinleaf/__init__.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 4f5eb17..05f2585 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -172,22 +172,22 @@ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): self._readable = pyrpc.readable self._writable = pyrpc.writable match pyrpc.type_str: - case _ if _.startswith('i'): self._data_type, self._signed = int, True - case _ if _.startswith('u'): self._data_type, self._signed = int, False - case _ if _.startswith('f'): self._data_type = float - case _ if _.startswith('s'): self._data_type = str + case t if t.startswith('i'): self._data_type, self._signed = int, True + case t if t.startswith('u'): self._data_type, self._signed = int, False + case t if t.startswith('f'): self._data_type = float + case t if t.startswith('s'): self._data_type = str case '' if self._size_bytes == 0: self._data_type = None case other: self._data_type = bytes def _call_with_arg(self, arg=None) -> _rpc_type: match self._data_type: - case _ if _ is int: + case t if t is int: return self._device._rpc_int(self.__name__, self._size_bytes, self._signed, arg) - case _ if _ is float: + case t if t is float: return self._device._rpc_float(self.__name__, self._size_bytes, arg) - case _ if _ is str: + case t if t is str: return self._device._rpc(self.__name__, arg.encode()).decode() - case _ if _ is bytes: + case t if t is bytes: return self._device._rpc(self.__name__, arg) case None: return self._device._rpc(self.__name__, b'') @@ -196,13 +196,13 @@ def _call_with_arg(self, arg=None) -> _rpc_type: def _call(self) -> _rpc_type: match self._data_type: - case _ if _ is int: + case t if t is int: return self._device._rpc_int(self.__name__, self._size_bytes, self._signed) - case _ if _ is float: + case t if t is float: return self._device._rpc_float(self.__name__, self._size_bytes) - case _ if _ is str: + case t if t is str: return self._device._rpc(self.__name__, b'').decode() - case _ if _ is bytes or _ is None: + case t if t is bytes or _ is None: return self._device._rpc(self.__name__, b'') case other: raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") From 9c829c462d34c50e813c7d4471f092e3241e9d75 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 12:11:55 -0400 Subject: [PATCH 16/26] Update examples/tl-samples.py --- examples/tl-samples.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/examples/tl-samples.py b/examples/tl-samples.py index 948a308..aa548e8 100755 --- a/examples/tl-samples.py +++ b/examples/tl-samples.py @@ -1,12 +1,23 @@ #!/usr/bin/env python3 import twinleaf +import pprint dev = twinleaf.Device() -# columns = [] # All samples -columns = ["imu.accel*"] # Wildcard -# columns = ["imu.accel.x", "imu.accel.y", "imu.accel.z"] # Specific columns +samples_dict_getter = dev.samples # All samples +samples_list_getter = dev.samples.imu.imu.accel # Wildcard samples +#samples_list_getter = dev.samples.imu.imu.accel.x # Specific column -for sample in dev._samples(n=None, columns=columns): - print(sample) +samples_dict = samples_dict_getter(n=10) +for _id, stream in samples_dict.items(): + for column, values in stream.items(): + print(f"{column}: {values}") + print() +print() + +samples_list = samples_list_getter(n=10) +for sample in samples_list: + for column in sample: + print(f"{column:<20}", end='') + print() From b57d39026a5d50c88168ac35c168826e3be28943 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 12:17:17 -0400 Subject: [PATCH 17/26] Chore: Type hint rpc._call_with_arg & hide _RpcNode._survey --- python/twinleaf/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 05f2585..d53dcc5 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -150,7 +150,7 @@ def __init__(self, name, device: Device): self.__name__ = name self._device = device - def survey(self) -> dict[str, _rpc_type]: + def _survey(self) -> dict[str, _rpc_type]: """ Recursively collect all readable RPC values in this subtree """ results = {} for name, attr in self.__dict__.items(): @@ -161,7 +161,7 @@ def survey(self) -> dict[str, _rpc_type]: results[attr.__name__] = attr() # Recursively survey children (works for both Rpc and Survey) - results |= attr.survey() + results |= attr._survey() return results class _RpcBase(_RpcNode): @@ -179,7 +179,7 @@ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): case '' if self._size_bytes == 0: self._data_type = None case other: self._data_type = bytes - def _call_with_arg(self, arg=None) -> _rpc_type: + def _call_with_arg(self, arg: _rpc_type=None) -> _rpc_type: match self._data_type: case t if t is int: return self._device._rpc_int(self.__name__, self._size_bytes, self._signed, arg) @@ -213,12 +213,12 @@ def __init__(self, name: str, device: Device): super().__init__(name, device) def __call__(self): - return self.survey() + return self._survey() def _Rpc(pyrpc: _twinleaf._Rpc, device: Device) -> _RpcNode: """ Factory function that creates an RPC with appropriate __call__ signature """ if pyrpc.writable: - def __call__(self, arg=None) -> _rpc_type: + def __call__(self, arg: _rpc_type=None) -> _rpc_type: if arg is None: return self._call() else: @@ -246,18 +246,18 @@ def __init__(self, device: Device, name: str, stream: str, columns: list[str]): class _SamplesDictBase(_SamplesBase): """ Returns samples as dict keyed by stream_id """ - def __call__(self, n: int = 1, **kwargs): + def __call__(self, n: int=1, **kwargs): return self._device._samples_dict(n, self._stream, self._columns, **kwargs) class _SamplesListBase(_SamplesBase): """ Returns samples as list for single stream """ - def __call__(self, n: int = 1, **kwargs): + def __call__(self, n: int=1, **kwargs): return self._device._samples_list(n, self._stream, self._columns, **kwargs) -def _SamplesDict(device: Device, name: str, stream: str = "", columns: list[str] | None=None): +def _SamplesDict(device: Device, name: str, stream: str="", columns: list[str] | None=None): """ Factory function that creates a sample dict """ return _SamplesDictBase(device, name, stream, columns if columns is not None else []) -def _SamplesList(device: Device, name: str, stream: str = "", columns: list[str] | None=None): +def _SamplesList(device: Device, name: str, stream: str="", columns: list[str] | None=None): """ Factory function that creates a sample list """ return _SamplesListBase(device, name, stream, columns if columns is not None else []) From 1f21ea009f34fd34968339ebb82ad9721887970d Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 13:52:52 -0400 Subject: [PATCH 18/26] Chore: don't confusingly override stream variable name --- python/twinleaf/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index d53dcc5..94480e4 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -79,14 +79,14 @@ def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] | None= # Convert to list with rows of data. Not super happy about how inefficient this is. if len(streams.items()) > 1: raise NotImplementedError("Stream concatenation not yet implemented for two different streams") - stream = list(streams.values())[0] - stream.pop('stream') + stream_dict = list(streams.values())[0] + stream_dict.pop('stream') if not time_column: - stream.pop('time') - data_columns = [column for column in stream.values() ] + stream_dict.pop('time') + data_columns = [column for column in stream_dict.values() ] data_rows = [list(row) for row in zip(*data_columns)] if title_row: - column_names = list(stream.keys()) + column_names = list(stream_dict.keys()) data_rows.insert(0,column_names) return data_rows From 4286aed1254886005a5e2a2e19a920d567da8b8d Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 13:54:35 -0400 Subject: [PATCH 19/26] Chore: remove unnecessary _RpcNode._readable and type hint _RpcBase._data_type --- python/twinleaf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 94480e4..2516963 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -157,7 +157,7 @@ def _survey(self) -> dict[str, _rpc_type]: if isinstance(attr, _RpcNode): # Check if it's an RPC that should be read if isinstance(attr, _RpcBase): - if attr._readable and attr._data_type is not None: + if attr._data_type not in { None, bytes }: results[attr.__name__] = attr() # Recursively survey children (works for both Rpc and Survey) @@ -169,8 +169,8 @@ class _RpcBase(_RpcNode): def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): super().__init__(pyrpc.name, device) self._size_bytes = pyrpc.size_bytes - self._readable = pyrpc.readable self._writable = pyrpc.writable + self._data_type: type | None = None match pyrpc.type_str: case t if t.startswith('i'): self._data_type, self._signed = int, True case t if t.startswith('u'): self._data_type, self._signed = int, False From fed1030782d56713a0c775f23321e47a28344a65 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 13:55:15 -0400 Subject: [PATCH 20/26] Feat: Consolidate _RpcBase._call and change unnecssary factory functions into class initializers --- python/twinleaf/__init__.py | 61 +++++++++++++------------------------ 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 2516963..0339658 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -150,6 +150,9 @@ def __init__(self, name, device: Device): self.__name__ = name self._device = device + def __call__(self) -> dict[str, _rpc_type]: + return self._survey() + def _survey(self) -> dict[str, _rpc_type]: """ Recursively collect all readable RPC values in this subtree """ results = {} @@ -179,60 +182,42 @@ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): case '' if self._size_bytes == 0: self._data_type = None case other: self._data_type = bytes - def _call_with_arg(self, arg: _rpc_type=None) -> _rpc_type: + def _call(self, arg: _rpc_type=None) -> _rpc_type: match self._data_type: case t if t is int: return self._device._rpc_int(self.__name__, self._size_bytes, self._signed, arg) case t if t is float: return self._device._rpc_float(self.__name__, self._size_bytes, arg) case t if t is str: + if arg is None: arg = '' return self._device._rpc(self.__name__, arg.encode()).decode() case t if t is bytes: + if arg is None: arg = b'' return self._device._rpc(self.__name__, arg) case None: return self._device._rpc(self.__name__, b'') case other: raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") - def _call(self) -> _rpc_type: - match self._data_type: - case t if t is int: - return self._device._rpc_int(self.__name__, self._size_bytes, self._signed) - case t if t is float: - return self._device._rpc_float(self.__name__, self._size_bytes) - case t if t is str: - return self._device._rpc(self.__name__, b'').decode() - case t if t is bytes or _ is None: - return self._device._rpc(self.__name__, b'') - case other: - raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") - -class _RpcSurveyBase(_RpcNode): - """ Internal class for RPC surveys """ - def __init__(self, name: str, device: Device): - super().__init__(name, device) - - def __call__(self): - return self._survey() - def _Rpc(pyrpc: _twinleaf._Rpc, device: Device) -> _RpcNode: """ Factory function that creates an RPC with appropriate __call__ signature """ - if pyrpc.writable: - def __call__(self, arg: _rpc_type=None) -> _rpc_type: - if arg is None: - return self._call() - else: - return self._call_with_arg(arg) + base_rpc = _RpcBase(pyrpc, device) + if base_rpc._writable and base_rpc._data_type is not None: + def __call__(self, arg=None): + return self._call(arg) + __call__.__annotations__ |= { 'arg': base_rpc._data_type | None } + __call__.__annotations__ |= { 'return': base_rpc._data_type } else: def __call__(self) -> _rpc_type: return self._call() + __call__.__annotations__ |= { 'return': base_rpc._data_type } cls = type('Rpc', (_RpcBase,), {'__call__': __call__}) return cls(pyrpc, device) def _RpcSurvey(name: str, device: Device) -> _RpcNode: """ Factory function that creates an RPC survey """ - cls = type('Survey', (_RpcSurveyBase,), {}) + cls = type('Survey', (_RpcNode,), {}) return cls(name, device) # Samples classes @@ -244,20 +229,18 @@ def __init__(self, device: Device, name: str, stream: str, columns: list[str]): self._stream = stream self._columns = columns -class _SamplesDictBase(_SamplesBase): +class _SamplesDict(_SamplesBase): """ Returns samples as dict keyed by stream_id """ + def __init__(self, device: Device, name: str, stream: str="", columns: list[str] | None=None): + super().__init__(device, name, stream, columns if columns is not None else [] ) + def __call__(self, n: int=1, **kwargs): return self._device._samples_dict(n, self._stream, self._columns, **kwargs) -class _SamplesListBase(_SamplesBase): +class _SamplesList(_SamplesBase): """ Returns samples as list for single stream """ + def __init__(self, device: Device, name: str, stream: str="", columns: list[str] | None=None): + super().__init__(device, name, stream, columns if columns is not None else [] ) + def __call__(self, n: int=1, **kwargs): return self._device._samples_list(n, self._stream, self._columns, **kwargs) - -def _SamplesDict(device: Device, name: str, stream: str="", columns: list[str] | None=None): - """ Factory function that creates a sample dict """ - return _SamplesDictBase(device, name, stream, columns if columns is not None else []) - -def _SamplesList(device: Device, name: str, stream: str="", columns: list[str] | None=None): - """ Factory function that creates a sample list """ - return _SamplesListBase(device, name, stream, columns if columns is not None else []) From a0a5be812cd64e50f8eeb0ae4ad6052965aaabd0 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 14:13:07 -0400 Subject: [PATCH 21/26] Chore: type hint + docstring _samples_dict and _samples_list; use _RpcBase._call to be safe --- python/twinleaf/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 0339658..91a0e5e 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -58,7 +58,8 @@ def _instantiate_rpcs_recursive(self, parent, prefix=''): setattr(parent, attr_name, child) self._instantiate_rpcs_recursive(child, full_path) - def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] | None=None): + def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] | None=None) -> dict[int, dict[str, list[int | float]]]: + """ Parse underlying sample iterator into dict """ if columns is None: columns = [] # Avoid mutable default samples = list(self._samples(n, stream=stream, columns=columns)) # Bin into streams @@ -73,7 +74,8 @@ def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] | None= streams[stream_id][key].append(value) return streams - def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] | None=None, time_column = True, title_row = True): + def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] | None=None, time_column = True, title_row = True) -> list[list[str | int | float]]: + """ Parse underlying sample iterator into tabular array """ if columns is None: columns = [] # Avoid mutable default streams = self._samples_dict(n, stream, columns) # Convert to list with rows of data. Not super happy about how inefficient this is. @@ -161,7 +163,7 @@ def _survey(self) -> dict[str, _rpc_type]: # Check if it's an RPC that should be read if isinstance(attr, _RpcBase): if attr._data_type not in { None, bytes }: - results[attr.__name__] = attr() + results[attr.__name__] = attr._call() # Recursively survey children (works for both Rpc and Survey) results |= attr._survey() From 3a1d4cea26ba2ff053e99d8406b94e71581203aa Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Mar 2026 14:21:21 -0400 Subject: [PATCH 22/26] Feat: Add backwards compatibility for previous _data_type and _data_size hidden fields --- python/twinleaf/__init__.py | 46 ++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index 91a0e5e..fd3913b 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -162,7 +162,7 @@ def _survey(self) -> dict[str, _rpc_type]: if isinstance(attr, _RpcNode): # Check if it's an RPC that should be read if isinstance(attr, _RpcBase): - if attr._data_type not in { None, bytes }: + if attr._type not in { None, bytes }: results[attr.__name__] = attr._call() # Recursively survey children (works for both Rpc and Survey) @@ -173,23 +173,37 @@ class _RpcBase(_RpcNode): """ Internal base class for RPCs """ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): super().__init__(pyrpc.name, device) - self._size_bytes = pyrpc.size_bytes + self._data_size = pyrpc.size_bytes self._writable = pyrpc.writable - self._data_type: type | None = None + self._type: type | None = None match pyrpc.type_str: - case t if t.startswith('i'): self._data_type, self._signed = int, True - case t if t.startswith('u'): self._data_type, self._signed = int, False - case t if t.startswith('f'): self._data_type = float - case t if t.startswith('s'): self._data_type = str - case '' if self._size_bytes == 0: self._data_type = None - case other: self._data_type = bytes + case t if t.startswith('u'): + self._type = int + self._data_type = 0 + self._signed = False + case t if t.startswith('i'): + self._type = int + self._data_type = 1 + self._signed = True + case t if t.startswith('f'): + self._type = float + self._data_type = 2 + case t if t.startswith('s'): + self._type = str + self._data_type = 3 + case '' if self._data_size == 0: + self._type = None + self._data_type = 0 + case other: + self._type = bytes + self._data_type = 0 def _call(self, arg: _rpc_type=None) -> _rpc_type: - match self._data_type: + match self._type: case t if t is int: - return self._device._rpc_int(self.__name__, self._size_bytes, self._signed, arg) + return self._device._rpc_int(self.__name__, self._data_size, self._signed, arg) case t if t is float: - return self._device._rpc_float(self.__name__, self._size_bytes, arg) + return self._device._rpc_float(self.__name__, self._data_size, arg) case t if t is str: if arg is None: arg = '' return self._device._rpc(self.__name__, arg.encode()).decode() @@ -204,15 +218,15 @@ def _call(self, arg: _rpc_type=None) -> _rpc_type: def _Rpc(pyrpc: _twinleaf._Rpc, device: Device) -> _RpcNode: """ Factory function that creates an RPC with appropriate __call__ signature """ base_rpc = _RpcBase(pyrpc, device) - if base_rpc._writable and base_rpc._data_type is not None: + if base_rpc._writable and base_rpc._type is not None: def __call__(self, arg=None): return self._call(arg) - __call__.__annotations__ |= { 'arg': base_rpc._data_type | None } - __call__.__annotations__ |= { 'return': base_rpc._data_type } + __call__.__annotations__ |= { 'arg': base_rpc._type | None } + __call__.__annotations__ |= { 'return': base_rpc._type } else: def __call__(self) -> _rpc_type: return self._call() - __call__.__annotations__ |= { 'return': base_rpc._data_type } + __call__.__annotations__ |= { 'return': base_rpc._type } cls = type('Rpc', (_RpcBase,), {'__call__': __call__}) return cls(pyrpc, device) From eda877d23b4343d275fc2c381cae42731bbe41ba Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 27 Mar 2026 14:10:09 -0400 Subject: [PATCH 23/26] Feat: Replace runtime-created Rpc class with _RpcReadOnly, _RpcWriteOnly, and _RpcReadWrite --- python/twinleaf/__init__.py | 76 +++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index fd3913b..b823a89 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -41,7 +41,7 @@ def _rpc_float(self, name: str, size: int, value: float | None = None) -> float: def _instantiate_rpcs(self): """ Set up Device.samples, then recursively instantiate RPCs """ self._registry = self._rpc_registry() - self.settings = _RpcSurvey('settings', self) + self.settings = _RpcSurvey('settings') self._instantiate_rpcs_recursive(self.settings) def _instantiate_rpcs_recursive(self, parent, prefix=''): @@ -54,7 +54,7 @@ def _instantiate_rpcs_recursive(self, parent, prefix=''): if rpc is not None: child = _Rpc(rpc, self) else: - child = _RpcSurvey(attr_name, self) + child = _RpcSurvey(attr_name) setattr(parent, attr_name, child) self._instantiate_rpcs_recursive(child, full_path) @@ -148,12 +148,8 @@ def _interact(self): type _rpc_type = int | float | str | bytes | None class _RpcNode: """ Base class for RPCs and surveys in the device tree """ - def __init__(self, name, device: Device): + def __init__(self, name: str): self.__name__ = name - self._device = device - - def __call__(self) -> dict[str, _rpc_type]: - return self._survey() def _survey(self) -> dict[str, _rpc_type]: """ Recursively collect all readable RPC values in this subtree """ @@ -161,20 +157,43 @@ def _survey(self) -> dict[str, _rpc_type]: for name, attr in self.__dict__.items(): if isinstance(attr, _RpcNode): # Check if it's an RPC that should be read - if isinstance(attr, _RpcBase): - if attr._type not in { None, bytes }: + if isinstance(attr, _Rpc): + if attr._readable and attr._type not in { None, bytes }: results[attr.__name__] = attr._call() # Recursively survey children (works for both Rpc and Survey) results |= attr._survey() return results -class _RpcBase(_RpcNode): - """ Internal base class for RPCs """ +class _RpcSurvey(_RpcNode): + """" Branch object that can collect all callable child RPC values """ + def __init__(self, name: str): + super().__init__(name) + + def __call__(self) -> dict[str, _rpc_type]: + return self._survey() + +class _Rpc(_RpcNode): + """ Base class for RPCs """ + def __new__(cls, pyrpc: _twinleaf._Rpc, device: Device): + match pyrpc: + case r if r.type_str == '' and r.size_bytes != 0: + subclass = _RpcReadWrite # unknown/bytes rpc + case r if r.readable and r.writable: + subclass = _RpcReadWrite + case r if r.writable: + subclass = _RpcWriteOnly + case _: + subclass = _RpcReadOnly # read-only or action rpc + rpc = super().__new__(subclass) + return rpc + def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): - super().__init__(pyrpc.name, device) + super().__init__(pyrpc.name) + self._device = device self._data_size = pyrpc.size_bytes - self._writable = pyrpc.writable + self._readable = pyrpc.readable + self._writable = pyrpc.writable self._type: type | None = None match pyrpc.type_str: case t if t.startswith('u'): @@ -215,26 +234,17 @@ def _call(self, arg: _rpc_type=None) -> _rpc_type: case other: raise TypeError(f"Invalid RPC type {other}, RPC types must be {_rpc_type}") -def _Rpc(pyrpc: _twinleaf._Rpc, device: Device) -> _RpcNode: - """ Factory function that creates an RPC with appropriate __call__ signature """ - base_rpc = _RpcBase(pyrpc, device) - if base_rpc._writable and base_rpc._type is not None: - def __call__(self, arg=None): - return self._call(arg) - __call__.__annotations__ |= { 'arg': base_rpc._type | None } - __call__.__annotations__ |= { 'return': base_rpc._type } - else: - def __call__(self) -> _rpc_type: - return self._call() - __call__.__annotations__ |= { 'return': base_rpc._type } - - cls = type('Rpc', (_RpcBase,), {'__call__': __call__}) - return cls(pyrpc, device) - -def _RpcSurvey(name: str, device: Device) -> _RpcNode: - """ Factory function that creates an RPC survey """ - cls = type('Survey', (_RpcNode,), {}) - return cls(name, device) +class _RpcReadOnly(_Rpc): + def __call__(self): + return self._call() + +class _RpcWriteOnly(_Rpc): + def __call__(self, arg): + return self._call(arg) + +class _RpcReadWrite(_Rpc): + def __call__(self, arg=None): + return self._call(arg) # Samples classes class _SamplesBase: From 20df8d5aa604a41805095d127af6f4b9a603ad61 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 27 Mar 2026 14:44:43 -0400 Subject: [PATCH 24/26] Feat: add __repr__s for python objects and update rust ones --- python/twinleaf/__init__.py | 23 +++++++++++++++++++++++ rust/src/lib.rs | 24 +++++++++++++++--------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index b823a89..b818514 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -12,6 +12,13 @@ def __init__(self, url=None, route=None, announce=False, instantiate=True): self._instantiate_rpcs() self._instantiate_samples(announce) + def __repr__(self): + try: + dev_info = self._rpc('dev.serial', b'').decode() + except RuntimeError: + dev_info = '' + return f"{self.__module__}.{self.__class__.__name__}('{dev_info}', url='{self._url}', route='{self._route}'" + def _rpc_int(self, name: str, size: int, signed: bool, value: int | None = None) -> int: """ Use struct to send int-typed RPCs """ import struct @@ -151,6 +158,9 @@ class _RpcNode: def __init__(self, name: str): self.__name__ = name + def __repr__(self): + return f"{self.__module__}.{self.__class__.__name__}('{self.__name__}')" + def _survey(self) -> dict[str, _rpc_type]: """ Recursively collect all readable RPC values in this subtree """ results = {} @@ -217,6 +227,16 @@ def __init__(self, pyrpc: _twinleaf._Rpc, device: Device): self._type = bytes self._data_type = 0 + def __repr__(self): + ret = super().__repr__().strip(')') + ", " + if hasattr(self, '_signed') and not self._signed: + ret += "u" + ret += self._type.__name__ + if self._data_size: # is not 0 or None + ret += str(self._data_size*8) + ret += ')' + return ret + def _call(self, arg: _rpc_type=None) -> _rpc_type: match self._type: case t if t is int: @@ -255,6 +275,9 @@ def __init__(self, device: Device, name: str, stream: str, columns: list[str]): self._stream = stream self._columns = columns + def __repr__(self): + return f"{self.__module__}.{self.__class__.__name__}('{self.__name__}', stream='{self._stream}', columns={self._columns})" + class _SamplesDict(_SamplesBase): """ Returns samples as dict keyed by stream_id """ def __init__(self, device: Device, name: str, stream: str="", columns: list[str] | None=None): diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1325554..1a6d9e4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -115,10 +115,10 @@ impl PyRpc { fn __repr__(&self) -> String { format!( - "Rpc(name='{}', type='{}', {})", + "_twinleaf._Rpc({} {}({}))", + self.inner.perm_str(), self.inner.full_name, self.inner.type_str(), - self.inner.perm_str() ) } } @@ -152,17 +152,13 @@ impl PyRegistry { } fn __repr__(&self) -> String { - let count = self.inner.search("").len(); - if let Some(h) = self.inner.hash { - format!("Registry({} RPCs, hash=0x{:08x})", count, h) - } else { - format!("Registry({} RPCs)", count) - } + format!("_twinleaf._RpcRegistry({:?})", self.children_of("")) } } #[pyclass(name = "_Device", subclass)] struct PyDevice { + root: String, proxy: proxy::Interface, route: proto::DeviceRoute, rpc: device::RpcClient, @@ -185,7 +181,17 @@ impl PyDevice { }; let proxy = proxy::Interface::new(&root); let rpc = device::RpcClient::open(&proxy, route.clone()).unwrap(); - Ok(PyDevice { proxy, route, rpc }) + Ok(PyDevice { root, proxy, route, rpc }) + } + + #[getter] + fn _url(&self) -> String { + self.root.clone() + } + + #[getter] + fn _route(&self) -> String { + format!("{}", self.route) } fn _rpc<'py>(&self, py: Python<'py>, name: &str, req: &[u8]) -> PyResult> { From a17dcc1d7134f51567a4e64132513544ed042783 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 27 Mar 2026 15:03:30 -0400 Subject: [PATCH 25/26] Feat: Add _RpcAction --- python/twinleaf/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index b818514..b041a34 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -193,8 +193,10 @@ def __new__(cls, pyrpc: _twinleaf._Rpc, device: Device): subclass = _RpcReadWrite case r if r.writable: subclass = _RpcWriteOnly + case r if r.readable: + subclass = _RpcReadOnly case _: - subclass = _RpcReadOnly # read-only or action rpc + subclass = _RpcAction rpc = super().__new__(subclass) return rpc @@ -266,6 +268,10 @@ class _RpcReadWrite(_Rpc): def __call__(self, arg=None): return self._call(arg) +class _RpcAction(_Rpc): + def __call__(self) -> None: + return self._call() + # Samples classes class _SamplesBase: """ Base class for sample objects """ From 0f3622c111652693ac144be3c4ca9b6664b5aacf Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 27 Mar 2026 15:44:01 -0400 Subject: [PATCH 26/26] Remove _SamplesList and _SamplesDict __call__ **kwargs in favour of actual options --- python/twinleaf/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/twinleaf/__init__.py b/python/twinleaf/__init__.py index b041a34..9f8cf46 100644 --- a/python/twinleaf/__init__.py +++ b/python/twinleaf/__init__.py @@ -81,7 +81,7 @@ def _samples_dict(self, n: int = 1, stream: str = "", columns: list[str] | None= streams[stream_id][key].append(value) return streams - def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] | None=None, time_column = True, title_row = True) -> list[list[str | int | float]]: + def _samples_list(self, n: int = 1, stream: str = "", columns: list[str] | None=None, time_column=True, title_row=True) -> list[list[str | int | float]]: """ Parse underlying sample iterator into tabular array """ if columns is None: columns = [] # Avoid mutable default streams = self._samples_dict(n, stream, columns) @@ -289,13 +289,13 @@ class _SamplesDict(_SamplesBase): def __init__(self, device: Device, name: str, stream: str="", columns: list[str] | None=None): super().__init__(device, name, stream, columns if columns is not None else [] ) - def __call__(self, n: int=1, **kwargs): - return self._device._samples_dict(n, self._stream, self._columns, **kwargs) + def __call__(self, n: int=1, *, time_column=True, title_row=True): + return self._device._samples_dict(n, self._stream, self._columns, time_column=time_column, title_row=title_row) class _SamplesList(_SamplesBase): """ Returns samples as list for single stream """ def __init__(self, device: Device, name: str, stream: str="", columns: list[str] | None=None): super().__init__(device, name, stream, columns if columns is not None else [] ) - def __call__(self, n: int=1, **kwargs): - return self._device._samples_list(n, self._stream, self._columns, **kwargs) + def __call__(self, n: int=1, *, time_column=True, title_row=True): + return self._device._samples_list(n, self._stream, self._columns, time_column=time_column, title_row=title_row)