Skip to content

feat(macros): derive ParameterSet for enums - #699

Open
azerupi wants to merge 1 commit into
azerupi/params/derive-parameter-variantfrom
azerupi/params/enum-parameter-sets
Open

feat(macros): derive ParameterSet for enums#699
azerupi wants to merge 1 commit into
azerupi/params/derive-parameter-variantfrom
azerupi/params/enum-parameter-sets

Conversation

@azerupi

@azerupi azerupi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The code in this PR was assisted by Claude Code.

This PR extends the macro and lifts a limitation. The ParameterSet trait can now be implemented for an enum that contains alternative sets of parameters but only one set is declared at any one time.

Problem

A node's configuration can sometimes represent mutually exclusive sets of parameters. Let's say for example a sensor that is either a lidar or a camera, a controller that is either PID or bang-bang. In both cases you would want to have different parameters depending on which type the sensor or controller is. A struct cannot express that easily.

Solution

#[derive(ParameterSet)] now accepts an enum. Each variant is one shape of the configuration, carrying only the parameters that belong to it:

/// Configuration for one sensor.
#[derive(ParameterSet, Debug)]
#[parameters(rename_all = "snake_case")]
enum SensorConfig {
    /// A 2D scanning lidar.
    Lidar {
        /// Scan rate in Hz.
        #[param(default = 30, range = 1..=100)]
        rate: i64,
        /// Maximum usable range in m.
        #[param(default = 25.0)]
        range_m: f64,
    },
    /// A USB camera, configured by an existing parameter set.
    Camera(CameraConfig),
    /// Present but not configured.
    Disabled,
}

A parameter file picks the shape, and that shape's values arrive as plain Rust:

/sensor:
  ros__parameters:
    type: lidar
    rate: 40
    range_m: 30.0
let config: SensorConfig = node.load_parameters()?;

match config {
    SensorConfig::Lidar { rate, range_m } => ..., // both present, neither an Option
    SensorConfig::Camera(camera) => ...,
    SensorConfig::Disabled => ...,
}

Declaration

In order to support this, the parameters for such enums are declared in two steps. First, the tag is declared as a read-only string parameter. And depending on the value that it is set to, the parameters of the chosen variant are then declared. Only the selected variant's parameters exist:

$ ros2 param list /sensor
  type
  rate
  range_m

The variants reach the tag's descriptor, and the enum's doc comment becomes its description, so an operator can discover the shapes without reading the source:

$ ros2 param describe /sensor type
  Type: string
  Description: Configuration for one sensor.
  Constraints: one of: lidar, camera, disabled
  Read only: true

A tag naming no variant fails the declaration the same way enum parameters do:

unknown SensorConfig 'banana', expected one of: lidar, camera, disabled

The tag is read-only because what is declared depends on it. Changing it at runtime would mean undeclaring one group of parameters and declaring another, invalidating handles the caller is holding. Changing which variant a node uses needs a restart.

Handles

declare_parameters returns live handles, as it does for a struct set. The generated SensorConfigParams holds the tag and a SensorConfigVariantParams mirroring the enum:

let params = node.declare_parameters::<SensorConfig>()?;
assert_eq!(params.tag(), "lidar");

match &params.variant {
    SensorConfigVariantParams::Lidar { rate, range_m } => {
        rate.set(50)?;
        assert!(rate.set(200).is_err()); // the range still applies
    }
    _ => ...,
}

let config: SensorConfig = params.snapshot();

…veral shapes

A configuration is often one of several things, each needing different
parameters: a sensor that is either a lidar or a camera, a controller that
is either PID or bang-bang. Expressed as a struct, that means a field for
every parameter of every shape, all declared whether they apply or not,
and nothing saying which combination is meaningful.

An enum says it directly:

    #[derive(ParameterSet, Debug)]
    #[parameters(rename_all = "snake_case")]
    enum SensorConfig {
        Lidar { #[param(default = 30)] rate: i64 },
        Camera(CameraConfig),
        Disabled,
    }

ROS 2 parameters are declared statically, so this is declared in two
steps: a read-only string parameter -- the tag, `type` unless renamed --
is declared first, and the parameters of whichever variant it names are
then declared alongside it. Only the selected variant's parameters exist,
so nothing is declared for a lidar when the file says camera.

The tag is read-only because what is declared depends on it. Changing it
at runtime would mean undeclaring one group of parameters and declaring
another, which would invalidate handles the caller is holding. Changing
which variant a node uses is a restart.

Struct variants declare their fields under the set's own namespace,
newtype variants over another set delegate to it so an existing config
struct can be reused unchanged, and unit variants declare nothing beyond
the tag. An enum set nests inside a struct set like any other, so the two
compose.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant