diff --git a/Cargo.toml b/Cargo.toml
index afac85b2..ad207180 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,6 @@
[workspace]
members = [
"rclrs",
+ "rclrs-macros",
]
resolver = "2"
diff --git a/rclrs-macros/Cargo.toml b/rclrs-macros/Cargo.toml
new file mode 100644
index 00000000..2447fbd1
--- /dev/null
+++ b/rclrs-macros/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "rclrs-macros"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+description = "Procedural macros for the rclrs ROS 2 client library"
+
+[lib]
+proc-macro = true
+
+[dependencies]
+proc-macro2 = "1.0"
+quote = "1.0"
+syn = { version = "2.0", features = ["full", "extra-traits"] }
diff --git a/rclrs-macros/package.xml b/rclrs-macros/package.xml
new file mode 100644
index 00000000..78917e00
--- /dev/null
+++ b/rclrs-macros/package.xml
@@ -0,0 +1,14 @@
+
+
+
+ rclrs-macros
+ 0.5.0
+ Procedural macros for the rclrs ROS 2 client library.
+ Esteve Fernandez
+ Apache License 2.0
+
+ ament_cargo
+
+
diff --git a/rclrs-macros/src/lib.rs b/rclrs-macros/src/lib.rs
new file mode 100644
index 00000000..ee3d015b
--- /dev/null
+++ b/rclrs-macros/src/lib.rs
@@ -0,0 +1,15 @@
+use proc_macro::TokenStream;
+
+/// Derive macro for declaring ROS 2 parameters from struct definitions.
+///
+/// See `rclrs::ParameterSet` for full documentation.
+#[proc_macro_derive(ParameterSet, attributes(parameters, param))]
+pub fn derive_parameter_set(input: TokenStream) -> TokenStream {
+ let input = syn::parse_macro_input!(input as syn::DeriveInput);
+ match parameter_set::expand(input) {
+ Ok(tokens) => tokens.into(),
+ Err(e) => e.to_compile_error().into(),
+ }
+}
+
+mod parameter_set;
diff --git a/rclrs-macros/src/parameter_set/codegen.rs b/rclrs-macros/src/parameter_set/codegen.rs
new file mode 100644
index 00000000..36882d69
--- /dev/null
+++ b/rclrs-macros/src/parameter_set/codegen.rs
@@ -0,0 +1,110 @@
+//! Code generation for individual fields in a `ParameterSet` struct.
+
+use proc_macro2::TokenStream;
+use quote::quote;
+
+use super::field_attrs::{FieldAttrs, ParameterType, RangeAttr};
+use super::field_info::FieldInfo;
+
+/// Generates the field initializer expression for one struct field.
+pub(crate) fn generate_field(field: &FieldInfo) -> TokenStream {
+ match field {
+ FieldInfo::Leaf {
+ ident,
+ attrs,
+ param_type,
+ } => {
+ let name_str = ident.to_string();
+ let builder_chain = generate_builder_chain(attrs);
+ let terminal_call = generate_terminal(*param_type);
+ quote! {
+ #ident: node
+ .declare_parameter(
+ rclrs::__private::param_name(prefix, #name_str)
+ )
+ #builder_chain
+ #terminal_call?
+ }
+ }
+ FieldInfo::Nested { ident, flatten } => generate_nested(ident, *flatten),
+ }
+}
+
+/// Generates the chained builder method calls from field attributes.
+fn generate_builder_chain(attrs: &FieldAttrs) -> TokenStream {
+ let mut calls = TokenStream::new();
+
+ if let Some(default) = &attrs.default {
+ calls.extend(quote! { .default(#default) });
+ }
+ if let Some(description) = &attrs.description {
+ calls.extend(quote! { .description(#description) });
+ }
+ if let Some(constraints) = &attrs.constraints {
+ calls.extend(quote! { .constraints(#constraints) });
+ }
+ if let Some(range) = &attrs.range {
+ let range_expr = generate_range(range);
+ calls.extend(quote! { .range(#range_expr) });
+ }
+ if attrs.ignore_override {
+ calls.extend(quote! { .ignore_override() });
+ }
+ if attrs.discard_mismatching_prior_value {
+ calls.extend(quote! { .discard_mismatching_prior_value() });
+ }
+ if let Some(discriminate) = &attrs.discriminate {
+ calls.extend(quote! { .discriminate(#discriminate) });
+ }
+
+ calls
+}
+
+/// Generates the terminal builder call (`.mandatory()`, `.optional()`, `.read_only()`).
+fn generate_terminal(param_type: ParameterType) -> TokenStream {
+ match param_type {
+ ParameterType::Mandatory => quote! { .mandatory() },
+ ParameterType::Optional => quote! { .optional() },
+ ParameterType::ReadOnly => quote! { .read_only() },
+ }
+}
+
+/// Generates a `ParameterRange { ... }` expression from parsed range attributes.
+fn generate_range(range: &RangeAttr) -> TokenStream {
+ let lower = match &range.lower {
+ Some(expr) => quote! { Some(#expr) },
+ None => quote! { None },
+ };
+ let upper = match &range.upper {
+ Some(expr) => quote! { Some(#expr) },
+ None => quote! { None },
+ };
+ let step = match &range.step {
+ Some(expr) => quote! { Some(#expr) },
+ None => quote! { None },
+ };
+ quote! {
+ rclrs::ParameterRange {
+ lower: #lower,
+ upper: #upper,
+ step: #step,
+ }
+ }
+}
+
+/// Generates the field initializer for a nested `ParameterSet` field.
+fn generate_nested(ident: &syn::Ident, flatten: bool) -> TokenStream {
+ let name_str = ident.to_string();
+ if flatten {
+ quote! {
+ #ident: rclrs::ParameterSet::declare(node, prefix)?
+ }
+ } else {
+ quote! {
+ #ident: rclrs::ParameterSet::declare(
+ node,
+ &rclrs::__private::param_name(prefix, #name_str),
+ )?
+ }
+ }
+}
diff --git a/rclrs-macros/src/parameter_set/field_attrs.rs b/rclrs-macros/src/parameter_set/field_attrs.rs
new file mode 100644
index 00000000..04bb8812
--- /dev/null
+++ b/rclrs-macros/src/parameter_set/field_attrs.rs
@@ -0,0 +1,154 @@
+//! Parsing of field-level `#[param(...)]` attributes.
+
+use syn::{Expr, LitStr};
+
+/// The kind of parameter: mandatory, optional, or read-only.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ParameterType {
+ Mandatory,
+ Optional,
+ ReadOnly,
+}
+
+/// Parsed range attribute: `range(lower = x, upper = y, step = z)`.
+/// All fields are optional.
+#[derive(Debug, Default)]
+pub(crate) struct RangeAttr {
+ pub lower: Option,
+ pub upper: Option,
+ pub step: Option,
+}
+
+/// Parsed field-level configuration from `#[param(...)]`.
+#[derive(Debug)]
+pub(crate) struct FieldAttrs {
+ /// Explicit parameter type (escape hatch for type aliases).
+ pub param_type: Option,
+ /// Default value expression.
+ pub default: Option,
+ /// Parameter description string.
+ pub description: Option,
+ /// Parameter constraints string.
+ pub constraints: Option,
+ /// Parameter range.
+ pub range: Option,
+ /// Whether to ignore parameter overrides from command line.
+ pub ignore_override: bool,
+ /// Whether to discard prior values with mismatching types.
+ pub discard_mismatching_prior_value: bool,
+ /// Path to a custom discriminator function.
+ pub discriminate: Option,
+ /// Whether this nested field should be flattened (no extra namespace).
+ pub flatten: bool,
+}
+
+impl FieldAttrs {
+ /// Parses all `#[param(...)]` attributes on a field.
+ pub fn parse(field: &syn::Field) -> syn::Result {
+ let mut attrs = Self {
+ param_type: None,
+ default: None,
+ description: None,
+ constraints: None,
+ range: None,
+ ignore_override: false,
+ discard_mismatching_prior_value: false,
+ discriminate: None,
+ flatten: false,
+ };
+
+ for attr in &field.attrs {
+ if !attr.path().is_ident("param") {
+ continue;
+ }
+ attr.parse_nested_meta(|meta| {
+ if meta.path.is_ident("mandatory") {
+ attrs.set_param_type(ParameterType::Mandatory, &meta)?;
+ Ok(())
+ } else if meta.path.is_ident("optional") {
+ attrs.set_param_type(ParameterType::Optional, &meta)?;
+ Ok(())
+ } else if meta.path.is_ident("read_only") {
+ attrs.set_param_type(ParameterType::ReadOnly, &meta)?;
+ Ok(())
+ } else if meta.path.is_ident("default") {
+ attrs.default = Some(meta.value()?.parse()?);
+ Ok(())
+ } else if meta.path.is_ident("description") {
+ attrs.description = Some(meta.value()?.parse()?);
+ Ok(())
+ } else if meta.path.is_ident("constraints") {
+ attrs.constraints = Some(meta.value()?.parse()?);
+ Ok(())
+ } else if meta.path.is_ident("range") {
+ attrs.range = Some(parse_range(&meta)?);
+ Ok(())
+ } else if meta.path.is_ident("ignore_override") {
+ attrs.ignore_override = true;
+ Ok(())
+ } else if meta.path.is_ident("discard_mismatching_prior_value") {
+ attrs.discard_mismatching_prior_value = true;
+ Ok(())
+ } else if meta.path.is_ident("discriminate") {
+ attrs.discriminate = Some(meta.value()?.parse()?);
+ Ok(())
+ } else if meta.path.is_ident("flatten") {
+ attrs.flatten = true;
+ Ok(())
+ } else {
+ Err(meta.error(format!(
+ "unknown param attribute: '{}'",
+ meta.path.get_ident().map_or("?".into(), |i| i.to_string())
+ )))
+ }
+ })?;
+ }
+
+ Ok(attrs)
+ }
+
+ /// Sets the parameter type, erroring if one was already set.
+ fn set_param_type(
+ &mut self,
+ param_type: ParameterType,
+ meta: &syn::meta::ParseNestedMeta<'_>,
+ ) -> syn::Result<()> {
+ if self.param_type.is_some() {
+ return Err(meta.error("only one of 'mandatory', 'optional', 'read_only' is allowed"));
+ }
+ self.param_type = Some(param_type);
+ Ok(())
+ }
+
+ /// Returns true if any leaf-only attribute was set.
+ pub fn has_leaf_attrs(&self) -> bool {
+ self.default.is_some()
+ || self.description.is_some()
+ || self.constraints.is_some()
+ || self.range.is_some()
+ || self.ignore_override
+ || self.discard_mismatching_prior_value
+ || self.discriminate.is_some()
+ || self.param_type.is_some()
+ }
+}
+
+/// Parses `range(lower = x, upper = y, step = z)`.
+fn parse_range(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result {
+ let mut range = RangeAttr::default();
+ meta.parse_nested_meta(|nested| {
+ if nested.path.is_ident("lower") {
+ range.lower = Some(nested.value()?.parse()?);
+ Ok(())
+ } else if nested.path.is_ident("upper") {
+ range.upper = Some(nested.value()?.parse()?);
+ Ok(())
+ } else if nested.path.is_ident("step") {
+ range.step = Some(nested.value()?.parse()?);
+ Ok(())
+ } else {
+ Err(nested.error("expected 'lower', 'upper', or 'step'"))
+ }
+ })?;
+ Ok(range)
+}
diff --git a/rclrs-macros/src/parameter_set/field_info.rs b/rclrs-macros/src/parameter_set/field_info.rs
new file mode 100644
index 00000000..ee8f23f6
--- /dev/null
+++ b/rclrs-macros/src/parameter_set/field_info.rs
@@ -0,0 +1,110 @@
+//! Field classification: determines if a field is a leaf parameter or a nested
+//! `ParameterSet` struct, and validates that attributes are used correctly.
+
+use syn::spanned::Spanned;
+
+use super::field_attrs::{FieldAttrs, ParameterType};
+
+/// A classified struct field.
+pub(crate) enum FieldInfo<'a> {
+ /// A leaf parameter field — generates builder calls.
+ Leaf {
+ ident: &'a syn::Ident,
+ attrs: FieldAttrs,
+ param_type: ParameterType,
+ },
+ /// A nested `ParameterSet` field — generates a recursive `declare` call.
+ Nested {
+ ident: &'a syn::Ident,
+ flatten: bool,
+ },
+}
+
+impl<'a> FieldInfo<'a> {
+ /// Classifies a field based on its type and attributes.
+ pub fn from_field(field: &'a syn::Field) -> syn::Result {
+ let ident = field
+ .ident
+ .as_ref()
+ .ok_or_else(|| syn::Error::new(field.span(), "ParameterSet requires named fields"))?;
+
+ let attrs = FieldAttrs::parse(field)?;
+ let inferred = infer_param_type_from_type(&field.ty);
+
+ // Validate contradictions between explicit attribute and inferred type
+ if let (Some(explicit), Some(inferred)) = (attrs.param_type, inferred) {
+ if explicit != inferred {
+ let explicit_name = param_type_name(explicit);
+ let inferred_name = param_type_name(inferred);
+ return Err(syn::Error::new(
+ field.ty.span(),
+ format!(
+ "field type implies '{inferred_name}' but attribute specifies \
+ '{explicit_name}'"
+ ),
+ ));
+ }
+ }
+
+ // Determine if leaf or nested
+ let param_type = attrs.param_type.or(inferred);
+ match param_type {
+ Some(param_type) => {
+ // It's a leaf — validate no nested-only attrs
+ if attrs.flatten {
+ return Err(syn::Error::new(
+ ident.span(),
+ "'flatten' can only be used on nested ParameterSet fields, \
+ not parameter fields",
+ ));
+ }
+ Ok(FieldInfo::Leaf {
+ ident,
+ attrs,
+ param_type,
+ })
+ }
+ None => {
+ // It's nested — validate no leaf-only attrs
+ if attrs.has_leaf_attrs() {
+ return Err(syn::Error::new(
+ ident.span(),
+ "parameter attributes (default, description, range, etc.) \
+ can only be used on parameter fields, not nested ParameterSet fields",
+ ));
+ }
+ Ok(FieldInfo::Nested {
+ ident,
+ flatten: attrs.flatten,
+ })
+ }
+ }
+ }
+}
+
+/// Tries to infer the parameter type from the field's type name.
+///
+/// Returns `Some(param_type)` if the outermost type is `MandatoryParameter`,
+/// `OptionalParameter`, or `ReadOnlyParameter`. Returns `None` for any other
+/// type (assumed to be a nested `ParameterSet`).
+fn infer_param_type_from_type(ty: &syn::Type) -> Option {
+ let syn::Type::Path(type_path) = ty else {
+ return None;
+ };
+ let segment = type_path.path.segments.last()?;
+ match segment.ident.to_string().as_str() {
+ "MandatoryParameter" => Some(ParameterType::Mandatory),
+ "OptionalParameter" => Some(ParameterType::Optional),
+ "ReadOnlyParameter" => Some(ParameterType::ReadOnly),
+ _ => None,
+ }
+}
+
+/// Returns the attribute name for a parameter type (for error messages).
+fn param_type_name(t: ParameterType) -> &'static str {
+ match t {
+ ParameterType::Mandatory => "mandatory",
+ ParameterType::Optional => "optional",
+ ParameterType::ReadOnly => "read_only",
+ }
+}
diff --git a/rclrs-macros/src/parameter_set/mod.rs b/rclrs-macros/src/parameter_set/mod.rs
new file mode 100644
index 00000000..c958d3c8
--- /dev/null
+++ b/rclrs-macros/src/parameter_set/mod.rs
@@ -0,0 +1,114 @@
+//! Implementation of the `#[derive(ParameterSet)]` macro.
+//!
+//! The macro generates a `ParameterSet` trait impl for a struct, expanding
+//! each field into either a parameter builder chain (for leaf fields) or a
+//! recursive `ParameterSet::declare` call (for nested structs).
+
+mod codegen;
+mod field_attrs;
+mod field_info;
+mod struct_attrs;
+
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::{spanned::Spanned, DeriveInput};
+
+use field_info::FieldInfo;
+use struct_attrs::StructAttrs;
+
+/// Converts a PascalCase string to snake_case.
+///
+/// Consecutive uppercase letters are each treated as separate words:
+/// `"HTTPServer"` becomes `"h_t_t_p_server"`. This is acceptable for
+/// typical ROS 2 PascalCase struct names.
+fn to_snake_case(s: &str) -> String {
+ let mut result = String::with_capacity(s.len() + 4);
+ for (i, c) in s.chars().enumerate() {
+ if c.is_uppercase() {
+ if i > 0 {
+ result.push('_');
+ }
+ result.push(c.to_lowercase().next().unwrap());
+ } else {
+ result.push(c);
+ }
+ }
+ result
+}
+
+/// Entry point for the derive macro expansion.
+pub(crate) fn expand(input: DeriveInput) -> syn::Result {
+ let struct_attrs = StructAttrs::parse(&input)?;
+ let ident = &input.ident;
+
+ // Only works on structs with named fields
+ let fields = match &input.data {
+ syn::Data::Struct(data) => match &data.fields {
+ syn::Fields::Named(fields) => &fields.named,
+ _ => {
+ return Err(syn::Error::new(
+ data.fields.span(),
+ "ParameterSet can only be derived for structs with named fields",
+ ))
+ }
+ },
+ syn::Data::Enum(e) => {
+ return Err(syn::Error::new(
+ e.enum_token.span(),
+ "ParameterSet cannot be derived for enums",
+ ))
+ }
+ syn::Data::Union(u) => {
+ return Err(syn::Error::new(
+ u.union_token.span(),
+ "ParameterSet cannot be derived for unions",
+ ))
+ }
+ };
+
+ // Classify each field and generate its initializer
+ let field_initializers: Vec = fields
+ .iter()
+ .map(|field| {
+ let info = FieldInfo::from_field(field)?;
+ Ok(codegen::generate_field(&info))
+ })
+ .collect::>>()?;
+
+ // Determine the default namespace
+ let namespace = match &struct_attrs.namespace {
+ Some(ns) => ns.clone(),
+ None => to_snake_case(&ident.to_string()),
+ };
+
+ Ok(quote! {
+ impl rclrs::ParameterSet for #ident {
+ fn default_namespace() -> &'static str {
+ #namespace
+ }
+
+ fn declare(
+ node: &rclrs::NodeState,
+ prefix: &str,
+ ) -> ::core::result::Result {
+ ::core::result::Result::Ok(Self {
+ #(#field_initializers,)*
+ })
+ }
+ }
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_to_snake_case() {
+ assert_eq!(to_snake_case("DriveConfig"), "drive_config");
+ assert_eq!(to_snake_case("Robot"), "robot");
+ assert_eq!(to_snake_case("SensorConfig"), "sensor_config");
+ assert_eq!(to_snake_case("A"), "a");
+ assert_eq!(to_snake_case("AB"), "a_b");
+ }
+}
diff --git a/rclrs-macros/src/parameter_set/struct_attrs.rs b/rclrs-macros/src/parameter_set/struct_attrs.rs
new file mode 100644
index 00000000..f05bb09d
--- /dev/null
+++ b/rclrs-macros/src/parameter_set/struct_attrs.rs
@@ -0,0 +1,50 @@
+//! Parsing of struct-level `#[parameters(...)]` attributes.
+
+use syn::{DeriveInput, LitStr};
+
+/// Parsed struct-level configuration from `#[parameters(...)]`.
+pub(crate) struct StructAttrs {
+ /// The namespace for this parameter set.
+ /// `None` means derive from struct name (snake_case).
+ /// `Some("")` means flatten (struct name not used as namespace).
+ /// `Some("custom")` means use this exact string.
+ pub namespace: Option,
+}
+
+impl StructAttrs {
+ /// Parses `#[parameters(...)]` attributes from the derive input.
+ pub fn parse(input: &DeriveInput) -> syn::Result {
+ let mut namespace = None;
+
+ for attr in &input.attrs {
+ if !attr.path().is_ident("parameters") {
+ continue;
+ }
+ attr.parse_nested_meta(|meta| {
+ if meta.path.is_ident("flatten") {
+ if namespace.is_some() {
+ return Err(meta.error("cannot combine 'flatten' with 'namespace'"));
+ }
+ namespace = Some(String::new());
+ Ok(())
+ } else if meta.path.is_ident("namespace") {
+ if namespace.is_some() {
+ return Err(meta.error(
+ "cannot specify 'namespace' more than once, or combine with 'flatten'",
+ ));
+ }
+ let value: LitStr = meta.value()?.parse()?;
+ namespace = Some(value.value());
+ Ok(())
+ } else {
+ Err(meta.error(format!(
+ "unknown parameters attribute: '{}'",
+ meta.path.get_ident().map_or("?".into(), |i| i.to_string())
+ )))
+ }
+ })?;
+ }
+
+ Ok(Self { namespace })
+ }
+}
diff --git a/rclrs/Cargo.toml b/rclrs/Cargo.toml
index 8692e19a..5fbb2775 100644
--- a/rclrs/Cargo.toml
+++ b/rclrs/Cargo.toml
@@ -54,6 +54,8 @@ tokio-stream = "0.1"
# Needed by action clients to generate UUID values for their goals
uuid = { version = "1", features = ["v4"] }
+rclrs-macros = { path = "../rclrs-macros" }
+
[dev-dependencies]
# Needed for e.g. writing yaml files in tests
tempfile = "3.3.0"
diff --git a/rclrs/src/lib.rs b/rclrs/src/lib.rs
index 9db4767f..ac59406f 100644
--- a/rclrs/src/lib.rs
+++ b/rclrs/src/lib.rs
@@ -221,6 +221,7 @@ pub use parameter::*;
pub use publisher::*;
pub use qos::*;
pub use rcl_bindings::rmw_request_id_t;
+pub use rclrs_macros::ParameterSet;
pub use service::*;
pub use subscription::*;
pub use time::*;
diff --git a/rclrs/src/node.rs b/rclrs/src/node.rs
index ed436df2..1b074a39 100644
--- a/rclrs/src/node.rs
+++ b/rclrs/src/node.rs
@@ -38,13 +38,13 @@ use crate::{
},
rcl_bindings::*,
ActionClient, ActionClientState, ActionGoalReceiver, ActionServer, ActionServerState,
- AnyTimerCallback, Client, ClientOptions, ClientState, Clock, ContextHandle, ExecutorCommands,
- IntoActionClientOptions, IntoActionServerOptions, IntoAsyncServiceCallback,
+ AnyTimerCallback, Client, ClientOptions, ClientState, Clock, ContextHandle, DeclarationError,
+ ExecutorCommands, IntoActionClientOptions, IntoActionServerOptions, IntoAsyncServiceCallback,
IntoAsyncSubscriptionCallback, IntoNodeServiceCallback, IntoNodeSubscriptionCallback,
IntoNodeTimerOneshotCallback, IntoNodeTimerRepeatingCallback, IntoTimerOptions, LogParams,
- Logger, MessageInfo, ParameterBuilder, ParameterInterface, ParameterVariant, Parameters,
- Promise, Publisher, PublisherOptions, PublisherState, RclrsError, RequestedGoal, Service,
- ServiceOptions, ServiceState, Subscription, SubscriptionOptions, SubscriptionState,
+ Logger, MessageInfo, ParameterBuilder, ParameterInterface, ParameterSet, ParameterVariant,
+ Parameters, Promise, Publisher, PublisherOptions, PublisherState, RclrsError, RequestedGoal,
+ Service, ServiceOptions, ServiceState, Subscription, SubscriptionOptions, SubscriptionState,
TerminatedGoal, TimeSource, Timer, TimerState, ToLogParams, Worker, WorkerOptions, WorkerState,
ENTITY_LIFECYCLE_MUTEX,
};
@@ -1430,6 +1430,69 @@ impl NodeState {
}
}
+ /// Declares a set of parameters from a struct that derives [`ParameterSet`].
+ ///
+ /// Uses the struct's default namespace (derived from the struct name in
+ /// snake_case) as the parameter prefix.
+ ///
+ /// # Example
+ /// ```no_run
+ /// use rclrs::*;
+ ///
+ /// #[derive(ParameterSet)]
+ /// struct DriveConfig {
+ /// #[param(default = 50.0)]
+ /// speed: MandatoryParameter,
+ /// }
+ ///
+ /// # let executor = Context::default_from_env()?.create_basic_executor();
+ /// # let node = executor.create_node("my_node")?;
+ /// // Declares "drive_config.speed"
+ /// let config: DriveConfig = node.declare_parameter_set()?;
+ /// # Ok::<(), RclrsError>(())
+ /// ```
+ pub fn declare_parameter_set(&self) -> Result {
+ T::declare(self, T::default_namespace())
+ }
+
+ /// Declares a set of parameters with an additional prefix prepended to the
+ /// struct's default namespace.
+ ///
+ /// For a struct `DriveConfig` (default namespace `"drive_config"`), calling
+ /// `declare_parameter_set_with_prefix("robot")` declares parameters under
+ /// `"robot.drive_config.*"`.
+ ///
+ /// # Example
+ /// ```no_run
+ /// use rclrs::*;
+ ///
+ /// #[derive(ParameterSet)]
+ /// struct DriveConfig {
+ /// #[param(default = 50.0)]
+ /// speed: MandatoryParameter,
+ /// }
+ ///
+ /// # let executor = Context::default_from_env()?.create_basic_executor();
+ /// # let node = executor.create_node("my_node")?;
+ /// // Declares "robot.drive_config.speed"
+ /// let config: DriveConfig = node.declare_parameter_set_with_prefix("robot")?;
+ /// # Ok::<(), RclrsError>(())
+ /// ```
+ pub fn declare_parameter_set_with_prefix(
+ &self,
+ prefix: &str,
+ ) -> Result {
+ let ns = T::default_namespace();
+ let full_prefix = if prefix.is_empty() {
+ ns.to_string()
+ } else if ns.is_empty() {
+ prefix.to_string()
+ } else {
+ format!("{prefix}.{ns}")
+ };
+ T::declare(self, &full_prefix)
+ }
+
/// Same as [`Self::notify_on_graph_change_with_period`] but uses a
/// recommended default period of 100ms.
pub fn notify_on_graph_change(
diff --git a/rclrs/src/parameter.rs b/rclrs/src/parameter.rs
index fe9fa091..ec286283 100644
--- a/rclrs/src/parameter.rs
+++ b/rclrs/src/parameter.rs
@@ -3,6 +3,9 @@ mod range;
mod service;
mod value;
+mod parameter_set;
+pub use parameter_set::*;
+
pub(crate) use override_map::*;
pub use range::*;
use service::*;
diff --git a/rclrs/src/parameter/parameter_set.rs b/rclrs/src/parameter/parameter_set.rs
new file mode 100644
index 00000000..57a741a0
--- /dev/null
+++ b/rclrs/src/parameter/parameter_set.rs
@@ -0,0 +1,63 @@
+use crate::{DeclarationError, NodeState};
+
+/// A collection of parameters that can be declared together on a node.
+///
+/// This trait is typically derived using `#[derive(ParameterSet)]` from the
+/// `rclrs` crate. The derive macro generates builder calls for each field
+/// based on struct annotations.
+///
+/// # Example
+///
+/// ```no_run
+/// use rclrs::*;
+///
+/// #[derive(ParameterSet)]
+/// struct MyParams {
+/// #[param(default = 50.0, description = "Max speed")]
+/// speed: MandatoryParameter,
+/// }
+///
+/// let executor = Context::default_from_env().unwrap().create_basic_executor();
+/// let node = executor.create_node("my_node").unwrap();
+/// let params: MyParams = node.declare_parameter_set().unwrap();
+/// ```
+pub trait ParameterSet: Sized {
+ /// The default namespace for this parameter set.
+ ///
+ /// Derived from the struct name converted to snake_case.
+ /// Override with `#[parameters(namespace = "custom")]`.
+ /// Returns `""` when `#[parameters(flatten)]` is used.
+ ///
+ /// Note: `flatten` only affects this struct's own namespace. It does not
+ /// propagate to nested `ParameterSet` fields — those still use their own
+ /// `default_namespace()` unless individually marked with `#[param(flatten)]`.
+ fn default_namespace() -> &'static str;
+
+ /// Declares all parameters on the node under the given prefix.
+ ///
+ /// An empty prefix means parameters are declared at root level.
+ /// A non-empty prefix results in parameter names like `"{prefix}.{field}"`.
+ fn declare(node: &NodeState, prefix: &str) -> Result;
+}
+
+/// Helper utilities used by the `ParameterSet` derive macro.
+///
+/// These are not part of the public API and may change without notice.
+#[doc(hidden)]
+pub mod __private {
+ /// Builds a parameter name from a prefix and field name.
+ ///
+ /// - Empty prefix: returns the field name directly.
+ /// - Non-empty prefix: returns `"{prefix}.{field}"`.
+ pub fn param_name(prefix: &str, field: &str) -> String {
+ if prefix.is_empty() {
+ field.to_string()
+ } else {
+ format!("{prefix}.{field}")
+ }
+ }
+}
+
+#[cfg(test)]
+#[path = "parameter_set_tests.rs"]
+mod tests;
diff --git a/rclrs/src/parameter/parameter_set_tests.rs b/rclrs/src/parameter/parameter_set_tests.rs
new file mode 100644
index 00000000..5fbfd3c6
--- /dev/null
+++ b/rclrs/src/parameter/parameter_set_tests.rs
@@ -0,0 +1,269 @@
+//! Integration tests for the `ParameterSet` derive macro.
+
+use crate as rclrs;
+use crate::*;
+
+#[derive(ParameterSet)]
+struct BasicParams {
+ #[param(default = 42)]
+ count: MandatoryParameter,
+
+ #[param(default = 3.14)]
+ ratio: OptionalParameter,
+
+ #[param(default = true)]
+ enabled: ReadOnlyParameter,
+}
+
+#[test]
+fn test_basic_parameter_set() {
+ assert_eq!(BasicParams::default_namespace(), "basic_params");
+
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let params: BasicParams = node.declare_parameter_set().unwrap();
+
+ // Values are correct
+ assert_eq!(params.count.get(), 42);
+ assert_eq!(params.ratio.get(), Some(3.14));
+ assert!(params.enabled.get());
+
+ // Parameters are declared under default namespace "basic_params.*"
+ assert_eq!(
+ node.use_undeclared_parameters()
+ .get::("basic_params.count"),
+ Some(42)
+ );
+}
+
+#[derive(ParameterSet)]
+struct SensorConfig {
+ #[param(default = 30)]
+ rate: MandatoryParameter,
+}
+
+#[derive(ParameterSet)]
+struct RobotParams {
+ #[param(default = 50.0)]
+ speed: MandatoryParameter,
+
+ sensors: SensorConfig,
+}
+
+#[test]
+fn test_nested_parameter_set() {
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let robot: RobotParams = node.declare_parameter_set().unwrap();
+
+ assert_eq!(robot.speed.get(), 50.0);
+ assert_eq!(robot.sensors.rate.get(), 30);
+
+ // Check namespacing: "robot_params.speed", "robot_params.sensors.rate"
+ assert_eq!(
+ node.use_undeclared_parameters()
+ .get::("robot_params.speed"),
+ Some(50.0)
+ );
+ assert_eq!(
+ node.use_undeclared_parameters()
+ .get::("robot_params.sensors.rate"),
+ Some(30)
+ );
+}
+
+#[test]
+fn test_custom_prefix() {
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ // Prefix is additive: "bot" + "robot_params" -> "bot.robot_params.*"
+ let _robot: RobotParams = node.declare_parameter_set_with_prefix("bot").unwrap();
+
+ assert_eq!(
+ node.use_undeclared_parameters()
+ .get::("bot.robot_params.speed"),
+ Some(50.0)
+ );
+ assert_eq!(
+ node.use_undeclared_parameters()
+ .get::("bot.robot_params.sensors.rate"),
+ Some(30)
+ );
+}
+
+#[derive(ParameterSet)]
+#[parameters(flatten)]
+struct FlattenedParams {
+ #[param(default = 100)]
+ global_limit: MandatoryParameter,
+}
+
+#[test]
+fn test_flattened_parameter_set() {
+ assert_eq!(FlattenedParams::default_namespace(), "");
+
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let params: FlattenedParams = node.declare_parameter_set().unwrap();
+
+ assert_eq!(params.global_limit.get(), 100);
+ assert_eq!(
+ node.use_undeclared_parameters().get::("global_limit"),
+ Some(100)
+ );
+}
+
+#[derive(ParameterSet)]
+#[parameters(namespace = "drive")]
+struct DriveConfig {
+ #[param(default = 50.0)]
+ speed: MandatoryParameter,
+}
+
+#[test]
+fn test_custom_namespace() {
+ assert_eq!(DriveConfig::default_namespace(), "drive");
+
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let config: DriveConfig = node.declare_parameter_set().unwrap();
+
+ assert_eq!(config.speed.get(), 50.0);
+ assert_eq!(
+ node.use_undeclared_parameters().get::("drive.speed"),
+ Some(50.0)
+ );
+}
+
+#[derive(ParameterSet)]
+struct Limits {
+ #[param(default = 100.0)]
+ max_force: MandatoryParameter,
+}
+
+#[derive(ParameterSet)]
+struct FlattenTest {
+ #[param(default = 1.0)]
+ speed: MandatoryParameter,
+
+ #[param(flatten)]
+ limits: Limits,
+}
+
+#[test]
+fn test_flatten() {
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let params: FlattenTest = node.declare_parameter_set().unwrap();
+
+ assert_eq!(params.speed.get(), 1.0);
+ assert_eq!(params.limits.max_force.get(), 100.0);
+
+ // "limits" should NOT appear in the parameter name
+ assert_eq!(
+ node.use_undeclared_parameters()
+ .get::("flatten_test.max_force"),
+ Some(100.0)
+ );
+ assert_eq!(
+ node.use_undeclared_parameters()
+ .get::("flatten_test.speed"),
+ Some(1.0)
+ );
+}
+
+#[derive(ParameterSet)]
+#[parameters(flatten)]
+struct FullOptions {
+ #[param(
+ default = 50,
+ description = "Motor speed",
+ constraints = "must be positive",
+ range(lower = 0, upper = 100),
+ ignore_override
+ )]
+ speed: MandatoryParameter,
+}
+
+#[test]
+fn test_full_builder_options() {
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let params: FullOptions = node.declare_parameter_set().unwrap();
+
+ assert_eq!(params.speed.get(), 50);
+ // Range should be enforced
+ assert!(params.speed.set(200).is_err());
+ assert!(params.speed.set(75).is_ok());
+ assert_eq!(params.speed.get(), 75);
+}
+
+fn always_max(_available: AvailableValues) -> Option {
+ // Return the known upper bound of the range
+ Some(100)
+}
+
+#[derive(ParameterSet)]
+#[parameters(flatten)]
+struct DiscriminateParams {
+ #[param(
+ default = 10,
+ range(lower = 0, upper = 100),
+ discriminate = always_max,
+ )]
+ value: MandatoryParameter,
+}
+
+#[test]
+fn test_discriminate() {
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let params: DiscriminateParams = node.declare_parameter_set().unwrap();
+
+ // Discriminator should pick upper bound (100), not default (10)
+ assert_eq!(params.value.get(), 100);
+}
+
+type Speed = MandatoryParameter;
+
+#[derive(ParameterSet)]
+#[parameters(flatten)]
+struct AliasedParams {
+ #[param(mandatory, default = 25.0)]
+ speed: Speed,
+}
+
+#[test]
+fn test_explicit_parameter_type_with_type_alias() {
+ let executor = Context::default().create_basic_executor();
+ let node = executor
+ .create_node(&format!("param_set_test_{}", line!()))
+ .unwrap();
+
+ let params: AliasedParams = node.declare_parameter_set().unwrap();
+ assert_eq!(params.speed.get(), 25.0);
+}