diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/config/costmap_common_params.yaml b/jetson/ros2_ws/src/saltybot_nav2_slam/config/costmap_common_params.yaml new file mode 100644 index 0000000..2b76a0b --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/config/costmap_common_params.yaml @@ -0,0 +1,47 @@ +costmap_common_params: + update_frequency: 10.0 + publish_frequency: 5.0 + global_frame: map + robot_base_frame: base_link + use_sim_time: false + rolling_window: false + robot_radius: 0.35 + plugins: + - name: static_layer + type: nav2_costmap_2d::StaticLayer + map_subscribe_transient_local: true + enabled: true + - name: obstacle_layer + type: nav2_costmap_2d::ObstacleLayer + enabled: true + observation_sources: scan camera_depth + scan: + topic: /scan + max_obstacle_height: 2.5 + min_obstacle_height: 0.0 + clearing: true + marking: true + data_type: LaserScan + camera_depth: + topic: /camera/depth/color/points + max_obstacle_height: 2.5 + min_obstacle_height: 0.0 + clearing: false + marking: true + data_type: PointCloud2 + sensor_frame: camera_link + expected_update_rate: 15.0 + observation_persistence: 0.0 + inf_is_valid: false + filter: passthrough + filter_value: 5.0 + - name: inflation_layer + type: nav2_costmap_2d::InflationLayer + enabled: true + inflation_radius: 0.55 + cost_scaling_factor: 10.0 + inflate_unknown: false + inflate_around_unknown: true + lethal_cost_threshold: 100 + unknown_cost_value: -1 + resolution: 0.05 diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/config/dwb_local_planner_params.yaml b/jetson/ros2_ws/src/saltybot_nav2_slam/config/dwb_local_planner_params.yaml new file mode 100644 index 0000000..fafda9f --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/config/dwb_local_planner_params.yaml @@ -0,0 +1,59 @@ +dwb_local_planner: + ros__parameters: + max_vel_x: 5.5 + min_vel_x: 0.0 + max_vel_y: 0.0 + min_vel_y: 0.0 + max_vel_theta: 3.0 + min_vel_theta: -3.0 + acc_lim_x: 2.5 + acc_lim_y: 0.0 + acc_lim_theta: 2.5 + decel_lim_x: -2.5 + decel_lim_y: 0.0 + decel_lim_theta: -2.5 + vx_samples: 20 + vy_samples: 1 + vtheta_samples: 40 + sim_time: 1.7 + sim_granularity: 0.05 + angular_sim_granularity: 0.1 + path_distance_bias: 32.0 + goal_distance_bias: 24.0 + occdist_scale: 0.02 + forward_point_distance: 0.325 + use_dwa: true + dwa_padding_inaccuracies: 0.1 + max_allowed_time_female: 0.0 + oscillation_max_iterations: 1800 + oscillation_max_distance: 0.02 + use_velocity_scaled_lookahead_dist: false + min_approach_linear_velocity: 0.1 + approach_velocity_scaling_dist: 1.0 + critics: ["RotateToGoal", "Oscillation", "BaseObstacle", "GoalAlign", "PathAlign", "PathDist", "GoalDist"] + rotate_to_goal: + slowing_factor: 5.0 + lookahead_time: -1.0 + base_obstacle: + scale: 0.02 + max_scaling_factor: 0.02 + forward_point_distance: 0.325 + goal_align: + scale: 24.0 + forward_point_distance: 0.325 + path_align: + scale: 32.0 + forward_point_distance: 0.325 + max_allowed_time_female: 0.0 + path_dist: + scale: 32.0 + max_allowed_time_female: 0.0 + goal_dist: + scale: 24.0 + max_allowed_time_female: 0.0 + prune_plan: true + prune_distance: 1.5 + debug_trajectory_details: false + publish_evaluation: true + publish_scored_sampling_policies: false + publish_trajectories: false diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/config/global_costmap_params.yaml b/jetson/ros2_ws/src/saltybot_nav2_slam/config/global_costmap_params.yaml new file mode 100644 index 0000000..58df832 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/config/global_costmap_params.yaml @@ -0,0 +1,36 @@ +global_costmap: + global_costmap: + ros__parameters: + update_frequency: 5.0 + publish_frequency: 2.0 + global_frame: map + robot_base_frame: base_link + use_sim_time: false + rolling_window: false + robot_radius: 0.35 + plugins: + - name: static_layer + type: nav2_costmap_2d::StaticLayer + map_subscribe_transient_local: true + enabled: true + - name: obstacle_layer + type: nav2_costmap_2d::ObstacleLayer + enabled: true + observation_sources: scan + scan: + topic: /scan + max_obstacle_height: 2.5 + min_obstacle_height: 0.0 + clearing: true + marking: true + data_type: LaserScan + - name: inflation_layer + type: nav2_costmap_2d::InflationLayer + enabled: true + inflation_radius: 0.55 + cost_scaling_factor: 10.0 + inflate_unknown: false + inflate_around_unknown: true + resolution: 0.05 + track_unknown_space: true + unknown_cost_value: -1 diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/config/local_costmap_params.yaml b/jetson/ros2_ws/src/saltybot_nav2_slam/config/local_costmap_params.yaml new file mode 100644 index 0000000..a21dd07 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/config/local_costmap_params.yaml @@ -0,0 +1,51 @@ +local_costmap: + local_costmap: + ros__parameters: + update_frequency: 10.0 + publish_frequency: 5.0 + global_frame: odom + robot_base_frame: base_link + use_sim_time: false + rolling_window: true + width: 3 + height: 3 + resolution: 0.05 + robot_radius: 0.35 + plugins: + - name: obstacle_layer + type: nav2_costmap_2d::ObstacleLayer + enabled: true + observation_sources: scan camera_depth + scan: + topic: /scan + max_obstacle_height: 2.5 + min_obstacle_height: 0.0 + clearing: true + marking: true + data_type: LaserScan + raytrace_max_range: 10.0 + raytrace_min_range: 0.0 + expected_update_rate: 5.5 + observation_persistence: 0.0 + camera_depth: + topic: /camera/depth/color/points + max_obstacle_height: 2.5 + min_obstacle_height: 0.0 + clearing: false + marking: true + data_type: PointCloud2 + sensor_frame: camera_link + expected_update_rate: 15.0 + observation_persistence: 0.5 + inf_is_valid: false + filter: passthrough + filter_value: 5.0 + - name: inflation_layer + type: nav2_costmap_2d::InflationLayer + enabled: true + inflation_radius: 0.55 + cost_scaling_factor: 10.0 + inflate_unknown: true + inflate_around_unknown: false + track_unknown_space: true + unknown_cost_value: 0 diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/config/nav2_params.yaml b/jetson/ros2_ws/src/saltybot_nav2_slam/config/nav2_params.yaml new file mode 100644 index 0000000..35209b8 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/config/nav2_params.yaml @@ -0,0 +1,219 @@ +amcl: + ros__parameters: + use_sim_time: false + alpha1: 0.2 + alpha2: 0.2 + alpha3: 0.2 + alpha4: 0.2 + alpha5: 0.2 + base_frame_id: "base_link" + beam_search_increment: 0.1 + do_beamskip: false + global_frame_id: "map" + lambda_short: 0.1 + laser_likelihood_max_dist: 2.0 + laser_model_type: "likelihood_field" + max_beams: 60 + max_particles: 2000 + min_particles: 500 + odom_frame_id: "odom" + pf_err: 0.05 + pf_z: 0.99 + recovery_alpha_fast: 0.0 + recovery_alpha_slow: 0.0 + resample_interval: 1 + robot_model_type: "differential" + save_pose_rate: 0.5 + sigma_hit: 0.2 + sigma_short: 0.05 + tf_broadcast: true + transform_tolerance: 1.0 + update_min_a: 0.2 + update_min_d: 0.25 + z_hit: 0.5 + z_max: 0.05 + z_rand: 0.5 + z_short: 0.05 + +amcl_map_client: + ros__parameters: + use_sim_time: false + +amcl_rclcpp_node: + ros__parameters: + use_sim_time: false + +bt_navigator: + ros__parameters: + use_sim_time: false + global_frame: map + robot_base_frame: base_link + odom_topic: /odom + enable_groot_monitoring: true + groot_zmq_publisher_port: 1666 + groot_zmq_server_port: 1667 + default_nav_through_poses_bt_xml: $(find-pkg-share nav2_bt_navigator)/behavior_trees/navigate_through_poses_w_replanning_and_recovery.xml + default_nav_to_pose_bt_xml: $(find-pkg-share nav2_bt_navigator)/behavior_trees/navigate_to_pose_w_replanning_and_recovery.xml + plugin_lib_names: + - nav2_compute_path_to_pose_action_bt_node + - nav2_compute_path_through_poses_action_bt_node + - nav2_follow_path_action_bt_node + - nav2_spin_action_bt_node + - nav2_wait_action_bt_node + - nav2_assisted_teleop_action_bt_node + - nav2_back_up_action_bt_node + - nav2_drive_on_heading_bt_node + - nav2_clear_costmap_service_bt_node + - nav2_is_stuck_condition_bt_node + - nav2_goal_updated_condition_bt_node + - nav2_initial_pose_received_condition_bt_node + - nav2_reinitialize_global_localization_service_bt_node + - nav2_rate_controller_bt_node + - nav2_distance_controller_bt_node + - nav2_speed_controller_bt_node + - nav2_truncate_path_action_bt_node + - nav2_truncate_path_local_action_bt_node + - nav2_remove_passed_goals_action_bt_node + - nav2_planner_selector_bt_node + - nav2_controller_selector_bt_node + - nav2_goal_checker_selector_bt_node + +bt_navigator_rclcpp_node: + ros__parameters: + use_sim_time: false + +controller_server: + ros__parameters: + use_sim_time: false + controller_frequency: 20.0 + min_x_velocity_threshold: 0.001 + min_y_velocity_threshold: 0.5 + min_theta_velocity_threshold: 0.001 + failure_tolerance: 0.3 + progress_checker_plugin: "progress_checker" + goal_checker_plugins: ["general_goal_checker"] + controller_plugins: ["FollowPath"] + + progress_checker: + plugin: "nav2_core::SimpleProgressChecker" + required_movement_radius: 0.5 + movement_time_allowance: 10.0 + + general_goal_checker: + stateful: True + plugin: "nav2_core::SimpleGoalChecker" + xy_goal_tolerance: 0.25 + yaw_goal_tolerance: 0.25 + + FollowPath: + plugin: "dwb_core::DWBLocalPlanner" + debug_trajectory_details: true + min_vel_x: 0.0 + max_vel_x: 5.5 + max_vel_theta: 3.0 + min_speed_xy: 0.0 + max_speed_xy: 5.5 + acc_lim_x: 2.5 + acc_lim_theta: 2.5 + decel_lim_x: -2.5 + decel_lim_theta: -2.5 + vx_samples: 20 + vtheta_samples: 40 + sim_time: 1.7 + linear_granularity: 0.05 + angular_granularity: 0.1 + transform_tolerance: 0.2 + trans_stopped_velocity: 0.25 + short_circuit_trajectory_evaluation: true + stateful: True + critics: ["RotateToGoal", "Oscillation", "BaseObstacle", "GoalAlign", "PathAlign", "PathDist", "GoalDist"] + BaseObstacle.scale: 0.02 + PathAlign.scale: 32.0 + GoalAlign.scale: 24.0 + PathDist.scale: 32.0 + GoalDist.scale: 24.0 + RotateToGoal.scale: 32.0 + +controller_server_rclcpp_node: + ros__parameters: + use_sim_time: false + +planner_server: + ros__parameters: + expected_planner_frequency: 20.0 + use_sim_time: false + planner_plugins: ["GridBased"] + GridBased: + plugin: "nav2_navfn_planner/NavfnPlanner" + tolerance: 0.5 + use_astar: true + allow_unknown: true + +planner_server_rclcpp_node: + ros__parameters: + use_sim_time: false + +behavior_server: + ros__parameters: + costmap_topic: local_costmap/costmap_raw + footprint_topic: local_costmap/published_footprint + cycle_frequency: 10.0 + behavior_plugins: ["spin", "backup", "drive_on_heading", "assisted_teleop", "wait"] + spin: + plugin: "nav2_behaviors/Spin" + backup: + plugin: "nav2_behaviors/BackUp" + drive_on_heading: + plugin: "nav2_behaviors/DriveOnHeading" + assisted_teleop: + plugin: "nav2_behaviors/AssistedTeleop" + wait: + plugin: "nav2_behaviors/Wait" + global_frame: odom + robot_base_frame: base_link + transform_timeout: 0.1 + use_sim_time: false + simulate_ahead_time: 2.0 + max_rotations: 1.0 + max_backup_dist: 0.15 + backup_speed: 0.5 + +behavior_server_rclcpp_node: + ros__parameters: + use_sim_time: false + +robot_state_publisher: + ros__parameters: + use_sim_time: false + +lifecycle_manager_navigation: + ros__parameters: + use_sim_time: false + autostart: true + node_names: ['controller_server', 'planner_server', 'behavior_server', 'bt_navigator'] + +lifecycle_manager_localization: + ros__parameters: + use_sim_time: false + autostart: true + node_names: ['amcl'] + +map_server: + ros__parameters: + use_sim_time: false + yaml_filename: "map.yaml" + +velocity_smoother: + ros__parameters: + use_sim_time: false + smoothing_frequency: 20.0 + scale_velocities: false + feedback: "odometry" + max_velocity: [5.5, 0.0, 3.0] + min_velocity: [-5.5, 0.0, -3.0] + max_accel: [2.5, 0.0, 2.5] + max_decel: [-2.5, 0.0, -2.5] + odom_topic: "odom" + odom_duration: 0.1 + deadband_velocity: [0.0, 0.0, 0.0] + velocity_timeout: 1.0 diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/config/slam_toolbox_params.yaml b/jetson/ros2_ws/src/saltybot_nav2_slam/config/slam_toolbox_params.yaml new file mode 100644 index 0000000..7344f25 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/config/slam_toolbox_params.yaml @@ -0,0 +1,58 @@ +slam_toolbox: + ros__parameters: + mode: mapping + async_mode: true + use_scan_matching: true + use_scan_bamatching: true + minimum_time_interval: 0.5 + minimum_travel_distance: 0.2 + minimum_rotation_deg: 5.0 + correlation_search_space_dimension: 0.5 + correlation_search_space_resolution: 0.05 + correlation_search_space_smear_deviation: 0.1 + correlation_search_space_std_dev_ratio: 0.5 + loop_search_space_dimension: 8.0 + loop_search_space_resolution: 0.05 + scan_buffer_size: 10 + scan_buffer_max_scan_distance: 12.0 + distance_variance_penalty: 0.5 + angle_variance_penalty: 1.0 + fine_search_angle_offset: 0.00349 + coarse_search_angle_offset: 0.349 + coarse_angle_search_step_size: 0.0349 + fine_angle_search_step_size: 0.00349 + solver_plugin: solver_plugins::CeresSolver + ceres_linear_solver: SPARSE_NORMAL_CHOLESKY + ceres_preconditioner: SCHUR_JACOBI + ceres_trust_strategy: LEVENBERG_MARQUARDT + ceres_dogleg_type: TRADITIONAL_DOGLEG + do_loop_closing: true + loop_match_minimum_chain_size: 10 + loop_match_maximum_variance_queue_size: 10 + loop_match_minimum_response_fine: 0.1 + loop_match_minimum_response_coarse: 0.3 + minimum_score_to_accept_loop_closure: 0.07 + do_final_optimization: true + final_optimization_particle_filter_size: 800 + final_optimization_min_particles: 400 + final_optimization_max_particles: 2000 + final_optimization_threshold_metric_improvement: 0.0002 + add_loops_as_edges: false + num_for_scan_matching: 0 + icp_iterations: 10 + interactive_mode: false + print_timing_information: false + map_frame: map + odom_frame: odom + base_frame: base_link + scan_topic: /scan + publish_tf: true + tf_buffer_duration: 30.0 + transform_timeout: 0.2 + queue_size: 10 + throttle_scans: 1 + use_map_saver: true + map_start_at_dock: true + map_start_pose: [0.0, 0.0, 0.0] + rollout_velocity_smoothing_factor: 0.07 + force_turtlebot_rep: false diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/launch/depth_to_costmap.launch.py b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/depth_to_costmap.launch.py new file mode 100644 index 0000000..20851b4 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/depth_to_costmap.launch.py @@ -0,0 +1,28 @@ +"""RealSense Depth to Costmap Integration""" + +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + + +def generate_launch_description(): + use_sim_time = LaunchConfiguration('use_sim_time') + + return LaunchDescription([ + DeclareLaunchArgument('use_sim_time', default_value='false'), + + Node( + package='depth_image_proc', + executable='convert_node', + name='depth_image_converter', + namespace='camera', + remappings=[ + ('image_rect', '/camera/aligned_depth_to_color/image_raw'), + ('camera_info', '/camera/color/camera_info'), + ('points', '/camera/depth/color/points'), + ], + parameters=[{'use_sim_time': use_sim_time}], + output='screen', + ), + ]) diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/launch/nav2_slam_bringup.launch.py b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/nav2_slam_bringup.launch.py new file mode 100644 index 0000000..25abd20 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/nav2_slam_bringup.launch.py @@ -0,0 +1,66 @@ +"""Nav2 SLAM Bringup for SaltyBot (Issue #422)""" + +import os +from launch import LaunchDescription +from launch.actions import IncludeLaunchDescription, DeclareLaunchArgument +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch.substitutions import LaunchConfiguration +from ament_index_python.packages import get_package_share_directory + + +def generate_launch_description(): + nav2_slam_dir = get_package_share_directory('saltybot_nav2_slam') + bringup_dir = get_package_share_directory('saltybot_bringup') + + nav2_params = os.path.join(nav2_slam_dir, 'config', 'nav2_params.yaml') + slam_params = os.path.join(nav2_slam_dir, 'config', 'slam_toolbox_params.yaml') + + return LaunchDescription([ + DeclareLaunchArgument('use_sim_time', default_value='false'), + + IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(bringup_dir, 'launch', 'sensors.launch.py') + ), + launch_arguments={'use_sim_time': LaunchConfiguration('use_sim_time')}.items(), + ), + + IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(nav2_slam_dir, 'launch', 'odometry_bridge.launch.py') + ), + launch_arguments={'use_sim_time': LaunchConfiguration('use_sim_time')}.items(), + ), + + IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(nav2_slam_dir, 'launch', 'nav2_slam_integrated.launch.py') + ), + launch_arguments={ + 'slam_params_file': slam_params, + 'use_sim_time': LaunchConfiguration('use_sim_time'), + }.items(), + ), + + IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(nav2_slam_dir, 'launch', 'depth_to_costmap.launch.py') + ), + launch_arguments={'use_sim_time': LaunchConfiguration('use_sim_time')}.items(), + ), + + IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join( + get_package_share_directory('nav2_bringup'), + 'launch', 'navigation_launch.py' + ) + ), + launch_arguments={ + 'use_sim_time': LaunchConfiguration('use_sim_time'), + 'params_file': nav2_params, + 'autostart': 'true', + 'map_subscribe_transient_local': 'true', + }.items(), + ), + ]) diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/launch/nav2_slam_integrated.launch.py b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/nav2_slam_integrated.launch.py new file mode 100644 index 0000000..3c1f7b2 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/nav2_slam_integrated.launch.py @@ -0,0 +1,35 @@ +"""SLAM Toolbox integrated launch for SaltyBot""" + +import os +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription +from launch.launch_description_sources import PythonLaunchDescriptionSource +from launch.substitutions import LaunchConfiguration +from ament_index_python.packages import get_package_share_directory + + +def generate_launch_description(): + slam_share = get_package_share_directory('slam_toolbox') + nav2_slam_dir = get_package_share_directory('saltybot_nav2_slam') + + slam_params = LaunchConfiguration('slam_params_file') + use_sim_time = LaunchConfiguration('use_sim_time') + + return LaunchDescription([ + DeclareLaunchArgument( + 'slam_params_file', + default_value=os.path.join(nav2_slam_dir, 'config', 'slam_toolbox_params.yaml'), + ), + + DeclareLaunchArgument('use_sim_time', default_value='false'), + + IncludeLaunchDescription( + PythonLaunchDescriptionSource( + os.path.join(slam_share, 'launch', 'online_async_launch.py') + ), + launch_arguments={ + 'params_file': slam_params, + 'use_sim_time': use_sim_time, + }.items(), + ), + ]) diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/launch/odometry_bridge.launch.py b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/odometry_bridge.launch.py new file mode 100644 index 0000000..0ec8e29 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/launch/odometry_bridge.launch.py @@ -0,0 +1,36 @@ +"""VESC Telemetry to Odometry Bridge""" + +import os +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node +from ament_index_python.packages import get_package_share_directory + + +def generate_launch_description(): + use_sim_time = LaunchConfiguration('use_sim_time') + + return LaunchDescription([ + DeclareLaunchArgument('use_sim_time', default_value='false'), + + Node( + package='saltybot_nav2_slam', + executable='vesc_odometry_bridge', + name='vesc_odometry_bridge', + parameters=[{ + 'use_sim_time': use_sim_time, + 'vesc_state_topic': '/vesc/state', + 'odometry_topic': '/odom', + 'odom_frame_id': 'odom', + 'base_frame_id': 'base_link', + 'update_frequency': 50, + 'wheel_base': 0.35, + 'wheel_radius': 0.10, + 'max_rpm': 60000, + 'publish_tf': True, + 'tf_queue_size': 10, + }], + output='screen', + ), + ]) diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/package.xml b/jetson/ros2_ws/src/saltybot_nav2_slam/package.xml new file mode 100644 index 0000000..2e1ce41 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/package.xml @@ -0,0 +1,39 @@ + + + + saltybot_nav2_slam + 0.1.0 + + Nav2 SLAM integration for SaltyBot autonomous navigation. + Combines SLAM Toolbox (RPLIDAR 2D SLAM), RealSense depth for obstacle avoidance, + VESC odometry, and DWB planner for autonomous navigation up to 20km/h. + + sl-controls + MIT + + rclpy + sensor_msgs + std_msgs + nav_msgs + geometry_msgs + tf2 + tf2_ros + + nav2_bringup + slam_toolbox + rplidar_ros + realsense2_camera + depth_image_proc + pointcloud_to_laserscan + + ament_python + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/resource/saltybot_nav2_slam b/jetson/ros2_ws/src/saltybot_nav2_slam/resource/saltybot_nav2_slam new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/saltybot_nav2_slam/__init__.py b/jetson/ros2_ws/src/saltybot_nav2_slam/saltybot_nav2_slam/__init__.py new file mode 100644 index 0000000..a3dc99b --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/saltybot_nav2_slam/__init__.py @@ -0,0 +1 @@ +# Nav2 SLAM integration for SaltyBot diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/saltybot_nav2_slam/vesc_odometry_bridge.py b/jetson/ros2_ws/src/saltybot_nav2_slam/saltybot_nav2_slam/vesc_odometry_bridge.py new file mode 100644 index 0000000..f75618d --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/saltybot_nav2_slam/vesc_odometry_bridge.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""VESC telemetry to Nav2 odometry bridge.""" + +import json +import math +import time + +import rclpy +from rclpy.node import Node +from std_msgs.msg import String +from nav_msgs.msg import Odometry +from geometry_msgs.msg import TransformStamped, Quaternion +from tf2_ros import TransformBroadcaster + + +class VESCOdometryBridge(Node): + """ROS2 node bridging VESC telemetry to Nav2 odometry.""" + + def __init__(self): + super().__init__("vesc_odometry_bridge") + + self.declare_parameter("vesc_state_topic", "/vesc/state") + self.declare_parameter("odometry_topic", "/odom") + self.declare_parameter("odom_frame_id", "odom") + self.declare_parameter("base_frame_id", "base_link") + self.declare_parameter("update_frequency", 50) + self.declare_parameter("wheel_base", 0.35) + self.declare_parameter("wheel_radius", 0.10) + self.declare_parameter("max_rpm", 60000) + self.declare_parameter("publish_tf", True) + + self.vesc_state_topic = self.get_parameter("vesc_state_topic").value + self.odometry_topic = self.get_parameter("odometry_topic").value + self.odom_frame_id = self.get_parameter("odom_frame_id").value + self.base_frame_id = self.get_parameter("base_frame_id").value + frequency = self.get_parameter("update_frequency").value + self.wheel_base = self.get_parameter("wheel_base").value + self.wheel_radius = self.get_parameter("wheel_radius").value + self.publish_tf = self.get_parameter("publish_tf").value + + self.last_rpm = 0 + self.last_update_time = time.time() + + self.x = 0.0 + self.y = 0.0 + self.theta = 0.0 + self.vx = 0.0 + self.vy = 0.0 + self.vtheta = 0.0 + + self.pub_odom = self.create_publisher(Odometry, self.odometry_topic, 10) + + if self.publish_tf: + self.tf_broadcaster = TransformBroadcaster(self) + + self.create_subscription(String, self.vesc_state_topic, self._on_vesc_state, 10) + + period = 1.0 / frequency + self.create_timer(period, self._publish_odometry) + + self.get_logger().info( + f"VESC odometry bridge initialized: " + f"wheel_base={self.wheel_base}m, wheel_radius={self.wheel_radius}m" + ) + + def _on_vesc_state(self, msg: String) -> None: + """Update VESC telemetry from JSON.""" + try: + data = json.loads(msg.data) + current_rpm = data.get("rpm", 0) + motor_rad_s = (current_rpm / 60.0) * (2.0 * math.pi) + wheel_velocity = motor_rad_s * self.wheel_radius + + self.vx = wheel_velocity + self.vy = 0.0 + self.vtheta = 0.0 + self.last_rpm = current_rpm + + except json.JSONDecodeError: + pass + + def _publish_odometry(self) -> None: + """Publish odometry message and TF.""" + current_time = time.time() + dt = current_time - self.last_update_time + self.last_update_time = current_time + + if abs(self.vtheta) > 0.001: + self.theta += self.vtheta * dt + self.x += (self.vx / self.vtheta) * (math.sin(self.theta) - math.sin(self.theta - self.vtheta * dt)) + self.y += (self.vx / self.vtheta) * (math.cos(self.theta - self.vtheta * dt) - math.cos(self.theta)) + else: + self.x += self.vx * math.cos(self.theta) * dt + self.y += self.vx * math.sin(self.theta) * dt + + odom_msg = Odometry() + odom_msg.header.stamp = self.get_clock().now().to_msg() + odom_msg.header.frame_id = self.odom_frame_id + odom_msg.child_frame_id = self.base_frame_id + + odom_msg.pose.pose.position.x = self.x + odom_msg.pose.pose.position.y = self.y + odom_msg.pose.pose.position.z = 0.0 + odom_msg.pose.pose.orientation = self._euler_to_quaternion(0, 0, self.theta) + + odom_msg.pose.covariance = [ + 0.1, 0, 0, 0, 0, 0, + 0, 0.1, 0, 0, 0, 0, + 0, 0, 0.1, 0, 0, 0, + 0, 0, 0, 0.1, 0, 0, + 0, 0, 0, 0, 0.1, 0, + 0, 0, 0, 0, 0, 0.1, + ] + + odom_msg.twist.twist.linear.x = self.vx + odom_msg.twist.twist.linear.y = self.vy + odom_msg.twist.twist.linear.z = 0.0 + odom_msg.twist.twist.angular.z = self.vtheta + + odom_msg.twist.covariance = [ + 0.05, 0, 0, 0, 0, 0, + 0, 0.05, 0, 0, 0, 0, + 0, 0, 0.05, 0, 0, 0, + 0, 0, 0, 0.05, 0, 0, + 0, 0, 0, 0, 0.05, 0, + 0, 0, 0, 0, 0, 0.05, + ] + + self.pub_odom.publish(odom_msg) + + if self.publish_tf: + self._publish_tf(odom_msg.header.stamp) + + def _publish_tf(self, timestamp) -> None: + """Publish odom → base_link TF.""" + tf_msg = TransformStamped() + tf_msg.header.stamp = timestamp + tf_msg.header.frame_id = self.odom_frame_id + tf_msg.child_frame_id = self.base_frame_id + + tf_msg.transform.translation.x = self.x + tf_msg.transform.translation.y = self.y + tf_msg.transform.translation.z = 0.0 + tf_msg.transform.rotation = self._euler_to_quaternion(0, 0, self.theta) + + self.tf_broadcaster.sendTransform(tf_msg) + + @staticmethod + def _euler_to_quaternion(roll: float, pitch: float, yaw: float) -> Quaternion: + """Convert Euler angles to quaternion.""" + cy = math.cos(yaw * 0.5) + sy = math.sin(yaw * 0.5) + cp = math.cos(pitch * 0.5) + sp = math.sin(pitch * 0.5) + cr = math.cos(roll * 0.5) + sr = math.sin(roll * 0.5) + + q = Quaternion() + q.w = cr * cp * cy + sr * sp * sy + q.x = sr * cp * cy - cr * sp * sy + q.y = cr * sp * cy + sr * cp * sy + q.z = cr * cp * sy - sr * sp * cy + + return q + + +def main(args=None): + rclpy.init(args=args) + node = VESCOdometryBridge() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/setup.cfg b/jetson/ros2_ws/src/saltybot_nav2_slam/setup.cfg new file mode 100644 index 0000000..d602608 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/saltybot_nav2_slam +[install] +install_scripts=$base/lib/saltybot_nav2_slam diff --git a/jetson/ros2_ws/src/saltybot_nav2_slam/setup.py b/jetson/ros2_ws/src/saltybot_nav2_slam/setup.py new file mode 100644 index 0000000..735c773 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_nav2_slam/setup.py @@ -0,0 +1,39 @@ +from setuptools import setup + +package_name = "saltybot_nav2_slam" + +setup( + name=package_name, + version="0.1.0", + packages=[package_name], + data_files=[ + ("share/ament_index/resource_index/packages", [f"resource/{package_name}"]), + (f"share/{package_name}", ["package.xml"]), + (f"share/{package_name}/launch", [ + "launch/nav2_slam_bringup.launch.py", + "launch/nav2_slam_integrated.launch.py", + "launch/depth_to_costmap.launch.py", + "launch/odometry_bridge.launch.py", + ]), + (f"share/{package_name}/config", [ + "config/nav2_params.yaml", + "config/slam_toolbox_params.yaml", + "config/costmap_common_params.yaml", + "config/global_costmap_params.yaml", + "config/local_costmap_params.yaml", + "config/dwb_local_planner_params.yaml", + ]), + ], + install_requires=["setuptools"], + zip_safe=True, + maintainer="sl-controls", + maintainer_email="sl-controls@saltylab.local", + description="Nav2 SLAM integration with RPLIDAR + RealSense for autonomous navigation", + license="MIT", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "vesc_odometry_bridge = saltybot_nav2_slam.vesc_odometry_bridge:main", + ], + }, +) diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/.gitignore b/jetson/ros2_ws/src/saltybot_remote_monitor/.gitignore new file mode 100644 index 0000000..4549348 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/.gitignore @@ -0,0 +1,9 @@ +build/ +install/ +log/ +*.pyc +__pycache__/ +.pytest_cache/ +*.egg-info/ +dist/ +*.egg diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/config/remote_monitor.yaml b/jetson/ros2_ws/src/saltybot_remote_monitor/config/remote_monitor.yaml new file mode 100644 index 0000000..02fa2e3 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/config/remote_monitor.yaml @@ -0,0 +1,6 @@ +remote_monitor: + ros__parameters: + ws_port: 9091 + auth_token: 'default_token' + update_rate_hz: 2 + enable_msgpack: true diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/launch/remote_monitor.launch.py b/jetson/ros2_ws/src/saltybot_remote_monitor/launch/remote_monitor.launch.py new file mode 100644 index 0000000..83d5ef0 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/launch/remote_monitor.launch.py @@ -0,0 +1,23 @@ +from launch import LaunchDescription +from launch_ros.actions import Node +from ament_index_python.packages import get_package_share_directory +import os + + +def generate_launch_description(): + pkg_dir = get_package_share_directory('saltybot_remote_monitor') + config_file = os.path.join(pkg_dir, 'config', 'remote_monitor.yaml') + + remote_monitor_node = Node( + package='saltybot_remote_monitor', + executable='remote_monitor', + name='remote_monitor', + parameters=[config_file], + output='screen', + respawn=True, + respawn_delay=5, + ) + + return LaunchDescription([ + remote_monitor_node, + ]) diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/package.xml b/jetson/ros2_ws/src/saltybot_remote_monitor/package.xml new file mode 100644 index 0000000..49c464c --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/package.xml @@ -0,0 +1,32 @@ + + + + saltybot_remote_monitor + 0.1.0 + + Remote monitoring WebSocket relay with mobile-friendly UI. + 2Hz JSON telemetry aggregation with msgpack compression, token auth, + 5min history buffer, and critical alerts (fall, low battery, node crash). + + seb + MIT + + rclpy + sensor_msgs + geometry_msgs + diagnostic_msgs + std_msgs + + python3-aiohttp + python3-msgpack + python3-launch-ros + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/resource/saltybot_remote_monitor b/jetson/ros2_ws/src/saltybot_remote_monitor/resource/saltybot_remote_monitor new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/saltybot_remote_monitor/__init__.py b/jetson/ros2_ws/src/saltybot_remote_monitor/saltybot_remote_monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/saltybot_remote_monitor/remote_monitor_node.py b/jetson/ros2_ws/src/saltybot_remote_monitor/saltybot_remote_monitor/remote_monitor_node.py new file mode 100644 index 0000000..77cfac6 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/saltybot_remote_monitor/remote_monitor_node.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 + +import asyncio +import json +import math +import threading +import time +from collections import deque +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +import msgpack +import rclpy +from aiohttp import web +from diagnostic_msgs.msg import DiagnosticArray +from geometry_msgs.msg import Twist +from rclpy.node import Node +from sensor_msgs.msg import Imu, NavSatFix +from std_msgs.msg import Float32, Bool, String + + +class TelemetryBuffer: + """Circular buffer for 5-minute history at 2Hz.""" + + def __init__(self, duration_seconds=300): + self.max_samples = int(duration_seconds * 2) + self.buffer = deque(maxlen=self.max_samples) + + def add(self, data: Dict): + self.buffer.append({**data, 'ts': time.time()}) + + def get_history(self): + return list(self.buffer) + + +class RemoteMonitorNode(Node): + """WebSocket relay for remote monitoring with telemetry aggregation.""" + + def __init__(self): + super().__init__('saltybot_remote_monitor') + + self.declare_parameter('ws_port', 9091) + self.declare_parameter('auth_token', 'default_token') + self.declare_parameter('update_rate_hz', 2) + self.declare_parameter('enable_msgpack', True) + + self.ws_port = self.get_parameter('ws_port').value + self.auth_token = self.get_parameter('auth_token').value + self.update_rate_hz = self.get_parameter('update_rate_hz').value + self.enable_msgpack = self.get_parameter('enable_msgpack').value + + self.battery = {'voltage': 0.0, 'current': 0.0, 'soc': 0.0} + self.motors = {'left': 0.0, 'right': 0.0} + self.imu = {'roll': 0.0, 'pitch': 0.0, 'yaw': 0.0} + self.gps = {'lat': 0.0, 'lon': 0.0, 'alt': 0.0} + self.health = {'cpu_temp': 0.0, 'gpu_temp': 0.0, 'ram_pct': 0.0, 'disk_pct': 0.0} + self.social = {'is_speaking': False, 'face_id': None} + self.alerts = [] + self.history_buffer = TelemetryBuffer() + self.ws_clients = set() + self.loop = None + + self.create_subscription(DiagnosticArray, '/diagnostics', self.diag_callback, 10) + self.create_subscription(Imu, '/saltybot/imu', self.imu_callback, 10) + self.create_subscription(NavSatFix, '/gps/fix', self.gps_callback, 10) + self.create_subscription(String, '/saltybot/balance_state', self.balance_callback, 10) + self.create_subscription(Bool, '/social/speech/is_speaking', self.social_speech_callback, 10) + self.create_subscription(String, '/social/face/active', self.social_face_callback, 10) + + period = 1.0 / self.update_rate_hz + self.update_timer = self.create_timer(period, self.telemetry_update_callback) + + self.ws_thread = threading.Thread(target=self.start_ws_server, daemon=True) + self.ws_thread.start() + + self.get_logger().info( + f'Remote monitor initialized on port {self.ws_port}, ' + f'update rate {self.update_rate_hz}Hz, msgpack={self.enable_msgpack}' + ) + + def diag_callback(self, msg: DiagnosticArray): + """Parse diagnostics for battery and system health.""" + for status in msg.status: + kv = {pair.key: pair.value for pair in status.values} + + if kv.get('battery_voltage_v'): + self.battery = { + 'voltage': float(kv.get('battery_voltage_v', 0)), + 'current': float(kv.get('battery_current_a', 0)), + 'soc': float(kv.get('battery_soc_pct', 0)), + } + if self.battery['soc'] < 15: + self.add_alert('CRITICAL', 'Low battery', self.battery['soc']) + + if kv.get('cpu_temp_c'): + self.health = { + 'cpu_temp': float(kv.get('cpu_temp_c', 0)), + 'gpu_temp': float(kv.get('gpu_temp_c', 0)), + 'ram_pct': float(kv.get('ram_pct', 0)), + 'disk_pct': float(kv.get('disk_pct', 0)), + } + + def imu_callback(self, msg: Imu): + """Extract roll/pitch/yaw from quaternion.""" + q = msg.orientation + self.imu = self.quat_to_euler(q.x, q.y, q.z, q.w) + + def gps_callback(self, msg: NavSatFix): + """Store GPS data.""" + self.gps = { + 'lat': msg.latitude, + 'lon': msg.longitude, + 'alt': msg.altitude, + } + + def balance_callback(self, msg: String): + """Parse balance state for motor commands.""" + try: + state = json.loads(msg.data) + cmd = state.get('motor_cmd', 0) + norm = max(-1, min(1, cmd / 1000)) + self.motors = {'left': norm, 'right': norm} + + if abs(norm) > 0.9: + self.add_alert('WARNING', 'High motor load', norm) + except Exception: + pass + + def social_speech_callback(self, msg: Bool): + """Update social speaking state.""" + self.social['is_speaking'] = msg.data + + def social_face_callback(self, msg: String): + """Update recognized face ID.""" + self.social['face_id'] = msg.data + + @staticmethod + def quat_to_euler(qx, qy, qz, qw): + """Convert quaternion to Euler angles.""" + sinr_cosp = 2 * (qw * qx + qy * qz) + cosr_cosp = 1 - 2 * (qx * qx + qy * qy) + roll = math.atan2(sinr_cosp, cosr_cosp) + + sinp = 2 * (qw * qy - qz * qx) + pitch = math.asin(max(-1, min(1, sinp))) + + siny_cosp = 2 * (qw * qz + qx * qy) + cosy_cosp = 1 - 2 * (qy * qy + qz * qz) + yaw = math.atan2(siny_cosp, cosy_cosp) + + return { + 'roll': (roll * 180) / math.pi, + 'pitch': (pitch * 180) / math.pi, + 'yaw': (yaw * 180) / math.pi, + } + + def add_alert(self, level: str, message: str, value: Optional[float] = None): + """Add alert with timestamp.""" + alert = { + 'ts': time.time(), + 'level': level, + 'message': message, + 'value': value, + } + self.alerts.append(alert) + if len(self.alerts) > 50: + self.alerts.pop(0) + + def telemetry_update_callback(self): + """Periodic telemetry aggregation and broadcast.""" + telemetry = { + 'timestamp': datetime.now().isoformat(), + 'battery': self.battery, + 'motors': self.motors, + 'imu': self.imu, + 'gps': self.gps, + 'health': self.health, + 'social': self.social, + 'alerts': self.alerts[-10:], + } + + self.history_buffer.add(telemetry) + + if self.ws_clients and self.loop: + try: + asyncio.run_coroutine_threadsafe( + self.broadcast_telemetry(telemetry), + self.loop + ) + except Exception as e: + self.get_logger().warn(f'Broadcast error: {e}') + + async def broadcast_telemetry(self, telemetry: Dict): + """Send telemetry to all connected WebSocket clients.""" + if self.enable_msgpack: + data = msgpack.packb(telemetry) + else: + data = json.dumps(telemetry).encode('utf-8') + + disconnected = set() + for ws in self.ws_clients: + try: + await ws.send_bytes(data) + except Exception: + disconnected.add(ws) + + self.ws_clients -= disconnected + + async def websocket_handler(self, request): + """Handle WebSocket connections.""" + token = request.rel_url.query.get('token') + + if token != self.auth_token: + return web.Response(status=401, text='Unauthorized') + + ws = web.WebSocketResponse() + await ws.prepare(request) + self.ws_clients.add(ws) + + try: + history = self.history_buffer.get_history() + if self.enable_msgpack: + await ws.send_bytes(msgpack.packb({'type': 'history', 'data': history})) + else: + await ws.send_str(json.dumps({'type': 'history', 'data': history})) + + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + try: + req = json.loads(msg.data) + if req.get('cmd') == 'get_history': + history = self.history_buffer.get_history() + if self.enable_msgpack: + await ws.send_bytes(msgpack.packb({'type': 'history', 'data': history})) + else: + await ws.send_str(json.dumps({'type': 'history', 'data': history})) + except Exception as e: + self.get_logger().error(f'WebSocket error: {e}') + + except Exception as e: + self.get_logger().warn(f'WebSocket connection lost: {e}') + finally: + self.ws_clients.discard(ws) + + return ws + + async def index_handler(self, request): + """Serve mobile-friendly HTML UI.""" + html = Path(__file__).parent.parent / 'static' / 'index.html' + if html.exists(): + return web.FileResponse(html) + return web.Response(text='UI not found', status=404) + + def start_ws_server(self): + """Start aiohttp WebSocket server.""" + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + app = web.Application() + app.router.add_get('/', self.index_handler) + app.router.add_get('/ws', self.websocket_handler) + + runner = web.AppRunner(app) + self.loop.run_until_complete(runner.setup()) + site = web.TCPSite(runner, '0.0.0.0', self.ws_port) + self.loop.run_until_complete(site.start()) + + self.get_logger().info(f'WebSocket server running on port {self.ws_port}') + self.loop.run_forever() + + +def main(args=None): + rclpy.init(args=args) + node = RemoteMonitorNode() + + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/setup.cfg b/jetson/ros2_ws/src/saltybot_remote_monitor/setup.cfg new file mode 100644 index 0000000..7daa7ab --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/setup.cfg @@ -0,0 +1,5 @@ +[develop] +script_dir=$base/lib/saltybot_remote_monitor + +[install] +script_dir=$base/lib/saltybot_remote_monitor diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/setup.py b/jetson/ros2_ws/src/saltybot_remote_monitor/setup.py new file mode 100644 index 0000000..638eadd --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/setup.py @@ -0,0 +1,34 @@ +from setuptools import setup +import os +from glob import glob + +package_name = 'saltybot_remote_monitor' + +setup( + name=package_name, + version='0.1.0', + packages=[package_name], + data_files=[ + ('share/ament_index/resource_index/packages', + ['resource/' + package_name]), + ('share/' + package_name, ['package.xml']), + (os.path.join('share', package_name, 'launch'), + glob('launch/*.py')), + (os.path.join('share', package_name, 'config'), + glob('config/*.yaml')), + (os.path.join('share', package_name, 'static'), + glob('static/*')), + ], + install_requires=['setuptools'], + zip_safe=True, + maintainer='seb', + maintainer_email='seb@vayrette.com', + description='Remote monitoring WebSocket relay with mobile UI', + license='MIT', + tests_require=['pytest'], + entry_points={ + 'console_scripts': [ + 'remote_monitor = saltybot_remote_monitor.remote_monitor_node:main', + ], + }, +) diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/static/index.html b/jetson/ros2_ws/src/saltybot_remote_monitor/static/index.html new file mode 100644 index 0000000..e16a673 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/static/index.html @@ -0,0 +1,180 @@ + + + + + + Saltybot Remote Monitor + + + +
+

