Skip to content

[FEAT] Add ROS compatible zenoh sink#316

Merged
gbin merged 1 commit into
copper-project:masterfrom
kamibo:cb/zenoh-ros-sink
May 23, 2025
Merged

[FEAT] Add ROS compatible zenoh sink#316
gbin merged 1 commit into
copper-project:masterfrom
kamibo:cb/zenoh-ros-sink

Conversation

@kamibo

@kamibo kamibo commented May 6, 2025

Copy link
Copy Markdown
Collaborator

This PR adds a ROS compatible Zenoh sink to forward data from Copper applications to ROS 2 via the Zenoh middleware (rmw_zenoh).

  • Introduces a new sink (cu-zenoh-ros-sink) with full implementation including liveliness, key expression formatting, and configuration handling.
  • Adds an example application (cu_zenoh_ros) and updates workspace members and dependencies accordingly.

@kamibo kamibo force-pushed the cb/zenoh-ros-sink branch from c349161 to dcdf651 Compare May 6, 2025 14:31
@makeecat makeecat requested a review from Copilot May 6, 2025 15:37
@makeecat makeecat added the enhancement New feature or request label May 6, 2025

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds a ROS compatible Zenoh sink to forward data from Copper applications to ROS 2 via the Zenoh middleware.

  • Introduces a new sink (cu-zenoh-ros-sink) with full implementation including liveliness, key expression formatting, and configuration handling.
  • Adds an example application (cu_zenoh_ros) and updates workspace members and dependencies accordingly.

