From 083127f480b1599561ba56202d248e6a83abba83 Mon Sep 17 00:00:00 2001 From: carter Date: Mon, 27 Jul 2026 19:30:25 +0000 Subject: [PATCH 1/3] Add support for ros2 wsting --- Cargo.lock | 1 + assets/ros2_test_msgs/msg/WStrings.msg | 10 ++ roslibrust_codegen/Cargo.toml | 1 + roslibrust_codegen/src/gen.rs | 15 +- roslibrust_codegen/src/lib.rs | 2 + roslibrust_codegen/src/parse/mod.rs | 34 ++++- roslibrust_codegen/src/wstring.rs | 155 ++++++++++++++++++++ roslibrust_test/src/ros2.rs | 43 ++++++ roslibrust_test/tests/ros2_codegen_tests.rs | 24 +++ 9 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 assets/ros2_test_msgs/msg/WStrings.msg create mode 100644 roslibrust_codegen/src/wstring.rs diff --git a/Cargo.lock b/Cargo.lock index 63decad9..ec6dbd8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3469,6 +3469,7 @@ version = "0.21.0" dependencies = [ "anyhow", "base64 0.22.1", + "cdr", "chrono", "env_logger 0.10.2", "hex", diff --git a/assets/ros2_test_msgs/msg/WStrings.msg b/assets/ros2_test_msgs/msg/WStrings.msg new file mode 100644 index 00000000..666d0ec6 --- /dev/null +++ b/assets/ros2_test_msgs/msg/WStrings.msg @@ -0,0 +1,10 @@ +wstring wstring_value +wstring wstring_value_default1 "Hello world!" +wstring wstring_value_default2 "Hellö wörld!" +wstring wstring_value_default3 "ハローワールド" +#wstring WSTRING_CONST="Hello world!" +#wstring<=22 bounded_wstring_value +#wstring<=22 bounded_wstring_value_default1 "Hello world!" +wstring[3] array_of_wstrings +wstring[<=3] bounded_sequence_of_wstrings +wstring[] unbounded_sequence_of_wstrings diff --git a/roslibrust_codegen/Cargo.toml b/roslibrust_codegen/Cargo.toml index c007e192..7a8abaee 100644 --- a/roslibrust_codegen/Cargo.toml +++ b/roslibrust_codegen/Cargo.toml @@ -43,6 +43,7 @@ anyhow = "1.0" tokio = { workspace = true } env_logger = "0.10" test-log = { workspace = true } +cdr = "0.2" [features] # For use with CI environment or any environment with ROS1 installed diff --git a/roslibrust_codegen/src/gen.rs b/roslibrust_codegen/src/gen.rs index 1aa5c12e..32a49e29 100644 --- a/roslibrust_codegen/src/gen.rs +++ b/roslibrust_codegen/src/gen.rs @@ -351,7 +351,10 @@ fn convert_ros_constant_type_to_rust_type( convert_ros_type_to_rust_type(version, &constant.constant_type) .map(|rust_type| { - if rust_type == "::std::string::String" { + if matches!( + rust_type, + "::std::string::String" | "::roslibrust::codegen::WString" + ) { "&'static str" } else { rust_type @@ -457,14 +460,20 @@ fn parse_ros_value( "int32" => generic_parse_value::(value, is_list), "uint64" => generic_parse_value::(value, is_list), "int64" => generic_parse_value::(value, is_list), - "string" => { + "string" | "wstring" => { // String is a special case because of quotes and to_string() if is_list { // TODO there is a bug here, no idea how I should be attempting to convert / escape single quotes here... let parsed: Vec = serde_json::from_str(value).map_err(|e| Error::with(format!("Failed to parse a literal value in a message file to the corresponding rust type: {value} to Vec").as_str(), e) )?; - let vec_str = format!("{parsed:?}.iter().map(|x| x.to_string()).collect()"); + let vec_str = if ros_type == "wstring" { + format!( + "{parsed:?}.iter().map(|x| ::roslibrust::codegen::WString::from(*x)).collect()" + ) + } else { + format!("{parsed:?}.iter().map(|x| x.to_string()).collect()") + }; Ok(quote! { #vec_str }) } else { match version { diff --git a/roslibrust_codegen/src/lib.rs b/roslibrust_codegen/src/lib.rs index a4f35210..9366f2fa 100644 --- a/roslibrust_codegen/src/lib.rs +++ b/roslibrust_codegen/src/lib.rs @@ -34,6 +34,8 @@ mod ros2_builtin_interfaces; pub mod integral_types; pub use integral_types::*; +mod wstring; +pub use wstring::WString; // Custom serde module for Vec that handles both base64 (rosbridge) and arrays (other formats) pub mod serde_rosmsg_bytes; diff --git a/roslibrust_codegen/src/parse/mod.rs b/roslibrust_codegen/src/parse/mod.rs index e3555c34..b2079bd5 100644 --- a/roslibrust_codegen/src/parse/mod.rs +++ b/roslibrust_codegen/src/parse/mod.rs @@ -52,9 +52,9 @@ lazy_static::lazy_static! { ("float32", "f32"), ("float64", "f64"), ("string", "::std::string::String"), + ("wstring", "::roslibrust::codegen::WString"), ("builtin_interfaces/Time", "::roslibrust::codegen::integral_types::Time"), ("builtin_interfaces/Duration", "::roslibrust::codegen::integral_types::Duration"), - // ("wstring", TODO), ].into_iter().collect(); } @@ -216,17 +216,26 @@ fn parse_field_type( } } -/// Specifically handles bounded string types, e.g. "string<=10" +/// Specifically handles bounded string types, e.g. "string<=10" or "wstring<=10". /// Returns the field_type and the string_capacity if it is a bounded string /// Otherwise returns the original type and None for the capacity fn parse_bounded_string(type_str: &str) -> Result<(String, Option), Error> { - if let Some(stripped) = type_str.strip_prefix("string<=") { - let capacity = stripped.parse::().map_err(|err| { + let bounded_type = type_str + .strip_prefix("string<=") + .map(|capacity| ("string", capacity)) + .or_else(|| { + type_str + .strip_prefix("wstring<=") + .map(|capacity| ("wstring", capacity)) + }); + + if let Some((field_type, capacity)) = bounded_type { + let capacity = capacity.parse::().map_err(|err| { Error::new(format!( "Unable to parse capacity of bounded string: {type_str}: {err}" )) })?; - Ok(("string".to_string(), Some(capacity))) + Ok((field_type.to_string(), Some(capacity))) } else { Ok((type_str.to_string(), None)) } @@ -326,6 +335,21 @@ mod test { assert_eq!(parsed.array_info, ArrayType::Unbounded); } + #[test_log::test] + fn parse_type_handles_bounded_wstring_correctly() { + let pkg = Package { + name: "test_pkg".to_string(), + path: "./not_a_path".into(), + version: Some(RosVersion::ROS2), + }; + let parsed = parse_type("wstring<=32[<=4]", &pkg).unwrap(); + + assert_eq!(parsed.field_type, "wstring"); + assert_eq!(parsed.string_capacity, Some(32)); + assert_eq!(parsed.array_info, ArrayType::Bounded(4)); + assert_eq!(parsed.package_name, None); + } + #[test_log::test] fn parse_constant_with_hash_in_value() { use crate::parse::parse_constant_field; diff --git a/roslibrust_codegen/src/wstring.rs b/roslibrust_codegen/src/wstring.rs new file mode 100644 index 00000000..a9c555d9 --- /dev/null +++ b/roslibrust_codegen/src/wstring.rs @@ -0,0 +1,155 @@ +use serde::{ + de::Error as DeError, ser::SerializeSeq, Deserialize, Deserializer, Serialize, Serializer, +}; +use std::{borrow::Borrow, fmt, ops::Deref, str::FromStr}; + +/// A ROS 2 `wstring`. +/// +/// ROS 2 represents this type as a [`std::u16string`](https://en.cppreference.com/w/cpp/string/basic_string) +/// in C++ and as a DDS `wstring` on the wire. Rust strings are UTF-8, so this +/// wrapper converts to and from UTF-16 when used with a binary serializer such +/// as CDR, while remaining a normal JSON string for human-readable serializers. +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct WString(String); + +impl WString { + pub fn new() -> Self { + Self::default() + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl Serialize for WString { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if serializer.is_human_readable() { + serializer.serialize_str(&self.0) + } else { + let code_units = self.0.encode_utf16(); + let mut sequence = serializer.serialize_seq(Some(code_units.clone().count()))?; + for code_unit in code_units { + sequence.serialize_element(&code_unit)?; + } + sequence.end() + } + } +} + +impl<'de> Deserialize<'de> for WString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + if deserializer.is_human_readable() { + String::deserialize(deserializer).map(Self) + } else { + let code_units = Vec::::deserialize(deserializer)?; + String::from_utf16(&code_units) + .map(Self) + .map_err(D::Error::custom) + } + } +} + +impl AsRef for WString { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Borrow for WString { + fn borrow(&self) -> &str { + self.as_str() + } +} + +impl Deref for WString { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl fmt::Display for WString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl From for WString { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for WString { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + +impl From for String { + fn from(value: WString) -> Self { + value.0 + } +} + +impl FromStr for WString { + type Err = std::convert::Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self::from(value)) + } +} + +impl PartialEq for WString { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for WString { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +#[cfg(test)] +mod tests { + use super::WString; + + #[test] + fn json_uses_a_normal_string() { + let value = WString::from("ハロー 🌍"); + let encoded = serde_json::to_string(&value).unwrap(); + assert_eq!(encoded, "\"ハロー 🌍\""); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), value); + } + + #[test] + fn cdr_uses_a_utf16_sequence_without_a_null_terminator() { + let value = WString::from("A🌍"); + let encoded = cdr::serialize::<_, _, cdr::CdrLe>(&value, cdr::Infinite).unwrap(); + + assert_eq!( + encoded, + [ + 0x00, 0x01, 0x00, 0x00, // CDR little-endian encapsulation + 0x03, 0x00, 0x00, 0x00, // three UTF-16 code units + 0x41, 0x00, // A + 0x3c, 0xd8, 0x0d, 0xdf, // U+1F30D surrogate pair + ] + ); + assert_eq!(cdr::deserialize::(&encoded).unwrap(), value); + } +} diff --git a/roslibrust_test/src/ros2.rs b/roslibrust_test/src/ros2.rs index 725741b3..94cc1f77 100644 --- a/roslibrust_test/src/ros2.rs +++ b/roslibrust_test/src/ros2.rs @@ -5759,6 +5759,49 @@ uint32 nanosec"####; PartialEq, )] #[serde(crate = "::roslibrust::codegen::serde")] + pub struct WStrings { + pub r#wstring_value: ::roslibrust::codegen::WString, + #[default("Hello world!")] + pub r#wstring_value_default1: ::roslibrust::codegen::WString, + #[default("Hellö wörld!")] + pub r#wstring_value_default2: ::roslibrust::codegen::WString, + #[default("ハローワールド")] + pub r#wstring_value_default3: ::roslibrust::codegen::WString, + pub r#array_of_wstrings: [::roslibrust::codegen::WString; 3], + pub r#bounded_sequence_of_wstrings: ::std::vec::Vec<::roslibrust::codegen::WString>, + pub r#unbounded_sequence_of_wstrings: ::std::vec::Vec<::roslibrust::codegen::WString>, + } + impl ::roslibrust::RosMessageType for WStrings { + const ROS_TYPE_NAME: &'static str = "ros2_test_msgs/WStrings"; + const MD5SUM: &'static str = "036bafa324da8e3fa41a7cb28fb90abe"; + const DEFINITION: &'static str = r####"wstring wstring_value +wstring wstring_value_default1 "Hello world!" +wstring wstring_value_default2 "Hellö wörld!" +wstring wstring_value_default3 "ハローワールド" +#wstring WSTRING_CONST="Hello world!" +#wstring<=22 bounded_wstring_value +#wstring<=22 bounded_wstring_value_default1 "Hello world!" +wstring[3] array_of_wstrings +wstring[<=3] bounded_sequence_of_wstrings +wstring[] unbounded_sequence_of_wstrings"####; + const ROS2_HASH: &'static [u8; 32] = &[ + 0x97, 0x46, 0xec, 0x29, 0xfa, 0x17, 0x7c, 0xc8, 0x91, 0x64, 0xe1, 0x88, 0xe0, 0xa7, + 0xcd, 0xae, 0x9f, 0xfb, 0x9f, 0xd0, 0xf3, 0x03, 0x5b, 0x8a, 0xa7, 0x71, 0x1f, 0x79, + 0x5d, 0x36, 0xca, 0x00, + ]; + const ROS2_TYPE_NAME: &'static str = "ros2_test_msgs::msg::dds_::WStrings_"; + } + #[allow(non_snake_case)] + #[allow(dead_code)] + #[derive( + :: roslibrust :: codegen :: Deserialize, + :: roslibrust :: codegen :: Serialize, + :: roslibrust :: codegen :: SmartDefault, + Debug, + Clone, + PartialEq, + )] + #[serde(crate = "::roslibrust::codegen::serde")] pub struct AddTwoIntsRequest { pub r#a: i64, pub r#b: i64, diff --git a/roslibrust_test/tests/ros2_codegen_tests.rs b/roslibrust_test/tests/ros2_codegen_tests.rs index 74c098d6..146bfcd5 100644 --- a/roslibrust_test/tests/ros2_codegen_tests.rs +++ b/roslibrust_test/tests/ros2_codegen_tests.rs @@ -11,6 +11,30 @@ fn test_defaults() { assert_eq!(x.f_samples, vec![-200.0, -1.0, 0.0]); } +#[test] +fn wstrings_generate_for_scalar_array_and_sequence_fields() { + use roslibrust::codegen::WString; + + let message = ros2_test_msgs::WStrings { + wstring_value: "ハローワールド".into(), + wstring_value_default1: "Hello world!".into(), + wstring_value_default2: "Hellö wörld!".into(), + wstring_value_default3: "ハローワールド".into(), + array_of_wstrings: ["one".into(), "二".into(), "🌍".into()], + bounded_sequence_of_wstrings: vec!["bounded".into(), "文字列".into()], + unbounded_sequence_of_wstrings: vec!["".into(), "ascii".into()], + }; + + assert_eq!(message.wstring_value, "ハローワールド"); + assert_eq!(message.array_of_wstrings[2], WString::from("🌍")); + assert_eq!(message.bounded_sequence_of_wstrings[1], "文字列"); + + let defaults = ros2_test_msgs::WStrings::default(); + assert_eq!(defaults.wstring_value_default1, "Hello world!"); + assert_eq!(defaults.wstring_value_default2, "Hellö wörld!"); + assert_eq!(defaults.wstring_value_default3, "ハローワールド"); +} + #[test] fn fixed_sized_arrays() { // Prove the default works, compiler failure here is the test From fc3efb7cb6e42135e67bdf17b3b7e023812cf51a Mon Sep 17 00:00:00 2001 From: carter Date: Mon, 27 Jul 2026 20:02:54 +0000 Subject: [PATCH 2/3] Attempt at wstring backwards compatibility layer, not sure I like it yet --- Cargo.lock | 2 + README.md | 26 ++++ .../test_msgs/msg/WStrings.msg | 10 ++ .../test_msgs/package.xml | 10 ++ roslibrust_codegen/src/wstring.rs | 12 +- roslibrust_common/src/lib.rs | 6 + .../src/serialization_context.rs | 131 ++++++++++++++++++ roslibrust_ros1/src/node/actor.rs | 22 +-- roslibrust_ros1/src/publisher.rs | 42 +++++- roslibrust_ros1/src/service_client.rs | 11 +- roslibrust_ros1/src/subscriber.rs | 4 +- roslibrust_ros2/tests/test_wstring_interop.rs | 69 +++++++++ roslibrust_rosbridge/src/integration_tests.rs | 63 +++++++++ roslibrust_test/Cargo.toml | 2 + roslibrust_test/src/main.rs | 7 +- roslibrust_test/src/ros2.rs | 74 ++++++++++ .../tests/ros1_wstring_backend_tests.rs | 68 +++++++++ roslibrust_test/tests/ros2_wstring_relay.py | 30 ++++ roslibrust_zenoh/src/lib.rs | 43 +++--- 19 files changed, 597 insertions(+), 35 deletions(-) create mode 100644 assets/ros2_wstring_test_msgs/test_msgs/msg/WStrings.msg create mode 100644 assets/ros2_wstring_test_msgs/test_msgs/package.xml create mode 100644 roslibrust_common/src/serialization_context.rs create mode 100644 roslibrust_ros2/tests/test_wstring_interop.rs create mode 100644 roslibrust_test/tests/ros1_wstring_backend_tests.rs create mode 100644 roslibrust_test/tests/ros2_wstring_relay.py diff --git a/Cargo.lock b/Cargo.lock index ec6dbd8e..273c4cc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3664,7 +3664,9 @@ dependencies = [ "roslibrust", "roslibrust_ros2", "roslibrust_rosbridge", + "roslibrust_serde_rosmsg", "roslibrust_zenoh", + "serde_json", "test-log", "tokio", "zenoh", diff --git a/README.md b/README.md index 2e354561..cdd52a40 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,32 @@ If you want to see what the generated code looks like checkout [our generated me While the macro is useful for getting started, we recommend using `roslibrust_codegen` with a `build.rs` as shown in [example_package](https://github.com/RosLibRust/roslibrust/tree/master/example_package). This allows cargo to know when message files are edited and automatically re-generate the code. +## ROS 2 `wstring` compatibility + +ROS 2 `wstring` fields are generated as `roslibrust::codegen::WString`. The +Rust value contains a normal UTF-8 `String` and supports conversion from `&str` +and `String` with `.into()`. + +The wire representation is selected automatically by the backend: + +- Native ROS 2 CDR backends, including ros-z, convert the Rust string to a DDS + `wstring`: a sequence of UTF-16 code units. Received UTF-16 is validated and + converted back to UTF-8; invalid UTF-16 produces a serialization error. +- Rosbridge represents `wstring` as a normal JSON string. JSON handles scalar, + array, and sequence fields without a special wrapper representation. +- ROS 1 has no native `wstring` type. The TCPROS and ROS 1 Zenoh backends + therefore encode each `WString` as an ordinary ROS 1 UTF-8 `string` and + convert it back automatically when receiving. This compatibility conversion + also applies to `wstring` arrays and sequences. + +The ROS 1 conversion does not add `wstring` to the ROS 1 type system. For topic +connections, the TCPROS and ROS 1 Zenoh backends automatically advertise a +compatible definition and MD5 using `string`, `string[N]`, or `string[]` in the +corresponding fields. The ROS 1 peer must have that compatible definition under +the same package and message name. String and sequence bounds remain in the +generated ROS 2 metadata but, like the existing bounded collection support, +are not enforced at runtime. + ## Getting Started / Examples - Checkout the [Quick Getting Started Guide](https://roslibrust.github.io/roslibrust/quick_getting_started.html) for a brief guide on how to get started with RosLibRust. diff --git a/assets/ros2_wstring_test_msgs/test_msgs/msg/WStrings.msg b/assets/ros2_wstring_test_msgs/test_msgs/msg/WStrings.msg new file mode 100644 index 00000000..666d0ec6 --- /dev/null +++ b/assets/ros2_wstring_test_msgs/test_msgs/msg/WStrings.msg @@ -0,0 +1,10 @@ +wstring wstring_value +wstring wstring_value_default1 "Hello world!" +wstring wstring_value_default2 "Hellö wörld!" +wstring wstring_value_default3 "ハローワールド" +#wstring WSTRING_CONST="Hello world!" +#wstring<=22 bounded_wstring_value +#wstring<=22 bounded_wstring_value_default1 "Hello world!" +wstring[3] array_of_wstrings +wstring[<=3] bounded_sequence_of_wstrings +wstring[] unbounded_sequence_of_wstrings diff --git a/assets/ros2_wstring_test_msgs/test_msgs/package.xml b/assets/ros2_wstring_test_msgs/test_msgs/package.xml new file mode 100644 index 00000000..b564533d --- /dev/null +++ b/assets/ros2_wstring_test_msgs/test_msgs/package.xml @@ -0,0 +1,10 @@ + + test_msgs + 2.0.0 + Minimal ROS 2 test_msgs fixture for wstring interoperability tests. + RosLibRust + Apache-2.0 + ament_cmake + rosidl_default_generators + rosidl_interface_packages + diff --git a/roslibrust_codegen/src/wstring.rs b/roslibrust_codegen/src/wstring.rs index a9c555d9..44bed5b5 100644 --- a/roslibrust_codegen/src/wstring.rs +++ b/roslibrust_codegen/src/wstring.rs @@ -7,8 +7,9 @@ use std::{borrow::Borrow, fmt, ops::Deref, str::FromStr}; /// /// ROS 2 represents this type as a [`std::u16string`](https://en.cppreference.com/w/cpp/string/basic_string) /// in C++ and as a DDS `wstring` on the wire. Rust strings are UTF-8, so this -/// wrapper converts to and from UTF-16 when used with a binary serializer such -/// as CDR, while remaining a normal JSON string for human-readable serializers. +/// wrapper converts to and from UTF-16 for ROS 2 CDR. Human-readable serializers +/// use a normal JSON string, and ROS 1 backends enable a compatibility scope +/// that represents the value as an ordinary UTF-8 ROS string. #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct WString(String); @@ -31,7 +32,8 @@ impl Serialize for WString { where S: Serializer, { - if serializer.is_human_readable() { + if serializer.is_human_readable() || roslibrust_common::ros1_wstring_compatibility_enabled() + { serializer.serialize_str(&self.0) } else { let code_units = self.0.encode_utf16(); @@ -49,7 +51,9 @@ impl<'de> Deserialize<'de> for WString { where D: Deserializer<'de>, { - if deserializer.is_human_readable() { + if deserializer.is_human_readable() + || roslibrust_common::ros1_wstring_compatibility_enabled() + { String::deserialize(deserializer).map(Self) } else { let code_units = Vec::::deserialize(deserializer)?; diff --git a/roslibrust_common/src/lib.rs b/roslibrust_common/src/lib.rs index d9331dd3..2b1d0b03 100644 --- a/roslibrust_common/src/lib.rs +++ b/roslibrust_common/src/lib.rs @@ -67,6 +67,12 @@ impl RosMessageType for ShapeShifter { /// These functions are needed both in roslibrust_ros1 and roslibrust_codegen so they're in this crate /// for the moment. pub mod md5sum; +mod serialization_context; +#[doc(hidden)] +pub use serialization_context::{ + ros1_message_description, ros1_wstring_compatibility_enabled, with_ros1_wstring_compatibility, + Ros1MessageDescription, +}; /// Contains the generic traits represent a pubsub system and service system. /// These traits will be implemented for specific backends to provides access to "ROS Like" functionality. diff --git a/roslibrust_common/src/serialization_context.rs b/roslibrust_common/src/serialization_context.rs new file mode 100644 index 00000000..a3f00d1c --- /dev/null +++ b/roslibrust_common/src/serialization_context.rs @@ -0,0 +1,131 @@ +use std::cell::Cell; + +use crate::RosMessageType; + +thread_local! { + static ROS1_WSTRING_COMPATIBILITY_DEPTH: Cell = const { Cell::new(0) }; +} + +/// Runs a ROS 1 serialization or deserialization operation with `wstring` +/// compatibility enabled. +/// +/// This is used internally by ROS 1 backends. ROS 1 has no native `wstring`, +/// so generated ROS 2 `wstring` values are represented as ordinary UTF-8 ROS +/// strings while this scope is active. +#[doc(hidden)] +pub fn with_ros1_wstring_compatibility(operation: impl FnOnce() -> T) -> T { + struct ScopeGuard; + + impl Drop for ScopeGuard { + fn drop(&mut self) { + ROS1_WSTRING_COMPATIBILITY_DEPTH.with(|depth| depth.set(depth.get() - 1)); + } + } + + ROS1_WSTRING_COMPATIBILITY_DEPTH.with(|depth| depth.set(depth.get() + 1)); + let _guard = ScopeGuard; + operation() +} + +/// Reports whether the current synchronous serde operation is being performed +/// by a ROS 1 backend. +#[doc(hidden)] +pub fn ros1_wstring_compatibility_enabled() -> bool { + ROS1_WSTRING_COMPATIBILITY_DEPTH.with(|depth| depth.get() > 0) +} + +/// ROS 1-compatible connection metadata for a message type. +#[doc(hidden)] +pub struct Ros1MessageDescription { + pub definition: String, + pub md5sum: String, +} + +/// Converts ROS 2 `wstring` field declarations to their closest ROS 1 `string` +/// equivalents and calculates the MD5 advertised to ROS 1 peers. +#[doc(hidden)] +pub fn ros1_message_description() -> Ros1MessageDescription { + let definition = ros1_compatible_definition(T::DEFINITION); + let md5sum = crate::md5sum::from_message_definition(T::ROS_TYPE_NAME, &definition) + .unwrap_or_else(|_| T::MD5SUM.to_owned()); + Ros1MessageDescription { definition, md5sum } +} + +fn ros1_compatible_definition(definition: &str) -> String { + definition + .lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + return line.to_owned(); + } + + let Some(type_end) = trimmed.find(char::is_whitespace) else { + return line.to_owned(); + }; + let field_type = &trimmed[..type_end]; + if !field_type.starts_with("wstring") { + return line.to_owned(); + } + + let array = field_type.find('[').map(|start| &field_type[start..]); + let ros1_array = match array { + Some(array) if array.starts_with("[<=") => "[]", + Some(array) => array, + None => "", + }; + let replacement = format!("string{ros1_array}"); + let indentation = &line[..line.len() - trimmed.len()]; + let remainder = trimmed[type_end..].trim_start(); + let field_name_end = remainder + .find(char::is_whitespace) + .unwrap_or(remainder.len()); + let field_name = &remainder[..field_name_end]; + if field_name.contains('=') { + format!("{indentation}{replacement} {remainder}") + } else { + // ROS 2 permits field defaults, but ROS 1 definitions do not. + format!("{indentation}{replacement} {field_name}") + } + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compatibility_scope_is_nested_and_restored() { + assert!(!ros1_wstring_compatibility_enabled()); + with_ros1_wstring_compatibility(|| { + assert!(ros1_wstring_compatibility_enabled()); + with_ros1_wstring_compatibility(|| { + assert!(ros1_wstring_compatibility_enabled()); + }); + assert!(ros1_wstring_compatibility_enabled()); + }); + assert!(!ros1_wstring_compatibility_enabled()); + } + + #[test] + fn definition_conversion_only_changes_wstring_type_tokens() { + let definition = r#"# wstring in a comment +wstring wstring_value +wstring<=22 bounded_wstring_value "default" +wstring[3] array_of_wstrings +wstring[<=3] bounded_sequence_of_wstrings +wstring[] unbounded_sequence_of_wstrings"#; + + assert_eq!( + ros1_compatible_definition(definition), + r#"# wstring in a comment +string wstring_value +string bounded_wstring_value +string[3] array_of_wstrings +string[] bounded_sequence_of_wstrings +string[] unbounded_sequence_of_wstrings"# + ); + } +} diff --git a/roslibrust_ros1/src/node/actor.rs b/roslibrust_ros1/src/node/actor.rs index ba0a2082..b10698b7 100644 --- a/roslibrust_ros1/src/node/actor.rs +++ b/roslibrust_ros1/src/node/actor.rs @@ -150,13 +150,14 @@ impl NodeServerHandle { ) -> Result<(broadcast::Sender, mpsc::Sender<()>), NodeError> { // Create a weak reference to pass to the publication let weak_node = self.downgrade(); + let description = roslibrust_common::ros1_message_description::(); let mut node = self.node.lock().await; node.register_publisher( topic.to_owned(), T::ROS_TYPE_NAME, queue_size, - T::DEFINITION.to_owned(), - T::MD5SUM.to_owned(), + description.definition, + description.md5sum, latching, weak_node, ) @@ -245,11 +246,15 @@ impl NodeServerHandle { // Uses Bytes for efficient handling of incoming request data let server_typeless = move |message: Bytes| -> Result, Box> { - let request = roslibrust_serde_rosmsg::from_slice::(&message) - .map_err(|err| Error::SerializationError(err.to_string()))?; + let request = roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::from_slice::(&message) + }) + .map_err(|err| Error::SerializationError(err.to_string()))?; let response = server(request)?; - Ok(roslibrust_serde_rosmsg::to_vec(&response) - .map_err(|err| Error::SerializationError(err.to_string()))?) + Ok(roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::to_vec(&response) + }) + .map_err(|err| Error::SerializationError(err.to_string()))?) }; let server_typeless = Box::new(server_typeless); @@ -281,13 +286,14 @@ impl NodeServerHandle { topic: &str, queue_size: usize, ) -> Result, NodeError> { + let description = roslibrust_common::ros1_message_description::(); let mut node = self.node.lock().await; node.register_subscriber( topic, T::ROS_TYPE_NAME, queue_size, - T::DEFINITION, - T::MD5SUM, + &description.definition, + &description.md5sum, ) .await .map_err(|err| { diff --git a/roslibrust_ros1/src/publisher.rs b/roslibrust_ros1/src/publisher.rs index 25e983f5..12d7c2b6 100644 --- a/roslibrust_ros1/src/publisher.rs +++ b/roslibrust_ros1/src/publisher.rs @@ -72,7 +72,9 @@ impl Publisher { let mut writer = buffer.writer(); // Write empty u32 for size writer.write(&[0, 0, 0, 0]).unwrap(); - roslibrust_serde_rosmsg::to_writer_skip_length(&mut writer, data)?; + roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::to_writer_skip_length(&mut writer, data) + })?; let mut buffer = writer.into_inner(); // Patch size back to front of buffer let size = buffer.len() as u32 - 4; @@ -491,3 +493,41 @@ impl From for PublisherError { Self::SerializingError(value.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + use roslibrust_test::ros2::test_msgs::WStrings; + + #[tokio::test] + async fn publisher_encodes_wstring_as_an_ordinary_ros1_string() { + let (sender, mut receiver) = broadcast::channel(1); + let (shutdown_sender, _shutdown_receiver) = tokio::sync::mpsc::channel(1); + let publisher = Publisher::new("/wstring_test", sender, shutdown_sender); + let mut message = WStrings::default(); + message.wstring_value = "ハロー 🌍".into(); + let description = roslibrust_common::ros1_message_description::(); + + assert!(description.definition.contains("string wstring_value")); + assert!(description + .definition + .contains("string[] bounded_sequence_of_wstrings")); + assert!(!description.definition.contains("wstring wstring_value")); + assert!(description + .definition + .contains("string wstring_value_default1\n")); + assert!(!description + .definition + .contains("string wstring_value_default1 \"Hello world!\"")); + + publisher.publish(&message).await.unwrap(); + let bytes = receiver.recv().await.unwrap(); + + let encoded_length = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize; + assert_eq!(encoded_length, message.wstring_value.len()); + assert_eq!( + &bytes[8..8 + encoded_length], + message.wstring_value.as_bytes() + ); + } +} diff --git a/roslibrust_ros1/src/service_client.rs b/roslibrust_ros1/src/service_client.rs index 0a1edd7d..319b3558 100644 --- a/roslibrust_ros1/src/service_client.rs +++ b/roslibrust_ros1/src/service_client.rs @@ -52,8 +52,10 @@ impl ServiceClient { } pub async fn call(&self, request: &T::Request) -> std::result::Result { - let request_payload = roslibrust_serde_rosmsg::to_vec(request) - .map_err(|err| Error::SerializationError(err.to_string()))?; + let request_payload = roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::to_vec(request) + }) + .map_err(|err| Error::SerializationError(err.to_string()))?; let (response_tx, response_rx) = oneshot::channel(); self.sender @@ -67,7 +69,10 @@ impl ServiceClient { self.service_name, result_payload ); - let response: T::Response = roslibrust_serde_rosmsg::from_slice(&result_payload) + let response: T::Response = + roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::from_slice(&result_payload) + }) .map_err(|err| Error::SerializationError(err.to_string()))?; Ok(response) } diff --git a/roslibrust_ros1/src/subscriber.rs b/roslibrust_ros1/src/subscriber.rs index 3c97ebad..24be2882 100644 --- a/roslibrust_ros1/src/subscriber.rs +++ b/roslibrust_ros1/src/subscriber.rs @@ -53,7 +53,9 @@ impl Subscriber { T::ROS_TYPE_NAME ); let tick = tokio::time::Instant::now(); - match roslibrust_serde_rosmsg::from_slice::(&data[..]) { + match roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::from_slice::(&data[..]) + }) { Ok(p) => { let duration = tick.elapsed(); trace!( diff --git a/roslibrust_ros2/tests/test_wstring_interop.rs b/roslibrust_ros2/tests/test_wstring_interop.rs new file mode 100644 index 00000000..941fb055 --- /dev/null +++ b/roslibrust_ros2/tests/test_wstring_interop.rs @@ -0,0 +1,69 @@ +#![cfg(feature = "ros2_zenoh_test")] + +mod common; + +use roslibrust_common::traits::*; +use roslibrust_ros2::ZenohClient; +use roslibrust_test::ros2::test_msgs::WStrings; +use std::process::{Child, Command, Stdio}; +use tokio::time::{sleep, timeout, Duration}; + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn spawn_ros2_relay(input_topic: &str, output_topic: &str) -> ChildGuard { + ChildGuard( + Command::new("python3") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../roslibrust_test/tests/ros2_wstring_relay.py" + )) + .args([input_topic, output_topic]) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("failed to start the rclpy wstring relay"), + ) +} + +fn test_message() -> WStrings { + WStrings { + wstring_value: "ハローワールド 🌍".into(), + wstring_value_default1: "Hello world!".into(), + wstring_value_default2: "Hellö wörld!".into(), + wstring_value_default3: "ハローワールド".into(), + array_of_wstrings: ["one".into(), "二".into(), "🌍".into()], + bounded_sequence_of_wstrings: vec!["bounded".into(), "文字列".into()], + unbounded_sequence_of_wstrings: vec!["".into(), "ascii".into(), "四".into()], + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn wstring_round_trips_through_an_rclpy_node() { + const INPUT_TOPIC: &str = "/roslibrust_ros_z_wstring_input"; + const OUTPUT_TOPIC: &str = "/roslibrust_ros_z_wstring_output"; + + let context = common::make_test_context(); + let client = ZenohClient::new(&context, "roslibrust_wstring_interop") + .await + .unwrap(); + let publisher = client.advertise::(INPUT_TOPIC).await.unwrap(); + let mut subscriber = client.subscribe::(OUTPUT_TOPIC).await.unwrap(); + let _relay = spawn_ros2_relay(INPUT_TOPIC, OUTPUT_TOPIC); + + sleep(Duration::from_secs(2)).await; + let expected = test_message(); + publisher.publish(&expected).await.unwrap(); + + let received = timeout(Duration::from_secs(5), subscriber.next()) + .await + .expect("timed out waiting for the rclpy relay") + .unwrap(); + assert_eq!(received, expected); +} diff --git a/roslibrust_rosbridge/src/integration_tests.rs b/roslibrust_rosbridge/src/integration_tests.rs index 181c82df..e460068b 100644 --- a/roslibrust_rosbridge/src/integration_tests.rs +++ b/roslibrust_rosbridge/src/integration_tests.rs @@ -528,4 +528,67 @@ mod integration_tests { assert_eq!(received, msg, "Messages do not match"); } + + #[cfg(feature = "ros2_test")] + #[test_log::test(tokio::test)] + async fn test_wstring_roundtrip_through_rclpy_node() { + use std::process::{Child, Command, Stdio}; + use test_msgs::WStrings; + + struct ChildGuard(Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + const INPUT_TOPIC: &str = "/roslibrust_rosbridge_wstring_input"; + const OUTPUT_TOPIC: &str = "/roslibrust_rosbridge_wstring_output"; + + let client = + ClientHandle::new_with_options(ClientHandleOptions::new(LOCAL_WS).timeout(TIMEOUT)) + .await + .expect("Failed to construct client"); + let publisher = client + .advertise::(INPUT_TOPIC) + .await + .expect("Failed to advertise"); + let subscriber = client + .subscribe::(OUTPUT_TOPIC) + .await + .expect("Failed to subscribe"); + let _relay = ChildGuard( + Command::new("python3") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../roslibrust_test/tests/ros2_wstring_relay.py" + )) + .args([INPUT_TOPIC, OUTPUT_TOPIC]) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to start the rclpy wstring relay"), + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + let expected = WStrings { + wstring_value: "ハローワールド 🌍".into(), + wstring_value_default1: "Hello world!".into(), + wstring_value_default2: "Hellö wörld!".into(), + wstring_value_default3: "ハローワールド".into(), + array_of_wstrings: ["one".into(), "二".into(), "🌍".into()], + bounded_sequence_of_wstrings: vec!["bounded".into(), "文字列".into()], + unbounded_sequence_of_wstrings: vec!["".into(), "ascii".into(), "四".into()], + }; + publisher + .publish(&expected) + .await + .expect("Failed to publish"); + + let received = timeout(Duration::from_secs(5), subscriber.next()) + .await + .expect("Timed out waiting for the rclpy relay"); + assert_eq!(received, expected); + } } diff --git a/roslibrust_test/Cargo.toml b/roslibrust_test/Cargo.toml index 46bad858..c986613a 100644 --- a/roslibrust_test/Cargo.toml +++ b/roslibrust_test/Cargo.toml @@ -22,6 +22,8 @@ roslibrust_zenoh = { path = "../roslibrust_zenoh" } roslibrust_ros2 = { path = "../roslibrust_ros2" } roslibrust_rosbridge = { path = "../roslibrust_rosbridge" } ros-z = { git = "https://github.com/ZettaScaleLabs/ros-z.git", rev = "9bb6305" } +roslibrust_serde_rosmsg = { workspace = true } +serde_json = "1.0" [[bin]] path = "src/performance_ramp.rs" diff --git a/roslibrust_test/src/main.rs b/roslibrust_test/src/main.rs index 44eec6c6..0fbf1a57 100644 --- a/roslibrust_test/src/main.rs +++ b/roslibrust_test/src/main.rs @@ -26,12 +26,17 @@ const ROS_2_SERVICE_MSGS_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../assets/ros2_required_msgs/rcl_interfaces/service_msgs" ); +const ROS_2_WSTRING_TEST_MSGS_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../assets/ros2_wstring_test_msgs" +); lazy_static! { static ref ROS_2_PATHS: Vec = vec![ ROS_2_PATH.into(), ROS_2_TEST_PATH.into(), ROS_2_REQUIRED_PATH.into(), - ROS_2_SERVICE_MSGS_PATH.into() + ROS_2_SERVICE_MSGS_PATH.into(), + ROS_2_WSTRING_TEST_MSGS_PATH.into() ]; } diff --git a/roslibrust_test/src/ros2.rs b/roslibrust_test/src/ros2.rs index 94cc1f77..3c69234d 100644 --- a/roslibrust_test/src/ros2.rs +++ b/roslibrust_test/src/ros2.rs @@ -11,6 +11,7 @@ pub mod actionlib_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -325,6 +326,7 @@ pub mod builtin_interfaces { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -414,6 +416,7 @@ pub mod diagnostic_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -801,6 +804,7 @@ pub mod geometry_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -2943,6 +2947,7 @@ pub mod nav_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -5498,6 +5503,7 @@ pub mod ros2_test_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -5871,6 +5877,7 @@ pub mod sensor_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -8688,6 +8695,7 @@ pub mod service_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -8772,6 +8780,7 @@ pub mod shape_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -9013,6 +9022,7 @@ pub mod std_msgs { use super::shape_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -10620,6 +10630,7 @@ pub mod std_srvs { use super::shape_msgs; use super::std_msgs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -10827,6 +10838,7 @@ pub mod stereo_msgs { use super::shape_msgs; use super::std_msgs; use super::std_srvs; + use super::test_msgs; use super::trajectory_msgs; use super::visualization_msgs; #[allow(non_snake_case)] @@ -11014,6 +11026,66 @@ uint32 nanosec"####; } } #[allow(unused_imports)] +pub mod test_msgs { + use super::actionlib_msgs; + use super::builtin_interfaces; + use super::diagnostic_msgs; + use super::geometry_msgs; + use super::nav_msgs; + use super::ros2_test_msgs; + use super::sensor_msgs; + use super::service_msgs; + use super::shape_msgs; + use super::std_msgs; + use super::std_srvs; + use super::stereo_msgs; + use super::trajectory_msgs; + use super::visualization_msgs; + #[allow(non_snake_case)] + #[allow(dead_code)] + #[derive( + :: roslibrust :: codegen :: Deserialize, + :: roslibrust :: codegen :: Serialize, + :: roslibrust :: codegen :: SmartDefault, + Debug, + Clone, + PartialEq, + )] + #[serde(crate = "::roslibrust::codegen::serde")] + pub struct WStrings { + pub r#wstring_value: ::roslibrust::codegen::WString, + #[default("Hello world!")] + pub r#wstring_value_default1: ::roslibrust::codegen::WString, + #[default("Hellö wörld!")] + pub r#wstring_value_default2: ::roslibrust::codegen::WString, + #[default("ハローワールド")] + pub r#wstring_value_default3: ::roslibrust::codegen::WString, + pub r#array_of_wstrings: [::roslibrust::codegen::WString; 3], + pub r#bounded_sequence_of_wstrings: ::std::vec::Vec<::roslibrust::codegen::WString>, + pub r#unbounded_sequence_of_wstrings: ::std::vec::Vec<::roslibrust::codegen::WString>, + } + impl ::roslibrust::RosMessageType for WStrings { + const ROS_TYPE_NAME: &'static str = "test_msgs/WStrings"; + const MD5SUM: &'static str = "036bafa324da8e3fa41a7cb28fb90abe"; + const DEFINITION: &'static str = r####"wstring wstring_value +wstring wstring_value_default1 "Hello world!" +wstring wstring_value_default2 "Hellö wörld!" +wstring wstring_value_default3 "ハローワールド" +#wstring WSTRING_CONST="Hello world!" +#wstring<=22 bounded_wstring_value +#wstring<=22 bounded_wstring_value_default1 "Hello world!" +wstring[3] array_of_wstrings +wstring[<=3] bounded_sequence_of_wstrings +wstring[] unbounded_sequence_of_wstrings"####; + const ROS2_HASH: &'static [u8; 32] = &[ + 0x5a, 0x2c, 0x8d, 0xdb, 0x20, 0x54, 0x60, 0x00, 0x23, 0xb9, 0xa1, 0x80, 0x65, 0x2f, + 0x2b, 0xa8, 0x95, 0x37, 0x32, 0x17, 0xca, 0xd4, 0xb1, 0xf0, 0xfc, 0x12, 0x58, 0xc9, + 0xe4, 0x42, 0x4a, 0x79, + ]; + const ROS2_TYPE_NAME: &'static str = "test_msgs::msg::dds_::WStrings_"; + } +} +#[allow(unused_imports)] pub mod trajectory_msgs { use super::actionlib_msgs; use super::builtin_interfaces; @@ -11027,6 +11099,7 @@ pub mod trajectory_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::visualization_msgs; #[allow(non_snake_case)] #[allow(dead_code)] @@ -11591,6 +11664,7 @@ pub mod visualization_msgs { use super::std_msgs; use super::std_srvs; use super::stereo_msgs; + use super::test_msgs; use super::trajectory_msgs; #[allow(non_snake_case)] #[allow(dead_code)] diff --git a/roslibrust_test/tests/ros1_wstring_backend_tests.rs b/roslibrust_test/tests/ros1_wstring_backend_tests.rs new file mode 100644 index 00000000..66f92683 --- /dev/null +++ b/roslibrust_test/tests/ros1_wstring_backend_tests.rs @@ -0,0 +1,68 @@ +use roslibrust::traits::{Publish, Subscribe, TopicProvider}; +use roslibrust_test::ros2::ros2_test_msgs::WStrings; + +fn wstrings_message() -> WStrings { + WStrings { + wstring_value: "ハローワールド 🌍".into(), + wstring_value_default1: "Hello world!".into(), + wstring_value_default2: "Hellö wörld!".into(), + wstring_value_default3: "ハローワールド".into(), + array_of_wstrings: ["one".into(), "二".into(), "🌍".into()], + bounded_sequence_of_wstrings: vec!["bounded".into(), "文字列".into()], + unbounded_sequence_of_wstrings: vec!["".into(), "ascii".into()], + } +} + +#[test] +fn tcpros_codec_uses_utf8_ros_strings_and_round_trips_wstrings() { + let message = wstrings_message(); + + // roslibrust_ros1 and roslibrust_zenoh both use this codec when publishing. + let bytes = roslibrust::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::ser::to_vec(&message) + }) + .unwrap(); + + // The first field has ordinary ROS 1 string encoding: its UTF-8 byte length + // followed by its UTF-8 bytes. + let encoded_string_bytes = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + assert_eq!(encoded_string_bytes as usize, message.wstring_value.len()); + assert_eq!( + &bytes[8..8 + encoded_string_bytes as usize], + message.wstring_value.as_bytes() + ); + + let decoded: WStrings = roslibrust::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::de::from_slice(&bytes) + }) + .unwrap(); + + assert_eq!(decoded, message); +} + +#[test] +fn rosbridge_json_represents_wstrings_as_json_strings() { + let json = serde_json::to_value(wstrings_message()).unwrap(); + + assert_eq!(json["wstring_value"], "ハローワールド 🌍"); + assert_eq!(json["array_of_wstrings"][1], "二"); + assert_eq!(json["bounded_sequence_of_wstrings"][1], "文字列"); +} + +#[tokio::test] +async fn mock_backend_round_trips_wstrings() { + let backend = roslibrust::mock::MockRos::new(); + let publisher = backend + .advertise::("/wstring_test") + .await + .unwrap(); + let mut subscriber = backend + .subscribe::("/wstring_test") + .await + .unwrap(); + let message = wstrings_message(); + + publisher.publish(&message).await.unwrap(); + + assert_eq!(subscriber.next().await.unwrap(), message); +} diff --git a/roslibrust_test/tests/ros2_wstring_relay.py b/roslibrust_test/tests/ros2_wstring_relay.py new file mode 100644 index 00000000..a092c3ad --- /dev/null +++ b/roslibrust_test/tests/ros2_wstring_relay.py @@ -0,0 +1,30 @@ +"""ROS 2 node used by the gated wstring backend interoperability tests.""" + +import sys + +import rclpy +from rclpy.node import Node +from test_msgs.msg import WStrings + + +class WStringRelay(Node): + def __init__(self, input_topic: str, output_topic: str) -> None: + super().__init__("roslibrust_wstring_relay") + self.publisher = self.create_publisher(WStrings, output_topic, 10) + self.subscription = self.create_subscription( + WStrings, input_topic, self.publisher.publish, 10 + ) + + +def main() -> None: + rclpy.init() + node = WStringRelay(sys.argv[1], sys.argv[2]) + try: + rclpy.spin(node) + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/roslibrust_zenoh/src/lib.rs b/roslibrust_zenoh/src/lib.rs index ee248313..c605dda6 100644 --- a/roslibrust_zenoh/src/lib.rs +++ b/roslibrust_zenoh/src/lib.rs @@ -243,9 +243,10 @@ impl Publish for ZenohPublisher { let size_hint = self.capacity_hint.load(Ordering::Relaxed); let mut bytes = Vec::with_capacity(size_hint); - roslibrust_serde_rosmsg::to_writer_skip_length(&mut bytes, data).map_err(|e| { - Error::SerializationError(format!("Failed to serialize message: {e:?}")) - })?; + roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::to_writer_skip_length(&mut bytes, data) + }) + .map_err(|e| Error::SerializationError(format!("Failed to serialize message: {e:?}")))?; if bytes.len() > size_hint { self.capacity_hint.store(bytes.len(), Ordering::Relaxed); @@ -296,8 +297,10 @@ impl Subscribe for ZenohSubscriber { fn deserialize_payload(payload: &ZBytes, context: &str) -> Result { // Note: Zenoh decided to not make the 4 byte length header part of the payload. let mut reader = payload.reader(); - roslibrust_serde_rosmsg::from_reader_known_length(&mut reader, payload.len() as u32) - .map_err(|e| Error::SerializationError(format!("Failed to deserialize {context}: {e:?}"))) + roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::from_reader_known_length(&mut reader, payload.len() as u32) + }) + .map_err(|e| Error::SerializationError(format!("Failed to deserialize {context}: {e:?}"))) } impl TopicProvider for ZenohClient { @@ -310,7 +313,9 @@ impl TopicProvider for ZenohClient { topic: impl ToGlobalTopicName, ) -> Result> { let topic: GlobalTopicName = topic.to_global_name()?; - let mangled_topic = mangle_topic(topic.as_ref(), MsgType::ROS_TYPE_NAME, MsgType::MD5SUM); + let description = roslibrust_common::ros1_message_description::(); + let mangled_topic = + mangle_topic(topic.as_ref(), MsgType::ROS_TYPE_NAME, &description.md5sum); let publisher = match self.session.declare_publisher(mangled_topic).await { Ok(publisher) => publisher, Err(e) => { @@ -325,7 +330,7 @@ impl TopicProvider for ZenohClient { DiscoveryClass::Publisher, topic.as_ref(), MsgType::ROS_TYPE_NAME, - MsgType::MD5SUM, + &description.md5sum, ) .await?; @@ -342,7 +347,9 @@ impl TopicProvider for ZenohClient { topic: impl ToGlobalTopicName, ) -> Result> { let topic: GlobalTopicName = topic.to_global_name()?; - let mangled_topic = mangle_topic(topic.as_ref(), MsgType::ROS_TYPE_NAME, MsgType::MD5SUM); + let description = roslibrust_common::ros1_message_description::(); + let mangled_topic = + mangle_topic(topic.as_ref(), MsgType::ROS_TYPE_NAME, &description.md5sum); let sub = match self.session.declare_subscriber(mangled_topic).await { Ok(sub) => sub, Err(e) => { @@ -357,7 +364,7 @@ impl TopicProvider for ZenohClient { DiscoveryClass::Subscriber, topic.as_ref(), MsgType::ROS_TYPE_NAME, - MsgType::MD5SUM, + &description.md5sum, ) .await?; Ok(ZenohSubscriber { @@ -485,9 +492,10 @@ pub struct ZenohServiceClient { impl Service for ZenohServiceClient { async fn call(&self, request: &T::Request) -> Result { // Note: Zenoh decided the 4 byte length header is not part of the payload - let request_bytes = roslibrust_serde_rosmsg::to_vec_skip_length(request).map_err(|e| { - Error::SerializationError(format!("Failed to serialize message: {e:?}")) - })?; + let request_bytes = roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::to_vec_skip_length(request) + }) + .map_err(|e| Error::SerializationError(format!("Failed to serialize message: {e:?}")))?; let query = match self .session @@ -640,11 +648,12 @@ impl ServiceProvider for ZenohClient { } }; - let Ok(response_bytes) = roslibrust_serde_rosmsg::to_vec_skip_length(&response) - .map_err(|e| { - error!("Failed to serialize response: {e:?}"); - }) - else { + let Ok(response_bytes) = roslibrust_common::with_ros1_wstring_compatibility(|| { + roslibrust_serde_rosmsg::to_vec_skip_length(&response) + }) + .map_err(|e| { + error!("Failed to serialize response: {e:?}"); + }) else { continue; }; From d236086dc82807c6821030ae393f5529185f8e67 Mon Sep 17 00:00:00 2001 From: carter Date: Mon, 27 Jul 2026 20:40:15 +0000 Subject: [PATCH 3/3] Trying to fix integration tests, adding test-msgs --- .github/workflows/ros2.yml | 5 +++ docker/galactic/Dockerfile | 2 +- docker/humble/Dockerfile | 2 +- docker/iron/Dockerfile | 2 +- docker/kilted/Dockerfile | 2 +- docker/rolling/Dockerfile | 3 +- roslibrust_ros2/tests/test_wstring_interop.rs | 33 ++++++++++++------- roslibrust_rosbridge/src/integration_tests.rs | 31 ++++++++++------- roslibrust_test/tests/ros2_wstring_relay.py | 1 + 9 files changed, 52 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ros2.yml b/.github/workflows/ros2.yml index 2ce1de81..9f24e483 100644 --- a/.github/workflows/ros2.yml +++ b/.github/workflows/ros2.yml @@ -58,6 +58,11 @@ jobs: - name: Lint run: source /root/.cargo/env && cargo fmt --all -- --check + - name: Install wstring interoperability test messages + run: | + apt-get update + apt-get install -y ros-${{ matrix.distro }}-test-msgs + - name: Start rosbridge services run: | source /opt/ros/${{ matrix.distro }}/setup.bash diff --git a/docker/galactic/Dockerfile b/docker/galactic/Dockerfile index b4e51ac1..86f313e3 100644 --- a/docker/galactic/Dockerfile +++ b/docker/galactic/Dockerfile @@ -4,7 +4,7 @@ LABEL maintainer="Carter Schultz " # Required by github CI for submodule support RUN apt update && apt install -y git -RUN apt update && apt install -y ros-galactic-rosbridge-suite +RUN apt update && apt install -y ros-galactic-rosbridge-suite ros-galactic-test-msgs # Install rosbag2 and MCAP storage plugin for MCAP integration testing # Note: MCAP is not the default storage format in Galactic, so we need the plugin diff --git a/docker/humble/Dockerfile b/docker/humble/Dockerfile index aad4ad88..c87dd968 100644 --- a/docker/humble/Dockerfile +++ b/docker/humble/Dockerfile @@ -4,7 +4,7 @@ LABEL maintainer="Carter Schultz " # Required by github CI for submodule support RUN apt update && apt install -y git -RUN apt update && apt install -y ros-humble-rosbridge-suite +RUN apt update && apt install -y ros-humble-rosbridge-suite ros-humble-test-msgs # Install rosbag2 and MCAP storage plugin for MCAP integration testing # Note: MCAP is not the default storage format in Humble, so we need the plugin diff --git a/docker/iron/Dockerfile b/docker/iron/Dockerfile index 2dec3c31..2039cc04 100644 --- a/docker/iron/Dockerfile +++ b/docker/iron/Dockerfile @@ -4,7 +4,7 @@ LABEL maintainer="Carter Schultz " # Required by github CI for submodule support RUN apt update && apt install -y git -RUN apt update && apt install -y ros-iron-rosbridge-suite +RUN apt update && apt install -y ros-iron-rosbridge-suite ros-iron-test-msgs # Install rosbag2 and MCAP storage plugin for MCAP integration testing # Note: MCAP is not the default storage format in Iron, so we need the plugin diff --git a/docker/kilted/Dockerfile b/docker/kilted/Dockerfile index c375c62b..aad36b78 100644 --- a/docker/kilted/Dockerfile +++ b/docker/kilted/Dockerfile @@ -5,7 +5,7 @@ LABEL maintainer="Carter Schultz " RUN apt update && apt install -y git # Install rosbridge suite for rosbridge backend testing -RUN apt update && apt install -y ros-kilted-rosbridge-suite +RUN apt update && apt install -y ros-kilted-rosbridge-suite ros-kilted-test-msgs # Install rosbag2 for MCAP integration testing RUN apt update && apt install -y ros-kilted-rosbag2 diff --git a/docker/rolling/Dockerfile b/docker/rolling/Dockerfile index 159c08bd..825664d8 100644 --- a/docker/rolling/Dockerfile +++ b/docker/rolling/Dockerfile @@ -5,7 +5,7 @@ LABEL maintainer="Carter Schultz " RUN apt update && apt install -y git # Install rosbridge suite for rosbridge backend testing -RUN apt update && apt install -y ros-rolling-rosbridge-suite +RUN apt update && apt install -y ros-rolling-rosbridge-suite ros-rolling-test-msgs # Install rosbag2 for MCAP integration testing RUN apt update && apt install -y ros-rolling-rosbag2 @@ -24,4 +24,3 @@ WORKDIR / COPY entrypoint.sh . RUN chmod +x entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] - diff --git a/roslibrust_ros2/tests/test_wstring_interop.rs b/roslibrust_ros2/tests/test_wstring_interop.rs index 941fb055..783a41e4 100644 --- a/roslibrust_ros2/tests/test_wstring_interop.rs +++ b/roslibrust_ros2/tests/test_wstring_interop.rs @@ -5,6 +5,7 @@ mod common; use roslibrust_common::traits::*; use roslibrust_ros2::ZenohClient; use roslibrust_test::ros2::test_msgs::WStrings; +use std::io::{BufRead, BufReader}; use std::process::{Child, Command, Stdio}; use tokio::time::{sleep, timeout, Duration}; @@ -18,18 +19,26 @@ impl Drop for ChildGuard { } fn spawn_ros2_relay(input_topic: &str, output_topic: &str) -> ChildGuard { - ChildGuard( - Command::new("python3") - .arg(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../roslibrust_test/tests/ros2_wstring_relay.py" - )) - .args([input_topic, output_topic]) - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .expect("failed to start the rclpy wstring relay"), - ) + let mut child = Command::new("python3") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../roslibrust_test/tests/ros2_wstring_relay.py" + )) + .args([input_topic, output_topic]) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("failed to start the rclpy wstring relay"); + let mut ready = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut ready) + .expect("failed to read readiness from the rclpy wstring relay"); + assert_eq!( + ready.trim(), + "READY", + "rclpy wstring relay exited before becoming ready" + ); + ChildGuard(child) } fn test_message() -> WStrings { diff --git a/roslibrust_rosbridge/src/integration_tests.rs b/roslibrust_rosbridge/src/integration_tests.rs index e460068b..c2734822 100644 --- a/roslibrust_rosbridge/src/integration_tests.rs +++ b/roslibrust_rosbridge/src/integration_tests.rs @@ -532,6 +532,7 @@ mod integration_tests { #[cfg(feature = "ros2_test")] #[test_log::test(tokio::test)] async fn test_wstring_roundtrip_through_rclpy_node() { + use std::io::{BufRead, BufReader}; use std::process::{Child, Command, Stdio}; use test_msgs::WStrings; @@ -558,18 +559,26 @@ mod integration_tests { .subscribe::(OUTPUT_TOPIC) .await .expect("Failed to subscribe"); - let _relay = ChildGuard( - Command::new("python3") - .arg(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../roslibrust_test/tests/ros2_wstring_relay.py" - )) - .args([INPUT_TOPIC, OUTPUT_TOPIC]) - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .expect("Failed to start the rclpy wstring relay"), + let mut relay = Command::new("python3") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../roslibrust_test/tests/ros2_wstring_relay.py" + )) + .args([INPUT_TOPIC, OUTPUT_TOPIC]) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to start the rclpy wstring relay"); + let mut ready = std::string::String::new(); + BufReader::new(relay.stdout.take().unwrap()) + .read_line(&mut ready) + .expect("Failed to read readiness from the rclpy wstring relay"); + assert_eq!( + ready.trim(), + "READY", + "rclpy wstring relay exited before becoming ready" ); + let _relay = ChildGuard(relay); tokio::time::sleep(Duration::from_secs(2)).await; let expected = WStrings { diff --git a/roslibrust_test/tests/ros2_wstring_relay.py b/roslibrust_test/tests/ros2_wstring_relay.py index a092c3ad..a9ca22ff 100644 --- a/roslibrust_test/tests/ros2_wstring_relay.py +++ b/roslibrust_test/tests/ros2_wstring_relay.py @@ -19,6 +19,7 @@ def __init__(self, input_topic: str, output_topic: str) -> None: def main() -> None: rclpy.init() node = WStringRelay(sys.argv[1], sys.argv[2]) + print("READY", flush=True) try: rclpy.spin(node) finally: