[FEAT] Add ROS compatible zenoh sink#316
Conversation
There was a problem hiding this comment.
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" }
makeecat
left a comment
There was a problem hiding this comment.
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:
- Consistency of Error Msg: Please either use
ZenohRosSinkorZenohSinkas the prefix of error msg. - Add more documentation for public API
- Fix
zenohversion back to1.3.4to avoid surprise - The CDR serialization adds an additional layer of conversion that could impact performance, worth noting it in README for future improvements.
- 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();
25155e8 to
9aead07
Compare
All done. |
|
TIL: They already planned to change the attachment format for the next release ros2/rmw_zenoh#600 😞 |
| #[derive(Clone, Debug, Serialize, Deserialize)] | ||
| pub struct Header { | ||
| pub stamp: Time, | ||
| pub frame_id: std::string::String, |
There was a problem hiding this comment.
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; | ||
| } |
There was a problem hiding this comment.
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 { ... }
}
}There was a problem hiding this comment.
Let me known if it sounds better now.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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, |
| } | ||
|
|
||
| #[allow(unreachable_code)] | ||
| fn convert(&self) -> Self::Output { |
There was a problem hiding this comment.
can't we just use the standard "Into" facility of Rust here?
| "std_msgs" | ||
| } | ||
|
|
||
| fn type_name() -> &'static str { |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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?
|
|
||
| pub struct ZenohRosContext { | ||
| session: zenoh::Session, | ||
| publisher: zenoh::pubsub::Publisher<'static>, |
There was a problem hiding this comment.
The publisher has to be static here?
There was a problem hiding this comment.
Actually, as far as I understand, it only means that the "key expr" is owned. But maybe I'm missing something.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
|
Lots of good points 👍 |
Allows to forward data to ROS using Zenoh. ROS nodes have to use the Zenoh backend (rmw_zenoh).
|
|
||
| pub struct ZenohRosContext { | ||
| session: zenoh::Session, | ||
| publisher: zenoh::pubsub::Publisher<'static>, |
There was a problem hiding this comment.
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; | ||
| } |
There was a problem hiding this comment.
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())) |
There was a problem hiding this comment.
How heavy it’s to create a new session per task, instead of having somehow a global ?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
in a separated project using copper I have instantiated within a global lazy operator
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
really good idea. Looking forward to it
|
Ok let's merge this and go back to it for improvements, I don't want the PR to go stale. Thanks everyone |
This PR adds a ROS compatible Zenoh sink to forward data from Copper applications to ROS 2 via the Zenoh middleware (rmw_zenoh).