⚡ REMOTE MONITOR

+
Connecting...
+
+ +
+
+
Battery
+
0%
+
+
0V
+
0A
+
+
+
+
+
Motors
+
+
L: 0%
+
R: 0%
+
+
+
+
Attitude
+
+
R: 0°
+
P: 0°
+
Y: 0°
+
+
+
+
GPS
+
+
Waiting...
+
--
+
-- m
+
+
+
+
CPU Temp
+
0°C
+
+
+
GPU Temp
+
0°C
+
+
+
RAM
+
0%
+
+
+
+
Disk
+
0%
+
+
+
+
Social
+
+
Speaking: No
+
Face: none
+
+
+
+
--
+ + + diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/test/__init__.py b/jetson/ros2_ws/src/saltybot_remote_monitor/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jetson/ros2_ws/src/saltybot_remote_monitor/test/test_remote_monitor.py b/jetson/ros2_ws/src/saltybot_remote_monitor/test/test_remote_monitor.py new file mode 100644 index 0000000..3b7fa67 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_remote_monitor/test/test_remote_monitor.py @@ -0,0 +1,30 @@ +import unittest +from pathlib import Path + + +class TestRemoteMonitor(unittest.TestCase): + """Basic tests for remote monitor functionality.""" + + def test_imports(self): + """Test that the module can be imported.""" + from saltybot_remote_monitor import remote_monitor_node + self.assertIsNotNone(remote_monitor_node) + + def test_config_file_exists(self): + """Test that config file exists.""" + config_file = Path(__file__).parent.parent / 'config' / 'remote_monitor.yaml' + self.assertTrue(config_file.exists()) + + def test_launch_file_exists(self): + """Test that launch file exists.""" + launch_file = Path(__file__).parent.parent / 'launch' / 'remote_monitor.launch.py' + self.assertTrue(launch_file.exists()) + + def test_html_ui_exists(self): + """Test that HTML UI exists.""" + html_file = Path(__file__).parent.parent / 'static' / 'index.html' + self.assertTrue(html_file.exists()) + + +if __name__ == '__main__': + unittest.main()