From 6df33db91e69cbcec0ed1e7473b16bd268af7f52 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Tue, 31 Mar 2026 10:10:02 +0200 Subject: [PATCH 01/12] Add watch_change function and ParamChangeStream pyclass --- rust/src/subsystems/param.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/rust/src/subsystems/param.rs b/rust/src/subsystems/param.rs index 5998b54..91d32be 100644 --- a/rust/src/subsystems/param.rs +++ b/rust/src/subsystems/param.rs @@ -84,6 +84,35 @@ pub struct Param { pub(crate) cf: Arc, } +#[gen_stub_pyclass] +#[pyclass] +struct ParamChangeStream { + rx: Arc>>, +} + +#[gen_stub_pymethods] +#[pymethods] +impl ParamChangeStream { + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __anext__<'py>(&mut self, py: Python<'py>) -> PyResult> { + let rx = self.rx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut rx = rx.lock().await; + match rx.next().await { + Some((parameter_name, parameter_value)) => Python::with_gil(|py| { + let py_parameter_name = parameter_name.into_pyobject(py)?; + let py_parameter_value = value_to_python(py, parameter_value)?; + (py_parameter_name, py_parameter_value).into_pyobject(py).map(|b| b.into_any().unbind()) + }), + None => Err(pyo3::exceptions::PyStopAsyncIteration::new_err(())), + } + }) + } +} + #[gen_stub_pymethods] #[pymethods] impl Param { @@ -250,6 +279,11 @@ impl Param { }) } + fn watch_change<'py>(&self, py: Python<'py>) -> ParamChangeStream { + let cf = self.cf.clone(); + ParamChangeStream { rx: cf.param.watch_change() } + } + /// Get the persistent storage state of a parameter /// /// Returns a PersistentParamState with: From 667012bfd8f87faf93e07008fdccfb6d046b4c0b Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Tue, 31 Mar 2026 10:35:37 +0200 Subject: [PATCH 02/12] Import param --- rust/src/lib.rs | 2 +- rust/src/subsystems/param.rs | 46 ++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3a23fa8..813326a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -35,7 +35,7 @@ pub mod value; use crazyflie::Crazyflie; use link_context::LinkContext; use subsystems::{ - Commander, Console, Log, LogBlock, LogData, LogStream, Param, PersistentParamState, Platform, AppChannel, + Commander, Console, Log, LogBlock, LogData, LogStream, Param, PersistentParamState, ParamChangeStream, Platform, AppChannel, Localization, EmergencyControl, ExternalPose, Lighthouse, LocoPositioning, LighthouseAngleData, LighthouseAngles, Memory, Poly, Poly4D, CompressedStart, CompressedSegment, LedRingColor, diff --git a/rust/src/subsystems/param.rs b/rust/src/subsystems/param.rs index 91d32be..c34a0f5 100644 --- a/rust/src/subsystems/param.rs +++ b/rust/src/subsystems/param.rs @@ -62,28 +62,6 @@ impl PersistentParamState { } } -/// Access to the Crazyflie Param Subsystem -/// -/// This struct provides methods to interact with the parameter subsystem. -/// -/// The Crazyflie exposes a param subsystem that allows to easily declare parameter -/// variables in the Crazyflie and to discover, read and write them from the ground. -/// -/// Variables are defined in a table of content that is downloaded upon connection. -/// Each param variable have a unique name composed from a group and a variable name. -/// Functions that accesses variables, take a `name` parameter that accepts a string -/// in the format "group.variable" -/// -/// During connection, the full param table of content is downloaded form the -/// Crazyflie as well as the values of all the variable. If a variable value -/// is modified by the Crazyflie during runtime, it sends a packet with the new -/// value which updates the local value cache. -#[gen_stub_pyclass] -#[pyclass] -pub struct Param { - pub(crate) cf: Arc, -} - #[gen_stub_pyclass] #[pyclass] struct ParamChangeStream { @@ -113,6 +91,28 @@ impl ParamChangeStream { } } +/// Access to the Crazyflie Param Subsystem +/// +/// This struct provides methods to interact with the parameter subsystem. +/// +/// The Crazyflie exposes a param subsystem that allows to easily declare parameter +/// variables in the Crazyflie and to discover, read and write them from the ground. +/// +/// Variables are defined in a table of content that is downloaded upon connection. +/// Each param variable have a unique name composed from a group and a variable name. +/// Functions that accesses variables, take a `name` parameter that accepts a string +/// in the format "group.variable" +/// +/// During connection, the full param table of content is downloaded form the +/// Crazyflie as well as the values of all the variable. If a variable value +/// is modified by the Crazyflie during runtime, it sends a packet with the new +/// value which updates the local value cache. +#[gen_stub_pyclass] +#[pyclass] +pub struct Param { + pub(crate) cf: Arc, +} + #[gen_stub_pymethods] #[pymethods] impl Param { @@ -281,7 +281,7 @@ impl Param { fn watch_change<'py>(&self, py: Python<'py>) -> ParamChangeStream { let cf = self.cf.clone(); - ParamChangeStream { rx: cf.param.watch_change() } + ParamChangeStream { rx: Arc::new(tokio::sync::Mutex::new(cf.param.watch_change().await)) } } /// Get the persistent storage state of a parameter From 1c5ad37534670bc995dea9b0dc6e7c4e99e8f813 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Tue, 31 Mar 2026 16:29:13 +0200 Subject: [PATCH 03/12] Registered new class --- rust/src/lib.rs | 1 + rust/src/subsystems/mod.rs | 2 +- rust/src/subsystems/param.rs | 30 +++++++++++++++++++++++------- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 813326a..a44e7d6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -55,6 +55,7 @@ fn _rust(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/rust/src/subsystems/mod.rs b/rust/src/subsystems/mod.rs index 4f2a722..2195c58 100644 --- a/rust/src/subsystems/mod.rs +++ b/rust/src/subsystems/mod.rs @@ -36,5 +36,5 @@ pub use high_level_commander::HighLevelCommander; pub use localization::{Localization, EmergencyControl, ExternalPose, Lighthouse, LocoPositioning, LighthouseAngleData, LighthouseAngles}; pub use log::{Log, LogBlock, LogData, LogStream}; pub use memory::{Memory, Poly, Poly4D, CompressedStart, CompressedSegment, LedRingColor}; -pub use param::{Param, PersistentParamState}; +pub use param::{Param, ParamChangeStream, PersistentParamState}; pub use platform::{Platform, AppChannel}; diff --git a/rust/src/subsystems/param.rs b/rust/src/subsystems/param.rs index c34a0f5..57a6218 100644 --- a/rust/src/subsystems/param.rs +++ b/rust/src/subsystems/param.rs @@ -21,8 +21,10 @@ //! Parameter subsystem - read and write configuration parameters +use futures::StreamExt; use pyo3::prelude::*; use pyo3_stub_gen_derive::*; +use std::pin::Pin; use std::sync::Arc; use crate::error::to_pyerr; @@ -64,8 +66,8 @@ impl PersistentParamState { #[gen_stub_pyclass] #[pyclass] -struct ParamChangeStream { - rx: Arc>>, +pub struct ParamChangeStream { + rx: Arc + Send>>>>, } #[gen_stub_pymethods] @@ -80,10 +82,10 @@ impl ParamChangeStream { pyo3_async_runtimes::tokio::future_into_py(py, async move { let mut rx = rx.lock().await; match rx.next().await { - Some((parameter_name, parameter_value)) => Python::with_gil(|py| { - let py_parameter_name = parameter_name.into_pyobject(py)?; + Some((parameter_name, parameter_value)) => Python::attach(|py| { + let py_parameter_name: Bound<'_, pyo3::types::PyString> = parameter_name.into_pyobject(py)?; let py_parameter_value = value_to_python(py, parameter_value)?; - (py_parameter_name, py_parameter_value).into_pyobject(py).map(|b| b.into_any().unbind()) + (py_parameter_name, py_parameter_value).into_pyobject(py).map(|b: Bound<'_, pyo3::types::PyTuple>| b.into_any().unbind()) }), None => Err(pyo3::exceptions::PyStopAsyncIteration::new_err(())), } @@ -279,9 +281,23 @@ impl Param { }) } - fn watch_change<'py>(&self, py: Python<'py>) -> ParamChangeStream { + /// Watch for parameter value changes + /// + /// Returns an async iterator that yields `(name, value)` tuples whenever + /// any parameter value changes. Each call creates an independent stream. + /// + /// # Returns + /// An async iterator yielding `(str, int | float)` tuples + #[gen_stub(override_return_type(type_repr = "collections.abc.Coroutine[typing.Any, typing.Any, ParamChangeStream]"))] + fn watch_change<'py>(&self, py: Python<'py>) -> PyResult> { let cf = self.cf.clone(); - ParamChangeStream { rx: Arc::new(tokio::sync::Mutex::new(cf.param.watch_change().await)) } + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let stream = cf.param.watch_change().await; + let rx = Arc::new(tokio::sync::Mutex::new(Box::pin(stream) as Pin + Send>>)); + Python::attach(|py| { + Ok(Py::new(py, ParamChangeStream { rx })?.into_any()) + }) + }) } /// Get the persistent storage state of a parameter From 11b611f5e65ec1f08908950aa2eef85456c2d73f Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Tue, 31 Mar 2026 16:29:37 +0200 Subject: [PATCH 04/12] Update readme for uv sync command --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ac1c8b9..ced4036 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ See the [examples/](examples/) directory for more. ``` This installs the `dev` dependency group which provides `maturin`, `pre-commit`, `pytest`, and other development tools. Commands prefixed with `uv run` below require this group. + It also triggers maturin to build Rust libraries used by Python. + 2. **Install pre-commit hooks:** ```bash uv run pre-commit install From a02d1cecd6a4d9d234a9b0bf35e60d0428eb69e7 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Tue, 31 Mar 2026 16:34:06 +0200 Subject: [PATCH 05/12] Regenerated stubs file --- cflib2/_rust.pyi | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/cflib2/_rust.pyi b/cflib2/_rust.pyi index a7ad243..c9e3e7a 100644 --- a/cflib2/_rust.pyi +++ b/cflib2/_rust.pyi @@ -779,10 +779,7 @@ class LedRingColor: Intensity percentage (0-100); values above 100 are clamped to 100 """ @intensity.setter - def intensity(self, value: builtins.int) -> None: - r""" - Intensity percentage (0-100); values above 100 are clamped to 100 - """ + def intensity(self, value: builtins.int) -> None: ... def __new__( cls, r: builtins.int = 0, @@ -797,7 +794,7 @@ class LedRingColor: * `r` - Red component (0-255, default 0) * `g` - Green component (0-255, default 0) * `b` - Blue component (0-255, default 0) - * `intensity` - Intensity percentage (0-100, default 100); clamped to 100 if higher + * `intensity` - Intensity percentage (0-100, default 100); values above 100 are clamped to 100 """ def set( self, @@ -1352,6 +1349,16 @@ class Param: # Returns The default value (int or float depending on parameter type) """ + async def watch_change(self) -> ParamChangeStream: + r""" + Watch for parameter value changes + + Returns an async iterator that yields `(name, value)` tuples whenever + any parameter value changes. Each call creates an independent stream. + + # Returns + An async iterator yielding `(str, int | float)` tuples + """ async def persistent_get_state(self, name: builtins.str) -> PersistentParamState: r""" Get the persistent storage state of a parameter @@ -1389,6 +1396,17 @@ class Param: * `name` - Parameter name in format "group.name" """ +@typing.final +class ParamChangeStream: + def __aiter__(self) -> ParamChangeStream: + r""" + Return self (async iterator protocol) + """ + async def __anext__(self) -> tuple[str, int | float]: + r""" + Return the next `(name, value)` tuple, or raise StopAsyncIteration + """ + class ParamError(CrazyflieError): r""" Parameter subsystem error. From 739280763faac7590c182b9f1bebcf7d29bb92d8 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Tue, 31 Mar 2026 16:34:27 +0200 Subject: [PATCH 06/12] Updated annotations and docstrings --- rust/src/subsystems/param.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rust/src/subsystems/param.rs b/rust/src/subsystems/param.rs index 57a6218..916a150 100644 --- a/rust/src/subsystems/param.rs +++ b/rust/src/subsystems/param.rs @@ -73,10 +73,13 @@ pub struct ParamChangeStream { #[gen_stub_pymethods] #[pymethods] impl ParamChangeStream { + /// Return self (async iterator protocol) fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { slf } + /// Return the next `(name, value)` tuple, or raise StopAsyncIteration + #[gen_stub(override_return_type(type_repr = "collections.abc.Coroutine[typing.Any, typing.Any, tuple[str, int | float]]"))] fn __anext__<'py>(&mut self, py: Python<'py>) -> PyResult> { let rx = self.rx.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { From c2022ceef3a6c3e31be24841b6ff4163903dc70d Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Wed, 1 Apr 2026 10:14:11 +0200 Subject: [PATCH 07/12] Added doc to ParamChangeStream --- rust/src/subsystems/param.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/src/subsystems/param.rs b/rust/src/subsystems/param.rs index 916a150..a3c0eaa 100644 --- a/rust/src/subsystems/param.rs +++ b/rust/src/subsystems/param.rs @@ -64,6 +64,7 @@ impl PersistentParamState { } } +/// Async iterator that yields `(name, value)` tuples when parameters change #[gen_stub_pyclass] #[pyclass] pub struct ParamChangeStream { From 522e2b9153ced306406da2e7a5ac08b9847b6bd2 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Wed, 1 Apr 2026 10:26:29 +0200 Subject: [PATCH 08/12] Added watch param change coroutine to param example --- examples/param.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/examples/param.py b/examples/param.py index 47dd807..8b844f6 100644 --- a/examples/param.py +++ b/examples/param.py @@ -45,16 +45,8 @@ class Args: """Crazyflie URI""" -async def main() -> None: - args = tyro.cli(Args) - - print(f"Connecting to {args.uri}...") - context = LinkContext() - cf = await Crazyflie.connect_from_uri(context, args.uri) - print("Connected!") - +async def get_and_set_values(cf: Crazyflie, param_name: str) -> None: param = cf.param() - param_name = "pm.lowVoltage" try: # Get original value @@ -96,5 +88,29 @@ async def main() -> None: print("Done!") +async def watch_param_changes(cf: Crazyflie) -> None: + param = cf.param() + param_change_stream = await param.watch_change() + + async for name, value in param_change_stream: + print(f"Watched change: {name}: {value}") + + +async def main() -> None: + args = tyro.cli(Args) + + print(f"Connecting to {args.uri}...") + context = LinkContext() + cf = await Crazyflie.connect_from_uri(context, args.uri) + print("Connected!") + + param_name = "pm.lowVoltage" + + await asyncio.gather( + get_and_set_values(cf, param_name), + watch_param_changes(cf), + ) + + if __name__ == "__main__": asyncio.run(main()) From 2f23d205cd7d27d1b6d0b77f8cbc642f46d53683 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Wed, 1 Apr 2026 10:47:12 +0200 Subject: [PATCH 09/12] Updated example to run watch_change as a background task, to be able to finish main function --- examples/param.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/param.py b/examples/param.py index 8b844f6..1008965 100644 --- a/examples/param.py +++ b/examples/param.py @@ -82,10 +82,8 @@ async def get_and_set_values(cf: Crazyflie, param_name: str) -> None: f"\n⚠ Warning: Final value ({final_value}) differs from original ({original_value})" ) - finally: - print("\nDisconnecting...") - await cf.disconnect() - print("Done!") + except Exception as e: + print(f"\nError: {e}") async def watch_param_changes(cf: Crazyflie) -> None: @@ -106,10 +104,13 @@ async def main() -> None: param_name = "pm.lowVoltage" - await asyncio.gather( - get_and_set_values(cf, param_name), - watch_param_changes(cf), - ) + watcher_task = asyncio.create_task(watch_param_changes(cf)) + await get_and_set_values(cf, param_name) + watcher_task.cancel() + + print("Disconnect...") + cf.disconnect() + print("Done!") if __name__ == "__main__": From ac3fd059925172aee980176d79c128a12d56d56f Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Wed, 1 Apr 2026 15:34:11 +0200 Subject: [PATCH 10/12] Regenerated stubs file --- cflib2/_rust.pyi | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cflib2/_rust.pyi b/cflib2/_rust.pyi index c9e3e7a..bbac553 100644 --- a/cflib2/_rust.pyi +++ b/cflib2/_rust.pyi @@ -1398,6 +1398,9 @@ class Param: @typing.final class ParamChangeStream: + r""" + Async iterator that yields `(name, value)` tuples when parameters change + """ def __aiter__(self) -> ParamChangeStream: r""" Return self (async iterator protocol) From c7f19cbc2393711479a24d8f2c53fe115d935be1 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Wed, 1 Apr 2026 15:39:17 +0200 Subject: [PATCH 11/12] Assuming rust function watch_changes returns Result. --- examples/param.py | 4 +++- rust/src/subsystems/param.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/param.py b/examples/param.py index 1008965..2a3b256 100644 --- a/examples/param.py +++ b/examples/param.py @@ -91,7 +91,9 @@ async def watch_param_changes(cf: Crazyflie) -> None: param_change_stream = await param.watch_change() async for name, value in param_change_stream: + print("--------------------") print(f"Watched change: {name}: {value}") + print("--------------------") async def main() -> None: @@ -104,9 +106,9 @@ async def main() -> None: param_name = "pm.lowVoltage" + # Let watch changes run in the background watcher_task = asyncio.create_task(watch_param_changes(cf)) await get_and_set_values(cf, param_name) - watcher_task.cancel() print("Disconnect...") cf.disconnect() diff --git a/rust/src/subsystems/param.rs b/rust/src/subsystems/param.rs index a3c0eaa..289815b 100644 --- a/rust/src/subsystems/param.rs +++ b/rust/src/subsystems/param.rs @@ -296,7 +296,7 @@ impl Param { fn watch_change<'py>(&self, py: Python<'py>) -> PyResult> { let cf = self.cf.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let stream = cf.param.watch_change().await; + let stream = cf.param.watch_change().await.map_err(to_pyerr)?; let rx = Arc::new(tokio::sync::Mutex::new(Box::pin(stream) as Pin + Send>>)); Python::attach(|py| { Ok(Py::new(py, ParamChangeStream { rx })?.into_any()) From 40502351ce001ed8e0a875697a10d41ec315a835 Mon Sep 17 00:00:00 2001 From: Stefan Thorstenson Date: Thu, 2 Apr 2026 08:55:32 +0200 Subject: [PATCH 12/12] param.py now always disconnects --- examples/param.py | 88 ++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/examples/param.py b/examples/param.py index 2a3b256..94ee8a1 100644 --- a/examples/param.py +++ b/examples/param.py @@ -48,42 +48,38 @@ class Args: async def get_and_set_values(cf: Crazyflie, param_name: str) -> None: param = cf.param() - try: - # Get original value - print(f"\n1. Getting original value of '{param_name}'...") - original_value: int | float = await param.get(param_name) - print(f" Original value: {original_value}V") - - # Set new value - new_value: float = 3.8 - print(f"\n2. Setting '{param_name}' to {new_value}V...") - await param.set(param_name, new_value) - print(f" Set complete!") - - # Get new value to confirm - print(f"\n3. Reading back '{param_name}'...") - current_value: int | float = await param.get(param_name) - print(f" Current value: {current_value}V") - - # Restore original value - print(f"\n4. Restoring '{param_name}' to original value ({original_value}V)...") - await param.set(param_name, float(original_value)) - print(f" Restored!") - - # Get final value to confirm restoration - print(f"\n5. Verifying restoration of '{param_name}'...") - final_value: int | float = await param.get(param_name) - print(f" Final value: {final_value}V") - - if final_value == original_value: - print("\n✓ Parameter successfully restored to original value!") - else: - print( - f"\n⚠ Warning: Final value ({final_value}) differs from original ({original_value})" - ) - - except Exception as e: - print(f"\nError: {e}") + # Get original value + print(f"\n1. Getting original value of '{param_name}'...") + original_value: int | float = await param.get(param_name) + print(f" Original value: {original_value}V") + + # Set new value + new_value: float = 3.8 + print(f"\n2. Setting '{param_name}' to {new_value}V...") + await param.set(param_name, new_value) + print(f" Set complete!") + + # Get new value to confirm + print(f"\n3. Reading back '{param_name}'...") + current_value: int | float = await param.get(param_name) + print(f" Current value: {current_value}V") + + # Restore original value + print(f"\n4. Restoring '{param_name}' to original value ({original_value}V)...") + await param.set(param_name, float(original_value)) + print(f" Restored!") + + # Get final value to confirm restoration + print(f"\n5. Verifying restoration of '{param_name}'...") + final_value: int | float = await param.get(param_name) + print(f" Final value: {final_value}V") + + if final_value == original_value: + print("\n✓ Parameter successfully restored to original value!") + else: + print( + f"\n⚠ Warning: Final value ({final_value}) differs from original ({original_value})" + ) async def watch_param_changes(cf: Crazyflie) -> None: @@ -97,6 +93,9 @@ async def watch_param_changes(cf: Crazyflie) -> None: async def main() -> None: + # Test config + param_name = "pm.lowVoltage" + args = tyro.cli(Args) print(f"Connecting to {args.uri}...") @@ -104,15 +103,18 @@ async def main() -> None: cf = await Crazyflie.connect_from_uri(context, args.uri) print("Connected!") - param_name = "pm.lowVoltage" + try: + # Let watch changes run in the background + _ = asyncio.create_task(watch_param_changes(cf)) + await get_and_set_values(cf, param_name) - # Let watch changes run in the background - watcher_task = asyncio.create_task(watch_param_changes(cf)) - await get_and_set_values(cf, param_name) + except Exception as e: + print(f"\nError: {e}") - print("Disconnect...") - cf.disconnect() - print("Done!") + finally: + print("Disconnect...") + await cf.disconnect() + print("Done!") if __name__ == "__main__":