diff --git a/README.md b/README.md index 8eb3269154..be75beae5f 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ The bag argument can be a directory containing `metadata.yaml` and one or more s to a single storage file such as `.mcap` or `.db3`. The Player will automatically detect which storage implementation to use for playing. A progress bar to track the playback progress will be displayed in the terminal by default. +By default, the player process exits when playback is stopped or when it reaches the end of the +bag. To play back multiple bags: @@ -340,6 +342,11 @@ Options: The reference to use for bag message chronological ordering. Choices: reception timestamp (`received`), publication timestamp (`sent`). Default: reception timestamp. +* `--persistent`: + Keep the player process alive after playback stops or reaches the end of the bag. + In persistent mode, playback can be started again without restarting `ros2 bag play`, including + future scheduled starts by setting `start_time` in the `~/play` service request. + Without this option, the player exits after playback stops or completes. * `--progress-bar-update-rate [Hz]`: Print a progress bar for the playback with a specified maximum update rate in times per second (Hz). Negative values mark an update for every published message, while a zero value disables @@ -350,6 +357,16 @@ Options: For more options, run with `--help`. +To keep the player available for repeated service-driven playback runs, start it in persistent +mode: + +```bash +$ ros2 bag play --persistent +``` + +With `--persistent`, the process stays alive after playback stops and only exits when explicitly +terminated, for example with `Ctrl+C`. + #### Playback action messages as action client If you want Rosbag2 to replay recorded action messages in the role of an action client, you need to specify the --send-actions-as-client parameter. @@ -395,7 +412,10 @@ The Rosbag2 player provides the following services for remote control, which can * `~/set_rate [rosbag2_interfaces/srv/SetRate]` * Sets the rate of playback, for example 2.0 will play messages twice as fast. * `~/stop [rosbag2_interfaces/srv/Stop]` - * Stop the player, putting the play head in "undefined position" outside the bag. Must call `play` before other operations can be done. + * Stop the player, putting the play head in "undefined position" outside the bag. Must call `play` + before other operations can be done. + * Without `--persistent`, stopping playback causes the player process to exit. With + `--persistent`, the process stays alive and can be controlled again through services. * `~/toggle_paused [rosbag2_interfaces/srv/TogglePaused]` * Pause if playing, resume if paused. diff --git a/ros2bag/ros2bag/verb/play.py b/ros2bag/ros2bag/verb/play.py index 1c89c55c0a..e3844ab897 100644 --- a/ros2bag/ros2bag/verb/play.py +++ b/ros2bag/ros2bag/verb/play.py @@ -15,6 +15,7 @@ from argparse import FileType import signal import threading +import time from rclpy.qos import InvalidQoSProfileException from ros2bag.api import add_standard_multi_reader_args @@ -207,6 +208,12 @@ def add_arguments(self, parser, cli_name): # noqa: D102 choices=['debug', 'info', 'warn', 'error', 'fatal'], help='Logging level.') progress_bar_group = parser.add_argument_group('Progress bar', 'Settings for progress bar') + parser.add_argument( + '--persistent', + action='store_true', default=False, + help='Keep the player process running after playback stops, allowing future ' + 'play/resume via service calls API.' + ) progress_bar_group.add_argument( '--progress-bar-update-rate', type=int, metavar='Hz', default=3, help='Print a progress bar for the playback with a specified maximum update rate in ' @@ -351,10 +358,13 @@ def main(self, *, args): # noqa: D102 player.play() # Wait for playback to finish with periodic checks for termination while not termination_requested.is_set(): - # Use a short timeout to periodically check the termination flag - if player.wait_for_playback_to_finish_exclusively(0.1): - break # Playback finished naturally - + if args.persistent: + # In persistent mode, just wait until termination is requested + time.sleep(0.1) + else: + # Use a short timeout to periodically check the termination flag + if player.wait_for_playback_to_finish_exclusively(0.1): + break # Playback finished naturally # If termination was requested, the player stop will be called in the 'finally' block except KeyboardInterrupt: pass diff --git a/rosbag2_tests/test/rosbag2_tests/test_rosbag2_play_end_to_end.cpp b/rosbag2_tests/test/rosbag2_tests/test_rosbag2_play_end_to_end.cpp index e12a7aee48..32b1d3243f 100644 --- a/rosbag2_tests/test/rosbag2_tests/test_rosbag2_play_end_to_end.cpp +++ b/rosbag2_tests/test/rosbag2_tests/test_rosbag2_play_end_to_end.cpp @@ -24,6 +24,7 @@ #include "rosbag2_interfaces/srv/resume.hpp" #include "rosbag2_interfaces/srv/stop.hpp" +#include "rosbag2_interfaces/srv/play.hpp" #include "rosbag2_test_common/process_execution_helpers.hpp" #include "rosbag2_test_common/subscription_manager.hpp" #include "rosbag2_test_common/tested_storage_ids.hpp" @@ -42,6 +43,7 @@ class PlayEndToEndTestFixture : public Test, public WithParamInterface("test_player_client"); cli_resume_ = client_node_->create_client("/rosbag2_player/resume"); cli_stop_ = client_node_->create_client("/rosbag2_player/stop"); + cli_play_ = client_node_->create_client("/rosbag2_player/play"); exec_ = std::make_shared(); exec_->add_node(client_node_); spin_thread_ = std::thread( @@ -113,6 +116,7 @@ class PlayEndToEndTestFixture : public Test, public WithParamInterface::SharedPtr cli_resume_; rclcpp::Client::SharedPtr cli_stop_; + rclcpp::Client::SharedPtr cli_play_; std::thread spin_thread_; std::shared_ptr exec_; const std::chrono::seconds service_call_timeout_ {10}; @@ -357,6 +361,62 @@ TEST_P(PlayEndToEndTestFixture, play_end_to_end_exits_gracefully_on_sigterm) { } #endif // #ifndef _WIN32 +TEST_P(PlayEndToEndTestFixture, play_persistent_allows_restart_via_play_service) +{ + const std::string topic_name = "/test_topic"; + sub_->add_subscription(topic_name, 3, sub_qos_); + + // Start ros2 bag play in pause + persistent mode + auto process_id = + start_execution("ros2 bag play --persistent -p " + bags_path_ + + "/cdr_test --topics " + topic_name); + auto cleanup_process_handle = rcpputils::make_scope_exit( + [process_id]() { + stop_execution(process_id); + }); + + EXPECT_TRUE(sub_->spin_and_wait_for_matched({topic_name})); + + ASSERT_TRUE(cli_resume_->wait_for_service(service_call_timeout_)); + successful_service_request(cli_resume_); + + sub_->spin_subscriptions_sync(); + auto primitive_msgs = sub_->get_received_messages(topic_name); + ASSERT_THAT(primitive_msgs, SizeIs(Ge(3u))); + + // Stop playback via stop service + ASSERT_TRUE(cli_stop_->wait_for_service(service_call_timeout_)); + successful_service_request(cli_stop_); + + sub_ = std::make_unique(); + sub_->add_subscription(topic_name, 3, sub_qos_); + EXPECT_TRUE(sub_->spin_and_wait_for_matched({topic_name})); + + ASSERT_TRUE(cli_play_->wait_for_service(service_call_timeout_)); + auto play_request = std::make_shared(); + + play_request->start_time = rclcpp::Time(0, 0); + play_request->start_offset = rclcpp::Time(0, 0); + play_request->playback_duration = rclcpp::Duration(-1, 0); + play_request->playback_until_timestamp = rclcpp::Time(-1); + + auto play_response = + successful_service_request(cli_play_, play_request); + + ASSERT_TRUE(play_response); + EXPECT_EQ(play_response->return_code, + rosbag2_interfaces::srv::Play::Response::RETURN_CODE_SUCCESS); + EXPECT_TRUE(play_response->error_string.empty()); + + sub_->spin_subscriptions_sync(); + primitive_msgs = sub_->get_received_messages(topic_name); + ASSERT_THAT(primitive_msgs, SizeIs(Ge(3u))); + + // Send SIGINT to child process and check exit code + stop_execution(process_id, SIGINT); + cleanup_process_handle.cancel(); +} + INSTANTIATE_TEST_SUITE_P( TestPlayEndToEnd, PlayEndToEndTestFixture,