Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Defining Messages

Messages (also called payloads) are the data that flows between tasks. In ROS, you’d define these in .msg files and run a code generator. In Copper, they’re just Rust structs with the right derives.

A custom payload

Here’s the message type from our template project (in src/tasks.rs):

#![allow(unused)]
fn main() {
use bincode::{Decode, Encode};
use cu29::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Default, Debug, Clone, Encode, Decode, Serialize, Deserialize, Reflect)]
pub struct MyPayload {
    value: i32,
}
}

What each derive does

Every derive on a payload struct has a specific purpose:

DerivePurpose
DefaultRequired. Copper pre-allocates message buffers at startup. Default provides the initial “empty” value.
Encode, DecodeBinary serialization for Copper’s zero-alloc message buffers and logging. These come from cu-bincode.
Serialize, DeserializeUsed for configuration parsing, log export (MCAP, JSON), and tooling. These come from serde.
ReflectRuntime type introspection for monitoring tools and simulation integration.
DebugHuman-readable printing for development.
CloneAllows copying messages when needed (e.g., forking data to multiple consumers).

You do not really have to worry about all of these derives for now. Just add them each time you define a message, and we’ll see how they come into action later.

Using primitive types

For simple cases, you don’t need a custom struct at all. Primitive types like i32, f64, and bool already implement all the required traits:

#![allow(unused)]
fn main() {
// In copperconfig.ron:
//   msg: "i32"

// In your task:
type Output<'m> = output_msg!(i32);
}

This is great for prototyping. As your robot grows, you’ll likely define richer message types with multiple fields.

Latched state updates

Not every payload is a fresh sample. Some values are low-rate state: camera calibration, static transforms, lookup tables, map metadata, and similar data that usually stays the same for many cycles.

Re-sending the full value every cycle is wasteful, but making it implicit would hurt determinism and replay. Copper therefore models this explicitly:

  • CuLatchedStateUpdate<T> is the message that flows across the connection
  • CuLatchedState<T> is the consumer-side cache stored in your task state

The update variants are:

  • NoChange – keep the previously latched value
  • Set(value) – replace the previously latched value
  • Clear – remove the previously latched value

The important mental model is that Copper does not automatically “remember the last message” for you. The state transition is the message. Each downstream task that needs the value keeps its own CuLatchedState<T> and applies updates as they arrive.

Producer side

A source or task that discovers calibration data might publish it once with Set(...), then emit NoChange on later cycles until something changes:

#![allow(unused)]
fn main() {
type Output<'m> = output_msg!(CuLatchedStateUpdate<CameraCalibration>);

fn process(&mut self, _ctx: &CuContext, output: &mut Self::Output<'_>) -> CuResult<()> {
    if let Some(calibration) = self.pending_calibration.take() {
        output.set_payload(CuLatchedStateUpdate::Set(calibration));
    } else {
        output.set_payload(CuLatchedStateUpdate::NoChange);
    }
    Ok(())
}
}

If the cached value becomes invalid, emit CuLatchedStateUpdate::Clear.

Consumer side

The consumer stores the latest latched value in its own task state:

#![allow(unused)]
fn main() {
pub struct DepthProjector {
    calibration: CuLatchedState<CameraCalibration>,
}

fn process(&mut self, _ctx: &CuContext, input: &Self::Input<'_>,
           output: &mut Self::Output<'_>) -> CuResult<()> {
    if let Some(update) = input.payload() {
        self.calibration.update(update);
    }

    let Some(calibration) = self.calibration.get() else {
        return Ok(());
    };

    output.set_payload(project_depth_with(calibration));
    Ok(())
}
}

This pattern keeps the common case cheap, because NoChange is just a tiny explicit update, while still making the state evolution visible in logs and deterministic replay.

Using units directly in payloads

Copper exposes the cu29-units wrappers (through cu29::units) so your payload fields can carry units directly instead of raw f32 values.

#![allow(unused)]
fn main() {
use bincode::{Decode, Encode};
use cu29::prelude::*;
use cu29::units::si::f32::{Length, Time, Velocity};
use cu29::units::si::length::{inch, meter};
use cu29::units::si::time::second;
use cu29::units::si::velocity::{kilometer_per_hour, meter_per_second};
use serde::{Deserialize, Serialize};

#[derive(Default, Debug, Clone, Encode, Decode, Serialize, Deserialize, Reflect)]
pub struct WheelSample {
    pub distance: Length,
    pub dt: Time,
    pub speed: Velocity,
}

impl WheelSample {
    pub fn from_raw(distance_m: f32, dt_s: f32) -> Self {
        let distance = Length::new::<meter>(distance_m);
        let dt = Time::new::<second>(dt_s);

        // m / s -> m/s
        let speed: Velocity = (distance.into_uom() / dt.into_uom()).into(); // this is type safe

        Self {
            distance,
            dt,
            speed,
        }
    }

    pub fn distance_in_inches(&self) -> f32 {
        self.distance.get::<inch>()
    }

    pub fn speed_mps(&self) -> f32 {
        self.speed.get::<meter_per_second>()
    }

    pub fn speed_kph(&self) -> f32 {
        self.speed.get::<kilometer_per_hour>()
    }
}
}

This gives you unit-safe fields in messages, unit-safe math when building messages, and explicit conversions when consuming them. Wrapper types support same-dimension arithmetic (+, -) and scalar scale (* f32, / f32) directly; for cross-dimension operations (like Length / Time), compute with the underlying uom quantity and convert back with .into() (or from_uom).

Designing good payloads

A few tips for payload design:

  • Keep payloads small. They’re pre-allocated and copied between cycles. Large payloads waste memory and cache space.
  • Use fixed-size types. Avoid String or Vec on the critical path. Prefer arrays, fixed-size buffers, or enums.
  • One struct per “topic”. Each connection in copperconfig.ron carries exactly one message type. If you need to send different kinds of data, define different structs and use separate connections.

Example: an IMU payload

Here’s what a more realistic payload might look like for an IMU sensor (from here):

#![allow(unused)]
fn main() {
#[derive(Default, Debug, Clone, Encode, Decode, Serialize, Deserialize, Reflect)]
pub struct ImuPayload {
    pub accel_x: Acceleration,
    pub accel_y: Acceleration,
    pub accel_z: Acceleration,
    pub gyro_x: AngularVelocity,
    pub gyro_y: AngularVelocity,
    pub gyro_z: AngularVelocity,
    pub temperature: ThermodynamicTemperature,
}
}

In the next chapter, we’ll see how tasks produce and consume these messages.

For more advanced unit algebra, dimensions, and available units, see the underlying uom crate docs.