diff --git a/src/FAST_LIO b/src/FAST_LIO index 35d558a..5547e51 120000 --- a/src/FAST_LIO +++ b/src/FAST_LIO @@ -1 +1 @@ -/home/yoo/FAST_LIO \ No newline at end of file +/home/yoo/duru_lio_ws \ No newline at end of file diff --git a/src/fori_collision_monitor_params.yaml b/src/fori_collision_monitor_params.yaml new file mode 100644 index 0000000..0ee34fe --- /dev/null +++ b/src/fori_collision_monitor_params.yaml @@ -0,0 +1,59 @@ +# Nav2 Collision Monitor: 마지막 안전 게이트. +# +# terrain_speed_node가 낸 /cmd_vel_terrain(Nav2 순찰/자율주행 + parking_controller의 +# 도킹/순찰 명령 모두 여기로 합쳐짐)을 받아서, /scan 라이다 데이터를 기준으로 갑자기 +# 튀어나온 사람/장애물이 있으면 감속하거나(SlowZone) 완전 정지시킨(StopZone) 뒤 최종 +# /cmd_vel로 내보낸다. FootprintApproach는 현재 속도 명령대로 주행했을 때 +# time_before_collision초 안에 로봇 발자국(costmap footprint)이 장애물과 닿는지 +# 시뮬레이션해서 미리 감속하는 예측형 정지 로직이다. +# +# 로봇 사양(fori_nav2_params.yaml의 robot_radius: 0.38m, 순항속도 0.3m/s)에 맞춰 +# 반경/시간을 잡았다. 실제 부지에서 저속 시운전하며 StopZone/SlowZone 반경과 +# slowdown_ratio를 조정할 것을 권장. +collision_monitor: + ros__parameters: + use_sim_time: False + base_frame_id: "base_link" + odom_frame_id: "odom" + cmd_vel_in_topic: "cmd_vel_terrain" + cmd_vel_out_topic: "cmd_vel" + transform_tolerance: 0.5 + source_timeout: 1.0 + base_shift_correction: True + stop_pub_timeout: 2.0 + polygons: ["StopZone", "SlowZone", "FootprintApproach"] + # 로봇 중심에서 0.45m(로봇 반경 0.38m + 여유 0.07m) 이내에 장애물이 잡히면 완전 정지. + StopZone: + type: "circle" + radius: 0.45 + action_type: "stop" + max_points: 3 + visualize: True + polygon_pub_topic: "collision_monitor/stop_zone" + enabled: True + # 0.90m 이내로 접근하면 지령 속도를 40%로 줄여 미리 서행. + SlowZone: + type: "circle" + radius: 0.90 + action_type: "slowdown" + slowdown_ratio: 0.4 + max_points: 3 + visualize: True + polygon_pub_topic: "collision_monitor/slow_zone" + enabled: True + # 현재 속도 명령으로 1.5초 앞을 시뮬레이션해서 로봇 발자국이 장애물과 겹치면 + # 미리 감속 (local_costmap이 robot_radius 기준으로 자동 발행하는 발자국 사용). + FootprintApproach: + type: "polygon" + action_type: "approach" + footprint_topic: "/local_costmap/published_footprint" + time_before_collision: 1.5 + simulation_time_step: 0.1 + max_points: 3 + visualize: False + enabled: True + observation_sources: ["scan"] + scan: + type: "scan" + topic: "/scan" + enabled: True diff --git a/src/fori_nav2.launch.py b/src/fori_nav2.launch.py index 5162bd8..7247755 100644 --- a/src/fori_nav2.launch.py +++ b/src/fori_nav2.launch.py @@ -1,21 +1,32 @@ import os from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription -from launch.actions import IncludeLaunchDescription, SetEnvironmentVariable +from launch.actions import GroupAction, IncludeLaunchDescription, SetEnvironmentVariable from launch.launch_description_sources import PythonLaunchDescriptionSource from launch.substitutions import LaunchConfiguration -from launch_ros.actions import Node +from launch_ros.actions import Node, SetRemap def generate_launch_description(): # --- [경로 설정 - 사용자 환경에 맞춰 수정] --- - # 1. 2D 지도 파일 경로 (데스크탑에 있는 파일 기준) - map_yaml_file = '/home/yoo/fori_map.yaml' + # 1. 2D 지도 파일 경로 (군산 부지 PCD -> 2.5D 변환 결과, pcd_gridmap_converter 산출물) + map_yaml_file = '/home/yoo/pcd_gunsan_output/map.yaml' # 2. Nav2 파라미터 파일 경로 - nav2_params_file = '/home/yoo/fori_ws/src/fori_nav2_params.yaml' + nav2_params_file = os.path.expanduser('~/fori_ws/fori_ws/src/fori_nav2_params.yaml') + if not os.path.exists(nav2_params_file): + nav2_params_file = os.path.expanduser('~/fori_ws/src/fori_nav2_params.yaml') + + # 2b. Collision Monitor 파라미터 파일 경로 (급출현 장애물 감속/정지 안전계층) + collision_monitor_params_file = os.path.expanduser( + '~/fori_ws/fori_ws/src/fori_collision_monitor_params.yaml') + if not os.path.exists(collision_monitor_params_file): + collision_monitor_params_file = os.path.expanduser( + '~/fori_ws/src/fori_collision_monitor_params.yaml') # 3. URDF 파일 경로 (분석된 full workspace 내 경로) - urdf_file_path = os.path.expanduser('~/fori_ws/src/FAST-LIVO2/urdf/fori_robot.urdf') + urdf_file_path = os.path.expanduser('~/fori_ws/fori_ws/src/FAST-LIVO2/urdf/fori_robot.urdf') + if not os.path.exists(urdf_file_path): + urdf_file_path = os.path.expanduser('~/fori_ws/src/FAST-LIVO2/urdf/fori_robot.urdf') # 패키지 경로 획득 nav2_bringup_dir = get_package_share_directory('nav2_bringup') @@ -34,6 +45,46 @@ def generate_launch_description(): output='screen' ) + # 2단계: Static TF for camera_init -> odom (FAST-LIO2/FAST_LIO uses camera_init, Nav2 uses odom) + static_tf_camera_init_node = Node( + package='tf2_ros', + executable='static_transform_publisher', + name='static_tf_camera_init_to_odom', + arguments=['0', '0', '0', '0', '0', '0', 'camera_init', 'odom'], + output='screen' + ) + + # 2b단계: Static TF for camera_init -> map. AMCL이 표준대로 map->odom을 발행하면 + # odom의 부모를 camera_init(위 정적 브릿지)과 map(AMCL)이 동시에 주장하게 돼 tf2 + # 트리가 깨진다(실측으로 base_link<->odom "unconnected trees" 확인함). 그래서 + # AMCL은 tf_broadcast:false로 TF 발행을 끄고(fori_nav2_params.yaml), 대신 map도 + # odom과 마찬가지로 camera_init의 자식으로 정적 연결한다 - map과 odom은 형제라 충돌이 + # 없다. AMCL은 여전히 /amcl_pose(스캔 매칭 기반 정밀 위치)는 정상 발행하므로 + # terrain_speed_node/parking_controller_node의 위치 소스로는 계속 쓸 수 있다. + static_tf_camera_init_to_map_node = Node( + package='tf2_ros', + executable='static_transform_publisher', + name='static_tf_camera_init_to_map', + arguments=['0', '0', '0', '0', '0', '0', 'camera_init', 'map'], + output='screen' + ) + + # 3단계-B: FAST_LIO (카메라 불필요, LiDAR+IMU만 사용하는 LIO). FAST-LIVO2와 달리 + # 카메라 연결 없이도 /cloud_registered + camera_init->aft_mapped TF를 발행해서 + # AMCL이 map->odom을 낼 수 있게 해준다(child_frame_id를 URDF 루트 이름인 + # "aft_mapped"에 맞춰 FAST_LIO/src/laserMapping.cpp를 수정해서 씀 - 원본 FAST_LIO는 + # "body"를 써서 그대로 두면 TF 트리가 끊긴다). 라이다 드라이버 + # (livox_ros_driver2 mid360s_fastlivo_launch.py)는 별도 터미널에서 먼저 띄워야 한다. + fast_lio_dir = get_package_share_directory('fast_lio') + fast_lio_config = os.path.join(fast_lio_dir, 'config', 'mid360s.yaml') + fast_lio_node = Node( + package='fast_lio', + executable='fastlio_mapping', + name='fastlio_mapping', + parameters=[fast_lio_config, {'use_sim_time': False}], + output='screen' + ) + # 3단계-A: 3D PointCloud -> 2D Scan 변환 pc_to_ls_node = Node( package='pointcloud_to_laserscan', @@ -62,8 +113,65 @@ def generate_launch_description(): }.items() ) + # Nav2 humble bringup은 controller_server가 낸 cmd_vel을 velocity_smoother가 받아 + # 감속 램프를 적용한 뒤 'cmd_vel_smoothed'라는 내부 이름으로 최종 'cmd_vel'에 발행한다 + # (navigation_launch.py에 하드코딩된 리맵: controller_server의 ('cmd_vel','cmd_vel_nav'), + # velocity_smoother의 ('cmd_vel','cmd_vel_nav')+('cmd_vel_smoothed','cmd_vel')). 즉 + # 실제로 모터까지 가는 "진짜 최종" 순찰/자율주행 명령은 velocity_smoother의 출력이다. + # 바깥 이름 'cmd_vel'을 통째로 가로채면(처음 시도했던 방식) velocity_smoother 내부의 + # 'cmd_vel'->'cmd_vel_nav' 입력 리맵까지 덮어써서 오히려 스무딩 단계를 건너뛰게 되고, + # 정작 진짜 최종 출력('cmd_vel_smoothed'->'cmd_vel')은 그대로 남아 terrain_speed_node/ + # collision_monitor를 완전히 우회해버린다(실측으로 확인함). 그래서 velocity_smoother의 + # 내부 입력 리맵은 건드리지 않고, 그 최종 출력 이름('cmd_vel_smoothed')만 정확히 + # /cmd_vel_raw로 리다이렉트한다. + # + # [알려진 한계] behavior_server(spin/backup/drive_on_heading 복구 동작)는 + # navigation_launch.py에서 cmd_vel 리맵이 없어 velocity_smoother를 거치지 않고 bare + # 'cmd_vel'에 직접 낸다 - 리맵 스코프가 GroupAction 단위라 controller_server의 + # cmd_vel->cmd_vel_nav 페어링을 건드리지 않으면서 behavior_server만 선택적으로 + # 가로챌 방법이 없다(nav2_bringup 내부 launch 파일을 포크하지 않는 한). 즉 복구 동작 + # 중에는 terrain_speed_node의 경사 보정과 collision_monitor의 급출현 장애물 감속/정지가 + # 적용되지 않는다. 다만 (1) 복구는 costmap이 이미 경로 막힘을 감지한 뒤에만 트리거되고 + # (2) 각 복구 동작 자체가 저속·단거리로 제한돼 있고 (3) serial_bridge_node의 + # target_linear_speed(0.3m/s) 하드 클램프는 경로와 무관하게 항상 적용되므로, 남은 + # 위험은 제한적이다. + nav2_launch_group = GroupAction([ + SetRemap('cmd_vel_smoothed', 'cmd_vel_raw'), + nav2_launch, + ]) + + # 5단계: Collision Monitor (최종 안전 게이트) - /cmd_vel_terrain(=terrain_speed_node + # 출력, Nav2 순찰/자율주행 + parking_controller 도킹/순찰 명령이 모두 여기로 합쳐짐)을 + # 받아 /scan 기준으로 급출현 장애물을 감속/정지시킨 뒤 최종 /cmd_vel로 발행한다. + # Nav2 자체 lifecycle_manager가 관리하는 노드 목록에는 없으므로 별도 lifecycle_manager로 + # 직접 configure/activate 시킨다. + collision_monitor_node = Node( + package='nav2_collision_monitor', + executable='collision_monitor', + name='collision_monitor', + parameters=[collision_monitor_params_file], + output='screen' + ) + + collision_monitor_lifecycle_manager = Node( + package='nav2_lifecycle_manager', + executable='lifecycle_manager', + name='lifecycle_manager_collision_monitor', + parameters=[{ + 'use_sim_time': False, + 'autostart': True, + 'node_names': ['collision_monitor'] + }], + output='screen' + ) + return LaunchDescription([ rsp_node, + static_tf_camera_init_node, + static_tf_camera_init_to_map_node, + fast_lio_node, pc_to_ls_node, - nav2_launch + nav2_launch_group, + collision_monitor_node, + collision_monitor_lifecycle_manager, ]) diff --git a/src/fori_nav2_params.yaml b/src/fori_nav2_params.yaml index bb2bd7e..9bb0bb2 100644 --- a/src/fori_nav2_params.yaml +++ b/src/fori_nav2_params.yaml @@ -1,6 +1,6 @@ map_server: ros__parameters: - yaml_filename: "/home/yoo/fori_map.yaml" + yaml_filename: "/home/yoo/pcd_gunsan_output/map.yaml" use_sim_time: False amcl: ros__parameters: @@ -12,16 +12,23 @@ amcl: alpha4: 0.2 base_frame_id: "base_link" global_frame_id: "map" - odom_frame_id: "camera_init" # FAST-LIO2의 오도메트리 프레임 + odom_frame_id: "odom" scan_topic: "scan" map_topic: "map" set_initial_pose: true + # fast_lio(LIDAR SLAM)가 camera_init->aft_mapped->base_link TF를 이미 발행하고, + # camera_init은 fori_nav2.launch.py의 static_tf_camera_init_node가 odom과 동일시해서 + # 브릿지한다. AMCL이 표준대로 map->odom을 또 발행하면 odom의 부모를 두 군데(camera_init + # 정적 브릿지 vs AMCL의 map->odom)에서 동시에 주장하게 돼 tf2가 base_link<->odom + # 연결을 못 찾는 "unconnected trees" 오류로 이어진다. tf_broadcast를 꺼서 AMCL은 + # /amcl_pose(위치 추정값)만 내고 TF는 발행하지 않게 한다. + tf_broadcast: false behavior_server: ros__parameters: local_frame: base_link global_frame: map - odom_frame: camera_init # <--- 이 부분이 odom 에러를 해결합니다. + odom_frame: odom use_sim_time: False device_id: "robot" simulate_ahead_time: 2.0 @@ -31,7 +38,12 @@ bt_navigator: use_sim_time: False global_frame: map robot_base_frame: base_link - odom_topic: /aft_mapped_to_init # FAST-LIO2의 위치 토픽 + odom_topic: /odom + default_nav_to_pose_bt_xml: "" # Use Nav2 built-in default BehaviorTree + default_nav_through_poses_bt_xml: "" # Use Nav2 built-in default BehaviorTree + bt_loop_duration: 10 + default_server_timeout: 20 + wait_for_service_timeout: 1000 controller_server: ros__parameters: @@ -50,7 +62,7 @@ controller_server: yaw_goal_tolerance: 0.35 FollowPath: plugin: "nav2_regulated_pure_pursuit_controller::RegulatedPurePursuitController" - desired_linear_vel: 0.25 + desired_linear_vel: 0.3 lookahead_dist: 0.6 min_lookahead_dist: 0.3 max_lookahead_dist: 0.9 @@ -65,7 +77,7 @@ local_costmap: ros__parameters: update_frequency: 5.0 publish_frequency: 2.0 - global_frame: camera_init # 지역 지도는 오도메트리 기준 + global_frame: odom robot_base_frame: base_link use_sim_time: False transform_tolerance: 1.0 diff --git a/src/fori_serial_bridge/fori_serial_bridge/__pycache__/parking_controller_node.cpython-310.pyc b/src/fori_serial_bridge/fori_serial_bridge/__pycache__/parking_controller_node.cpython-310.pyc index 3be71fd..059a49c 100644 Binary files a/src/fori_serial_bridge/fori_serial_bridge/__pycache__/parking_controller_node.cpython-310.pyc and b/src/fori_serial_bridge/fori_serial_bridge/__pycache__/parking_controller_node.cpython-310.pyc differ diff --git a/src/fori_serial_bridge/fori_serial_bridge/__pycache__/serial_bridge_node.cpython-310.pyc b/src/fori_serial_bridge/fori_serial_bridge/__pycache__/serial_bridge_node.cpython-310.pyc index 2015449..3506907 100644 Binary files a/src/fori_serial_bridge/fori_serial_bridge/__pycache__/serial_bridge_node.cpython-310.pyc and b/src/fori_serial_bridge/fori_serial_bridge/__pycache__/serial_bridge_node.cpython-310.pyc differ diff --git a/src/fori_serial_bridge/fori_serial_bridge/__pycache__/ui_server_node.cpython-310.pyc b/src/fori_serial_bridge/fori_serial_bridge/__pycache__/ui_server_node.cpython-310.pyc index be3c52b..f9e1296 100644 Binary files a/src/fori_serial_bridge/fori_serial_bridge/__pycache__/ui_server_node.cpython-310.pyc and b/src/fori_serial_bridge/fori_serial_bridge/__pycache__/ui_server_node.cpython-310.pyc differ diff --git a/src/fori_serial_bridge/fori_serial_bridge/odom_relay_node.py b/src/fori_serial_bridge/fori_serial_bridge/odom_relay_node.py new file mode 100644 index 0000000..457ed1a --- /dev/null +++ b/src/fori_serial_bridge/fori_serial_bridge/odom_relay_node.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Simple relay node: /odom_wheels -> /odom +Provides standard /odom topic for Nav2 from wheel odometry source. +No external topic_tools package dependency required. +""" +import rclpy +from rclpy.node import Node +from nav_msgs.msg import Odometry +from rclpy.qos import qos_profile_sensor_data + + +class OdomRelayNode(Node): + def __init__(self): + super().__init__('odom_relay') + self.pub = self.create_publisher(Odometry, '/odom', 10) + self.sub = self.create_subscription( + Odometry, '/odom_wheels', self.callback, qos_profile_sensor_data + ) + self.get_logger().info('OdomRelay: /odom_wheels -> /odom') + + def callback(self, msg): + # Ensure frame_id is 'odom' for Nav2 compatibility + msg.header.frame_id = 'odom' + msg.child_frame_id = 'base_link' + self.pub.publish(msg) + + +def main(args=None): + rclpy.init(args=args) + node = OdomRelayNode() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/src/fori_serial_bridge/fori_serial_bridge/parking_controller_node.py b/src/fori_serial_bridge/fori_serial_bridge/parking_controller_node.py index 89a5e59..64a2ddc 100755 --- a/src/fori_serial_bridge/fori_serial_bridge/parking_controller_node.py +++ b/src/fori_serial_bridge/fori_serial_bridge/parking_controller_node.py @@ -39,14 +39,12 @@ class ParkingControllerNode(Node): self.kp_yaw = self.get_parameter('kp_yaw').value # Declare patrol waypoints as parameter (flat list of x, y, yaw) - # Default patrol coordinates following the 100% obstacle-free map circuit - default_patrol = [ - 1.10, -1.35, 2.35, - -2.20, 2.00, 3.14, - -3.00, 0.50, -1.57, - -1.80, 0.80, -0.78, - -0.50, 0.00, -0.78 - ] + # No default coordinates: these were tuned for the old indoor map and are + # meaningless (possibly unsafe) on the new Gunsan site map. Patrol falls back + # to "go home and scan" until the user supplies real waypoints for the new + # map via this parameter (see send_patrol_goal/advance_patrol below, which + # already append the home position regardless of this list's length). + default_patrol = [] self.declare_parameter('patrol_waypoints', default_patrol) self.patrol_waypoints_raw = self.get_parameter('patrol_waypoints').value self.patrol_waypoints = [] @@ -57,7 +55,14 @@ class ParkingControllerNode(Node): 'y': self.patrol_waypoints_raw[i+1], 'yaw': self.patrol_waypoints_raw[i+2] }) - + + if not self.patrol_waypoints: + self.get_logger().warn( + '[PATROL] patrol_waypoints 파라미터가 비어 있습니다 (신규 군산 지도용 좌표 미설정). ' + '순찰 시작 시 홈 위치에서 360도 스캔만 수행합니다. rviz2로 새 지도를 확인한 뒤 ' + 'patrol_waypoints 파라미터로 실제 순찰 좌표를 설정하세요.' + ) + self.patrol_targets = [] self.patrol_index = 0 self.patrol_start_x = 0.0 @@ -136,6 +141,18 @@ class ParkingControllerNode(Node): self.create_subscription(Odometry, '/odom_wheels', self.odom_callback, qos_profile_sensor_data) self.create_subscription(PoseStamped, '/goal_pose', self.mock_nav2_goal_callback, 10) self.create_subscription(PoseWithCovarianceStamped, '/initialpose', self.initial_pose_callback, 10) + + # AMCL pose subscriber (Real AGV mode: map-frame accurate position) + # Used to update sim_x/sim_y/sim_yaw with real localization data + from rclpy.qos import QoSReliabilityPolicy + amcl_qos = QoSProfile(depth=5, + reliability=QoSReliabilityPolicy.BEST_EFFORT, + durability=DurabilityPolicy.VOLATILE) + self.last_amcl_time = None + self.create_subscription( + PoseWithCovarianceStamped, '/amcl_pose', + self.amcl_pose_callback, amcl_qos + ) # ROS2 OccupancyGrid map server uses TRANSIENT_LOCAL durability. map_qos = QoSProfile(depth=1, durability=DurabilityPolicy.TRANSIENT_LOCAL) @@ -173,6 +190,21 @@ class ParkingControllerNode(Node): self.send_nav2_goal() def odom_callback(self, msg): + # Only use wheel odom if AMCL hasn't updated in the last 3 seconds + if self.last_amcl_time is not None: + elapsed = (self.get_clock().now() - self.last_amcl_time).nanoseconds / 1e9 + if elapsed < 3.0: + return # AMCL data is fresher, skip wheel odom update + self.sim_x = msg.pose.pose.position.x + self.sim_y = msg.pose.pose.position.y + q = msg.pose.pose.orientation + siny_cosp = 2 * (q.w * q.z + q.x * q.y) + cosy_cosp = 1 - 2 * (q.y * q.y + q.z * q.z) + self.sim_yaw = math.atan2(siny_cosp, cosy_cosp) + + def amcl_pose_callback(self, msg): + """Primary pose update for Real AGV mode (map frame, AMCL localization).""" + self.last_amcl_time = self.get_clock().now() self.sim_x = msg.pose.pose.position.x self.sim_y = msg.pose.pose.position.y q = msg.pose.pose.orientation diff --git a/src/fori_serial_bridge/fori_serial_bridge/serial_bridge_node.py b/src/fori_serial_bridge/fori_serial_bridge/serial_bridge_node.py index f25bcdb..1116698 100755 --- a/src/fori_serial_bridge/fori_serial_bridge/serial_bridge_node.py +++ b/src/fori_serial_bridge/fori_serial_bridge/serial_bridge_node.py @@ -220,68 +220,98 @@ class ForiSerialBridge(Node): msg.design_capacity = 60.0 # 60Ah LiFePO4 msg.capacity = 60.0 # 60Ah LiFePO4 msg.power_supply_technology = BatteryState.POWER_SUPPLY_TECHNOLOGY_LIFE # LiFePO4 - - voltage = 25.1 # Default nominal voltage for mock simulation mode + + # === LiFePO4 8S Battery Voltage Bounds === + # Full charge: 8 × 3.65V = 29.2V + # Cutoff: 8 × 2.80V = 22.4V + v_min = 22.4 + v_max = 29.2 + + # Initialize last-valid-voltage storage + if not hasattr(self, 'last_valid_voltage'): + self.last_valid_voltage = None # None = no valid reading yet + + voltage = None # Will be set from Modbus or fallback + if not self.is_mock_mode and self.control_method == 'direct_pc': try: - # Read both 0x20A0 (External Voltage) and 0x20A1 (Bus Voltage) + # Read registers 0x20A0 (External Voltage) and 0x20A1 (Bus Voltage) v_resp = self.modbus.read_registers(1, 0x20A0, 2) if v_resp and len(v_resp) > 0: raw_a0 = abs(v_resp[0]) raw_a1 = abs(v_resp[1]) if len(v_resp) > 1 else raw_a0 - - # Uncalibrated raw voltage from driver sensor + + # ZLAC8015D reports voltage in units of 0.1V (e.g. 283 = 28.3V) + # or sometimes 0.01V (e.g. 2830 = 28.3V). Detect scale by range. raw_v = 0.0 - if 200 <= raw_a0 <= 320: + if 200 <= raw_a0 <= 320: # 20.0V ~ 32.0V in 0.1V steps raw_v = raw_a0 * 0.1 - elif 2000 <= raw_a0 <= 3200: + elif 2000 <= raw_a0 <= 3200: # 20.0V ~ 32.0V in 0.01V steps raw_v = raw_a0 * 0.01 elif 200 <= raw_a1 <= 320: raw_v = raw_a1 * 0.1 elif 2000 <= raw_a1 <= 3200: raw_v = raw_a1 * 0.01 else: + # Fallback guess raw_v = raw_a1 * 0.01 if raw_a1 > 1000 else (raw_a1 * 0.1 if raw_a1 > 100 else float(raw_a1)) - - # Offset-Linear Calibration for 25.1V Multimeter Baseline - # Midpoint of driver's raw reading band for 25.1V is 28.2V - raw_baseline = 28.2 - voltage = 25.1 + (raw_v - raw_baseline) * 0.88536 - - self.get_logger().info(f'[BATTERY TELEMETRY] Driver Raw: {raw_v:.2f}V -> Calibrated Real Battery: {voltage:.2f}V', throttle_duration_sec=3.0) + + # Sanity check: plausible battery voltage range (20V ~ 32V) + if 20.0 <= raw_v <= 32.0: + voltage = raw_v + self.last_valid_voltage = voltage + self.get_logger().info( + f'[BATTERY] Driver raw: {raw_v:.2f}V | ' + f'{max(0.0, min(100.0, (raw_v - v_min) / (v_max - v_min) * 100)):.1f}%', + throttle_duration_sec=3.0 + ) + else: + self.get_logger().warn( + f'[BATTERY] Raw voltage out of plausible range: raw_a0={raw_a0}, raw_a1={raw_a1}, computed={raw_v:.2f}V', + throttle_duration_sec=5.0 + ) + else: + self.get_logger().warn('[BATTERY] Modbus read returned empty response.', throttle_duration_sec=5.0) except Exception as e: - pass - - # 2. Sliding Window Median + Heavy EMA Filter (eliminates IR drop jumps & Modbus spikes) + self.get_logger().warn(f'[BATTERY] Modbus read failed: {e}', throttle_duration_sec=5.0) + + # Fallback priority: last valid reading > mock nominal + if voltage is None: + if self.last_valid_voltage is not None: + voltage = self.last_valid_voltage + self.get_logger().warn('[BATTERY] Using last valid voltage reading as fallback.', throttle_duration_sec=10.0) + else: + # Absolute fallback: use midpoint of nominal LiFePO4 8S range + voltage = (v_min + v_max) / 2.0 # ~25.8V nominal + self.get_logger().warn(f'[BATTERY] No valid reading yet - using nominal fallback: {voltage:.1f}V', throttle_duration_sec=10.0) + + # Sliding Window Median + Heavy EMA Filter (eliminates Modbus spikes) if not hasattr(self, 'voltage_history'): import collections self.voltage_history = collections.deque(maxlen=10) - + self.voltage_history.append(voltage) - - # Median filtering to reject any sudden Modbus voltage spike + sorted_v = sorted(self.voltage_history) median_v = sorted_v[len(sorted_v) // 2] - + if not hasattr(self, 'filtered_battery_voltage') or self.filtered_battery_voltage is None: self.filtered_battery_voltage = median_v else: - # Heavy EMA smoothing (Alpha = 0.05 for 10-second smooth transition) + # EMA smoothing (Alpha=0.05 = ~10s transition) self.filtered_battery_voltage = self.filtered_battery_voltage * 0.95 + median_v * 0.05 - + voltage = self.filtered_battery_voltage - - # 3. 24V LiFePO4 8S battery voltage bounds: Full = 28.0V, Cutoff = 21.6V - v_min = 21.6 - v_max = 28.0 + percentage = (voltage - v_min) / (v_max - v_min) percentage = max(0.0, min(1.0, percentage)) - + msg.voltage = float(voltage) msg.percentage = float(percentage) msg.power_supply_status = BatteryState.POWER_SUPPLY_STATUS_DISCHARGING self.battery_pub.publish(msg) + def init_direct_drivers(self): """ Initializes ZLAC8015D parameters (Operating mode to Velocity Control). diff --git a/src/fori_serial_bridge/fori_serial_bridge/terrain_speed_node.py b/src/fori_serial_bridge/fori_serial_bridge/terrain_speed_node.py new file mode 100644 index 0000000..27c0b5b --- /dev/null +++ b/src/fori_serial_bridge/fori_serial_bridge/terrain_speed_node.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Terrain-aware cmd_vel speed shaper. + +Sits between raw velocity command producers (Nav2's controller_server, remapped in +fori_nav2.launch.py, and parking_controller_node's own patrol/docking/mock-drive +commands, remapped in fori_full.launch.py) and the real /cmd_vel that +serial_bridge_node drives the motors from. Looks up the 2.5D elevation map +(pcd_gridmap_converter output) at the robot's current position and a short +lookahead point in the direction of travel, computes the local grade, and scales +the commanded forward speed: faster uphill (more torque margin needed), slower +downhill (safer descent). serial_bridge_node's own target_linear_speed clamp is +still the final hard safety ceiling regardless of this node's output. +""" +import math + +import numpy as np +import rclpy +import yaml +from geometry_msgs.msg import PoseWithCovarianceStamped, Twist +from nav_msgs.msg import Odometry +from rclpy.node import Node +from rclpy.qos import DurabilityPolicy, QoSProfile, QoSReliabilityPolicy, qos_profile_sensor_data + + +def _read_pgm_token(f): + token = b'' + while True: + c = f.read(1) + if not c: + raise EOFError('Unexpected EOF while reading PGM header') + if c in b' \t\n\r': + if token: + return token + continue + if c == b'#': + while c not in (b'\n', b''): + c = f.read(1) + continue + token += c + + +class ElevationMap: + """Loads a pcd_gridmap_converter elevation_map.pgm + elevation_map.yaml pair. + + Pixel 0 means unknown. Pixel 1..255 linearly encodes height between + min_elevation and max_elevation (see pcd_to_gridmap_node.cpp's + publishAndSaveMaps()). Image rows are stored top-to-bottom for the highest-y + grid row first, mirroring how nav2_map_server decodes map.pgm. + """ + + def __init__(self, pgm_path, yaml_path, logger=None): + self.logger = logger + self.valid = False + try: + with open(yaml_path, 'r') as f: + meta = yaml.safe_load(f) + self.resolution = float(meta['resolution']) + origin = meta['origin'] + self.origin_x = float(origin[0]) + self.origin_y = float(origin[1]) + self.width = int(meta['width']) + self.height = int(meta['height']) + self.min_elevation = float(meta['min_elevation']) + self.max_elevation = float(meta['max_elevation']) + self.unknown_value = int(meta.get('unknown_value', 0)) + + with open(pgm_path, 'rb') as f: + magic = _read_pgm_token(f) + if magic != b'P5': + raise ValueError(f'Unsupported PGM magic: {magic!r}') + w = int(_read_pgm_token(f)) + h = int(_read_pgm_token(f)) + _read_pgm_token(f) # maxval, assumed 255 + data = f.read(w * h) + + if len(data) != w * h: + raise ValueError('Truncated PGM pixel data') + if (w, h) != (self.width, self.height): + raise ValueError(f'PGM size {w}x{h} does not match yaml size {self.width}x{self.height}') + + self.pixels = np.frombuffer(data, dtype=np.uint8).reshape((h, w)) + self.valid = True + except Exception as e: # noqa: BLE001 - any load failure -> safe passthrough mode + if self.logger: + self.logger.error(f'[ELEVATION] 고도 지도 로드 실패 ({pgm_path}): {e}') + + def lookup(self, x, y): + if not self.valid: + return None + col = int(math.floor((x - self.origin_x) / self.resolution)) + grid_y = int(math.floor((y - self.origin_y) / self.resolution)) + if col < 0 or col >= self.width or grid_y < 0 or grid_y >= self.height: + return None + image_row = (self.height - 1) - grid_y + pixel = int(self.pixels[image_row, col]) + if pixel == self.unknown_value: + return None + return self.min_elevation + (pixel - 1) / 254.0 * (self.max_elevation - self.min_elevation) + + +class TerrainSpeedNode(Node): + def __init__(self): + super().__init__('terrain_speed_node') + + self.declare_parameter('elevation_pgm', '/home/yoo/pcd_gunsan_output/elevation_map.pgm') + self.declare_parameter('elevation_yaml', '/home/yoo/pcd_gunsan_output/elevation_map.yaml') + # 기본값은 /cmd_vel (목 모드 등 collision_monitor가 없는 구성에서 안전한 기본). + # 실기체 모드에서는 fori_full.launch.py가 이 값을 /cmd_vel_terrain으로 넘겨서 + # collision_monitor(급출현 장애물 감속/정지)가 마지막 안전 게이트로 끼어들게 한다. + self.declare_parameter('output_topic', '/cmd_vel') + self.declare_parameter('lookahead_dist', 0.5) + self.declare_parameter('uphill_gain', 3.0) + self.declare_parameter('downhill_gain', 4.0) + self.declare_parameter('max_boost', 1.3) + self.declare_parameter('min_scale', 0.5) + self.declare_parameter('flat_deadband', 0.02) + self.declare_parameter('smoothing_alpha', 0.25) + + self.lookahead_dist = self.get_parameter('lookahead_dist').value + self.uphill_gain = self.get_parameter('uphill_gain').value + self.downhill_gain = self.get_parameter('downhill_gain').value + self.max_boost = self.get_parameter('max_boost').value + self.min_scale = self.get_parameter('min_scale').value + self.flat_deadband = self.get_parameter('flat_deadband').value + self.smoothing_alpha = self.get_parameter('smoothing_alpha').value + + pgm_path = self.get_parameter('elevation_pgm').value + yaml_path = self.get_parameter('elevation_yaml').value + self.elevation = ElevationMap(pgm_path, yaml_path, logger=self.get_logger()) + if self.elevation.valid: + self.get_logger().info( + f'[TERRAIN] 고도 지도 로드 완료: {pgm_path} ' + f'({self.elevation.width}x{self.elevation.height}px, ' + f'{self.elevation.min_elevation:.2f}m ~ {self.elevation.max_elevation:.2f}m)' + ) + else: + self.get_logger().warn( + '[TERRAIN] 고도 지도를 사용할 수 없어 경사 보정 없이 /cmd_vel_raw를 그대로 통과시킵니다.' + ) + + self.pose_x = 0.0 + self.pose_y = 0.0 + self.pose_yaw = 0.0 + self.last_amcl_time = None + self.scale = 1.0 + + # AMCL(맵 기준 정밀 위치)을 우선 사용하고, 3초 이상 갱신이 없으면 휠 오도메트리로 + # 대체한다 - parking_controller_node.py의 amcl_pose_callback/odom_callback과 동일한 + # freshness 기준. + amcl_qos = QoSProfile(depth=5, + reliability=QoSReliabilityPolicy.BEST_EFFORT, + durability=DurabilityPolicy.VOLATILE) + self.create_subscription(PoseWithCovarianceStamped, '/amcl_pose', self.amcl_pose_callback, amcl_qos) + self.create_subscription(Odometry, '/odom_wheels', self.odom_callback, qos_profile_sensor_data) + self.create_subscription(Twist, '/cmd_vel_raw', self.cmd_vel_callback, 10) + output_topic = self.get_parameter('output_topic').value + self.cmd_vel_pub = self.create_publisher(Twist, output_topic, 10) + self.get_logger().info(f'[TERRAIN] 출력 토픽: {output_topic}') + + def _update_pose(self, x, y, q): + self.pose_x = x + self.pose_y = y + siny_cosp = 2 * (q.w * q.z + q.x * q.y) + cosy_cosp = 1 - 2 * (q.y * q.y + q.z * q.z) + self.pose_yaw = math.atan2(siny_cosp, cosy_cosp) + + def amcl_pose_callback(self, msg): + self.last_amcl_time = self.get_clock().now() + self._update_pose(msg.pose.pose.position.x, msg.pose.pose.position.y, msg.pose.pose.orientation) + + def odom_callback(self, msg): + if self.last_amcl_time is not None: + elapsed = (self.get_clock().now() - self.last_amcl_time).nanoseconds / 1e9 + if elapsed < 3.0: + return + self._update_pose(msg.pose.pose.position.x, msg.pose.pose.position.y, msg.pose.pose.orientation) + + def cmd_vel_callback(self, msg): + out = Twist() + out.angular.z = msg.angular.z + + # 제자리 회전이나 지도가 없는 경우엔 경사 보정 없이 그대로 통과 (안전 기본값) + if not self.elevation.valid or abs(msg.linear.x) < 0.02: + out.linear.x = msg.linear.x + self.scale = 1.0 + self.cmd_vel_pub.publish(out) + return + + direction = 1.0 if msg.linear.x >= 0.0 else -1.0 + to_x = self.pose_x + direction * math.cos(self.pose_yaw) * self.lookahead_dist + to_y = self.pose_y + direction * math.sin(self.pose_yaw) * self.lookahead_dist + + z_from = self.elevation.lookup(self.pose_x, self.pose_y) + z_to = self.elevation.lookup(to_x, to_y) + + if z_from is None or z_to is None: + raw_scale = 1.0 + else: + grade = (z_to - z_from) / self.lookahead_dist + if abs(grade) < self.flat_deadband: + raw_scale = 1.0 + elif grade > 0.0: + raw_scale = 1.0 + self.uphill_gain * grade # 오르막: 속도 부스트 + else: + raw_scale = 1.0 + self.downhill_gain * grade # 내리막: 속도 감쇠 (grade<0) + raw_scale = max(self.min_scale, min(self.max_boost, raw_scale)) + + # 셀 경계를 지날 때 속도가 튀지 않도록 EMA로 스무딩 + self.scale = self.scale * (1.0 - self.smoothing_alpha) + raw_scale * self.smoothing_alpha + out.linear.x = msg.linear.x * self.scale + self.cmd_vel_pub.publish(out) + + +def main(args=None): + rclpy.init(args=args) + node = TerrainSpeedNode() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/src/fori_serial_bridge/fori_serial_bridge/ui_server_node.py b/src/fori_serial_bridge/fori_serial_bridge/ui_server_node.py index 83ba729..68b6494 100755 --- a/src/fori_serial_bridge/fori_serial_bridge/ui_server_node.py +++ b/src/fori_serial_bridge/fori_serial_bridge/ui_server_node.py @@ -25,7 +25,9 @@ class UIServerNode(Node): if not os.path.exists(self.ui_dir): # Fallback to source directory for development / symlink install - self.ui_dir = os.path.expanduser('~/fori_ws/src/fori_serial_bridge/ui') + self.ui_dir = os.path.expanduser('~/fori_ws/fori_ws/src/fori_serial_bridge/ui') + if not os.path.exists(self.ui_dir): + self.ui_dir = os.path.expanduser('~/fori_ws/src/fori_serial_bridge/ui') self.get_logger().info(f'Serving UI files from directory: {self.ui_dir}') @@ -34,22 +36,35 @@ class UIServerNode(Node): self.server_thread.start() def start_server(self): - # Change working directory of the handler to UI directory - os.chdir(self.ui_dir) - handler = http.server.SimpleHTTPRequestHandler + # Use a custom handler that serves from the UI directory without changing + # the global working directory (which would break other ROS node operations) + ui_dir = self.ui_dir + + class UIHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=ui_dir, **kwargs) + + def log_message(self, format, *args): + pass # Suppress HTTP access logs in ROS terminal output try: - self.server = ThreadingHTTPServer(("", self.port), handler) + self.server = ThreadingHTTPServer(("", self.port), UIHandler) self.get_logger().info('==========================================') self.get_logger().info(f' Web UI Dashboard server launched! ') self.get_logger().info(f' Connect via: http://localhost:{self.port} ') self.get_logger().info('==========================================') - # Automatically open default web browser - try: - webbrowser.open(f"http://localhost:{self.port}") - except Exception as browser_err: - self.get_logger().warn(f'Failed to auto-open web browser: {browser_err}') + # Automatically open default web browser after 1 second delay + def auto_open_browser(): + url = f"http://localhost:{self.port}" + try: + opened = webbrowser.open(url) + if not opened: + os.system(f"xdg-open {url} >/dev/null 2>&1 &") + except Exception: + os.system(f"xdg-open {url} >/dev/null 2>&1 &") + + threading.Timer(1.0, auto_open_browser).start() self.server.serve_forever() except Exception as e: diff --git a/src/fori_serial_bridge/launch/fori_full.launch.py b/src/fori_serial_bridge/launch/fori_full.launch.py index 5c90881..19ca0cb 100644 --- a/src/fori_serial_bridge/launch/fori_full.launch.py +++ b/src/fori_serial_bridge/launch/fori_full.launch.py @@ -14,8 +14,11 @@ def generate_launch_description(): serial_bridge_dir = get_package_share_directory('fori_serial_bridge') hik_camera_dir = get_package_share_directory('hik_camera_ros2_driver') - # Map configuration file path - map_yaml_file = '/home/yoo/fori_map.yaml' + # Map configuration file path (군산 부지 PCD -> 2.5D 변환 결과, pcd_gridmap_converter 산출물) + map_output_dir = '/home/yoo/pcd_gunsan_output' + map_yaml_file = os.path.join(map_output_dir, 'map.yaml') + elevation_pgm_file = os.path.join(map_output_dir, 'elevation_map.pgm') + elevation_yaml_file = os.path.join(map_output_dir, 'elevation_map.yaml') # 2. Include Hikrobot Camera launch description (pass camera config to isolate it from Nav2 parameters) camera_launch = IncludeLaunchDescription( @@ -36,18 +39,26 @@ def generate_launch_description(): ) # 4. Launch Motor Bridge Node (ZLAC8015D direct PC RS485 Mode by default) + # C++ port (fori_serial_bridge_cpp) replaces the Python node here: same + # topics/params, but a CRC-validated low-latency serial transport removes + # the Python control-loop jitter/delay. motor_bridge_node = Node( - package='fori_serial_bridge', + package='fori_serial_bridge_cpp', executable='serial_bridge_node', name='serial_bridge_node', parameters=[{ 'control_method': 'direct_pc', 'port': '/dev/ttyUSB0', 'baud': 115200, - 'target_linear_speed': 0.5, + 'target_linear_speed': 0.3, 'accel_limit': 0.5, 'wheel_base': 0.374, - 'wheel_radius': 0.127 + 'wheel_radius': 0.127, + # 실기체 모드는 fori_nav2.launch.py가 fast_lio(LIDAR SLAM)를 같이 띄워서 + # camera_init->aft_mapped->base_link TF를 이미 발행하므로, 여기서 휠 + # 오도메트리로 odom->base_link를 또 쏘면 base_link 부모가 충돌한다. 모의 + # 모드(SLAM 없음)에서만 휠 오도메트리 TF를 켠다. + 'publish_odom_tf': mock_mode_param }], output='screen' ) @@ -69,18 +80,47 @@ def generate_launch_description(): 'mock_mode': mock_mode_param }], remappings=[ - ('/cmd_vel', '/cmd_vel') # Keep topic clean + # 최종 속도 명령이 아니라 원시(raw) 명령으로 발행한다. terrain_speed_node가 + # 이 값을 경사도에 맞게 보정한 뒤 실제 /cmd_vel로 발행한다. + ('/cmd_vel', '/cmd_vel_raw') ], output='screen' ) - + + # 5b. Terrain-aware speed shaper: /cmd_vel_raw(parking_controller의 모의주행/도킹/순찰 + # 명령 + Nav2 controller_server가 fori_nav2.launch.py에서 리맵되어 들어오는 실주행 + # 명령)를 받아 진행방향 지면 경사(오르막/내리막)에 맞춰 선속도를 보정한 뒤 발행한다. + # 이 노드가 없으면 /cmd_vel_raw -> /cmd_vel 연결이 끊겨 로봇이 전혀 움직이지 않으므로 + # 항상 실행되어야 한다. + # 실기체 모드에서는 fori_nav2.launch.py에 있는 collision_monitor(급출현 장애물 + # 감속/정지 안전계층)가 마지막 게이트로 끼어들 수 있게 /cmd_vel_terrain으로 낸다. + # 목 모드는 collision_monitor가 없으므로 기본값인 /cmd_vel로 그대로 발행. + terrain_output_topic = '/cmd_vel_terrain' if not mock_mode_param else '/cmd_vel' + terrain_speed_node = Node( + package='fori_serial_bridge', + executable='terrain_speed_node', + name='terrain_speed_node', + parameters=[{ + 'elevation_pgm': elevation_pgm_file, + 'elevation_yaml': elevation_yaml_file, + 'output_topic': terrain_output_topic, + }], + output='screen' + ) + # 6. Launch ROSbridge WebSocket Server (run node directly to bypass XML syntax conflict) + # max_message_size 기본값(1MB)보다 큰 메시지(/map GetMap 응답 등, 약 2.5MB)는 rosbridge가 + # 'fragment' 프로토콜 메시지로 쪼개서 보내는데, 대시보드가 쓰는 roslib.js(CDN, + # index.html)는 fragment 재조립을 구현하지 않아 이 경우 메시지가 그냥 유실된다(맵만 + # 계속 빈 화면으로 보이는 원인이었음). 맵을 쪼개지지 않는 단일 메시지로 보내도록 상한을 + # 넉넉히(20MB) 올려서 아예 fragmentation 경로를 타지 않게 한다. rosbridge_node = Node( package='rosbridge_server', executable='rosbridge_websocket', name='rosbridge_websocket', parameters=[{ - 'port': 9090 + 'port': 9090, + 'max_message_size': 20000000 }], output='screen' ) @@ -117,6 +157,7 @@ def generate_launch_description(): ld.add_action(aruco_detector_node) ld.add_action(motor_bridge_node) ld.add_action(parking_controller_node) + ld.add_action(terrain_speed_node) ld.add_action(rosbridge_node) ld.add_action(ui_server_node) ld.add_action(web_video_server_node) @@ -158,7 +199,9 @@ def generate_launch_description(): ) # 4. Robot State Publisher (URDF) to define joints/frames - urdf_file_path = os.path.expanduser('~/fori_ws/src/FAST-LIVO2/urdf/fori_robot.urdf') + urdf_file_path = os.path.expanduser('~/fori_ws/fori_ws/src/FAST-LIVO2/urdf/fori_robot.urdf') + if not os.path.exists(urdf_file_path): + urdf_file_path = os.path.expanduser('~/fori_ws/src/FAST-LIVO2/urdf/fori_robot.urdf') with open(urdf_file_path, 'r') as infp: robot_desc = infp.read() @@ -176,9 +219,23 @@ def generate_launch_description(): ld.add_action(rsp_node) else: # In real mode, include the full Nav2/AMCL/LIDAR localization launch - nav2_launch = IncludeLaunchDescription( - PythonLaunchDescriptionSource('/home/yoo/fori_ws/src/fori_nav2.launch.py') + nav2_launch_path = os.path.expanduser('~/fori_ws/fori_ws/src/fori_nav2.launch.py') + if not os.path.exists(nav2_launch_path): + nav2_launch_path = os.path.expanduser('~/fori_ws/src/fori_nav2.launch.py') + if os.path.exists(nav2_launch_path): + nav2_launch = IncludeLaunchDescription( + PythonLaunchDescriptionSource(nav2_launch_path) + ) + ld.add_action(nav2_launch) + + # Relay /odom_wheels -> /odom so Nav2 receives standard odometry + # (Uses internal odom_relay_node - no topic_tools dependency needed) + odom_relay_node = Node( + package='fori_serial_bridge', + executable='odom_relay_node', + name='odom_relay', + output='screen' ) - ld.add_action(nav2_launch) + ld.add_action(odom_relay_node) return ld diff --git a/src/fori_serial_bridge/setup.py b/src/fori_serial_bridge/setup.py index c3998ce..28075ff 100644 --- a/src/fori_serial_bridge/setup.py +++ b/src/fori_serial_bridge/setup.py @@ -11,7 +11,7 @@ setup( ['resource/' + package_name]), ('share/' + package_name, ['package.xml']), ('share/' + package_name + '/launch', ['launch/fori_full.launch.py']), - ('share/' + package_name + '/ui', ['ui/index.html', 'ui/style.css', 'ui/app.js']), + ('share/' + package_name + '/ui', ['ui/index.html', 'ui/style.css', 'ui/app.js', 'ui/manifest.json', 'ui/sw.js']), ], install_requires=['setuptools'], zip_safe=True, @@ -24,7 +24,9 @@ setup( 'console_scripts': [ 'serial_bridge_node = fori_serial_bridge.serial_bridge_node:main', 'parking_controller_node = fori_serial_bridge.parking_controller_node:main', - 'ui_server_node = fori_serial_bridge.ui_server_node:main' + 'ui_server_node = fori_serial_bridge.ui_server_node:main', + 'odom_relay_node = fori_serial_bridge.odom_relay_node:main', + 'terrain_speed_node = fori_serial_bridge.terrain_speed_node:main', ], }, ) diff --git a/src/fori_serial_bridge/ui/app.js b/src/fori_serial_bridge/ui/app.js index 34c09ba..18edb0d 100644 --- a/src/fori_serial_bridge/ui/app.js +++ b/src/fori_serial_bridge/ui/app.js @@ -18,6 +18,8 @@ ros.on('connection', () => { statusIndicator.className = 'pulse-indicator green'; statusText.innerText = 'Connected'; console.log('Connected to rosbridge WebSocket server.'); + // Request map immediately via service (handles transient_local QoS on reconnect) + setTimeout(() => requestMapFromService(), 1500); }); ros.on('error', (error) => { @@ -83,20 +85,78 @@ const arucoPoseSub = new ROSLIB.Topic({ throttle_rate: 50 }); -// Map subscriber (throttled to 1Hz since maps update infrequently) +// Map subscriber const mapSub = new ROSLIB.Topic({ ros: ros, name: '/map', messageType: 'nav_msgs/msg/OccupancyGrid', - throttle_rate: 1000 + throttle_rate: 2000 }); -// Odometry subscriber (throttled to 10Hz for smoother rendering without CPU spikes) +// Map service client - to request map from map_server after connection +const mapServiceClient = new ROSLIB.Service({ + ros: ros, + name: '/map_server/map', + serviceType: 'nav_msgs/srv/GetMap' +}); + +// Also try the GetMap service name used in Nav2 Humble +const getMapServiceClient = new ROSLIB.Service({ + ros: ros, + name: '/map_server/get_map', + serviceType: 'nav_msgs/srv/GetMap' +}); + +function requestMapFromService() { + // Try /map_server/get_map first (Nav2 Humble standard) + getMapServiceClient.callService(new ROSLIB.ServiceRequest({}), (result) => { + if (result && result.map) { + console.log('Map received via /map_server/get_map service.'); + processMapMessage(result.map); + } + }, (err) => { + // Fallback: try /map_server/map + mapServiceClient.callService(new ROSLIB.ServiceRequest({}), (result2) => { + if (result2 && result2.map) { + console.log('Map received via /map_server/map service.'); + processMapMessage(result2.map); + } + }, (err2) => { + console.warn('Both map services unavailable, waiting for /map topic publish:', err2); + }); + }); +} + +// Primary AMCL pose subscriber (Nav2 Map frame pose) +const amclPoseSub = new ROSLIB.Topic({ + ros: ros, + name: '/amcl_pose', + messageType: 'geometry_msgs/msg/PoseWithCovarianceStamped', + throttle_rate: 50 +}); + +// FAST-LIO2 LiDAR Odometry subscriber +const fastLioOdomSub = new ROSLIB.Topic({ + ros: ros, + name: '/aft_mapped_to_init', + messageType: 'nav_msgs/msg/Odometry', + throttle_rate: 50 +}); + +// Standard Odometry subscriber const odomSub = new ROSLIB.Topic({ + ros: ros, + name: '/odom', + messageType: 'nav_msgs/msg/Odometry', + throttle_rate: 50 +}); + +// Wheel Odometry subscriber +const wheelOdomSub = new ROSLIB.Topic({ ros: ros, name: '/odom_wheels', messageType: 'nav_msgs/msg/Odometry', - throttle_rate: 100 + throttle_rate: 50 }); // Joint State subscriber (throttled to 5Hz to update RPM gauges efficiently) @@ -116,39 +176,31 @@ const batterySub = new ROSLIB.Topic({ }); batterySub.subscribe(function(msg) { - const voltage = msg.voltage ? msg.voltage.toFixed(1) : '26.4'; - const pct = Math.round((msg.percentage !== undefined ? msg.percentage : 0.85) * 100); - + // percentage is 0.0-1.0 range from sensor_msgs/BatteryState + let pct; + if (msg.percentage !== undefined && msg.percentage !== null && !isNaN(msg.percentage)) { + // Normalize: if value > 1.0, it's already in percent form + pct = msg.percentage > 1.0 ? Math.round(msg.percentage) : Math.round(msg.percentage * 100); + } else if (msg.voltage && msg.voltage > 0) { + // Fallback: estimate from voltage (24V LiFePO4 8S: 21.6V=0%, 28.0V=100%) + const minV = 21.6, maxV = 28.0; + pct = Math.round(Math.max(0, Math.min(100, (msg.voltage - minV) / (maxV - minV) * 100))); + } else { + return; // No valid data + } + const badge = document.getElementById('battery-badge'); const bar = document.getElementById('battery-bar'); - - if (badge) { - badge.innerText = `${pct}% (${voltage}V)`; - if (pct >= 50) { - badge.style.background = 'rgba(34, 197, 94, 0.2)'; - badge.style.color = '#4ade80'; - badge.style.borderColor = 'rgba(74, 222, 128, 0.4)'; - } else if (pct >= 20) { - badge.style.background = 'rgba(234, 179, 8, 0.2)'; - badge.style.color = '#facc15'; - badge.style.borderColor = 'rgba(250, 204, 21, 0.4)'; - } else { - badge.style.background = 'rgba(239, 68, 68, 0.2)'; - badge.style.color = '#f87171'; - badge.style.borderColor = 'rgba(248, 113, 113, 0.4)'; - } - } - - if (bar) { - bar.style.width = `${pct}%`; - if (pct >= 50) { - bar.style.background = 'linear-gradient(90deg, #22c55e, #4ade80)'; - } else if (pct >= 20) { - bar.style.background = 'linear-gradient(90deg, #eab308, #facc15)'; - } else { - bar.style.background = 'linear-gradient(90deg, #ef4444, #f87171)'; - } + const valDisplay = document.getElementById('battery-value-display'); + const ringFill = document.getElementById('battery-ring-fill'); + + if (badge) badge.innerText = `${pct}%`; + if (valDisplay) valDisplay.innerText = `${pct}%`; + if (ringFill) { + const strokeDash = 188 - Math.round((pct / 100) * 188); + ringFill.setAttribute('stroke-dashoffset', strokeDash); } + if (bar) bar.style.width = `${pct}%`; }); @@ -177,7 +229,16 @@ let panStart = { x: 0, y: 0 }; const canvas = document.getElementById('map-canvas'); const ctx = canvas.getContext('2d'); +// requestAnimationFrame throttle to avoid redundant redraws +let drawMapPending = false; function drawMap() { + if (drawMapPending) return; // Already queued + drawMapPending = true; + requestAnimationFrame(_doDrawMap); +} + +function _doDrawMap() { + drawMapPending = false; if (!mapData || !mapInfo) return; const w = mapInfo.width; @@ -260,16 +321,19 @@ function drawRobot(rx, ry, ryaw, color) { ctx.rotate(-ryaw); // Draw triangle - ctx.fillStyle = color; - ctx.shadowBlur = 10; - ctx.shadowColor = color; + ctx.fillStyle = '#22A774'; + ctx.strokeStyle = '#047857'; + ctx.lineWidth = 2; + ctx.shadowBlur = 6; + ctx.shadowColor = 'rgba(34, 167, 116, 0.4)'; ctx.beginPath(); - ctx.moveTo(10, 0); - ctx.lineTo(-8, -6); - ctx.lineTo(-4, 0); - ctx.lineTo(-8, 6); + ctx.moveTo(12, 0); + ctx.lineTo(-9, -7); + ctx.lineTo(-5, 0); + ctx.lineTo(-9, 7); ctx.closePath(); ctx.fill(); + ctx.stroke(); ctx.restore(); } @@ -300,8 +364,8 @@ function drawTarget(rx, ry, ryaw, color) { } // --- [Subscribe Listeners] --- -// Map listener -mapSub.subscribe((message) => { +// processMapMessage: shared handler for both /map topic and /map_server/map service +function processMapMessage(message) { mapData = message.data; mapInfo = message.info; @@ -320,11 +384,14 @@ mapSub.subscribe((message) => { const val = mapData[i]; let r, g, b, a; if (val === 0) { - r = 15; g = 18; b = 32; a = 255; + // Free space: Clean crisp white + r = 255; g = 255; b = 255; a = 255; } else if (val === 100) { - r = 157; g = 78; b = 221; a = 255; + // Occupied wall: Dark slate + r = 30; g = 41; b = 59; a = 255; } else { - r = 6; g = 6; b = 10; a = 255; + // Unknown area: Soft light gray + r = 226; g = 232; b = 240; a = 255; } const col = i % w; @@ -339,33 +406,82 @@ mapSub.subscribe((message) => { offscreenCtx.putImageData(imgData, 0, 0); drawMap(); +} + +// Map listener - handles live updates +mapSub.subscribe((message) => { + processMapMessage(message); }); -// Odom listener -odomSub.subscribe((message) => { - const pose = message.pose.pose; - robotPose.x = pose.position.x; - robotPose.y = pose.position.y; +// --- [Pose Tracking] --- +// Strategy: Use AMCL pose (map frame) as primary. Fall back to wheel odom ONLY if AMCL +// has not been received for 3+ seconds. NEVER mix frame origins. +let amclActive = false; +let lastAmclTime = 0; - // Quaternion to Euler yaw - const q = pose.orientation; +function applySmoothedPose(newX, newY, newYaw) { + // Strong smoothing to prevent UI jitter (alpha=0.4 = 40% new, 60% old) + const alpha = 0.4; + robotPose.x = alpha * newX + (1 - alpha) * robotPose.x; + robotPose.y = alpha * newY + (1 - alpha) * robotPose.y; + + // Yaw wrap-around aware interpolation + let diffYaw = newYaw - robotPose.yaw; + while (diffYaw > Math.PI) diffYaw -= 2 * Math.PI; + while (diffYaw < -Math.PI) diffYaw += 2 * Math.PI; + robotPose.yaw += alpha * diffYaw; + + const poseVal = document.getElementById('val-pose'); + if (poseVal) { + let yawDeg = Math.round(robotPose.yaw * 180 / Math.PI); + if (yawDeg < 0) yawDeg += 360; + poseVal.innerHTML = `X: ${robotPose.x.toFixed(2)}m | Y: ${robotPose.y.toFixed(2)}m | Yaw: ${yawDeg}°`; + } + drawMap(); +} + +function quatToYaw(q) { const siny_cosp = 2 * (q.w * q.z + q.x * q.y); const cosy_cosp = 1 - 2 * (q.y * q.y + q.z * q.z); - robotPose.yaw = Math.atan2(siny_cosp, cosy_cosp); + return Math.atan2(siny_cosp, cosy_cosp); +} + +// 1. AMCL Pose Listener - PRIMARY source for Real AGV mode (map frame, most accurate) +amclPoseSub.subscribe((message) => { + amclActive = true; + lastAmclTime = Date.now(); + const pose = message.pose.pose; + applySmoothedPose(pose.position.x, pose.position.y, quatToYaw(pose.orientation)); +}); + +// 2. Wheel Odometry - FALLBACK only when AMCL is not active (simulation/odom frame) +wheelOdomSub.subscribe((message) => { + // Only use wheel odom if AMCL hasn't been received in last 3 seconds + if (Date.now() - lastAmclTime < 3000) return; + const pose = message.pose.pose; + applySmoothedPose(pose.position.x, pose.position.y, quatToYaw(pose.orientation)); - // Update speeds const twist = message.twist.twist; document.getElementById('val-linear').innerText = `${twist.linear.x.toFixed(2)} m/s`; document.getElementById('val-angular').innerText = `${twist.angular.z.toFixed(2)} rad/s`; +}); - // Update real-time pose metrics - const poseVal = document.getElementById('val-pose'); - if (poseVal) { - const yawDeg = Math.round(robotPose.yaw * 180 / Math.PI); - poseVal.innerHTML = `X: ${robotPose.x.toFixed(2)}m | Y: ${robotPose.y.toFixed(2)}m | Yaw: ${yawDeg}°`; +// 3. Standard /odom - Only update velocity display, never pose (to avoid frame mixing) +odomSub.subscribe((message) => { + const twist = message.twist.twist; + document.getElementById('val-linear').innerText = `${twist.linear.x.toFixed(2)} m/s`; + document.getElementById('val-angular').innerText = `${twist.angular.z.toFixed(2)} rad/s`; +}); + +// 4. FAST-LIO2 - Only velocity display, AMCL gives more accurate map-frame pose +fastLioOdomSub.subscribe((message) => { + if (!amclActive) { + // If no AMCL available (e.g. before localization converges), use FAST-LIO pose + if (Date.now() - lastAmclTime > 3000) { + const pose = message.pose.pose; + applySmoothedPose(pose.position.x, pose.position.y, quatToYaw(pose.orientation)); + } } - - drawMap(); }); // Camera stream setup with automatic fallback @@ -473,6 +589,14 @@ arucoPoseSub.subscribe((message) => { }, 1000); }); +// Helper to update top mode summary card +function updateTopModeText(modeStr) { + const topEl = document.getElementById('val-state-top'); + if (topEl) { + topEl.innerText = modeStr; + } +} + // Robot state listener robotModeStatusSub.subscribe((message) => { document.getElementById('val-state').innerText = message.data; @@ -490,24 +614,28 @@ robotModeStatusSub.subscribe((message) => { if (status.includes('mode: nav2')) { currentMode = 'nav2'; + updateTopModeText('Nav2'); document.getElementById('btn-mode-nav2').className = 'btn btn-primary active'; document.getElementById('btn-mode-patrol').className = 'btn btn-primary'; document.getElementById('btn-mode-parking').className = 'btn btn-primary'; document.getElementById('btn-emergency-stop').className = 'btn btn-danger'; } else if (status.includes('mode: patrol')) { currentMode = 'patrol'; + updateTopModeText('Patrol'); document.getElementById('btn-mode-patrol').className = 'btn btn-primary active'; document.getElementById('btn-mode-nav2').className = 'btn btn-primary'; document.getElementById('btn-mode-parking').className = 'btn btn-primary'; document.getElementById('btn-emergency-stop').className = 'btn btn-danger'; } else if (status.includes('mode: parking')) { currentMode = 'parking'; + updateTopModeText('Parking'); document.getElementById('btn-mode-parking').className = 'btn btn-primary active'; document.getElementById('btn-mode-nav2').className = 'btn btn-primary'; document.getElementById('btn-mode-patrol').className = 'btn btn-primary'; document.getElementById('btn-emergency-stop').className = 'btn btn-danger'; } else if (status.includes('mode: stop') || status.includes('state: estop')) { currentMode = 'stop'; + updateTopModeText('STOP'); document.getElementById('btn-mode-nav2').className = 'btn btn-primary'; document.getElementById('btn-mode-patrol').className = 'btn btn-primary'; document.getElementById('btn-mode-parking').className = 'btn btn-primary'; @@ -549,6 +677,7 @@ document.getElementById('btn-mode-nav2').addEventListener('click', () => { currentMode = 'nav2'; nav2GoalActive = false; parkingWaypoint = null; // Clear waypoint on UI + updateTopModeText('Nav2'); document.getElementById('btn-mode-nav2').className = 'btn btn-primary active'; document.getElementById('btn-mode-patrol').className = 'btn btn-primary'; document.getElementById('btn-mode-parking').className = 'btn btn-primary'; @@ -562,6 +691,7 @@ document.getElementById('btn-mode-patrol').addEventListener('click', () => { currentMode = 'patrol'; parkingWaypoint = null; nav2GoalActive = false; + updateTopModeText('Patrol'); document.getElementById('btn-mode-patrol').className = 'btn btn-primary active'; document.getElementById('btn-mode-nav2').className = 'btn btn-primary'; document.getElementById('btn-mode-parking').className = 'btn btn-primary'; @@ -574,6 +704,7 @@ document.getElementById('btn-mode-patrol').addEventListener('click', () => { document.getElementById('btn-mode-parking').addEventListener('click', () => { currentMode = 'parking'; parkingWaypoint = null; // Clear waypoint on UI until set by user + updateTopModeText('Parking'); document.getElementById('btn-mode-parking').className = 'btn btn-primary active'; document.getElementById('btn-mode-nav2').className = 'btn btn-primary'; document.getElementById('btn-mode-patrol').className = 'btn btn-primary'; @@ -597,6 +728,7 @@ document.getElementById('btn-emergency-stop').addEventListener('click', () => { currentMode = 'stop'; nav2GoalActive = false; parkingWaypoint = null; // Clear waypoint on UI + updateTopModeText('STOP'); document.getElementById('btn-mode-nav2').className = 'btn btn-primary'; document.getElementById('btn-mode-patrol').className = 'btn btn-primary'; document.getElementById('btn-mode-parking').className = 'btn btn-primary'; @@ -787,6 +919,93 @@ canvas.addEventListener('wheel', (event) => { drawMap(); }, { passive: false }); +// Mobile Touch Interactions (touchstart, touchmove, touchend) +let touchStartCoords = { x: 0, y: 0 }; +let isTouchDragging = false; + +canvas.addEventListener('touchstart', (e) => { + if (e.touches.length === 1) { + e.preventDefault(); + const touch = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + const scale = Math.min(rect.width / canvas.width, rect.height / canvas.height); + const dx_padding = (rect.width - canvas.width * scale) / 2; + const dy_padding = (rect.height - canvas.height * scale) / 2; + + const clickU = (touch.clientX - rect.left - dx_padding) / scale; + const clickV = (touch.clientY - rect.top - dy_padding) / scale; + + touchStartCoords.x = touch.clientX; + touchStartCoords.y = touch.clientY; + dragStartCoords.x = clickU; + dragStartCoords.y = clickV; + + const coords = defCanvasToRos(clickU, clickV); + dragStartRos.x = coords.rx; + dragStartRos.y = coords.ry; + + document.getElementById('wp-x').value = coords.rx.toFixed(2); + document.getElementById('wp-y').value = coords.ry.toFixed(2); + + isTouchDragging = true; + + if (poseEstimateMode) { + robotPose.x = coords.rx; + robotPose.y = coords.ry; + } else if (currentMode === 'nav2') { + nav2Goal.x = coords.rx; + nav2Goal.y = coords.ry; + nav2GoalActive = true; + } else { + parkingWaypoint = { x: coords.rx, y: coords.ry, yaw: 0.0 }; + } + drawMap(); + } +}, { passive: false }); + +canvas.addEventListener('touchmove', (e) => { + if (!isTouchDragging || e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + const scale = Math.min(rect.width / canvas.width, rect.height / canvas.height); + + const dx = touch.clientX - touchStartCoords.x; + const dy = touch.clientY - touchStartCoords.y; + + if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { + currentDragYaw = Math.atan2(-dy, dx); + let yawDeg = Math.round(currentDragYaw * 180.0 / Math.PI); + if (yawDeg < 0) yawDeg += 360; + document.getElementById('wp-yaw').value = yawDeg; + + if (poseEstimateMode) { + robotPose.yaw = currentDragYaw; + } else if (currentMode === 'nav2') { + nav2Goal.yaw = currentDragYaw; + } else { + if (parkingWaypoint) parkingWaypoint.yaw = currentDragYaw; + } + drawMap(); + } +}, { passive: false }); + +canvas.addEventListener('touchend', (e) => { + if (isTouchDragging) { + isTouchDragging = false; + if (poseEstimateMode) { + publishInitialPose(dragStartRos.x, dragStartRos.y, currentDragYaw); + poseEstimateMode = false; + document.getElementById('btn-pose-estimate').className = 'btn btn-secondary'; + document.getElementById('btn-pose-estimate').innerText = '📍 로봇 위치 초기화 (Pose Estimate)'; + } else if (currentMode === 'nav2') { + publishNav2Goal(); + } else { + publishWaypoint(); + } + } +}); + // Double-click to reset zoom & pan translation canvas.addEventListener('dblclick', () => { zoom = 1.0; @@ -844,6 +1063,12 @@ function publishNav2Goal() { } function publishInitialPose(x, y, yaw) { + // Covariance is 6x6 row-major, indices [0][0]=x, [1][1]=y, [5][5]=yaw + const cov = new Array(36).fill(0.0); + cov[0] = 0.25; // xx uncertainty (0.5m std dev) + cov[7] = 0.25; // yy uncertainty (0.5m std dev) + cov[35] = 0.06853891945200942; // yaw*yaw uncertainty (~15deg std dev) + const msg = new ROSLIB.Message({ header: { frame_id: 'map', @@ -859,18 +1084,30 @@ function publishInitialPose(x, y, yaw) { w: Math.cos(yaw * 0.5) } }, - covariance: [ - 0.25, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.25, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.06853891945200942, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0, 0.0, 0.06853891945200942 - ] + covariance: cov } }); initialPosePub.publish(msg); console.log(`Published Initial Pose Reset: x=${x.toFixed(2)}, y=${y.toFixed(2)}, yaw=${(yaw * 180 / Math.PI).toFixed(0)}deg`); - drawMap(); +} + +// --- [Mobile UI Functions] --- + +// Toggle Mobile Drawer Sidebar +function toggleMobileSidebar() { + const sidebar = document.getElementById('appSidebar'); + const overlay = document.getElementById('sidebarOverlay'); + if (sidebar && overlay) { + sidebar.classList.toggle('mobile-open'); + overlay.classList.toggle('active'); + } +} + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./sw.js') + .then(reg => console.log('PWA ServiceWorker registered:', reg.scope)) + .catch(err => console.warn('PWA ServiceWorker registration failed:', err)); + }); } diff --git a/src/fori_serial_bridge/ui/index.html b/src/fori_serial_bridge/ui/index.html index d027adc..814febf 100644 --- a/src/fori_serial_bridge/ui/index.html +++ b/src/fori_serial_bridge/ui/index.html @@ -1,194 +1,318 @@
- - -