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

Task Anatomy: Types and Methods

Now that you’ve seen the three task traits, let’s look closely at the associated types and methods that make them work.

Associated Types

type Resources<'r> and impl Freezable

We’ll cover these in detail in the Advanced Task Features chapter. For now, notice that both are defined as empty – type Resources<'r> = () and impl Freezable for MyTask {} – which is all you need for a simple project.

type Input<'m> (CuTask and CuSinkTask only)

#![allow(unused)]
fn main() {
type Input<'m> = input_msg!(MyPayload);
}

Declares what messages this task receives from upstream. The input_msg!() macro wraps your payload type into Copper’s message container (CuMsg<T>), which carries:

  • The payload itself (your struct)
  • Metadata (timestamps, status flags)
  • Time of Validity (tov)

Multiple inputs: If a task receives data from multiple upstream tasks, list the types separated by commas:

#![allow(unused)]
fn main() {
type Input<'m> = input_msg!(SensorA, SensorB);
}

In process(), the input parameter becomes a tuple that you can destructure:

#![allow(unused)]
fn main() {
fn process(&mut self, _ctx: &CuContext, input: &Self::Input<'_>, ...) -> CuResult<()> {
    let (sensor_a_msg, sensor_b_msg) = *input;
    // Use sensor_a_msg.payload() and sensor_b_msg.payload()
    Ok(())
}
}

type Output<'m> (CuSrcTask and CuTask only)

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

Declares what messages this task produces for downstream. The output buffer is pre-allocated by the runtime at startup. You don’t create messages – you fill them using set_payload().

Methods

fn new(config, resources) -> CuResult<Self>

#![allow(unused)]
fn main() {
fn new(
    _config: Option<&ComponentConfig>,
    _resources: Self::Resources<'_>,
) -> CuResult<Self>
where
    Self: Sized,
{
    Ok(Self {})
}
}

The constructor. Called once when the runtime builds the task graph.

config: Option<&ComponentConfig> is a key-value map from the task’s config block in copperconfig.ron. For example, if your RON file has:

(
    id: "motor",
    type: "tasks::MotorDriver",
    config: { "pin": 4, "max_speed": 1.0 },
),

You can read the values in new():

#![allow(unused)]
fn main() {
fn new(config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self> {
    let cfg = config.ok_or("MotorDriver requires a config block")?;
    let pin: u8 = cfg
        .get::<u8>("pin")?
        .ok_or_else(|| CuError::from("MotorDriver missing `pin`"))?;
    let max_speed: f64 = cfg
        .get::<f64>("max_speed")?
        .ok_or_else(|| CuError::from("MotorDriver missing `max_speed`"))?;
    Ok(Self { pin, max_speed })
}
}

We’ll discuss resources in future chapters.

fn process() – the main loop

This is where your task does its work. The runtime calls it every cycle. The signature depends on the trait:

TraitSignature
CuSrcTaskprocess(&mut self, ctx, output)
CuTaskprocess(&mut self, ctx, input, output)
CuSinkTaskprocess(&mut self, ctx, input)

In our simple example, the source ignores most parameters and just writes a value:

#![allow(unused)]
fn main() {
fn process(&mut self, _ctx: &CuContext, output: &mut Self::Output<'_>) -> CuResult<()> {
    output.set_payload(MyPayload { value: 42 });
    Ok(())
}
}

Let’s look at what each parameter gives you.

Reading input

input.payload() returns Option<&T> – an Option because the message could be empty (e.g., the upstream task had nothing to send this cycle). In production you should handle None; in our example we just unwrap:

#![allow(unused)]
fn main() {
let value = input.payload().unwrap();
}

Writing output

output.set_payload(value) writes your data into a buffer that was pre-allocated at startup.

For values that should persist on the consumer side until explicitly replaced, Copper also has a generic latched-state pattern:

  • CuLatchedStateUpdate<T>::NoChange – keep the cached value
  • CuLatchedStateUpdate<T>::Set(value) – replace the cached value
  • CuLatchedStateUpdate<T>::Clear – remove the cached value

Downstream tasks can store that state in CuLatchedState<T> and apply updates as they arrive. This is a good fit for calibration bundles, static transforms, lookup tables, and other low-rate metadata that should not be rebuilt every cycle. The full producer and consumer pattern is covered in Defining Messages.

The context: &CuContext

Every lifecycle callback receives a ctx parameter of type &CuContext. It dereferences to Copper’s monotonic RobotClock, so ctx.now() gives you the current Copper time, and it also carries runtime metadata such as ctx.cl_id(), ctx.instance_id(), and the currently executing task ID.

ctx.now() returns a CuTime (a u64 of nanoseconds since startup). There is no UTC or wall-clock in Copper; tasks should never call std::time::SystemTime::now() or std::time::Instant::now().

In our simple project we prefix the parameter with _ because we don’t use it. But on a real robot you’d use it like this:

Timestamp your output (typical for source tasks):

#![allow(unused)]
fn main() {
fn process(&mut self, ctx: &CuContext, output: &mut Self::Output<'_>) -> CuResult<()> {
    output.set_payload(MyPayload { value: read_sensor() });
    output.tov = Tov::Time(ctx.now());
    Ok(())
}
}

Compute a time delta (e.g., for a PID controller):

#![allow(unused)]
fn main() {
fn process(&mut self, ctx: &CuContext, input: &Self::Input<'_>, output: &mut Self::Output<'_>) -> CuResult<()> {
    let now = ctx.now();
    let dt = now - self.last_time;  // CuDuration in nanoseconds
    self.last_time = now;

    let error = self.target - input.payload().unwrap().value;
    let correction = self.kp * error + self.ki * self.integral * dt.as_secs_f64();
    // ...
    Ok(())
}
}

Detect a timeout:

#![allow(unused)]
fn main() {
fn process(&mut self, ctx: &CuContext, input: &Self::Input<'_>) -> CuResult<()> {
    if input.payload().is_none() {
        let elapsed = ctx.now() - self.last_seen;
        if elapsed > CuDuration::from_millis(100) {
            debug!("Sensor timeout! No data for {}ms", elapsed.as_millis());
        }
    }
    Ok(())
}
}

Why not use the system clock? Because Copper supports deterministic replay. When you replay a recorded run, the runtime feeds your tasks the exact same clock values from the original recording. If you used std::time, the replay would have different timestamps and your tasks would behave differently. With CuContext (and its underlying RobotClock), same clock + same inputs = same outputs, every time.

The golden rule of process()

Because process() runs on the real-time critical path (potentially thousands of times per second), you should avoid heap allocation inside it. Operations like Vec::push(), String::from(), or Box::new() ask the system allocator for memory, which can take an unpredictable amount of time and cause your cycle to miss its deadline.

Copper’s architecture is designed so you never need to allocate in process(): messages are pre-allocated, and the structured logger writes to pre-mapped memory. Keep your process() fast and predictable.


So far we’ve focused on new() and process() – the two methods you’ll always implement. But Copper tasks have a richer lifecycle with optional hooks for setup, teardown, and work that doesn’t need to run on the critical path. Let’s look at that next.