diff --git a/doc/images/six_wheeler_steering.drawio b/doc/images/six_wheeler_steering.drawio
new file mode 100644
index 0000000000..7e125b56b8
--- /dev/null
+++ b/doc/images/six_wheeler_steering.drawio
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/images/six_wheeler_steering.svg b/doc/images/six_wheeler_steering.svg
new file mode 100644
index 0000000000..9201e493ee
--- /dev/null
+++ b/doc/images/six_wheeler_steering.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/doc/six_wheel_rover_controller.rst b/doc/six_wheel_rover_controller.rst
new file mode 100644
index 0000000000..074c41db08
--- /dev/null
+++ b/doc/six_wheel_rover_controller.rst
@@ -0,0 +1,133 @@
+.. _doc_six_wheel_rover_controller:
+
+six_wheel_rover_controller
+=============================
+
+This page introduces the kinematic model for a six-wheeled rover featuring four-wheel steering, commonly used in rocker-bogie style vehicles.
+
+Kinematics of the system
+------------------------
+
+The controller uses a standard Ackermann-style kinematic model adapted for a six-wheel configuration. The core principle is that to avoid wheel slip during a turn, the axes of all wheels must intersect at a single point known as the **Instantaneous Center of Rotation (ICR)**.
+
+The parameters shown in the diagram are essential for the kinematic calculations.
+
+Diagram
+------------------
+.. image:: images/six_wheeler_steering.svg
+ :width: 600px
+ :align: center
+ :alt: Six-wheel steering controller kinematic diagram
+
+Inverse Kinematics
+------------------
+
+Inverse kinematics determine the required joint commands from a desired vehicle body velocity (a ``Twist`` message).
+
+**Steering Joint Angles**
+
+Given a desired turning radius, :math:`R`, the required steering angles for the inner and outer front wheels are calculated as follows:
+
+.. math::
+
+ \theta_{inner} = \arctan\left(\frac{d_3}{|R| - d_1}\right)
+
+.. math::
+
+ \theta_{outer} = \arctan\left(\frac{d_3}{|R| + d_1}\right)
+
+The rear wheels are commanded to the same angle magnitudes but in the opposite direction to articulate the turn.
+
+**Drive Wheel Velocities**
+
+The velocity of each drive wheel is proportional to its distance from the ICR. The vehicle's angular velocity, :math:`\omega_{center}`, is first determined from the desired linear velocity, :math:`v`:
+
+.. math::
+
+ \omega_{center} = \frac{v}{R}
+
+The linear velocity for each wheel (:math:`v_{wheel}`) is then found based on its unique turning radius. This is finally converted to the motor's angular velocity command, :math:`\omega_{wheel}`:
+
+.. math::
+
+ \omega_{wheel} = \frac{v_{wheel}}{r_{wheel}}
+
+Forward Kinematics
+------------------
+
+Forward kinematics estimate the vehicle's current ``Twist`` from its joint states. The rover's linear velocity, :math:`v_x`, is estimated from the average velocity of the two non-steerable middle wheels.
+
+.. math::
+
+ v_x = \frac{(\omega_{left\_middle} + \omega_{right\_middle})}{2} \cdot r_{wheel}
+
+The vehicle's angular velocity, :math:`\omega_z`, is then derived from this linear velocity and an estimation of the current turning radius, :math:`R_{approx}`.
+
+.. math::
+
+ \omega_z = \frac{v_x}{R_{approx}}
+
+Controller Parameters
+---------------------
+
+.. list-table:: Controller Parameters
+ :widths: 20 80
+ :header-rows: 1
+
+ * - Parameter
+ - Description
+ * - ``traction_joints``
+ - A list of the six drive wheel joint names from the robot's URDF model, in the following order: front-left, front-right, middle-left, middle-right, rear-left, rear-right.
+ * - ``steering_joints``
+ - A list of the four corner steering joint names from the robot's URDF model, in the following order: front-left, front-right, rear-left, rear-right.
+ * - ``d1``
+ - Half the front/rear track width (meters). Lateral distance from the longitudinal center to the steering pivot.
+ * - ``d2``
+ - Longitudinal distance from rover center to the rear axle (meters).
+ * - ``d3``
+ - Longitudinal distance from rover center to the front axle (meters).
+ * - ``d4``
+ - Half the middle track width (meters). Lateral distance from the center to the middle wheel's center.
+ * - ``wheel_radius``
+ - Radius of the drive wheels (meters).
+ * - ``odom_frame_id``
+ - The name of the odometry frame. Default: ``odom``.
+ * - ``base_frame_id``
+ - The name of the robot's base frame. Default: ``base_link``.
+
+Example Configuration
+---------------------
+
+.. code-block:: yaml
+
+ six_wheel_steering_controller:
+ ros__parameters:
+
+ # ---- JOINT CONFIGURATION ----
+ # [ACTION REQUIRED] Replace with the exact joint names from your robot's URDF.
+ traction_joints: [
+ "front_left_wheel_joint",
+ "middle_left_wheel_joint",
+ "rear_left_wheel_joint",
+ "front_right_wheel_joint",
+ "middle_right_wheel_joint",
+ "rear_right_wheel_joint"
+ ]
+ steering_joints: [
+ "front_left_steer_joint",
+ "rear_left_steer_joint",
+ "front_right_steer_joint",
+ "rear_right_steer_joint"
+ ]
+
+ # ---- KINEMATIC PARAMETERS (in meters) ----
+ # [ACTION REQUIRED] Measure these values from your specific rover.
+ d1: 0.4
+ d2: 0.5
+ d3: 0.5
+ d4: 0.45
+ wheel_radius: 0.15
+
+ # ---- ODOMETRY CONFIGURATION ----
+ odom_frame_id: "odom"
+ base_frame_id: "base_link"
diff --git a/generic_steering_controller/CMakeLists.txt b/generic_steering_controller/CMakeLists.txt
new file mode 100644
index 0000000000..f8a1eef2a5
--- /dev/null
+++ b/generic_steering_controller/CMakeLists.txt
@@ -0,0 +1,139 @@
+cmake_minimum_required(VERSION 3.16)
+project(generic_steering_controller)
+
+find_package(ros2_control_cmake REQUIRED)
+set_compiler_options()
+export_windows_symbols()
+
+# --- Dependencies ---
+set(THIS_PACKAGE_INCLUDE_DEPENDS
+ controller_interface
+ hardware_interface
+ pluginlib
+ rclcpp
+ rclcpp_lifecycle
+ realtime_tools
+ control_msgs
+ geometry_msgs
+ nav_msgs
+ tf2_msgs
+ tf2_geometry_msgs
+ std_msgs
+ lifecycle_msgs
+ generate_parameter_library
+)
+
+find_package(ament_cmake REQUIRED)
+foreach(Dependency IN ITEMS ${THIS_PACKAGE_INCLUDE_DEPENDS})
+ find_package(${Dependency} REQUIRED)
+endforeach()
+
+# --- Parameter Library ---
+generate_parameter_library(generic_steering_controller_parameters
+ config/generic_steering_controller_parameters.yaml
+)
+
+# --- Main Controller Library ---
+add_library(generic_steering_controller SHARED
+ src/generic_steering_controller.cpp
+)
+target_compile_features(generic_steering_controller PUBLIC cxx_std_17)
+target_include_directories(generic_steering_controller PUBLIC
+ "$"
+ "$"
+)
+target_link_libraries(generic_steering_controller PUBLIC
+ generic_steering_controller_parameters
+ controller_interface::controller_interface
+ hardware_interface::hardware_interface
+ pluginlib::pluginlib
+ rclcpp::rclcpp
+ rclcpp_lifecycle::rclcpp_lifecycle
+ realtime_tools::realtime_tools
+ ${control_msgs_TARGETS}
+ ${geometry_msgs_TARGETS}
+ ${nav_msgs_TARGETS}
+ ${tf2_msgs_TARGETS}
+ ${tf2_geometry_msgs_TARGETS}
+ ${std_msgs_TARGETS}
+ ${lifecycle_msgs_TARGETS}
+)
+
+# --- Plugin Export for the REAL Controller ---
+pluginlib_export_plugin_description_file(controller_interface generic_steering_controller.xml)
+
+# --- Testing Block ---
+if(BUILD_TESTING)
+ find_package(ament_cmake_gmock REQUIRED)
+
+ # Build the Mock Plugin as a separate SHARED library
+ add_library(mock_kinematic_plugin SHARED
+ test/mock_kinematic_plugin/mock_kinematic_plugin.cpp
+ )
+ target_include_directories(mock_kinematic_plugin PUBLIC
+ $
+ $
+ $
+ )
+ target_link_libraries(mock_kinematic_plugin PUBLIC
+ pluginlib::pluginlib
+ rclcpp::rclcpp
+ rclcpp_lifecycle::rclcpp_lifecycle
+ ${nav_msgs_TARGETS}
+ ${control_msgs_TARGETS}
+ ${geometry_msgs_TARGETS}
+ )
+
+ # Build the Test Executable
+ ament_add_gmock(
+ test_generic_steering_controller
+ test/test_generic_steering_controller.cpp
+ test/test_generic_steering_controller.hpp
+ )
+ target_include_directories(test_generic_steering_controller PRIVATE
+ $
+ $
+ )
+ target_link_libraries(test_generic_steering_controller
+ generic_steering_controller
+ generic_steering_controller_parameters
+ )
+
+ # Install the mock plugin so it can be found by pluginlib
+ install(TARGETS mock_kinematic_plugin
+ LIBRARY DESTINATION lib
+ )
+
+ # Export the mock plugin description
+ pluginlib_export_plugin_description_file(
+ generic_steering_controller
+ test/mock_kinematic_plugin/mock_kinematic_plugin.xml
+ )
+endif()
+# --- Install Rules ---
+install(
+ DIRECTORY include/
+ DESTINATION include
+)
+
+install(
+ TARGETS
+ generic_steering_controller
+ generic_steering_controller_parameters
+ EXPORT export_generic_steering_controller
+ RUNTIME DESTINATION bin
+ ARCHIVE DESTINATION lib
+ LIBRARY DESTINATION lib
+)
+
+# Install the REAL controller's plugin description
+install(
+ FILES generic_steering_controller.xml
+ DESTINATION share/${PROJECT_NAME}
+)
+
+# --- Ament Export ---
+ament_export_include_directories(include)
+ament_export_targets(export_generic_steering_controller HAS_LIBRARY_TARGET)
+ament_export_dependencies(${THIS_PACKAGE_INCLUDE_DEPENDS})
+ament_package()
diff --git a/generic_steering_controller/config/generic_steering_controller_parameters.yaml b/generic_steering_controller/config/generic_steering_controller_parameters.yaml
new file mode 100644
index 0000000000..3887b0d13a
--- /dev/null
+++ b/generic_steering_controller/config/generic_steering_controller_parameters.yaml
@@ -0,0 +1,61 @@
+generic_steering_controller_parameters:
+ plugin_name:
+ type: string
+ default_value: "mock_kinematic_plugin/MockKinematicModel"
+ description: "Name of the kinematics plugin to use"
+ ref_timeout:
+ type: double
+ default_value: 0.5
+ description: "Timeout for reference commands (seconds)"
+ enable_odom_tf:
+ type: bool
+ default_value: true
+ description: "Whether to publish odometry as TF"
+ wheelbase:
+ type: double
+ default_value: 1.0
+ description: "Wheelbase for kinematic plugin (meters)"
+ wheel_radius:
+ type: double
+ default_value: 0.15
+ description: "Wheel radius for kinematic plugin (meters)"
+ odom_frame_id:
+ type: string
+ default_value: "odom"
+ description: "Frame ID for odometry"
+ base_frame_id:
+ type: string
+ default_value: "base_link"
+ description: "Frame ID for robot base"
+ open_loop:
+ type: bool
+ default_value: false
+ description: "Use open loop kinematics"
+ reduce_wheel_speed_until_steering_reached:
+ type: bool
+ default_value: false
+ description: "Reduce wheel speed until steering angle is reached"
+ position_feedback:
+ type: bool
+ default_value: false
+ description: "Use position feedback for traction wheels"
+ traction_joints_names:
+ type: string_array
+ default_value: ["left_wheel_joint", "right_wheel_joint"]
+ description: "Names of the traction (drive) joints"
+ steering_joints_names:
+ type: string_array
+ default_value: ["steering_joint"]
+ description: "Names of the steering joints"
+ pose_covariance_diagonal: {
+ type: double_array,
+ default_value: [0.0, 7.0, 14.0, 21.0, 28.0, 35.0],
+ description: "diagonal values of pose covariance matrix.",
+ read_only: false,
+ }
+ twist_covariance_diagonal: {
+ type: double_array,
+ default_value: [0.0, 7.0, 14.0, 21.0, 28.0, 35.0],
+ description: "diagonal values of twist covariance matrix.",
+ read_only: false,
+ }
diff --git a/generic_steering_controller/generic_steering_controller.xml b/generic_steering_controller/generic_steering_controller.xml
new file mode 100644
index 0000000000..8f110a1fcc
--- /dev/null
+++ b/generic_steering_controller/generic_steering_controller.xml
@@ -0,0 +1,10 @@
+
+
+
+ Generic, plugin-based steering controller for arbitrary kinematics.
+
+
+
diff --git a/generic_steering_controller/include/generic_steering_controller/generic_steering_controller.hpp b/generic_steering_controller/include/generic_steering_controller/generic_steering_controller.hpp
new file mode 100644
index 0000000000..05f624d358
--- /dev/null
+++ b/generic_steering_controller/include/generic_steering_controller/generic_steering_controller.hpp
@@ -0,0 +1,166 @@
+// Copyright (c) 2025 Berkan Tali
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef GENERIC_STEERING_CONTROLLER__GENERIC_STEERING_CONTROLLER_HPP_
+#define GENERIC_STEERING_CONTROLLER__GENERIC_STEERING_CONTROLLER_HPP_
+
+// C++ Standard Library
+#include
+#include
+#include
+#include
+
+// ros2_control and controller_interface
+#include "controller_interface/chainable_controller_interface.hpp"
+#include "hardware_interface/types/hardware_interface_type_values.hpp"
+#include "lifecycle_msgs/msg/state.hpp"
+// pluginlib
+#include "pluginlib/class_loader.hpp"
+
+#include
+#include
+#include "control_msgs/msg/steering_controller_status.hpp"
+#include "geometry_msgs/msg/twist_stamped.hpp"
+#include "nav_msgs/msg/odometry.hpp"
+#include "tf2_msgs/msg/tf_message.hpp"
+
+// rclcpp and realtime_tools
+#include "rclcpp/duration.hpp"
+#include "rclcpp/time.hpp"
+#include "rclcpp_lifecycle/state.hpp"
+#include "realtime_tools/realtime_box.hpp"
+#include "realtime_tools/realtime_publisher.hpp"
+#include "realtime_tools/realtime_thread_safe_box.hpp"
+// Project-specific
+#include "generic_steering_controller/generic_steering_controller_parameters.hpp"
+#include "generic_steering_controller/kinematic_model_base.hpp"
+
+namespace generic_steering_controller
+{
+/**
+ * @brief A generic steering controller that uses kinematic plugins.
+ *
+ * This controller subscribes to a command topic and uses a dynamically loaded
+ * plugin to calculate wheel and steering commands for various robot kinematics.
+ */
+class GenericSteeringController : public controller_interface::ChainableControllerInterface
+{
+public:
+ GenericSteeringController();
+
+ virtual void initialize_implementation_parameter_listener();
+
+ controller_interface::CallbackReturn on_init() override;
+
+ controller_interface::InterfaceConfiguration command_interface_configuration() const override;
+
+ controller_interface::InterfaceConfiguration state_interface_configuration() const override;
+
+ controller_interface::CallbackReturn configure_odometry();
+
+ controller_interface::CallbackReturn on_configure(
+ const rclcpp_lifecycle::State & previous_state) override;
+
+ controller_interface::CallbackReturn on_activate(
+ const rclcpp_lifecycle::State & previous_state) override;
+
+ controller_interface::CallbackReturn on_deactivate(
+ const rclcpp_lifecycle::State & previous_state) override;
+
+ controller_interface::return_type update_reference_from_subscribers(
+ const rclcpp::Time & time, const rclcpp::Duration & period) override;
+
+ controller_interface::return_type update_and_write_commands(
+ const rclcpp::Time & time, const rclcpp::Duration & period) override;
+
+ using ControllerTwistReferenceMsg = geometry_msgs::msg::TwistStamped;
+ using ControllerStateMsgOdom = nav_msgs::msg::Odometry;
+ using ControllerStateMsgTf = tf2_msgs::msg::TFMessage;
+ using GenericSteeringControllerStateMsg = control_msgs::msg::SteeringControllerStatus;
+
+ inline std::unordered_map get_state_interface_map() const
+ {
+ std::unordered_map current_map;
+ for (const auto & iface : state_interfaces_)
+ {
+ current_map[iface.get_name()] = iface.get_value();
+ }
+ return current_map;
+ }
+
+protected:
+ controller_interface::CallbackReturn set_interface_numbers(
+ size_t nr_state_itfs, size_t nr_cmd_itfs, size_t nr_ref_itfs);
+
+ std::shared_ptr param_listener_;
+ generic_steering_controller_parameters::Params params_;
+
+ // the RT Box containing the command message
+ realtime_tools::RealtimeThreadSafeBox input_ref_;
+ ControllerTwistReferenceMsg current_ref_;
+ rclcpp::Duration ref_timeout_ = rclcpp::Duration::from_seconds(0.0); // 0ms
+
+ // Command subscribers and Controller State publisher
+ rclcpp::Subscription::SharedPtr ref_subscriber_twist_ = nullptr;
+ using ControllerStatePublisherOdom = realtime_tools::RealtimePublisher;
+ using ControllerStatePublisherTf = realtime_tools::RealtimePublisher;
+
+ rclcpp::Publisher::SharedPtr odom_s_publisher_;
+ rclcpp::Publisher::SharedPtr tf_odom_s_publisher_;
+
+ std::unique_ptr rt_odom_state_publisher_;
+ std::unique_ptr rt_tf_odom_state_publisher_;
+
+ std::vector on_export_reference_interfaces() override;
+
+ bool on_set_chained_mode(bool chained_mode) override;
+
+ /// Odometry:
+
+ GenericSteeringControllerStateMsg published_state_;
+
+ using ControllerStatePublisher =
+ realtime_tools::RealtimePublisher;
+ rclcpp::Publisher::SharedPtr controller_s_publisher_;
+ std::unique_ptr controller_state_publisher_;
+
+ // name constants for state interfaces
+ size_t nr_state_itfs_;
+ // name constants for command interfaces
+ size_t nr_cmd_itfs_;
+ // name constants for reference interfaces
+ size_t nr_ref_itfs_;
+
+ // last velocity commands for open loop odometry
+ double last_linear_velocity_ = 0.0;
+ double last_angular_velocity_ = 0.0;
+
+ std::vector traction_joints_state_names_;
+ std::vector steering_joints_state_names_;
+ bool open_loop_;
+
+ void reference_callback(const std::shared_ptr msg);
+
+private:
+ // callback for topic interface
+
+ pluginlib::ClassLoader kinematic_loader;
+ std::shared_ptr kinematic_model_;
+ std::unordered_map state_map;
+ std::string kinematic_plugin_name;
+};
+
+} // namespace generic_steering_controller
+
+#endif // GENERIC_STEERING_CONTROLLER__GENERIC_STEERING_CONTROLLER_HPP_
diff --git a/generic_steering_controller/include/generic_steering_controller/kinematic_model_base.hpp b/generic_steering_controller/include/generic_steering_controller/kinematic_model_base.hpp
new file mode 100644
index 0000000000..718a9461aa
--- /dev/null
+++ b/generic_steering_controller/include/generic_steering_controller/kinematic_model_base.hpp
@@ -0,0 +1,113 @@
+// Copyright (c) 2025 Berkan Tali
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef GENERIC_STEERING_CONTROLLER__KINEMATIC_MODEL_BASE_HPP_
+#define GENERIC_STEERING_CONTROLLER__KINEMATIC_MODEL_BASE_HPP_
+
+// C++ Standard Library
+#include
+#include
+#include
+#include
+
+// ROS Messages
+#include "control_msgs/msg/steering_controller_status.hpp"
+#include "geometry_msgs/msg/twist.hpp"
+#include "nav_msgs/msg/odometry.hpp"
+
+// rclcpp
+#include "rclcpp/duration.hpp"
+#include "rclcpp/node.hpp"
+#include "rclcpp/time.hpp"
+#include "rclcpp_lifecycle/lifecycle_node.hpp"
+
+namespace kinematic_model
+{
+/**
+ * @brief Abstract base class for kinematic model plugins.
+ *
+ * This class defines the standard interface for all kinematic models that can be loaded
+ * by the GenericSteeringController.
+ */
+class KinematicModelBase
+{
+public:
+ virtual ~KinematicModelBase() = default;
+
+ /**
+ * @brief Configure the kinematic model plugin.
+ * @param node The node interface for the controller.
+ * @param traction_joint_names A list of names for the traction (drive) joints.
+ * @param steering_joint_names A list of names for the steering joints.
+ */
+ virtual void configure(
+ const rclcpp_lifecycle::LifecycleNode::SharedPtr & node,
+ const std::vector & traction_joint_names,
+ const std::vector & steering_joint_names) = 0;
+
+ /**
+ * @brief Update the internal reference command from the subscriber.
+ * @param twist_msg The command message from the subscriber.
+ * @param time The current time.
+ */
+ virtual void update_reference(
+ const geometry_msgs::msg::Twist & twist_msg, const rclcpp::Time & time) = 0;
+ /**
+ * @brief Get the latest odometry message calculated by the plugin.
+ * @param time The current time.
+ * @return A unique_ptr to the odometry message.
+ */
+ virtual std::unique_ptr get_odometry_message(
+ const rclcpp::Duration & time) = 0;
+
+ /**
+ * \brief Calculates inverse kinematics for the desired linear and angular velocities
+ * \param v_bx Desired linear velocity of the robot in x_b-axis direction
+ * \param omega_bz Desired angular velocity of the robot around x_z-axis
+ * \param open_loop If false, the IK will be calculated using measured steering angle
+ * \param reduce_wheel_speed_until_steering_reached Reduce wheel speed until the steering angle
+ * has been reached
+ * \return Tuple of velocity commands and steering commands
+ */
+ virtual std::tuple, std::vector> get_commands(
+ const double v_bx, const double omega_bz, const bool open_loop = true,
+ const bool reduce_wheel_speed_until_steering_reached = false) = 0;
+
+ /**
+ * @brief Update all joint states at once.
+ * @param all_states Vector containing all measured joint states (traction first, then steering).
+ */
+ virtual void update_states(const std::unordered_map & all_states)
+ {
+ states = all_states;
+ }
+
+protected:
+ // Node for plugins to get parameters, etc.
+ rclcpp_lifecycle::LifecycleNode::SharedPtr node_;
+
+ // Store joint names for later use
+ std::vector traction_joint_names_;
+ std::vector steering_joint_names_;
+
+ // Store the latest state from the hardware
+ std::vector traction_states_;
+ std::vector steering_states_;
+
+ std::unordered_map states;
+};
+
+} // namespace kinematic_model
+
+#endif // GENERIC_STEERING_CONTROLLER__KINEMATIC_MODEL_BASE_HPP_
diff --git a/generic_steering_controller/package.xml b/generic_steering_controller/package.xml
new file mode 100644
index 0000000000..9df95c36d5
--- /dev/null
+++ b/generic_steering_controller/package.xml
@@ -0,0 +1,35 @@
+
+
+
+ generic_steering_controller
+ 0.0.0
+ A generic, plugin-based steering controller for various vehicle kinematics
+ silanus23
+
+ Apache-2.0
+
+ ament_cmake
+
+ controller_interface
+ hardware_interface
+ pluginlib
+ rclcpp
+ rclcpp_lifecycle
+ realtime_tools
+ control_msgs
+ geometry_msgs
+ nav_msgs
+ tf2_msgs
+ tf2_geometry_msgs
+ std_msgs
+ lifecycle_msgs
+ generate_parameter_library
+ ament_lint_auto
+ ament_lint_common
+ ament_cmake_gmock
+
+
+ ament_cmake
+
+
+
diff --git a/generic_steering_controller/src/generic_steering_controller.cpp b/generic_steering_controller/src/generic_steering_controller.cpp
new file mode 100644
index 0000000000..a651522e7d
--- /dev/null
+++ b/generic_steering_controller/src/generic_steering_controller.cpp
@@ -0,0 +1,572 @@
+// Copyright (c) 2025 Berkan Tali
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "generic_steering_controller/generic_steering_controller.hpp"
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include "hardware_interface/types/hardware_interface_type_values.hpp"
+#include "tf2_geometry_msgs/tf2_geometry_msgs.hpp"
+
+namespace
+{ // utility
+
+using ControllerTwistReferenceMsg =
+ generic_steering_controller::GenericSteeringController::ControllerTwistReferenceMsg;
+
+void reset_controller_reference_msg(
+ ControllerTwistReferenceMsg & msg, const std::shared_ptr & node)
+{
+ msg.header.stamp = node->now();
+ msg.twist.linear.x = std::numeric_limits::quiet_NaN();
+ msg.twist.linear.y = std::numeric_limits::quiet_NaN();
+ msg.twist.linear.z = std::numeric_limits::quiet_NaN();
+ msg.twist.angular.x = std::numeric_limits::quiet_NaN();
+ msg.twist.angular.y = std::numeric_limits::quiet_NaN();
+ msg.twist.angular.z = std::numeric_limits::quiet_NaN();
+}
+
+} // namespace
+
+namespace generic_steering_controller
+{
+GenericSteeringController::GenericSteeringController()
+: controller_interface::ChainableControllerInterface(),
+ kinematic_loader("generic_steering_controller", "kinematic_model::KinematicModelBase")
+{
+}
+
+controller_interface::CallbackReturn GenericSteeringController::on_init()
+{
+ try
+ {
+ param_listener_ =
+ std::make_shared(get_node());
+ initialize_implementation_parameter_listener();
+ }
+ catch (const std::exception & e)
+ {
+ fprintf(stderr, "Exception thrown during controller's init with message: %s \n", e.what());
+ return controller_interface::CallbackReturn::ERROR;
+ }
+
+ return controller_interface::CallbackReturn::SUCCESS;
+}
+
+controller_interface::CallbackReturn GenericSteeringController::set_interface_numbers(
+ size_t nr_state_itfs = 2, size_t nr_cmd_itfs = 2, size_t nr_ref_itfs = 2)
+{
+ nr_state_itfs_ = nr_state_itfs;
+ nr_cmd_itfs_ = nr_cmd_itfs;
+ nr_ref_itfs_ = nr_ref_itfs;
+ return controller_interface::CallbackReturn::SUCCESS;
+}
+
+controller_interface::CallbackReturn GenericSteeringController::on_configure(
+ const rclcpp_lifecycle::State & /*previous_state*/)
+{
+ params_ = param_listener_->get_params();
+
+ if (params_.traction_joints_names.empty())
+ {
+ RCLCPP_ERROR(get_node()->get_logger(), "No traction joints specified!");
+ return controller_interface::CallbackReturn::ERROR;
+ }
+ if (params_.steering_joints_names.empty())
+ {
+ RCLCPP_ERROR(get_node()->get_logger(), "No steering joints specified!");
+ return controller_interface::CallbackReturn::ERROR;
+ }
+ RCLCPP_INFO(get_node()->get_logger(), "Params created");
+
+ traction_joints_state_names_ = params_.traction_joints_names;
+ steering_joints_state_names_ = params_.steering_joints_names;
+
+ auto subscribers_qos = rclcpp::SystemDefaultsQoS();
+ subscribers_qos.keep_last(1);
+ subscribers_qos.best_effort();
+
+ ref_timeout_ = rclcpp::Duration::from_seconds(params_.ref_timeout);
+ ref_subscriber_twist_ = get_node()->create_subscription(
+ "~/reference", subscribers_qos,
+ std::bind(&GenericSteeringController::reference_callback, this, std::placeholders::_1));
+
+ reset_controller_reference_msg(current_ref_, get_node());
+ input_ref_.set(current_ref_);
+
+ try
+ {
+ odom_s_publisher_ = get_node()->create_publisher(
+ "~/odometry", rclcpp::SystemDefaultsQoS());
+ rt_odom_state_publisher_ = std::make_unique(odom_s_publisher_);
+ }
+ catch (const std::exception & e)
+ {
+ fprintf(
+ stderr, "Exception thrown during publisher creation at configure stage with message : %s \n",
+ e.what());
+ return controller_interface::CallbackReturn::ERROR;
+ }
+
+ rt_odom_state_publisher_->lock();
+ rt_odom_state_publisher_->msg_.header.stamp = get_node()->now();
+ rt_odom_state_publisher_->msg_.header.frame_id = params_.odom_frame_id;
+ rt_odom_state_publisher_->msg_.child_frame_id = params_.base_frame_id;
+ rt_odom_state_publisher_->msg_.pose.pose.position.z = 0;
+ rt_odom_state_publisher_->unlock();
+
+ auto & covariance = rt_odom_state_publisher_->msg_.twist.covariance;
+ constexpr size_t NUM_DIMENSIONS = 6;
+ for (size_t index = 0; index < 6; ++index)
+ {
+ // 0, 7, 14, 21, 28, 35
+ const size_t diagonal_index = NUM_DIMENSIONS * index + index;
+ covariance[diagonal_index] = params_.pose_covariance_diagonal[index];
+ covariance[diagonal_index] = params_.twist_covariance_diagonal[index];
+ }
+
+ try
+ {
+ // Tf State publisher
+ tf_odom_s_publisher_ = get_node()->create_publisher(
+ "~/tf_odometry", rclcpp::SystemDefaultsQoS());
+ rt_tf_odom_state_publisher_ =
+ std::make_unique(tf_odom_s_publisher_);
+ }
+ catch (const std::exception & e)
+ {
+ fprintf(
+ stderr, "Exception thrown during publisher creation at configure stage with message : %s \n",
+ e.what());
+ return controller_interface::CallbackReturn::ERROR;
+ }
+
+ rt_tf_odom_state_publisher_->lock();
+ rt_tf_odom_state_publisher_->msg_.transforms.resize(1);
+ rt_tf_odom_state_publisher_->msg_.transforms[0].header.stamp = get_node()->now();
+ rt_tf_odom_state_publisher_->msg_.transforms[0].header.frame_id = params_.odom_frame_id;
+ rt_tf_odom_state_publisher_->msg_.transforms[0].child_frame_id = params_.base_frame_id;
+ rt_tf_odom_state_publisher_->msg_.transforms[0].transform.translation.z = 0.0;
+ rt_tf_odom_state_publisher_->unlock();
+
+ try
+ {
+ // State publisher
+ controller_s_publisher_ = get_node()->create_publisher(
+ "~/controller_state", rclcpp::SystemDefaultsQoS());
+ controller_state_publisher_ =
+ std::make_unique(controller_s_publisher_);
+ }
+ catch (const std::exception & e)
+ {
+ fprintf(
+ stderr, "Exception thrown during publisher creation at configure stage with message : %s \n",
+ e.what());
+ return controller_interface::CallbackReturn::ERROR;
+ }
+
+ controller_state_publisher_->lock();
+ controller_state_publisher_->msg_.header.stamp = get_node()->now();
+ controller_state_publisher_->msg_.header.frame_id = params_.odom_frame_id;
+ controller_state_publisher_->unlock();
+ RCLCPP_INFO(get_node()->get_logger(), "configure successful");
+ size_t total_state_interfaces =
+ traction_joints_state_names_.size() + steering_joints_state_names_.size();
+ size_t total_command_interfaces =
+ params_.traction_joints_names.size() + params_.steering_joints_names.size();
+
+ set_interface_numbers(total_state_interfaces, total_command_interfaces, 2);
+ open_loop_ = params_.open_loop;
+ try
+ {
+ kinematic_model_ = kinematic_loader.createSharedInstance(params_.plugin_name);
+ kinematic_model_->configure(
+ std::dynamic_pointer_cast(get_node()),
+ params_.traction_joints_names, params_.steering_joints_names);
+ }
+ catch (const std::exception & e)
+ {
+ RCLCPP_ERROR(get_node()->get_logger(), "Failed to load kinematic plugin: %s", e.what());
+ return controller_interface::CallbackReturn::ERROR;
+ }
+
+ return controller_interface::CallbackReturn::SUCCESS;
+}
+
+void GenericSteeringController::reference_callback(
+ const std::shared_ptr msg)
+{
+ if (msg->header.stamp.sec == 0 && msg->header.stamp.nanosec == 0u)
+ {
+ RCLCPP_WARN(
+ get_node()->get_logger(),
+ "Timestamp in header is missing, using current time as command timestamp.");
+ msg->header.stamp = get_node()->now();
+ }
+ const auto age_of_last_command = get_node()->now() - msg->header.stamp;
+
+ if (ref_timeout_ == rclcpp::Duration::from_seconds(0) || age_of_last_command <= ref_timeout_)
+ {
+ input_ref_.set(*msg);
+ }
+ else
+ {
+ RCLCPP_ERROR(
+ get_node()->get_logger(),
+ "Received message has timestamp %.10f older for %.10f which is more then allowed timeout "
+ "(%.4f).",
+ rclcpp::Time(msg->header.stamp).seconds(), age_of_last_command.seconds(),
+ ref_timeout_.seconds());
+ }
+}
+
+controller_interface::InterfaceConfiguration
+GenericSteeringController::command_interface_configuration() const
+{
+ controller_interface::InterfaceConfiguration command_interfaces_config;
+ command_interfaces_config.type = controller_interface::interface_configuration_type::INDIVIDUAL;
+ command_interfaces_config.names.reserve(nr_cmd_itfs_);
+ for (size_t i = 0; i < params_.traction_joints_names.size(); i++)
+ {
+ command_interfaces_config.names.push_back(
+ params_.traction_joints_names[i] + "/" + hardware_interface::HW_IF_VELOCITY);
+ }
+
+ for (size_t i = 0; i < params_.steering_joints_names.size(); i++)
+ {
+ command_interfaces_config.names.push_back(
+ params_.steering_joints_names[i] + "/" + hardware_interface::HW_IF_POSITION);
+ }
+ return command_interfaces_config;
+}
+
+controller_interface::InterfaceConfiguration
+GenericSteeringController::state_interface_configuration() const
+{
+ RCLCPP_INFO(get_node()->get_logger(), "State interface conf");
+ controller_interface::InterfaceConfiguration state_interfaces_config;
+ state_interfaces_config.type = controller_interface::interface_configuration_type::INDIVIDUAL;
+
+ state_interfaces_config.names.reserve(nr_state_itfs_);
+ const auto traction_wheels_feedback = params_.position_feedback
+ ? hardware_interface::HW_IF_POSITION
+ : hardware_interface::HW_IF_VELOCITY;
+
+ for (size_t i = 0; i < traction_joints_state_names_.size(); i++)
+ {
+ state_interfaces_config.names.push_back(
+ traction_joints_state_names_[i] + "/" + traction_wheels_feedback);
+ }
+
+ // Add steering joint interfaces
+ for (size_t i = 0; i < steering_joints_state_names_.size(); ++i)
+ {
+ state_interfaces_config.names.push_back(
+ steering_joints_state_names_[i] + "/" + hardware_interface::HW_IF_POSITION);
+ }
+
+ return state_interfaces_config;
+
+ return state_interfaces_config;
+}
+
+std::vector
+GenericSteeringController::on_export_reference_interfaces()
+{
+ RCLCPP_INFO(get_node()->get_logger(), "ref int exp conf: %zu", nr_ref_itfs_);
+ reference_interfaces_.resize(nr_ref_itfs_, std::numeric_limits::quiet_NaN());
+
+ std::vector reference_interfaces;
+ reference_interfaces.reserve(nr_ref_itfs_);
+
+ reference_interfaces.push_back(
+ hardware_interface::CommandInterface(
+ get_node()->get_name() + std::string("/linear"), hardware_interface::HW_IF_VELOCITY,
+ &reference_interfaces_[0]));
+
+ reference_interfaces.push_back(
+ hardware_interface::CommandInterface(
+ get_node()->get_name() + std::string("/angular"), hardware_interface::HW_IF_VELOCITY,
+ &reference_interfaces_[1]));
+
+ return reference_interfaces;
+}
+
+bool GenericSteeringController::on_set_chained_mode(bool /*chained_mode*/) { return true; }
+
+controller_interface::CallbackReturn GenericSteeringController::on_activate(
+ const rclcpp_lifecycle::State & /*previous_state*/)
+{
+ // Reset reference message to NaN
+ reset_controller_reference_msg(current_ref_, get_node());
+ input_ref_.try_set(current_ref_);
+
+ // Set all command interfaces to NaN for safety
+ for (auto & command_interface : command_interfaces_)
+ {
+ if (!command_interface.set_value(std::numeric_limits::quiet_NaN()))
+ {
+ RCLCPP_WARN(
+ get_node()->get_logger(),
+ "Failed to set NaN value for command interface '%s' during activation.",
+ command_interface.get_name().c_str());
+ }
+ }
+
+ RCLCPP_INFO(get_node()->get_logger(), "GenericSteeringController activated");
+ return controller_interface::CallbackReturn::SUCCESS;
+}
+
+controller_interface::CallbackReturn GenericSteeringController::on_deactivate(
+ const rclcpp_lifecycle::State & /*previous_state*/)
+{
+ // Set all command interfaces to NaN for safety
+ for (auto & command_interface : command_interfaces_)
+ {
+ if (!command_interface.set_value(std::numeric_limits::quiet_NaN()))
+ {
+ RCLCPP_WARN(
+ get_node()->get_logger(),
+ "Failed to set NaN value for command interface '%s' during deactivation.",
+ command_interface.get_name().c_str());
+ }
+ }
+
+ RCLCPP_INFO(get_node()->get_logger(), "GenericSteeringController deactivated");
+ return controller_interface::CallbackReturn::SUCCESS;
+}
+
+controller_interface::return_type GenericSteeringController::update_reference_from_subscribers(
+ const rclcpp::Time & time, const rclcpp::Duration & /*period*/)
+{
+ auto current_ref_msg = input_ref_.try_get();
+ if (current_ref_msg.has_value())
+ {
+ current_ref_ = current_ref_msg.value();
+ }
+ const auto age_of_last_command = time - current_ref_.header.stamp;
+
+ if (age_of_last_command <= ref_timeout_ || ref_timeout_ == rclcpp::Duration::from_seconds(0))
+ {
+ if (!std::isnan(current_ref_.twist.linear.x) && !std::isnan(current_ref_.twist.linear.y))
+ {
+ reference_interfaces_[0] = current_ref_.twist.linear.x;
+ reference_interfaces_[1] = current_ref_.twist.angular.z;
+
+ if (ref_timeout_ == rclcpp::Duration::from_seconds(0))
+ {
+ current_ref_.twist.linear.x = std::numeric_limits::quiet_NaN();
+ current_ref_.twist.angular.z = std::numeric_limits::quiet_NaN();
+ input_ref_.try_set(current_ref_);
+ }
+ }
+ }
+ else
+ {
+ if (!std::isnan(current_ref_.twist.linear.x) && !std::isnan(current_ref_.twist.angular.z))
+ {
+ reference_interfaces_[0] = std::numeric_limits::quiet_NaN();
+ reference_interfaces_[1] = std::numeric_limits::quiet_NaN();
+
+ current_ref_.twist.linear.x = std::numeric_limits::quiet_NaN();
+ current_ref_.twist.angular.z = std::numeric_limits::quiet_NaN();
+ input_ref_.try_set(current_ref_);
+ }
+ }
+ return controller_interface::return_type::OK;
+}
+
+// Call values from plugin and pass them to hardware interfaces
+controller_interface::return_type GenericSteeringController::update_and_write_commands(
+ const rclcpp::Time & time, const rclcpp::Duration & period)
+{
+ // Check kinematic plugin
+ if (!kinematic_model_)
+ {
+ RCLCPP_ERROR(get_node()->get_logger(), "Kinematic model plugin is not loaded!");
+ return controller_interface::return_type::ERROR;
+ }
+
+ // Update kinematic model states if not open loop
+ if (!open_loop_)
+ {
+ state_map = get_state_interface_map();
+ kinematic_model_->update_states(state_map);
+ }
+
+ // If reference is valid, compute and send commands
+ if (!std::isnan(reference_interfaces_[0]) && !std::isnan(reference_interfaces_[1]))
+ {
+ auto [traction_commands, steering_commands] = kinematic_model_->get_commands(
+ reference_interfaces_[0], reference_interfaces_[1], params_.open_loop,
+ params_.reduce_wheel_speed_until_steering_reached);
+
+ // Write traction commands
+ for (size_t i = 0; i < params_.traction_joints_names.size(); i++)
+ {
+ if (!command_interfaces_[i].set_value(traction_commands[i]))
+ {
+ RCLCPP_WARN(
+ get_node()->get_logger(), "Unable to set traction command at index %zu: value = %f", i,
+ traction_commands[i]);
+ }
+ }
+ // Write steering commands
+ for (size_t i = 0; i < params_.steering_joints_names.size(); i++)
+ {
+ size_t idx = i + params_.traction_joints_names.size();
+ if (!command_interfaces_[idx].set_value(steering_commands[i]))
+ {
+ RCLCPP_WARN(
+ get_node()->get_logger(), "Unable to set steering command at index %zu: value = %f", i,
+ steering_commands[i]);
+ }
+ }
+ }
+ else
+ {
+ // Reference is invalid: zero traction commands
+ for (size_t i = 0; i < params_.traction_joints_names.size(); i++)
+ {
+ if (!command_interfaces_[i].set_value(0.0))
+ {
+ RCLCPP_WARN(
+ get_node()->get_logger(), "Unable to set traction command to zero at index %zu", i);
+ }
+ }
+ // Optionally zero steering commands (uncomment if desired)
+ // for (size_t i = 0; i < params_.steering_joints_names.size(); i++) {
+ // size_t idx = i + params_.traction_joints_names.size();
+ // if (!command_interfaces_[idx].set_value(0.0)) {
+ // RCLCPP_WARN(get_node()->get_logger(),
+ // "Unable to set steering command to zero at index %zu", i);
+ // }
+ // }
+ }
+
+ // Publish controller state message
+ if (controller_state_publisher_->trylock())
+ {
+ controller_state_publisher_->msg_.header.stamp = time;
+ controller_state_publisher_->msg_.traction_wheels_position.clear();
+ controller_state_publisher_->msg_.traction_wheels_velocity.clear();
+ controller_state_publisher_->msg_.traction_command.clear();
+ controller_state_publisher_->msg_.steer_positions.clear();
+ controller_state_publisher_->msg_.steering_angle_command.clear();
+
+ auto number_of_traction_wheels = params_.traction_joints_names.size();
+ auto number_of_steering_wheels = params_.steering_joints_names.size();
+
+ // Read state interfaces using get_optional and log missing values
+ for (size_t i = 0; i < number_of_traction_wheels; ++i)
+ {
+ auto value_op = state_interfaces_[i].get_optional();
+ if (params_.position_feedback)
+ {
+ controller_state_publisher_->msg_.traction_wheels_position.push_back(
+ value_op.value_or(0.0));
+ }
+ else
+ {
+ controller_state_publisher_->msg_.traction_wheels_velocity.push_back(
+ value_op.value_or(0.0));
+ }
+ if (!value_op.has_value())
+ {
+ RCLCPP_DEBUG(
+ get_node()->get_logger(), "Unable to retrieve %s feedback data for traction wheel %zu",
+ params_.position_feedback ? "position" : "velocity", i);
+ }
+ }
+
+ for (size_t i = 0; i < number_of_steering_wheels; ++i)
+ {
+ size_t steering_base_index = number_of_traction_wheels;
+ auto steer_state_op = state_interfaces_[steering_base_index + i].get_optional();
+ controller_state_publisher_->msg_.steer_positions.push_back(steer_state_op.value_or(0.0));
+ if (!steer_state_op.has_value())
+ {
+ RCLCPP_DEBUG(
+ get_node()->get_logger(),
+ "Unable to retrieve position feedback data for steering wheel %zu", i);
+ }
+
+ auto steer_cmd_op = command_interfaces_[number_of_traction_wheels + i].get_optional();
+ controller_state_publisher_->msg_.steering_angle_command.push_back(
+ steer_cmd_op.value_or(0.0));
+ if (!steer_cmd_op.has_value())
+ {
+ RCLCPP_DEBUG(
+ get_node()->get_logger(),
+ "Unable to retrieve command interface value for steering wheel %zu", i);
+ }
+ }
+
+ controller_state_publisher_->unlockAndPublish();
+ }
+
+ // Publish odometry and tf messages (unchanged)
+ auto odom_msg = kinematic_model_->get_odometry_message(period);
+ const auto & q = odom_msg->pose.pose.orientation;
+ tf2::Quaternion quat(q.x, q.y, q.z, q.w);
+ double roll, pitch, yaw;
+ tf2::Matrix3x3(quat).getRPY(roll, pitch, yaw);
+
+ tf2::Quaternion orientation;
+ orientation.setRPY(0.0, 0.0, yaw);
+
+ if (rt_odom_state_publisher_->trylock())
+ {
+ rt_odom_state_publisher_->msg_.header = odom_msg->header;
+ rt_odom_state_publisher_->msg_.pose = odom_msg->pose;
+ rt_odom_state_publisher_->unlockAndPublish();
+ }
+
+ if (params_.enable_odom_tf && rt_tf_odom_state_publisher_->trylock())
+ {
+ rt_tf_odom_state_publisher_->msg_.transforms.front().header.stamp = time;
+ rt_tf_odom_state_publisher_->msg_.transforms.front().transform.translation.x =
+ odom_msg->pose.pose.position.x;
+ rt_tf_odom_state_publisher_->msg_.transforms.front().transform.translation.y =
+ odom_msg->pose.pose.position.y;
+ rt_tf_odom_state_publisher_->msg_.transforms.front().transform.rotation =
+ odom_msg->pose.pose.orientation;
+ rt_tf_odom_state_publisher_->unlockAndPublish();
+ }
+
+ // Clear reference after use
+ reference_interfaces_[0] = std::numeric_limits::quiet_NaN();
+ reference_interfaces_[1] = std::numeric_limits::quiet_NaN();
+
+ return controller_interface::return_type::OK;
+}
+
+void GenericSteeringController::initialize_implementation_parameter_listener()
+{
+ RCLCPP_DEBUG(get_node()->get_logger(), "Generic steering controller parameters initialized");
+}
+
+} // namespace generic_steering_controller
+
+#include "pluginlib/class_list_macros.hpp"
+
+PLUGINLIB_EXPORT_CLASS(
+ generic_steering_controller::GenericSteeringController,
+ controller_interface::ChainableControllerInterface)
diff --git a/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.cpp b/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.cpp
new file mode 100644
index 0000000000..961f725644
--- /dev/null
+++ b/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.cpp
@@ -0,0 +1,19 @@
+// Copyright (c) 2025 Berkan Tali
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "mock_kinematic_plugin.hpp"
+#include "pluginlib/class_list_macros.hpp"
+
+PLUGINLIB_EXPORT_CLASS(
+ mock_kinematic_plugin::MockKinematicModel, kinematic_model::KinematicModelBase)
diff --git a/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.hpp b/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.hpp
new file mode 100644
index 0000000000..8064614cae
--- /dev/null
+++ b/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.hpp
@@ -0,0 +1,57 @@
+// Copyright (c) 2025 Berkan Tali
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include "generic_steering_controller/kinematic_model_base.hpp"
+#include "nav_msgs/msg/odometry.hpp"
+#include "rclcpp_lifecycle/lifecycle_node.hpp"
+
+namespace mock_kinematic_plugin
+{
+
+class MockKinematicModel : public kinematic_model::KinematicModelBase
+{
+public:
+ MockKinematicModel() = default;
+ ~MockKinematicModel() override = default;
+
+ void update_reference(const geometry_msgs::msg::Twist &, const rclcpp::Time &) override {}
+
+ void configure(
+ const std::shared_ptr &, const std::vector &,
+ const std::vector &) override
+ {
+ }
+
+ void update_states(const std::unordered_map &) override {}
+
+ std::tuple, std::vector> get_commands(
+ double linear, double angular, bool, bool) override
+ {
+ return std::make_tuple(
+ std::vector{linear, linear}, std::vector{angular, angular});
+ }
+
+ std::unique_ptr get_odometry_message(const rclcpp::Duration &) override
+ {
+ return std::make_unique();
+ }
+};
+
+} // namespace mock_kinematic_plugin
diff --git a/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.xml b/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.xml
new file mode 100644
index 0000000000..b1c3bee1f2
--- /dev/null
+++ b/generic_steering_controller/test/mock_kinematic_plugin/mock_kinematic_plugin.xml
@@ -0,0 +1,10 @@
+
+
+
+ Minimal mock kinematic model for testing.
+
+
+
diff --git a/generic_steering_controller/test/test_generic_steering_controller.cpp b/generic_steering_controller/test/test_generic_steering_controller.cpp
new file mode 100644
index 0000000000..1016c0fb9f
--- /dev/null
+++ b/generic_steering_controller/test/test_generic_steering_controller.cpp
@@ -0,0 +1,112 @@
+// Copyright (c) 2025 Berkan Tali
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "test_generic_steering_controller.hpp"
+#include "gtest/gtest.h"
+
+class GenericSteeringControllerTest
+: public GenericSteeringControllerFixture
+{
+};
+
+TEST_F(GenericSteeringControllerTest, check_exported_interfaces)
+{
+ SetUpController();
+
+ ASSERT_EQ(controller_->on_configure(rclcpp_lifecycle::State()), NODE_SUCCESS);
+
+ auto cmd_if_conf = controller_->command_interface_configuration();
+ ASSERT_EQ(cmd_if_conf.names.size(), joint_command_values_.size());
+ EXPECT_EQ(
+ cmd_if_conf.names[CMD_TRACTION_RIGHT_WHEEL],
+ traction_joints_names_[0] + "/" + traction_interface_name_);
+ EXPECT_EQ(
+ cmd_if_conf.names[CMD_TRACTION_LEFT_WHEEL],
+ traction_joints_names_[1] + "/" + traction_interface_name_);
+ EXPECT_EQ(
+ cmd_if_conf.names[CMD_STEER_WHEEL], steering_joints_names_[0] + "/" + steering_interface_name_);
+
+ auto state_if_conf = controller_->state_interface_configuration();
+ ASSERT_EQ(state_if_conf.names.size(), joint_state_values_.size());
+ EXPECT_EQ(
+ state_if_conf.names[STATE_TRACTION_RIGHT_WHEEL],
+ controller_->traction_joints_state_names_[0] + "/" + traction_interface_name_);
+ EXPECT_EQ(
+ state_if_conf.names[STATE_TRACTION_LEFT_WHEEL],
+ controller_->traction_joints_state_names_[1] + "/" + traction_interface_name_);
+ EXPECT_EQ(
+ state_if_conf.names[STATE_STEER_WHEEL],
+ controller_->steering_joints_state_names_[0] + "/" + steering_interface_name_);
+
+ auto reference_interfaces = controller_->export_reference_interfaces();
+ ASSERT_EQ(reference_interfaces.size(), joint_reference_interfaces_.size());
+ for (size_t i = 0; i < joint_reference_interfaces_.size(); ++i)
+ {
+ const std::string ref_itf_prefix_name =
+ std::string(controller_->get_node()->get_name()) + "/" + joint_reference_interfaces_[i];
+ EXPECT_EQ(reference_interfaces[i]->get_prefix_name(), ref_itf_prefix_name);
+ EXPECT_EQ(
+ reference_interfaces[i]->get_name(),
+ ref_itf_prefix_name + "/" + hardware_interface::HW_IF_VELOCITY);
+ EXPECT_EQ(reference_interfaces[i]->get_interface_name(), hardware_interface::HW_IF_VELOCITY);
+ }
+}
+
+TEST_F(GenericSteeringControllerTest, CommandTimeoutSetsZeroVelocity)
+{
+ SetUpController();
+ controller_->get_node()->set_parameter(rclcpp::Parameter("ref_timeout", 0.5));
+
+ ASSERT_EQ(
+ controller_->on_configure(rclcpp_lifecycle::State()),
+ controller_interface::CallbackReturn::SUCCESS);
+ ASSERT_EQ(
+ controller_->on_activate(rclcpp_lifecycle::State()),
+ controller_interface::CallbackReturn::SUCCESS);
+
+ auto msg = std::make_shared();
+
+ auto now = controller_->get_node()->now();
+ msg->header.stamp = now - rclcpp::Duration::from_seconds(0.1); // 0.1s ago, within 0.5s timeout
+ msg->twist.linear.x = 1.0;
+ msg->twist.angular.z = 0.5;
+
+ // Fresh reference (within timeout)
+ controller_->reference_callback(msg);
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[0]));
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[1]));
+ controller_->update(controller_->get_node()->now(), rclcpp::Duration::from_seconds(0.01));
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[0]));
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[1]));
+
+ // Timeout case (older than timeout)
+ msg->header.stamp = controller_->get_node()->now() - rclcpp::Duration::from_seconds(1.0);
+ msg->twist.linear.x = 1.0;
+ msg->twist.angular.z = 0.5;
+ controller_->reference_callback(msg);
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[0]));
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[1]));
+ controller_->update(controller_->get_node()->now(), rclcpp::Duration::from_seconds(0.01));
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[0]));
+ EXPECT_TRUE(std::isnan(controller_->reference_interfaces_[1]));
+}
+
+int main(int argc, char ** argv)
+{
+ ::testing::InitGoogleTest(&argc, argv);
+ rclcpp::init(argc, argv);
+ int result = RUN_ALL_TESTS();
+ rclcpp::shutdown();
+ return result;
+}
diff --git a/generic_steering_controller/test/test_generic_steering_controller.hpp b/generic_steering_controller/test/test_generic_steering_controller.hpp
new file mode 100644
index 0000000000..a62a8c80fd
--- /dev/null
+++ b/generic_steering_controller/test/test_generic_steering_controller.hpp
@@ -0,0 +1,307 @@
+// Copyright (c) 2023, Stogl Robotics Consulting UG (haftungsbeschränkt)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef TEST_GENERIC_STEERING_CONTROLLER_HPP_
+#define TEST_GENERIC_STEERING_CONTROLLER_HPP_
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "generic_steering_controller/generic_steering_controller.hpp"
+#include "hardware_interface/loaned_command_interface.hpp"
+#include "hardware_interface/loaned_state_interface.hpp"
+#include "pluginlib/class_list_macros.hpp"
+#include "rclcpp/executor.hpp"
+#include "rclcpp/executors.hpp"
+#include "rclcpp/time.hpp"
+#include "rclcpp_lifecycle/node_interfaces/lifecycle_node_interface.hpp"
+#include "rclcpp_lifecycle/state.hpp"
+
+using ControllerStateMsg =
+ generic_steering_controller::GenericSteeringController::GenericSteeringControllerStateMsg;
+using ControllerReferenceMsg =
+ generic_steering_controller::GenericSteeringController::ControllerTwistReferenceMsg;
+
+// NOTE: Testing steering_controllers_library for Ackermann vehicle configuration only
+
+// name constants for state interfaces
+static constexpr size_t STATE_TRACTION_RIGHT_WHEEL = 0;
+static constexpr size_t STATE_TRACTION_LEFT_WHEEL = 1;
+static constexpr size_t STATE_STEER_WHEEL = 2;
+
+static constexpr size_t CMD_TRACTION_RIGHT_WHEEL = 0;
+static constexpr size_t CMD_TRACTION_LEFT_WHEEL = 1;
+static constexpr size_t CMD_STEER_WHEEL = 2;
+
+static constexpr size_t NR_STATE_ITFS = 3;
+static constexpr size_t NR_CMD_ITFS = 3;
+static constexpr size_t NR_REF_ITFS = 2;
+
+static constexpr double WHEELBASE_ = 3.24644;
+static constexpr double WHEELS_TRACK_ = 2.12321;
+static constexpr double WHEELS_RADIUS_ = 0.45;
+
+namespace
+{
+constexpr auto NODE_SUCCESS = controller_interface::CallbackReturn::SUCCESS;
+constexpr auto NODE_ERROR = controller_interface::CallbackReturn::ERROR;
+} // namespace
+
+// subclassing and friending so we can access member variables
+class TestableGenericSteeringController
+: public generic_steering_controller::GenericSteeringController
+{
+ FRIEND_TEST(GenericSteeringControllerTest, check_exported_interfaces);
+ FRIEND_TEST(GenericSteeringControllerTest, CommandTimeoutSetsZeroVelocity);
+ // FRIEND_TEST(SteeringControllersLibraryTest, test_position_feedback_ref_timeout);
+ // FRIEND_TEST(SteeringControllersLibraryTest, test_velocity_feedback_ref_timeout);
+
+public:
+ controller_interface::CallbackReturn on_configure(
+ const rclcpp_lifecycle::State & previous_state) override
+ {
+ return generic_steering_controller::GenericSteeringController::on_configure(previous_state);
+ }
+
+ controller_interface::CallbackReturn on_activate(
+ const rclcpp_lifecycle::State & previous_state) override
+ {
+ auto ref_itfs = on_export_reference_interfaces();
+ return generic_steering_controller::GenericSteeringController::on_activate(previous_state);
+ }
+
+ /**
+ * @brief wait_for_command blocks until a new ControllerReferenceMsg is received.
+ * Requires that the executor is not spinned elsewhere between the
+ * message publication and the call to this function.
+ */
+ void wait_for_command(
+ rclcpp::Executor & executor,
+ const std::chrono::milliseconds & timeout = std::chrono::milliseconds{500})
+ {
+ auto until = get_node()->get_clock()->now() + timeout;
+ while (get_node()->get_clock()->now() < until)
+ {
+ executor.spin_some();
+ std::this_thread::sleep_for(std::chrono::microseconds(10));
+ }
+ }
+
+ void wait_for_commands(
+ rclcpp::Executor & executor,
+ const std::chrono::milliseconds & timeout = std::chrono::milliseconds{500})
+ {
+ wait_for_command(executor, timeout);
+ }
+
+ // implementing methods which are declared virtual in the steering_controllers_library.hpp
+ void initialize_implementation_parameter_listener() override
+ {
+ param_listener_ =
+ std::make_shared(get_node());
+ }
+};
+
+// We are using template class here for easier reuse of Fixture in specializations of controllers
+template
+class GenericSteeringControllerFixture : public ::testing::Test
+{
+public:
+ static void SetUpTestCase() {}
+
+ void SetUp()
+ {
+ // initialize controller
+ controller_ = std::make_unique();
+
+ // Create the node for parameters (this is just for publishing commands)
+ command_publisher_node_ = std::make_shared("command_publisher");
+
+ command_publisher_ = command_publisher_node_->create_publisher(
+ "/test_generic_steering_controller/reference", rclcpp::SystemDefaultsQoS());
+ }
+
+ void SetUpController(const std::string controller_name = "test_generic_steering_controller")
+ {
+ ASSERT_EQ(
+ controller_->init(controller_name, "", 0, "", controller_->define_custom_node_options()),
+ controller_interface::return_type::OK);
+
+ // No parameter setting! Use YAML defaults.
+
+ if (position_feedback_ == true)
+ {
+ traction_interface_name_ = "position";
+ }
+ else
+ {
+ traction_interface_name_ = "velocity";
+ }
+
+ // Manually create interfaces using test fixture values
+ std::vector command_ifs;
+ command_itfs_.reserve(joint_command_values_.size());
+ command_ifs.reserve(joint_command_values_.size());
+
+ command_itfs_.emplace_back(
+ hardware_interface::CommandInterface(
+ traction_joints_names_[0], traction_interface_name_,
+ &joint_command_values_[CMD_TRACTION_RIGHT_WHEEL]));
+ command_ifs.emplace_back(command_itfs_.back());
+
+ command_itfs_.emplace_back(
+ hardware_interface::CommandInterface(
+ traction_joints_names_[1], traction_interface_name_,
+ &joint_command_values_[CMD_TRACTION_LEFT_WHEEL]));
+ command_ifs.emplace_back(command_itfs_.back());
+
+ command_itfs_.emplace_back(
+ hardware_interface::CommandInterface(
+ steering_joints_names_[0], steering_interface_name_,
+ &joint_command_values_[CMD_STEER_WHEEL]));
+ command_ifs.emplace_back(command_itfs_.back());
+
+ std::vector state_ifs;
+ state_itfs_.reserve(joint_state_values_.size());
+ state_ifs.reserve(joint_state_values_.size());
+
+ state_itfs_.emplace_back(
+ hardware_interface::StateInterface(
+ traction_joints_names_[0], traction_interface_name_,
+ &joint_state_values_[STATE_TRACTION_RIGHT_WHEEL]));
+ state_ifs.emplace_back(state_itfs_.back());
+
+ state_itfs_.emplace_back(
+ hardware_interface::StateInterface(
+ traction_joints_names_[1], traction_interface_name_,
+ &joint_state_values_[STATE_TRACTION_LEFT_WHEEL]));
+ state_ifs.emplace_back(state_itfs_.back());
+
+ state_itfs_.emplace_back(
+ hardware_interface::StateInterface(
+ steering_joints_names_[0], steering_interface_name_,
+ &joint_state_values_[STATE_STEER_WHEEL]));
+ state_ifs.emplace_back(state_itfs_.back());
+
+ controller_->assign_interfaces(std::move(command_ifs), std::move(state_ifs));
+ }
+ void subscribe_and_get_messages(ControllerStateMsg & msg)
+ {
+ // create a new subscriber
+ ControllerStateMsg::SharedPtr received_msg;
+ rclcpp::Node test_subscription_node("test_subscription_node");
+ auto subs_callback = [&](const ControllerStateMsg::SharedPtr cb_msg) { received_msg = cb_msg; };
+ auto subscription = test_subscription_node.create_subscription(
+ "/test_generic_steering_controller/controller_state", 10, subs_callback);
+ rclcpp::executors::SingleThreadedExecutor executor;
+ executor.add_node(test_subscription_node.get_node_base_interface());
+
+ // call update to publish the test value
+ ASSERT_EQ(
+ controller_->update(rclcpp::Time(0, 0, RCL_ROS_TIME), rclcpp::Duration::from_seconds(0.01)),
+ controller_interface::return_type::OK);
+ // call update to publish the test value
+ // since update doesn't guarantee a published message, republish until received
+ int max_sub_check_loop_count = 5; // max number of tries for pub/sub loop
+ while (max_sub_check_loop_count--)
+ {
+ controller_->update(rclcpp::Time(0, 0, RCL_ROS_TIME), rclcpp::Duration::from_seconds(0.01));
+ const auto timeout = std::chrono::milliseconds{5};
+ const auto until = test_subscription_node.get_clock()->now() + timeout;
+ while (!received_msg && test_subscription_node.get_clock()->now() < until)
+ {
+ executor.spin_some();
+ std::this_thread::sleep_for(std::chrono::microseconds(10));
+ }
+ // check if message has been received
+ if (received_msg.get())
+ {
+ break;
+ }
+ }
+ ASSERT_GE(max_sub_check_loop_count, 0) << "Test was unable to publish a message through "
+ "controller/broadcaster update loop";
+ ASSERT_TRUE(received_msg);
+
+ // take message from subscription
+ msg = *received_msg;
+ }
+
+ void publish_commands(const double linear = 0.1, const double angular = 0.2)
+ {
+ auto wait_for_topic = [&](const auto topic_name)
+ {
+ size_t wait_count = 0;
+ while (command_publisher_node_->count_subscribers(topic_name) == 0)
+ {
+ if (wait_count >= 5)
+ {
+ auto error_msg =
+ std::string("publishing to ") + topic_name + " but no node subscribes to it";
+ throw std::runtime_error(error_msg);
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ ++wait_count;
+ }
+ };
+
+ wait_for_topic(command_publisher_->get_topic_name());
+
+ ControllerReferenceMsg msg;
+ msg.twist.linear.x = linear;
+ msg.twist.angular.z = angular;
+
+ command_publisher_->publish(msg);
+ }
+
+protected:
+ // Controller-related parameters
+ double reference_timeout_ = 2.0;
+ bool open_loop_ = false;
+ unsigned int velocity_rolling_window_size_ = 10;
+ bool position_feedback_ = false;
+ std::vector traction_joints_names_ = {"left_wheel_joint", "right_wheel_joint"};
+ std::vector steering_joints_names_ = {"steering_joint"};
+ std::vector joint_names_ = {
+ traction_joints_names_[0], traction_joints_names_[1], steering_joints_names_[0]};
+
+ std::vector traction_joints_preceding_names_ = {
+ "pid_controller/rear_right_wheel_joint", "pid_controller/rear_left_wheel_joint"};
+ std::vector steering_joints_preceding_names_ = {
+ "pid_controller/front_right_steering_joint", "pid_controller/front_left_steering_joint"};
+
+ std::array joint_state_values_ = {{0.5, 0.5, 0.0}};
+ std::array joint_command_values_ = {{1.1, 3.3, 2.2}};
+
+ std::array joint_reference_interfaces_ = {{"linear", "angular"}};
+ std::string steering_interface_name_ = "position";
+ // defined in setup
+ std::string traction_interface_name_ = "";
+ std::string preceding_prefix_ = "pid_controller";
+
+ std::vector state_itfs_;
+ std::vector command_itfs_;
+
+ // Test related parameters
+ std::unique_ptr controller_;
+ rclcpp::Node::SharedPtr command_publisher_node_;
+ rclcpp::Publisher::SharedPtr command_publisher_;
+};
+
+#endif // TEST_GENERIC_STEERING_CONTROLLER_HPP_