diff --git a/client/cpp/ProjectAirsimClientLib/src/Client.cpp b/client/cpp/ProjectAirsimClientLib/src/Client.cpp index 6e4c6f12..11ff40e3 100644 --- a/client/cpp/ProjectAirsimClientLib/src/Client.cpp +++ b/client/cpp/ProjectAirsimClientLib/src/Client.cpp @@ -766,7 +766,12 @@ void Client::Impl::RequestSendingThreadProc(void) { SendRequest(str_method, json_params, pasyncresultprovider_message); // If the send failed, complete the async result with the error - if (status != Status::OK) pasyncresultprovider_message->SetDone(status); + if (status != Status::OK) + pasyncresultprovider_message->SetDone(status); + } else { + // Complete the async result so a caller blocked in Wait() is + // released. + pasyncresultprovider_message->SetDone(Status::Canceled); } // Done with request in this method @@ -833,9 +838,11 @@ void Client::Impl::ResponseReceivingThreadProc(void) { NNG_FLAG_ALLOC); if (err == nngi::NNG_ETIMEDOUT) { // Check to see whether we should exit - if (run_request_threads_) - continue; // Nope, keep going - else { + if (run_request_threads_) { + // Reply lost or never arrived within NNG_OPT_RECVTIMEO; + // fail the request instead of retrying forever. + pasyncresultprovider_message->SetDone(Status::TimedOut); + } else { // Yes, cancel the request and exit pasyncresultprovider_message->SetDone(Status::Canceled); } diff --git a/core_sim/include/core_sim/message/clock_message.hpp b/core_sim/include/core_sim/message/clock_message.hpp new file mode 100644 index 00000000..586aae21 --- /dev/null +++ b/core_sim/include/core_sim/message/clock_message.hpp @@ -0,0 +1,34 @@ +// Copyright (C) 2026 IAMAI CONSULTING CORP +// +// MIT License. All rights reserved. + +#ifndef CORE_SIM_INCLUDE_CORE_SIM_MESSAGE_CLOCK_MESSAGE_HPP_ +#define CORE_SIM_INCLUDE_CORE_SIM_MESSAGE_CLOCK_MESSAGE_HPP_ + +#include + +#include "core_sim/clock.hpp" +#include "core_sim/message/message.hpp" + +namespace microsoft { +namespace projectairsim { + +class ClockMessage : public Message { + public: + explicit ClockMessage(TimeNano time_stamp_val); + ClockMessage(); + ~ClockMessage() override; + + TimeNano GetTimeStamp() const; + + std::string Serialize() const override; + void Deserialize(const std::string& buffer) override; + + private: + class Impl; +}; + +} // namespace projectairsim +} // namespace microsoft + +#endif // CORE_SIM_INCLUDE_CORE_SIM_MESSAGE_CLOCK_MESSAGE_HPP_ diff --git a/core_sim/include/core_sim/message/message.hpp b/core_sim/include/core_sim/message/message.hpp index a5ed4d4d..61c0bdd4 100644 --- a/core_sim/include/core_sim/message/message.hpp +++ b/core_sim/include/core_sim/message/message.hpp @@ -46,6 +46,7 @@ enum class MessageType { kPose = 28, kIntList = 29, kFloat = 30, + kClock = 31, }; class Message { diff --git a/core_sim/include/core_sim/topic.hpp b/core_sim/include/core_sim/topic.hpp index f8fb7ff4..b41d165c 100644 --- a/core_sim/include/core_sim/topic.hpp +++ b/core_sim/include/core_sim/topic.hpp @@ -50,6 +50,7 @@ class Topic { friend class EnvActor; friend class Battery; friend class ViewportCameraImpl; + friend class Scene; Topic(const std::string& name, const std::string& path, TopicType type, int frequency, MessageType message_type); diff --git a/core_sim/src/CMakeLists.txt b/core_sim/src/CMakeLists.txt index 613bf8fe..3a59486a 100644 --- a/core_sim/src/CMakeLists.txt +++ b/core_sim/src/CMakeLists.txt @@ -28,6 +28,7 @@ add_library( message/airspeed_message.cpp message/barometer_message.cpp message/camera_info_message.cpp + message/clock_message.cpp message/collision_info_message.cpp message/distance_sensor_message.cpp message/flight_control_setpoint_message.cpp diff --git a/core_sim/src/message/clock_message.cpp b/core_sim/src/message/clock_message.cpp new file mode 100644 index 00000000..6d6ad51f --- /dev/null +++ b/core_sim/src/message/clock_message.cpp @@ -0,0 +1,62 @@ +// Copyright (C) 2026 IAMAI CONSULTING CORP +// +// MIT License. All rights reserved. + +#include "core_sim/message/clock_message.hpp" + +#include +#include + +#include "message_impl.hpp" +#include "msgpack.hpp" + +namespace microsoft { +namespace projectairsim { + +class ClockMessage::Impl : public MessageImpl { + public: + Impl() : MessageImpl(MessageType::kClock), time_stamp(0) {} + explicit Impl(TimeNano time_stamp_val) + : MessageImpl(MessageType::kClock), time_stamp(time_stamp_val) {} + + TimeNano GetTimeStamp() const { return time_stamp; } + + std::string Serialize() override { + std::stringstream stream; + msgpack::packer packer(stream); + this->msgpack_pack(packer); + return stream.str(); + } + + void Deserialize(const std::string& buffer) override { + auto handle = msgpack::unpack(buffer.data(), buffer.size()); + this->msgpack_unpack(handle.get()); + } + + MSGPACK_DEFINE_MAP(time_stamp); + + private: + TimeNano time_stamp; +}; + +ClockMessage::ClockMessage() : Message(std::make_shared()) {} + +ClockMessage::ClockMessage(TimeNano time_stamp_val) + : Message(std::make_shared(time_stamp_val)) {} + +ClockMessage::~ClockMessage() {} + +TimeNano ClockMessage::GetTimeStamp() const { + return static_cast(pimpl_.get())->GetTimeStamp(); +} + +std::string ClockMessage::Serialize() const { + return static_cast(pimpl_.get())->Serialize(); +} + +void ClockMessage::Deserialize(const std::string& buffer) { + static_cast(pimpl_.get())->Deserialize(buffer); +} + +} // namespace projectairsim +} // namespace microsoft diff --git a/core_sim/src/message/message_utils.cpp b/core_sim/src/message/message_utils.cpp index dd163d93..bfe53359 100644 --- a/core_sim/src/message/message_utils.cpp +++ b/core_sim/src/message/message_utils.cpp @@ -6,6 +6,7 @@ #include "message_utils.hpp" #include "core_sim/error.hpp" +#include "core_sim/message/clock_message.hpp" #include "core_sim/message/flight_control_rc_input_message.hpp" #include "core_sim/message/flight_control_setpoint_message.hpp" #include "core_sim/message/int8_message.hpp" @@ -20,6 +21,11 @@ namespace projectairsim { Message MessageUtils::ToMessage(const Topic& topic, const std::string& buffer) { switch (topic.GetMessageType()) { + case MessageType::kClock: { + ClockMessage message; + message.Deserialize(buffer); + return message; + } case MessageType::kInt8: { Int8Message message; message.Deserialize(buffer); diff --git a/core_sim/src/scene.cpp b/core_sim/src/scene.cpp index b63d2555..0e4281bb 100644 --- a/core_sim/src/scene.cpp +++ b/core_sim/src/scene.cpp @@ -19,7 +19,9 @@ #include "core_sim/error.hpp" #include "core_sim/file_utils.hpp" #include "core_sim/geodetic_converter.hpp" +#include "core_sim/message/clock_message.hpp" #include "core_sim/service_method.hpp" +#include "core_sim/topic.hpp" #include "core_sim/viewport_camera.hpp" #include "json.hpp" #include "message/common_utils.hpp" @@ -194,6 +196,8 @@ class Scene::Impl : public ComponentWithTopicsAndServiceMethods { bool SceneTick(); + void CreateTopics(); + void EnableViewportCamera(bool enable); // set enable_unreal_viewport_camera_callback_ @@ -218,6 +222,8 @@ class Scene::Impl : public ComponentWithTopicsAndServiceMethods { std::function physics_stop_callback_; ScheduledExecutor executor_; TimeNano sim_time_; + Topic clock_topic_; + bool clock_topic_registered_ = false; ClockSettings clock_settings_; SegmentationSettings segmentation_settings_; HomeGeoPoint home_geo_point_; @@ -635,6 +641,19 @@ void Scene::Impl::StopSceneTick() { } } +void Scene::Impl::CreateTopics() { + constexpr TimeNano kNanosPerSecond = 1000000000LL; + const int frequency = + clock_settings_.scene_tick_period > 0 + ? static_cast(kNanosPerSecond / + clock_settings_.scene_tick_period) + : 0; + clock_topic_ = Topic("clock", topic_path_, TopicType::kPublished, frequency, + MessageType::kClock); + topic_manager_.RegisterTopic(clock_topic_); + clock_topic_registered_ = true; +} + void Scene::Impl::OnBeginUpdate() { for (auto& actor : actors_) { if (actor->GetType() == ActorType::kRobot) { @@ -1010,6 +1029,12 @@ bool Scene::Impl::SceneTick() { TimeNano sim_dt_nanos = new_sim_time - sim_time_; sim_time_ = new_sim_time; + // Publish the authoritative simulator clock from the same tick that stamps + // sensor samples. Clients must not poll GetSimTime to drive ROS /clock: a + // blocked service request would otherwise stop time for every use_sim_time + // node even though the simulation itself is still running. + topic_manager_.PublishTopic(clock_topic_, ClockMessage(sim_time_)); + if (clock_settings_.type == ClockType::kSteppable && state_manager_.IsDistributed() && state_manager_.IsClockSource()) { state_manager_.SendSimTime(sim_time_); @@ -1192,6 +1217,8 @@ void Scene::Loader::LoadSceneWithJSON(const json& json) { SimClock::Get(std::make_shared()); } + impl_.CreateTopics(); + // Load segmentation settings for initializing scene object segmentation IDs LoadSegmentationSettings(json); @@ -1219,6 +1246,11 @@ void Scene::Loader::LoadSceneWithJSON(const json& json) { } void Scene::Loader::UnloadScene() { + if (impl_.clock_topic_registered_) { + impl_.topic_manager_.UnregisterTopic(impl_.clock_topic_); + impl_.clock_topic_ = Topic(); + impl_.clock_topic_registered_ = false; + } impl_.topic_manager_.SetTopicPublishedCallbackEnabled(false); impl_.topic_manager_.SetCallbackTopicPublished(nullptr); diff --git a/core_sim/src/topic_manager.cpp b/core_sim/src/topic_manager.cpp index 0a517bbb..afbf270a 100644 --- a/core_sim/src/topic_manager.cpp +++ b/core_sim/src/topic_manager.cpp @@ -864,6 +864,9 @@ void TopicManager::Impl::CreateTopicList() { case MessageType::kDistanceSensor: topic_info.message_type = "distance-sensor"; break; + case MessageType::kClock: + topic_info.message_type = "clock"; + break; default: break; } diff --git a/core_sim/test/CMakeLists.txt b/core_sim/test/CMakeLists.txt index c58b8d58..ba90651e 100644 --- a/core_sim/test/CMakeLists.txt +++ b/core_sim/test/CMakeLists.txt @@ -52,6 +52,7 @@ add_executable( gtest_unreal_vehicle_actuator.cpp gtest_noise_model_utils.cpp gtest_clock.cpp + gtest_clock_message.cpp gtest_message.cpp gtest_image_message.cpp gtest_topic_manager.cpp diff --git a/core_sim/test/gtest_clock_message.cpp b/core_sim/test/gtest_clock_message.cpp new file mode 100644 index 00000000..e9512b07 --- /dev/null +++ b/core_sim/test/gtest_clock_message.cpp @@ -0,0 +1,19 @@ +// Copyright (C) 2026 IAMAI CONSULTING CORP +// +// MIT License. All rights reserved. + +#include "core_sim/message/clock_message.hpp" +#include "gtest/gtest.h" + +namespace projectairsim = microsoft::projectairsim; + +TEST(ClockMessage, PreservesNanosecondTimestamp) { + constexpr TimeNano kTimestamp = 1234567890123456LL; + projectairsim::ClockMessage original(kTimestamp); + + EXPECT_EQ(original.GetType(), projectairsim::MessageType::kClock); + + projectairsim::ClockMessage unpacked; + unpacked.Deserialize(original.Serialize()); + EXPECT_EQ(unpacked.GetTimeStamp(), kTimestamp); +} diff --git a/docs/ros/ros2.md b/docs/ros/ros2.md index 106d7e46..e43a2864 100644 --- a/docs/ros/ros2.md +++ b/docs/ros/ros2.md @@ -104,7 +104,6 @@ when Project AirSim reports topic information. | `publish_tf` | `true` | Broadcast TF transforms from `/actual_pose` and camera pose payloads. | | `tf_world_frame_id` | `map` | Parent frame used for TF transforms. | | `refresh_topics_period_sec` | `0.0` | Periodic topic discovery interval. `0.0` disables polling; use a positive value only if scenes/topics can change outside this node. | -| `publish_clock_period_sec` | `0.02` | Periodic `/clock` publish interval in seconds. `0.0` disables clock publishing. | | `vehicle_name` | `Drone1` | Vehicle used for single-drone services/actions. | | `service_root` | `/projectairsim` | Root namespace for command services and actions. | | `image_qos_depth` | `5` | `KEEP_LAST` depth for image publishers. Values below `1` are rejected. | @@ -180,6 +179,22 @@ unset FASTDDS_DEFAULT_PROFILES_FILE unset FASTRTPS_DEFAULT_PROFILES_FILE ``` +The simulator publishes `/Sim//clock` from the scene tick. The bridge +subscribes to that native clock stream and republishes every sample on ROS +`/clock`; it does not poll `GetSimTime`. Sensor message headers likewise retain +their native per-sample `time_stamp` values. If a sensor payload has no valid +`time_stamp`, the bridge warns and publishes the conventional +invalid/uninitialized ROS timestamp (`sec: 0`, `nanosec: 0`) instead of +substituting the bridge's current time. ROS time fields are integers, so they +cannot represent NaN. + +For optimized native image messages, an absent `time_stamp` or an unsigned +value larger than `INT64_MAX` produces the same zero timestamp while preserving +the image. A present `time_stamp` encoded with the wrong MessagePack type +(such as a string, floating-point value, Boolean, or null), or encoded as a +negative integer, makes the image payload malformed; the bridge warns and +drops that image instead of substituting ROS node time. + ## Topics List ROS2 topics: @@ -214,7 +229,7 @@ The bridge also publishes: | Topic | Type | Description | |---|---|---| | `/projectairsim/topic_info` | `std_msgs/msg/String` | JSON list of Project AirSim topic paths from the first discovery pass after startup or scene load. | -| `/clock` | `rosgraph_msgs/msg/Clock` | Project AirSim simulation time from `World::GetSimTime()`. | +| `/clock` | `rosgraph_msgs/msg/Clock` | Native Project AirSim scene-clock topic, republished without service polling. | | `/tf` | `tf2_msgs/msg/TFMessage` | Vehicle and camera transforms when `publish_tf=true`. | To echo a topic: diff --git a/ros/projectairsim_ros2_cpp/include/projectairsim_ros2_cpp/ros2_conversion_utils.hpp b/ros/projectairsim_ros2_cpp/include/projectairsim_ros2_cpp/ros2_conversion_utils.hpp index 4c4a032a..6e974a16 100644 --- a/ros/projectairsim_ros2_cpp/include/projectairsim_ros2_cpp/ros2_conversion_utils.hpp +++ b/ros/projectairsim_ros2_cpp/include/projectairsim_ros2_cpp/ros2_conversion_utils.hpp @@ -194,10 +194,6 @@ inline double NumberOr(const json& object, const char* key, } inline bool JsonToInt64(const json& value, std::int64_t* out) { - if (value.is_number_integer()) { - *out = value.get(); - return true; - } if (value.is_number_unsigned()) { const auto unsigned_value = value.get(); if (unsigned_value > @@ -207,8 +203,20 @@ inline bool JsonToInt64(const json& value, std::int64_t* out) { *out = static_cast(unsigned_value); return true; } + if (value.is_number_integer()) { + *out = value.get(); + return true; + } if (value.is_number_float()) { - *out = static_cast(std::llround(value.get())); + const auto floating_value = value.get(); + if (!std::isfinite(floating_value) || + static_cast(floating_value) < + static_cast(std::numeric_limits::min()) || + static_cast(floating_value) > + static_cast(std::numeric_limits::max())) { + return false; + } + *out = static_cast(std::llround(floating_value)); return true; } return false; @@ -229,7 +237,8 @@ inline bool ExtractSimTimeNanos(const json& value, std::int64_t* nanosec) { } } - for (const auto* key : {"time_nanos", "sim_time_nanos", "nanosec", "nanos"}) { + for (const auto* key : {"time_stamp", "time_nanos", "sim_time_nanos", + "nanosec", "nanos"}) { auto it = value.find(key); if (it != value.end() && JsonToInt64(*it, nanosec)) return true; } @@ -440,6 +449,8 @@ inline bool PopulateImagePayloadFromJson(const json& msg, } struct NativeImageMetadata { + std::uint64_t time_stamp = 0; + bool has_time_stamp = false; std::string source_encoding; float pos_x = 0.0F; float pos_y = 0.0F; @@ -539,7 +550,10 @@ inline bool PopulateImagePayloadFromMsgpack(const std::string& payload, if (key.type != msgpack::type::STR) continue; const std::string_view field(key.via.str.ptr, key.via.str.size); - if (field == "height") { + if (field == "time_stamp") { + parsed_metadata.time_stamp = detail::ImageUnsigned(value, "time_stamp"); + parsed_metadata.has_time_stamp = true; + } else if (field == "height") { image->height = detail::ImageUint32(value, "height"); has_height = true; } else if (field == "width") { diff --git a/ros/projectairsim_ros2_cpp/src/projectairsim_ros2_cpp_node.cpp b/ros/projectairsim_ros2_cpp/src/projectairsim_ros2_cpp_node.cpp index d3177e3e..ce97792c 100644 --- a/ros/projectairsim_ros2_cpp/src/projectairsim_ros2_cpp_node.cpp +++ b/ros/projectairsim_ros2_cpp/src/projectairsim_ros2_cpp_node.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include +#include "builtin_interfaces/msg/time.hpp" #include "geometry_msgs/msg/pose_stamped.hpp" #include "geometry_msgs/msg/transform_stamped.hpp" #include "msgpack.hpp" @@ -105,14 +107,55 @@ std::string StatusToString(pasc::Status status) { return std::string(buffer); } -std_msgs::msg::Header MakeHeader(rclcpp::Node& node, - const std::string& frame_id) { +std_msgs::msg::Header MakeCurrentTimeHeader(rclcpp::Node& node, + const std::string& frame_id) { std_msgs::msg::Header header; header.stamp = node.get_clock()->now(); header.frame_id = frame_id; return header; } +std_msgs::msg::Header MakeHeader(rclcpp::Node& node, + const std::string& frame_id, + std::int64_t time_stamp) { + std_msgs::msg::Header header; + header.frame_id = frame_id; + if (time_stamp < 0) { + RCLCPP_WARN_THROTTLE( + node.get_logger(), *node.get_clock(), 5000, + "Sensor message for frame '%s' has an invalid timestamp; publishing " + "an invalid zero timestamp", + frame_id.c_str()); + return header; + } + + header.stamp = rclcpp::Time(time_stamp); + return header; +} + +// Stamp from the simulator's own per-sample timestamp when the payload carries +// one. Missing or invalid sensor timestamps remain the conventional zero ROS +// timestamp; they must never be replaced with the bridge node's current time. +// The simulator emits time_stamp in nanoseconds as the first member of every +// sensor message map, for example core_sim/src/message/imu_message.cpp:39. +std_msgs::msg::Header MakeHeader(rclcpp::Node& node, + const std::string& frame_id, const json& msg) { + std::int64_t time_stamp = 0; + if (msg.is_object() && msg.contains("time_stamp") && + JsonToInt64(msg["time_stamp"], &time_stamp)) { + return MakeHeader(node, frame_id, time_stamp); + } + + std_msgs::msg::Header header; + header.frame_id = frame_id; + RCLCPP_WARN_THROTTLE( + node.get_logger(), *node.get_clock(), 5000, + "Sensor message for frame '%s' has no valid time_stamp; publishing an " + "invalid zero timestamp", + frame_id.c_str()); + return header; +} + } // namespace class ProjectAirSimROS2CppNode final : public rclcpp::Node { @@ -146,8 +189,6 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { declare_parameter("tf_world_frame_id", "map"); refresh_topics_period_sec_ = declare_parameter("refresh_topics_period_sec", 0.0); - publish_clock_period_sec_ = - declare_parameter("publish_clock_period_sec", 0.02); vehicle_name_ = declare_parameter("vehicle_name", "Drone1"); service_root_ = declare_parameter("service_root", "/projectairsim"); @@ -189,12 +230,12 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { clock_publisher_ = create_publisher("/clock", rclcpp::QoS(10)); if (publish_tf_) { - tf_broadcaster_ = std::make_unique(*this); + tf_broadcaster_ = + std::make_unique(*this); } CreateServices(vehicle_name_); CreateActionServer(vehicle_name_); DiscoverAndSubscribeIfReady(); - StartClockTimerIfReady(); if (refresh_topics_period_sec_ > 0.0) { refresh_timer_ = create_wall_timer( std::chrono::duration_cast( @@ -232,18 +273,16 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return false; } - clock_parent_topic_ = world_->GetParentTopic(); scene_loaded_ = true; reported_waiting_for_scene_ = false; RCLCPP_INFO(get_logger(), "Using existing Project AirSim scene %s", - clock_parent_topic_.c_str()); + world_->GetParentTopic()); return true; } void DiscoverAndSubscribeIfReady() { if (!scene_loaded_ && !AdoptLoadedSceneIfAvailable()) return; DiscoverAndSubscribe(); - StartClockTimerIfReady(); } void DiscoverAndSubscribe() { @@ -259,14 +298,13 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { topic_info_msg.data = json(topics).dump(); topic_info_publisher_->publish(topic_info_msg); for (const auto& topic : topics) { - RCLCPP_INFO(get_logger(), " Project AirSim topic: %s", topic.c_str()); + RCLCPP_INFO(get_logger(), " Project AirSim topic: %s", + topic.c_str()); } listed_projectairsim_topics_ = true; } for (const auto& topic : topics) { - CacheClockParentTopic(topic); - { std::lock_guard lock(subscriptions_mutex_); if (std::find(subscribed_topics_.begin(), subscribed_topics_.end(), @@ -313,27 +351,51 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { } std::optional CreateHandler(const std::string& topic) { + if (EndsWith(topic, "/clock")) return CreateClockPublisher(); if (EndsWith(topic, "/gps")) return CreateGpsPublisher(topic); if (EndsWith(topic, "/actual_pose")) return CreatePosePublisher(topic); if (EndsWith(topic, "/imu") || EndsWith(topic, "/imu_kinematics")) { return CreateImuPublisher(topic); } - if (EndsWith(topic, "/barometer")) return CreateBarometerPublisher(topic); + if (EndsWith(topic, "/barometer")) + return CreateBarometerPublisher(topic); if (EndsWith(topic, "/magnetometer")) - return CreateMagnetometerPublisher(topic); + return CreateMagnetometerPublisher(topic); if (EndsWith(topic, "/lidar")) return CreateLidarPublisher(topic); if (EndsWith(topic, "/radar_detections")) - return CreateRadarScanPublisher(topic); + return CreateRadarScanPublisher(topic); if (EndsWith(topic, "/radar_tracks")) - return CreateRadarTracksPublisher(topic); + return CreateRadarTracksPublisher(topic); if (EndsWith(topic, "_camera_info")) - return CreateCameraInfoPublisher(topic); + return CreateCameraInfoPublisher(topic); if (publish_unmatched_as_json_) return CreateJsonPublisher(topic); return std::nullopt; } + TopicHandler CreateClockPublisher() { + return [this](const std::string&, const json& msg) { + std::int64_t nanosec = 0; + if (!ExtractSimTimeNanos(msg, &nanosec) || nanosec < 0) { + RCLCPP_WARN_THROTTLE( + get_logger(), *get_clock(), 5000, + "Project AirSim clock topic carried an invalid timestamp"); + return; + } + + latest_clock_nanos_.store(nanosec, std::memory_order_relaxed); + has_clock_sample_.store(true, std::memory_order_release); + + rosgraph_msgs::msg::Clock clock_msg; + clock_msg.clock.sec = static_cast(nanosec / 1000000000LL); + clock_msg.clock.nanosec = + static_cast(nanosec % 1000000000LL); + clock_publisher_->publish(clock_msg); + }; + } + std::string ToRosTopic(const std::string& projectairsim_topic) const { - if (StartsWithPathPrefix(projectairsim_topic, projectairsim_topic_root_)) { + if (StartsWithPathPrefix(projectairsim_topic, + projectairsim_topic_root_)) { return ros_topic_root_ + projectairsim_topic.substr(projectairsim_topic_root_.size()); } @@ -342,11 +404,13 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { void MaybeBroadcastTransform(const std::string& parent_frame_id, const std::string& child_frame_id, - const json& position, const json& orientation) { + const builtin_interfaces::msg::Time& time_stamp, + const json& position, + const json& orientation) { if (!tf_broadcaster_ || child_frame_id.empty()) return; geometry_msgs::msg::TransformStamped transform; - transform.header.stamp = get_clock()->now(); + transform.header.stamp = time_stamp; transform.header.frame_id = parent_frame_id; transform.child_frame_id = child_frame_id; transform.transform.translation = ToRosVector3(position); @@ -356,11 +420,12 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { void MaybeBroadcastTransform(const std::string& parent_frame_id, const std::string& child_frame_id, + const builtin_interfaces::msg::Time& time_stamp, const NativeImageMetadata& metadata) { if (!tf_broadcaster_ || child_frame_id.empty()) return; geometry_msgs::msg::TransformStamped transform; - transform.header.stamp = get_clock()->now(); + transform.header.stamp = time_stamp; transform.header.frame_id = parent_frame_id; transform.child_frame_id = child_frame_id; transform.transform.translation.x = metadata.pos_x; @@ -380,12 +445,13 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return [this, publisher, frame_id = TopicToFrameId(topic), topic]( const std::string&, const json& msg) { sensor_msgs::msg::NavSatFix ros_msg; - ros_msg.header = MakeHeader(*this, frame_id); + ros_msg.header = MakeHeader(*this, frame_id, msg); ros_msg.status.status = NumberOr(msg, "fix_type") >= 2.0 ? sensor_msgs::msg::NavSatStatus::STATUS_SBAS_FIX : sensor_msgs::msg::NavSatStatus::STATUS_NO_FIX; - ros_msg.status.service = sensor_msgs::msg::NavSatStatus::SERVICE_GPS; + ros_msg.status.service = + sensor_msgs::msg::NavSatStatus::SERVICE_GPS; ros_msg.latitude = NumberOr(msg, "latitude"); ros_msg.longitude = NumberOr(msg, "longitude"); ros_msg.altitude = NumberOr(msg, "altitude"); @@ -402,11 +468,13 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return [this, publisher, child_frame_id = TopicToFrameId(topic)]( const std::string&, const json& msg) { geometry_msgs::msg::PoseStamped ros_msg; - ros_msg.header = MakeHeader(*this, tf_world_frame_id_); - ros_msg.pose.position = ToRosPoint(msg.value("position", json::object())); + ros_msg.header = MakeHeader(*this, tf_world_frame_id_, msg); + ros_msg.pose.position = + ToRosPoint(msg.value("position", json::object())); ros_msg.pose.orientation = ToRosQuaternion(msg.value("orientation", json::object())); MaybeBroadcastTransform(tf_world_frame_id_, child_frame_id, + ros_msg.header.stamp, msg.value("position", json::object()), msg.value("orientation", json::object())); publisher->publish(ros_msg); @@ -421,7 +489,7 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return [this, publisher, frame_id = TopicToFrameId(topic), topic]( const std::string&, const json& msg) { sensor_msgs::msg::Imu ros_msg; - ros_msg.header = MakeHeader(*this, frame_id); + ros_msg.header = MakeHeader(*this, frame_id, msg); ros_msg.orientation = ToRosQuaternion(msg.value("orientation", json::object())); ros_msg.angular_velocity = @@ -442,7 +510,7 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return [this, publisher, frame_id = TopicToFrameId(topic)]( const std::string&, const json& msg) { sensor_msgs::msg::FluidPressure ros_msg; - ros_msg.header = MakeHeader(*this, frame_id); + ros_msg.header = MakeHeader(*this, frame_id, msg); ros_msg.fluid_pressure = NumberOr(msg, "pressure"); ros_msg.variance = 0.0; publisher->publish(ros_msg); @@ -456,13 +524,14 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return [this, publisher, frame_id = TopicToFrameId(topic)]( const std::string&, const json& msg) { sensor_msgs::msg::MagneticField ros_msg; - ros_msg.header = MakeHeader(*this, frame_id); + ros_msg.header = MakeHeader(*this, frame_id, msg); ros_msg.magnetic_field = ToRosVector3(msg.value("magnetic_field_body", json::array())); ros_msg.magnetic_field_covariance.fill(0.0); - const auto covariance = - ArrayNumbers(msg.value("magnetic_field_covariance", json::array())); - for (size_t i = 0; i < std::min(covariance.size(), 9); ++i) { + const auto covariance = ArrayNumbers( + msg.value("magnetic_field_covariance", json::array())); + for (size_t i = 0; i < std::min(covariance.size(), 9); + ++i) { ros_msg.magnetic_field_covariance[i] = covariance[i]; } publisher->publish(ros_msg); @@ -475,9 +544,11 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { ros_topic, rclcpp::SensorDataQoS()); return [this, publisher, frame_id = TopicToFrameId(topic)]( const std::string&, const json& msg) { - const auto points = ArrayNumbers(msg.value("point_cloud", json::array())); + const auto points = + ArrayNumbers(msg.value("point_cloud", json::array())); sensor_msgs::msg::PointCloud2 cloud; - cloud.header = MakeHeader(*this, msg.value("frame_id", frame_id)); + cloud.header = + MakeHeader(*this, msg.value("frame_id", frame_id), msg); cloud.height = 1; cloud.width = static_cast(points.size() / 3); cloud.is_bigendian = false; @@ -502,26 +573,28 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { TopicHandler CreateRadarScanPublisher(const std::string& topic) { const auto ros_topic = ToRosTopic(topic); - auto publisher = create_publisher( + auto publisher = + create_publisher( ros_topic, rclcpp::SensorDataQoS()); return [this, publisher, frame_id = TopicToFrameId(topic)]( const std::string&, const json& msg) { projectairsim_ros2_cpp::msg::RadarScan ros_msg; - ros_msg.header = MakeHeader(*this, frame_id); - AppendRadarReturnsFromJson(msg.value("radar_detections", json::array()), - &ros_msg); + ros_msg.header = MakeHeader(*this, frame_id, msg); + AppendRadarReturnsFromJson( + msg.value("radar_detections", json::array()), &ros_msg); publisher->publish(ros_msg); }; } TopicHandler CreateRadarTracksPublisher(const std::string& topic) { const auto ros_topic = ToRosTopic(topic); - auto publisher = create_publisher( + auto publisher = + create_publisher( ros_topic, rclcpp::SensorDataQoS()); return [this, publisher, frame_id = TopicToFrameId(topic)]( const std::string&, const json& msg) { projectairsim_ros2_cpp::msg::RadarTracks ros_msg; - ros_msg.header = MakeHeader(*this, frame_id); + ros_msg.header = MakeHeader(*this, frame_id, msg); AppendRadarTracksFromJson(msg.value("radar_tracks", json::array()), &ros_msg); publisher->publish(ros_msg); @@ -537,7 +610,7 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return [this, publisher, frame_id = CameraFrameId(topic)]( const std::string&, const json& msg) { sensor_msgs::msg::CameraInfo ros_msg; - ros_msg.header = MakeHeader(*this, frame_id); + ros_msg.header = MakeHeader(*this, frame_id, msg); PopulateCameraInfoFromJson(msg, &ros_msg); publisher->publish(ros_msg); }; @@ -552,7 +625,6 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return [this, publisher, frame_id = CameraFrameId(topic), topic]( const std::string&, const std::string& payload) { auto ros_msg = std::make_unique(); - ros_msg->header = MakeHeader(*this, frame_id); NativeImageMetadata metadata; if (!PopulateImagePayloadFromMsgpack(payload, ros_msg.get(), &metadata)) { RCLCPP_WARN_THROTTLE(get_logger(), *get_clock(), 5000, @@ -560,8 +632,15 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { topic.c_str(), metadata.source_encoding.c_str()); return; } + const auto time_stamp = + metadata.has_time_stamp && + metadata.time_stamp <= static_cast( + std::numeric_limits::max()) + ? static_cast(metadata.time_stamp) + : -1; + ros_msg->header = MakeHeader(*this, frame_id, time_stamp); MaybeBroadcastTransform(tf_world_frame_id_, ros_msg->header.frame_id, - metadata); + ros_msg->header.stamp, metadata); publisher->publish(std::move(ros_msg)); }; } @@ -591,8 +670,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { mp::ResponseMessage response; response.Deserialize(message_response); if (response.GetErrorCode() != 0) { - RCLCPP_ERROR(get_logger(), "%s rejected by server: %s", method.c_str(), - response.GetResult().dump().c_str()); + RCLCPP_ERROR(get_logger(), "%s rejected by server: %s", + method.c_str(), response.GetResult().dump().c_str()); return false; } @@ -615,7 +694,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { params = json::parse(json_parameters); } catch (const json::parse_error& error) { *error_code = -1; - *status_text = std::string("Invalid JSON parameters: ") + error.what(); + *status_text = + std::string("Invalid JSON parameters: ") + error.what(); *result_json = json({{"error", *status_text}}).dump(); raw_response->clear(); return false; @@ -664,7 +744,7 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { bool RequestWorldJson(const std::string& method, const json& params, json* result, std::string* status_text) { - const auto parent_topic = ResolveClockParentTopic(); + const std::string parent_topic = world_->GetParentTopic(); if (parent_topic.empty()) { *status_text = "Project AirSim scene parent topic is not resolved"; return false; @@ -697,7 +777,7 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { bool RequestVehicleJson(const std::string& vehicle_name, const std::string& method, const json& params, json* result, std::string* status_text) { - const auto parent_topic = ResolveClockParentTopic(); + const std::string parent_topic = world_->GetParentTopic(); if (parent_topic.empty()) { *status_text = "Project AirSim scene parent topic is not resolved"; return false; @@ -732,7 +812,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { all ? client_->UnsubscribeAll() : client_->Unsubscribe(topics); *status_text = StatusToString(status); if (status == pasc::Status::OK) { - std::lock_guard subscriptions_lock(subscriptions_mutex_); + std::lock_guard subscriptions_lock( + subscriptions_mutex_); if (all) { subscribed_topics_.clear(); handlers_.clear(); @@ -763,69 +844,6 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return status == pasc::Status::OK; } - void CacheClockParentTopic(const std::string& topic) { - if (!clock_parent_topic_.empty()) return; - const auto parent_topic = - ParentTopicFromProjectAirSimTopic(topic, projectairsim_topic_root_); - if (parent_topic.empty()) return; - - clock_parent_topic_ = parent_topic; - RCLCPP_INFO(get_logger(), "Resolved Project AirSim clock parent topic: %s", - clock_parent_topic_.c_str()); - } - - std::string ResolveClockParentTopic() { - if (!clock_parent_topic_.empty()) return clock_parent_topic_; - - const std::string world_parent_topic = world_->GetParentTopic(); - if (IsSceneParentTopic(world_parent_topic, projectairsim_topic_root_)) { - clock_parent_topic_ = world_parent_topic; - return clock_parent_topic_; - } - return clock_parent_topic_; - } - - bool RequestSimTime(std::int64_t* nanosec) { - if (!scene_loaded_) return false; - const auto parent_topic = ResolveClockParentTopic(); - if (!parent_topic.empty()) { - const auto method = parent_topic + "/GetSimTime"; - pasc::Message message_response; - pasc::Status status = pasc::Status::OK; - { - std::lock_guard lock(client_mutex_); - status = client_->Request(method, json::object(), &message_response); - } - if (status == pasc::Status::OK) { - mp::ResponseMessage response; - response.Deserialize(message_response); - if (response.GetErrorCode() == 0 && - ExtractSimTimeNanos(response.GetResult(), nanosec)) { - return true; - } - - RCLCPP_WARN_THROTTLE( - get_logger(), *get_clock(), 5000, - "%s returned an invalid sim time response: error=%d result=%s", - method.c_str(), response.GetErrorCode(), - response.GetResult().dump().c_str()); - } else { - RCLCPP_WARN_THROTTLE(get_logger(), *get_clock(), 5000, - "%s request failed: %s", method.c_str(), - StatusToString(status).c_str()); - } - } - - const auto status = world_->GetSimTime(nanosec); - if (status != pasc::Status::OK) { - RCLCPP_WARN_THROTTLE(get_logger(), *get_clock(), 5000, - "GetSimTime fallback failed: %s", - StatusToString(status).c_str()); - return false; - } - return true; - } - std::shared_ptr GetDrone(const std::string& vehicle_name) { std::lock_guard lock(drones_mutex_); auto it = drones_.find(vehicle_name); @@ -914,7 +932,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { StatusToString(disarm_status).c_str()); } if (disable_status != pasc::Status::OK) { - RCLCPP_ERROR(get_logger(), "DisableAPIControl after disarm failed: %s", + RCLCPP_ERROR(get_logger(), + "DisableAPIControl after disarm failed: %s", StatusToString(disable_status).c_str()); } return disarm_status == pasc::Status::OK && @@ -945,8 +964,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { if (!drone) return false; auto result = drone->MoveOnPathAsync( PathFromRos(path), static_cast(velocity), timeout_sec, - YawModeFromDriveTrain(drive_train_type), yaw_is_rate, yaw, lookahead, - adaptive_lookahead); + YawModeFromDriveTrain(drive_train_type), yaw_is_rate, yaw, + lookahead, adaptive_lookahead); return !wait_on_last_task || result.Wait() == pasc::Status::OK; } @@ -955,13 +974,15 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { const std::string& output_file, pasc::BoolArray* voxel_grid = nullptr) { if (x_size <= 0 || y_size <= 0 || z_size <= 0 || resolution <= 0.0) { - RCLCPP_ERROR(get_logger(), + RCLCPP_ERROR( + get_logger(), "CreateVoxelGrid requires positive sizes and resolution"); return false; } pasc::BoolArray local_voxel_grid; - auto& result_grid = voxel_grid != nullptr ? *voxel_grid : local_voxel_grid; + auto& result_grid = + voxel_grid != nullptr ? *voxel_grid : local_voxel_grid; const bool write_file = !output_file.empty(); const auto status = world_->CreateVoxelGrid( PoseFromPosition(x, y, z), static_cast(x_size), @@ -982,12 +1003,16 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { double resolution) { (void)center_z; nav_msgs::msg::OccupancyGrid grid; - grid.header = MakeHeader(*this, "map"); + grid.header = MakeCurrentTimeHeader(*this, "map"); grid.info.resolution = static_cast(resolution); - grid.info.width = static_cast(CellsFromSize(x_size, resolution)); - grid.info.height = static_cast(CellsFromSize(y_size, resolution)); - grid.info.origin.position.x = center_x - 0.5 * static_cast(x_size); - grid.info.origin.position.y = center_y - 0.5 * static_cast(y_size); + grid.info.width = + static_cast(CellsFromSize(x_size, resolution)); + grid.info.height = + static_cast(CellsFromSize(y_size, resolution)); + grid.info.origin.position.x = + center_x - 0.5 * static_cast(x_size); + grid.info.origin.position.y = + center_y - 0.5 * static_cast(y_size); grid.info.origin.position.z = 0.0; grid.info.origin.orientation.w = 1.0; @@ -1020,32 +1045,10 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return grid; } - bool GetClock(std::int64_t* nanosec) { return RequestSimTime(nanosec); } - - void PublishClock() { - if (!scene_loaded_) return; - std::int64_t nanosec = 0; - if (!RequestSimTime(&nanosec)) { - return; - } - - rosgraph_msgs::msg::Clock clock_msg; - const auto sec = nanosec / 1000000000LL; - auto nsec = nanosec % 1000000000LL; - if (nsec < 0) nsec += 1000000000LL; - clock_msg.clock.sec = static_cast(sec); - clock_msg.clock.nanosec = static_cast(nsec); - clock_publisher_->publish(clock_msg); - } - - void StartClockTimerIfReady() { - if (!scene_loaded_ || publish_clock_period_sec_ <= 0.0 || clock_timer_) { - return; - } - clock_timer_ = create_wall_timer( - std::chrono::duration_cast( - std::chrono::duration(publish_clock_period_sec_)), - std::bind(&ProjectAirSimROS2CppNode::PublishClock, this)); + bool GetClock(std::int64_t* nanosec) { + if (!has_clock_sample_.load(std::memory_order_acquire)) return false; + *nanosec = latest_clock_nanos_.load(std::memory_order_relaxed); + return true; } bool GetOriginGeoPoint(double* latitude, double* longitude, @@ -1053,7 +1056,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { const auto& config = world_->GetConfiguration(); if (!config.contains("home-geo-point") || !config["home-geo-point"].is_object()) { - RCLCPP_ERROR(get_logger(), "Scene configuration has no home-geo-point"); + RCLCPP_ERROR(get_logger(), + "Scene configuration has no home-geo-point"); return false; } const auto& home_geo_point = config["home-geo-point"]; @@ -1063,7 +1067,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { return true; } - bool LoadSceneRuntime(const std::string& scene_file, bool is_primary_client) { + bool LoadSceneRuntime(const std::string& scene_file, + bool is_primary_client) { if (!is_primary_client) { return AdoptLoadedSceneIfAvailable(); } @@ -1088,12 +1093,11 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { handlers_.clear(); image_handlers_.clear(); } - clock_parent_topic_ = world_->GetParentTopic(); scene_loaded_ = true; reported_waiting_for_scene_ = false; + has_clock_sample_.store(false, std::memory_order_release); listed_projectairsim_topics_ = false; DiscoverAndSubscribe(); - StartClockTimerIfReady(); return true; } @@ -1116,7 +1120,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { template void CreateGroupService(const std::string& name, Fn&& fn) { CreateService( - name, [fn = std::forward(fn)](const auto request, auto response) { + name, + [fn = std::forward(fn)](const auto request, auto response) { response->success = true; for (const auto& vehicle_name : request->vehicle_names) { response->success = fn(vehicle_name, request->wait_on_last_task) && @@ -1129,7 +1134,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { const std::string prefix = service_root_ + "/" + vehicle_name; CreateService( - service_root_ + "/request", [this](const auto request, auto response) { + service_root_ + "/request", + [this](const auto request, auto response) { response->success = RequestJson(request->method, request->json_parameters, &response->error_code, &response->result_json, @@ -1139,8 +1145,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { service_root_ + "/get_client_info", [this](const auto request, auto response) { (void)request; - response->success = - GetClientInfo(&response->client_version, &response->nng_version, + response->success = GetClientInfo(&response->client_version, + &response->nng_version, &response->build_commit_hash); }); CreateService( @@ -1231,7 +1237,9 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { }); CreateGroupService( service_root_ + "/arm_group", - [this](const std::string& name, bool wait) { return Arm(name, wait); }); + [this](const std::string& name, bool wait) { + return Arm(name, wait); + }); CreateGroupService( service_root_ + "/disarm_group", [this](const std::string& name, bool wait) { @@ -1258,8 +1266,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { if (response->success) { response->map = OccupancyGridFromVoxels( voxel_grid, request->position_x, request->position_y, - request->position_z, request->ncells_x, request->ncells_y, - request->ncells_z, request->res); + request->position_z, request->ncells_x, + request->ncells_y, request->ncells_z, request->res); } }); CreateService( @@ -1362,7 +1370,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { &response->status); }); CreateService( - service_root_ + "/reset", [this](const auto request, auto response) { + service_root_ + "/reset", + [this](const auto request, auto response) { (void)request; response->success = RequestBool("/Sim/Reset"); }); @@ -1434,7 +1443,8 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { const auto msg = UnpackProjectAirSimMessage(payload); if (handler) handler(topic_name, msg); } catch (const std::exception& ex) { - RCLCPP_WARN_THROTTLE(get_logger(), *get_clock(), 5000, + RCLCPP_WARN_THROTTLE( + get_logger(), *get_clock(), 5000, "Failed to convert Project AirSim topic %s: %s", topic_name.c_str(), ex.what()); } @@ -1456,17 +1466,16 @@ class ProjectAirSimROS2CppNode final : public rclcpp::Node { std::string vehicle_name_; std::string service_root_; int image_qos_depth_; - std::string clock_parent_topic_; + std::atomic latest_clock_nanos_{0}; + std::atomic has_clock_sample_{false}; bool publish_unmatched_as_json_; bool publish_tf_; std::string tf_world_frame_id_; double refresh_topics_period_sec_; - double publish_clock_period_sec_; bool listed_projectairsim_topics_ = false; bool scene_loaded_ = false; bool reported_waiting_for_scene_ = false; rclcpp::TimerBase::SharedPtr refresh_timer_; - rclcpp::TimerBase::SharedPtr clock_timer_; rclcpp::Publisher::SharedPtr topic_info_publisher_; rclcpp::Publisher::SharedPtr clock_publisher_; std::unique_ptr tf_broadcaster_; diff --git a/ros/projectairsim_ros2_cpp/test/ros2_conversion_utils_test.cpp b/ros/projectairsim_ros2_cpp/test/ros2_conversion_utils_test.cpp index 30ef7f33..9cedb3ea 100644 --- a/ros/projectairsim_ros2_cpp/test/ros2_conversion_utils_test.cpp +++ b/ros/projectairsim_ros2_cpp/test/ros2_conversion_utils_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -25,12 +26,16 @@ using bridge::json; std::string PackNativeImage(const std::string& encoding, std::uint32_t width, std::uint32_t height, const std::vector& data, - bool include_data = true) { + bool include_data = true, + bool include_time_stamp = true) { msgpack::sbuffer buffer; msgpack::packer packer(buffer); - packer.pack_map(include_data ? 15 : 14); - packer.pack("time_stamp"); - packer.pack(123U); + packer.pack_map(13 + static_cast(include_data) + + static_cast(include_time_stamp)); + if (include_time_stamp) { + packer.pack("time_stamp"); + packer.pack(123U); + } packer.pack("height"); packer.pack(height); packer.pack("width"); @@ -100,6 +105,9 @@ TEST(Ros2ConversionUtils, SimTimeExtractionAcceptsSupportedPayloadShapes) { bridge::ExtractSimTimeNanos(json{{"sim_time_nanos", 5678}}, &nanos)); EXPECT_EQ(nanos, 5678); + ASSERT_TRUE(bridge::ExtractSimTimeNanos(json{{"time_stamp", 9012}}, &nanos)); + EXPECT_EQ(nanos, 9012); + ASSERT_TRUE( bridge::ExtractSimTimeNanos(json{{"sec", 2}, {"nanosec", 9}}, &nanos)); EXPECT_EQ(nanos, 2000000009LL); @@ -107,6 +115,15 @@ TEST(Ros2ConversionUtils, SimTimeExtractionAcceptsSupportedPayloadShapes) { EXPECT_FALSE(bridge::ExtractSimTimeNanos(json{{"time", "bad"}}, &nanos)); } +TEST(Ros2ConversionUtils, TimestampConversionRejectsInvalidNumericValues) { + std::int64_t value = 0; + + EXPECT_FALSE(bridge::JsonToInt64( + std::numeric_limits::max(), &value)); + EXPECT_FALSE(bridge::JsonToInt64( + std::numeric_limits::infinity(), &value)); +} + TEST(Ros2ConversionUtils, CoordinateConversionsApplyRosAxisConvention) { const auto vector_from_array = bridge::ToRosVector3(json::array({1.0, 2.0, 3.0})); @@ -188,6 +205,8 @@ TEST(Ros2ConversionUtils, NativeBgrImagePayloadIsConvertedWithoutJson) { EXPECT_EQ(image.encoding, "bgr8"); EXPECT_EQ(image.step, 6U); EXPECT_EQ(image.data, pixels); + EXPECT_TRUE(metadata.has_time_stamp); + EXPECT_EQ(metadata.time_stamp, 123U); EXPECT_FLOAT_EQ(metadata.pos_y, 2.0F); EXPECT_FLOAT_EQ(metadata.rot_w, 0.5F); } @@ -204,6 +223,17 @@ TEST(Ros2ConversionUtils, NativeDepthImagePayloadIsConverted) { EXPECT_EQ(image.data, pixels); } +TEST(Ros2ConversionUtils, NativeImageMetadataTracksMissingTimestamp) { + sensor_msgs::msg::Image image; + bridge::NativeImageMetadata metadata; + + ASSERT_TRUE(bridge::PopulateImagePayloadFromMsgpack( + PackNativeImage("BGR", 1, 1, {1, 2, 3}, true, false), &image, + &metadata)); + EXPECT_FALSE(metadata.has_time_stamp); + EXPECT_EQ(metadata.time_stamp, 0U); +} + TEST(Ros2ConversionUtils, NativeHalfDepthImagePayloadIsConvertedToFloat) { // IEEE 754 binary16 little-endian: 1.0 and +inf. const std::vector pixels{0x00, 0x3C, 0x00, 0x7C};