Reviewed Changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
examples/cu_zenoh_ros/src/main.rs New example showcasing usage of the ROS sink.
examples/cu_zenoh_ros/build.rs & README.md Build and documentation updates for the new example.
examples/cu_zenoh_ros/Cargo.toml Dependency and configuration for the example.
components/sinks/cu_zenoh_sink/src/lib.rs Minor change from .ok_or_else to .ok_or in error handling.
components/sinks/cu_zenoh_sink/Cargo.toml Updated dependency version for zenoh.
components/sinks/cu_zenoh_ros_sink/* New implementation of the ROS sink including keyexpr, node, liveliness…
components/payloads/cu_ros_payloads/* Adds ROS compatible payload types and adapter implementation.
Cargo.toml Workspace member updates to include the new ROS payloads and sink crates.
Files not reviewed (1)
  • examples/cu_zenoh_ros/copperconfig.ron: Language not supported
Comments suppressed due to low confidence (1)

components/sinks/cu_zenoh_sink/Cargo.toml:14

  • The zenoh version was changed from 1.3.4 to 1.3; please confirm that this broader version range is intentional to avoid unintended compatibility issues.
zenoh = { version = "1.3" }

Comment thread components/sinks/cu_zenoh_ros_sink/src/lib.rs Outdated

@makeecat makeecat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new zenoh sink component that allows Copper to publish data to ROS 2 using the Zenoh middleware. This enables seamless integration between Copper applications and ROS 2 nodes, using the RMW zenoh implementation.

Several Actions items:

  1. Consistency of Error Msg: Please either use ZenohRosSink or ZenohSink as the prefix of error msg.
  2. Add more documentation for public API
  3. Fix zenoh version back to 1.3.4 to avoid surprise
  4. The CDR serialization adds an additional layer of conversion that could impact performance, worth noting it in README for future improvements.
  5. In components/sinks/cu_zenoh_ros_sink/src/lib.rs (process method), CDR serialization uses .unwrap() which can panic, please handle the error:
    let serial_ros_payload = cdr::serialize::<_, _, CdrBe>(&ros_payload, Infinite).unwrap();

@kamibo kamibo force-pushed the cb/zenoh-ros-sink branch 2 times, most recently from 25155e8 to 9aead07 Compare May 7, 2025 09:55
@kamibo

kamibo commented May 7, 2025

Copy link
Copy Markdown
Collaborator Author

This PR adds a new zenoh sink component that allows Copper to publish data to ROS 2 using the Zenoh middleware. This enables seamless integration between Copper applications and ROS 2 nodes, using the RMW zenoh implementation.

Several Actions items:

1. Consistency of Error Msg: Please either use `ZenohRosSink` or `ZenohSink` as the prefix of error msg.

2. Add more documentation for public API

3. Fix `zenoh` version back to `1.3.4` to avoid surprise

4. The CDR serialization adds an additional layer of conversion that could impact performance, worth noting it in README for future improvements.

5. In `components/sinks/cu_zenoh_ros_sink/src/lib.rs` (process method), CDR serialization uses `.unwrap()` which can panic, please handle the error:
   `let serial_ros_payload = cdr::serialize::<_, _, CdrBe>(&ros_payload, Infinite).unwrap();`

All done.
Regarding the API documentation, I refactored the ROS message adapter trait to make it easier to use and added some comments.
Let me know if there’s anything else we can do to improve it.

@kamibo kamibo requested a review from makeecat May 7, 2025 10:01
@makeecat makeecat requested a review from gbin May 7, 2025 10:32

@makeecat makeecat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me. @gbin Do you have some comments on the use of string ? I believe we can improve that in a second PR to have a baseline.

@kamibo

kamibo commented May 7, 2025

Copy link
Copy Markdown
Collaborator Author

TIL: They already planned to change the attachment format for the next release ros2/rmw_zenoh#600 😞

@gbin gbin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome PR,

tried to prod on some things that looked weird, feel free to push back.

Thank you so much!

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Header {
pub stamp: Time,
pub frame_id: std::string::String,

@gbin gbin May 7, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if the string is short maybe use a short string like compact_str we use in cu29_runtime if possible?

fn namespace() -> &'static str;
fn type_name() -> &'static str;
fn convert(&self) -> Self::Output;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about forcing static here, if you parametrize the type I think the implementor/users of that trait can always use static for it:

pub trait RosMsgAdapter<'a> {
    type Output: Serialize;

    fn namespace() -> &'a str;
    fn type_name() -> &'a str;
    fn convert(&self) -> Self::Output;
}
impl<'a> RosMsgAdapter<'a> for MyMsg {
    type Output = MyRosCompatibleMsg;

    fn namespace() -> &'a str {
        "std_msgs"
    }

    fn type_name() -> &'a str {
        "String"
    }

    fn convert(&self) -> Self::Output {
        MyRosCompatibleMsg { ... }
    }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me known if it sounds better now.

@gbin gbin May 16, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about the convert method ... this is just a not very idiomatic way of a From/Into, you can force the trait with a +

pub trait RosMsgAdapter<'a>: Into<Self::Output> + Sized
where
    Self::Output: Serialize + 'a
{
    fn namespace() -> &'a str;
    fn type_name() -> &'a str {
        ros_type_name!(Self::Output)
    }
}

^ that feels more like the signature I would expect

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually it tried to do it this way but I failed.
The point is that Into requires self to be moved fn into(self) -> T; and it would force to use clone into the sink implementation.
Is there a way to workaround this issue?

categories.workspace = true
homepage.workspace = true
repository.workspace = true
description = "Those are ROS compatible payloads"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be the basic public description on Crates.io, maybe mention that it is part of copper/extra payload for copper.

// sensor_msgs/PointField
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PointField {
pub name: String,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe small_str

}

#[allow(unreachable_code)]
fn convert(&self) -> Self::Output {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we just use the standard "Into" facility of Rust here?

"std_msgs"
}

fn type_name() -> &'static str {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the type_name is almost always the Struct name you can provide it for the user with a small default impl in the trait maybe?

pub trait RosMsgAdapter<'a> {
    type Output: serde::Serialize;

    fn convert(&self) -> Self::Output;

    fn namespace() -> &'a str;

    fn type_name() -> &'a str {
        ros_type_name!(Self::Output)
    }
}

#[macro_export]
macro_rules! ros_type_name {
    ($t:ty) => {{
        std::any::type_name::<$t>()
            .rsplit("::")
            .next()
            .unwrap()
    }};
}

link to the playground with a little test:
https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=c10055c62bf8aadd4e77e9920d3965a7

note that the user can override this behavior in the implementation

(
id: "publisher",
config: {
"zenoh_config_file": "/opt/ros/humble/share/rmw_zenoh_cpp/config/DEFAULT_RMW_ZENOH_SESSION_CONFIG.json5",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That breaks the "self-contained" nature of the Copper config.

We do an include! of the RON file at compile time to be able to have a 1 file deployment and a path to a no_std deployment too: ie. no filesystem at all.

is it possible, maybe in another PR to provide an embedded version of it: for example a config_str: and the inline version of the json file?

Comment thread components/sinks/cu_zenoh_ros_sink/src/error.rs

pub struct ZenohRosContext {
session: zenoh::Session,
publisher: zenoh::pubsub::Publisher<'static>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The publisher has to be static here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, as far as I understand, it only means that the "key expr" is owned. But maybe I'm missing something.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what I understand is that basically either it is a static string OR you won't be able to deallocate the publisher (Box::leak), so the ZenohRosContext, so the session etc.. ?

I believe you need to forward those lifetimes to the Context.

'static is really what it means, it will be never ever be deallocated.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you need to forward those lifetimes to the Context.

I also tried this but I can't figure out to forward those lifetimes beyond code generated from the RON file.
But I'm not sure it is a big deal.
This lifetime is related to the keyexpr stored inside the publisher and according the documentation
https://docs.rs/zenoh-keyexpr/latest/zenoh_keyexpr/

KeyExpr works like a Cow, but also stores some additional context internal to Zenoh to optimize routing and network usage.

AFIU this means that using static lifetime implies key expr cannot be borrowed https://github.com/eclipse-zenoh/zenoh/blob/63a6e33fe58eb98485c13dad0924f673baedc060/zenoh/src/api/key_expr.rs#L40
And we don't care about that as we have to compute the key expr.

'static is really what it means, it will be never ever be deallocated.

Not necessarily. My understanding is it can be hold for as long as needed.

https://doc.rust-lang.org/rust-by-example/scope/lifetime/static_lifetime.html#trait-bound

But maybe I'm missing something...


impl<'cl, P> CuSinkTask<'cl> for ZenohRosSink<P>
where
P: CuMsgPayload + RosMsgAdapter + 'cl + 'static,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while correct rust, this 'static feels out of place. Conceptually those types only need to live for the duration of one CopperList ie. one execution cycle.

@kamibo

kamibo commented May 9, 2025

Copy link
Copy Markdown
Collaborator Author

Lots of good points 👍
Note: I'm going be busy for a week (team meeting in Paris). I'll continue when I get back

@makeecat makeecat changed the title Add ROS compatible zenoh sink [FEAT] Add ROS compatible zenoh sink May 10, 2025
@kamibo kamibo force-pushed the cb/zenoh-ros-sink branch from 9aead07 to e77180e Compare May 16, 2025 14:56
@kamibo kamibo requested a review from gbin May 16, 2025 14:57
Allows to forward data to ROS using Zenoh.
ROS nodes have to use the Zenoh backend (rmw_zenoh).
@kamibo kamibo force-pushed the cb/zenoh-ros-sink branch from e77180e to 3033f92 Compare May 16, 2025 15:36

pub struct ZenohRosContext {
session: zenoh::Session,
publisher: zenoh::pubsub::Publisher<'static>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what I understand is that basically either it is a static string OR you won't be able to deallocate the publisher (Box::leak), so the ZenohRosContext, so the session etc.. ?

I believe you need to forward those lifetimes to the Context.

'static is really what it means, it will be never ever be deallocated.

fn namespace() -> &'static str;
fn type_name() -> &'static str;
fn convert(&self) -> Self::Output;
}

@gbin gbin May 16, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about the convert method ... this is just a not very idiomatic way of a From/Into, you can force the trait with a +

pub trait RosMsgAdapter<'a>: Into<Self::Output> + Sized
where
    Self::Output: Serialize + 'a
{
    fn namespace() -> &'a str;
    fn type_name() -> &'a str {
        ros_type_name!(Self::Output)
    }
}

^ that feels more like the signature I would expect

}

fn start(&mut self, _clock: &RobotClock) -> CuResult<()> {
let session = zenoh::Wait::wait(zenoh::open(self.config.session.clone()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How heavy it’s to create a new session per task, instead of having somehow a global ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. At some point, it would be nice to find a way to have a single session and multiple publishers across many tasks.

@edgarriba edgarriba May 20, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in a separated project using copper I have instantiated within a global lazy operator

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds great. However I'm wondering how to define a single zenoh session config for all the tasks inside the RON file? 🤔
@gbin any thoughts about this?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we really need a new kind of "service" task just holding a state / running a daemon thread. I propose to add that as an issue for that and discuss the arch and determinism on the issue. For now let's move forward with this PR with the known limitation then refactor it with the new facility in the framework.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

really good idea. Looking forward to it

@gbin

gbin commented May 23, 2025

Copy link
Copy Markdown
Collaborator

Ok let's merge this and go back to it for improvements, I don't want the PR to go stale.

Thanks everyone

@gbin gbin merged commit 32fa692 into copper-project:master May 23, 2025
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants