diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 36a8b6e3..198b41ad 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ "workspaceFolder": "/ws", "containerEnv": { "ROS_DOMAIN_ID": "42", - "ROS_LOCALHOST_ONLY": "1", + "ROS_LOCALHOST_ONLY": "0", "CYCLONEDDS_URI": "file:///ws/docker/cyclonedds.xml" }, "customizations": { diff --git a/.gitignore b/.gitignore index 2734fac9..a054c13f 100644 --- a/.gitignore +++ b/.gitignore @@ -176,4 +176,6 @@ cython_debug/ # Ros build ignored install log +logs +kart_logs build diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml index b9d40caf..c4d88d88 100644 --- a/compose/docker-compose.yml +++ b/compose/docker-compose.yml @@ -3,7 +3,7 @@ services: dev: image: ghcr.io/evc-purdue/ros2-humble-dev:main container_name: ros2-dev - hostname: ros2-dev + hostname: "${TARGET_DEVICE:-jetson}-dev" volumes: - ../:/ws - ros_cache:/root/.cache @@ -11,18 +11,19 @@ services: environment: - ROS_DISTRO=humble - ROS_DOMAIN_ID=42 - - ROS_LOCALHOST_ONLY=1 + - ROS_LOCALHOST_ONLY=0 - CYCLONEDDS_URI=file:///ws/docker/cyclonedds.xml - ports: - - "8000:8000" - devices: - - "/dev/ttyACM0:/dev/ttyACM0" - - "/dev/ttyTHS1:/dev/ttyTHS1" # GPS - - "/dev/i2c-7:/dev/i2c-7" + - TARGET_DEVICE=${TARGET_DEVICE:-jetson} + - ROS_NETWORK_INTERFACE=${ROS_NETWORK_INTERFACE:-enP8p1s0} +# devices: +# - "/dev/ttyACM0:/dev/ttyACM0" # canable +# - "/dev/ttyTHS1:/dev/ttyTHS1" # GPS +# - "/dev/i2c-7:/dev/i2c-7" # IMU +# - "/dev/video0:/dev/video0" # camera stdin_open: true tty: true restart: always command: bash /ws/scripts/start.bash network_mode: host volumes: - ros_cache: + ros_cache: \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index eca954a4..5b5141f7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -20,6 +20,7 @@ RUN apt-get -o Acquire::http::Pipeline-Depth="0" \ ros-${ROS_DISTRO}-rclpy ros-${ROS_DISTRO}-ros2bag \ ros-${ROS_DISTRO}-tf2-tools ros-${ROS_DISTRO}-image-transport \ ros-${ROS_DISTRO}-cv-bridge \ + openssh-client \ git curl nano \ socat rtklib i2c-tools && \ rm -rf /var/lib/apt/lists/* diff --git a/docker/cyclonedds.xml b/docker/cyclonedds.xml index 22c864df..37b993e5 100644 --- a/docker/cyclonedds.xml +++ b/docker/cyclonedds.xml @@ -3,6 +3,14 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://cdds.io/config https://raw.githubusercontent.com/eclipse-cyclonedds/cyclonedds/master/etc/cyclonedds.xsd"> + + true + + + + + + auto 32 diff --git a/docs/README/quickstart.md b/docs/README/quickstart.md index 994b324e..97625582 100644 --- a/docs/README/quickstart.md +++ b/docs/README/quickstart.md @@ -24,18 +24,19 @@ sudo docker compose -f compose/docker-compose.yml up -d dev ``` 3) Exec into container ```bash -docker exec -it bash +docker exec -it ros2-dev bash ``` 4) Setup ROS ```bash source /opt/ros/humble/setup.bash colcon build source install/setup.bash -ros2 launch autonomous_kart bringup_pi.launch.py +ros2 launch autonomous_kart bringup_${TARGET_DEVICE:-jetson}.launch.py ``` ```bash ros2 bag record -s mcap -o /ws/bags/$(date +%Y%m%d_%H%M%S) /mpc/status /mpc/residual_mode /mpc/residual_revert /cmd_drive /odom /gps /imu /imu/calibration_status /e_comms/kart_speed_m_per_s /e_comms/throttle_pwm /e_comms/steering_pwm /e_comms/adcb_state /e_comms/rc_mode /system_state /track_angles /manual_commands /pathfinder_params ``` + docker cp ros2-dev:/ws/bags . diff --git a/scripts/start.bash b/scripts/start.bash index af0722e6..9ca04e74 100755 --- a/scripts/start.bash +++ b/scripts/start.bash @@ -7,24 +7,25 @@ source /opt/ros/humble/setup.bash colcon build pkill -f master_api || true -# Lat/long are approx west lafayette, not important to be highly accurate mkdir -p /ws/logs -str2str -in ntrip://shayman1:shayman1@108.59.49.226:9000/MSM4_VRS -out tcpsvr://:9195 -b 1 -p 40.4376975222 -86.9444409756 200 >> /ws/logs/str2str.log 2>&1 & - -# Logs supervisor - crash tolerant -( - while true; do - RUN_NAME="run_$(date -u +%Y%m%d_%H%M%S)" - ros2 bag record -a \ - --storage mcap \ - --storage-preset-profile zstd_fast \ - --max-bag-duration 60 \ - --exclude '/camera/.*' \ - --output "/ws/logs/$RUN_NAME" \ - >> /ws/logs/recorder.log 2>&1 - echo "[$(date -u +%FT%TZ)] recorder exited, restarting in 1s" >> /ws/logs/recorder.log - sleep 1 - done -) & +if [ "$TARGET_DEVICE" == "jetson" ]; then + # Lat/long are approx west lafayette, not important to be highly accurate + str2str -in ntrip://shayman1:shayman1@108.59.49.226:9000/MSM4_VRS -out tcpsvr://:9195 -b 1 -p 40.4376975222 -86.9444409756 200 >> /ws/logs/str2str.log 2>&1 & +elif [ "$TARGET_DEVICE" == "rubik" ]; then + ( + while true; do + RUN_NAME="run_$(date -u +%Y%m%d_%H%M%S)" + ros2 bag record -a \ + --storage mcap \ + --storage-preset-profile zstd_fast \ + --max-bag-duration 60 \ + --exclude '/camera/.*' \ + --output "/ws/logs/$RUN_NAME" \ + >> /ws/logs/recorder.log 2>&1 + echo "[$(date -u +%FT%TZ)] recorder exited, restarting in 1s" >> /ws/logs/recorder.log + sleep 1 + done + ) & +fi sleep infinity \ No newline at end of file diff --git a/src/autonomous_kart/autonomous_kart/launch/bringup_pi.launch.py b/src/autonomous_kart/autonomous_kart/launch/bringup_all.launch.py similarity index 100% rename from src/autonomous_kart/autonomous_kart/launch/bringup_pi.launch.py rename to src/autonomous_kart/autonomous_kart/launch/bringup_all.launch.py diff --git a/src/autonomous_kart/autonomous_kart/launch/bringup_jetson.launch.py b/src/autonomous_kart/autonomous_kart/launch/bringup_jetson.launch.py new file mode 100644 index 00000000..111ee4de --- /dev/null +++ b/src/autonomous_kart/autonomous_kart/launch/bringup_jetson.launch.py @@ -0,0 +1,64 @@ +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, GroupAction +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node, SetParameter, SetParametersFromFile +from ament_index_python.packages import get_package_share_directory +import os + + +def generate_launch_description(): + pkg_share = get_package_share_directory("autonomous_kart") + + gps_yaml = os.path.join(pkg_share, "params", "gps.yaml") + safety_yaml = os.path.join(pkg_share, "params", "safety.yaml") + system_yaml = os.path.join(pkg_share, "params", "system.yaml") + pathfinder_yaml = os.path.join(pkg_share, "params", "pathfinder.yaml") + imu_yaml = os.path.join(pkg_share, "params", "imu.yaml") + e_comms_yaml = os.path.join(pkg_share, "params", "e_comms.yaml") + actuators_yaml = os.path.join(pkg_share, "params", "actuators.yaml") + localization_yaml = os.path.join(pkg_share, "params", "localization.yaml") + opencv_yaml = os.path.join(pkg_share, "params", "opencv_pathfinder.yaml") + + sim_mode = LaunchConfiguration("simulation_mode") + + return LaunchDescription( + [ + DeclareLaunchArgument("simulation_mode", default_value="false"), + GroupAction( + [ + SetParametersFromFile(system_yaml), + SetParameter(name="simulation_mode", value=sim_mode), + Node( + package="autonomous_kart", + executable="pathfinder_node", + name="pathfinder_node", + parameters=[pathfinder_yaml, safety_yaml], + ), + Node( + package="autonomous_kart", + executable="localization_node", + name="localization_node", + parameters=[pathfinder_yaml, localization_yaml], + ), + Node( + package="autonomous_kart", + executable="e_comms_node", + name="e_comms_node", + parameters=[e_comms_yaml, actuators_yaml, pathfinder_yaml], + ), + Node( + package="autonomous_kart", + executable="gps_node", + name="gps_node", + parameters=[gps_yaml], + ), + Node( + package="autonomous_kart", + executable="imu_node", + name="imu_node", + parameters=[imu_yaml], + ), + ] + ), + ] + ) diff --git a/src/autonomous_kart/autonomous_kart/launch/bringup_rubik.launch.py b/src/autonomous_kart/autonomous_kart/launch/bringup_rubik.launch.py new file mode 100644 index 00000000..07414f51 --- /dev/null +++ b/src/autonomous_kart/autonomous_kart/launch/bringup_rubik.launch.py @@ -0,0 +1,40 @@ +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, GroupAction, ExecuteProcess +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node, SetParameter, SetParametersFromFile +from ament_index_python.packages import get_package_share_directory +from datetime import datetime +import os + + +def generate_launch_description(): + pkg_share = get_package_share_directory("autonomous_kart") + + camera_yaml = os.path.join(pkg_share, "params", "camera.yaml") + system_yaml = os.path.join(pkg_share, "params", "system.yaml") + + sim_mode = LaunchConfiguration("simulation_mode") + + return LaunchDescription( + [ + DeclareLaunchArgument("simulation_mode", default_value="false"), + GroupAction( + [ + SetParametersFromFile(system_yaml), + SetParameter(name="simulation_mode", value=sim_mode), + + Node( + package="autonomous_kart", + executable="camera_node", + name="camera_node", + parameters=[camera_yaml], + ), + Node( + package="autonomous_kart", + executable="master_api", + name="master_api", + ), + ] + ), + ] + ) diff --git a/src/autonomous_kart/autonomous_kart/nodes/camera/camera_node.py b/src/autonomous_kart/autonomous_kart/nodes/camera/camera_node.py index f8514c34..2c223d99 100644 --- a/src/autonomous_kart/autonomous_kart/nodes/camera/camera_node.py +++ b/src/autonomous_kart/autonomous_kart/nodes/camera/camera_node.py @@ -54,7 +54,7 @@ def __init__(self): raise RuntimeError(f"Failed to open video: {video_path}") else: - self.cap = cv2.VideoCapture(0) + self.cap = cv2.VideoCapture('/dev/video0') self.video_fps = self.fps if not self.cap.isOpened(): @@ -84,6 +84,7 @@ def timer_callback(self): self.logger.warning("No frame available") if publish_frame is not None: + self.logger.info(f"Publishing frame {self.frame_counter}") self.image_pub.publish(publish_frame) self.frame_counter += 1 diff --git a/src/autonomous_kart/autonomous_kart/nodes/master/master_api.py b/src/autonomous_kart/autonomous_kart/nodes/master/master_api.py index fec7904e..6fd8973f 100644 --- a/src/autonomous_kart/autonomous_kart/nodes/master/master_api.py +++ b/src/autonomous_kart/autonomous_kart/nodes/master/master_api.py @@ -221,6 +221,53 @@ def lines_endpoint(): "static": static_xy, "dynamic": master_node.get_dynamic_line(), }) + +@app.route("/jetson/hotswap", methods=["POST"]) +def jetson_hotswap(): + if not master_node: + return jsonify({"error": "not initialized"}), 500 + + master_node.hotswap_jetson() + + return jsonify({ + "message": "jetson hotswap started" + }) + + +@app.route("/jetson/restart", methods=["POST"]) +def jetson_restart(): + if not master_node: + return jsonify({"error": "not initialized"}), 500 + + master_node.restart_jetson() + + return jsonify({ + "message": "jetson restart started" + }) + + +@app.route("/jetson/update", methods=["POST"]) +def jetson_update(): + if not master_node: + return jsonify({"error": "not initialized"}), 500 + + master_node.update_jetson() + + return jsonify({ + "message": "jetson update started" + }) + + +@app.route("/jetson/rebuild", methods=["POST"]) +def jetson_rebuild(): + if not master_node: + return jsonify({"error": "not initialized"}), 500 + + master_node.rebuild_jetson() + + return jsonify({ + "message": "jetson rebuild started" + }) @app.route("/residual/status", methods=["GET"]) diff --git a/src/autonomous_kart/autonomous_kart/nodes/master/master_node.py b/src/autonomous_kart/autonomous_kart/nodes/master/master_node.py index faa6a5f3..46230e4c 100644 --- a/src/autonomous_kart/autonomous_kart/nodes/master/master_node.py +++ b/src/autonomous_kart/autonomous_kart/nodes/master/master_node.py @@ -1,6 +1,7 @@ import json import threading import time +import subprocess from enum import Enum import rclpy @@ -450,6 +451,52 @@ def get_gps_status(self): with self._lock: return dict(self.gps_status_data) + # Jetson remote management + def _ssh_fire(self, remote_command: str, log_msg: str): + """Shared helper: open a non-blocking SSH subprocess to the Jetson.""" + ssh_command = [ + "ssh", "-t", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + f"{self.jetson_user}@{self.jetson_ip}", + remote_command, + ] + + self.logger.info(log_msg) + subprocess.Popen(ssh_command) + + def hotswap_jetson(self): + """Git-pull and rebuild inside the *running* container, then relaunch bringup_rubik. + The container is never stopped — fastest way to pick up new code mid-session.""" + self._ssh_fire( + "bash ~/run_remote.sh --hotswap", + "Hotswapping: pulling latest code and relaunching inside running container...", + ) + + def restart_jetson(self): + """Stop the container, rebuild the ROS workspace, and relaunch bringup. + No git pull, no Docker image pull — fastest way to bounce the stack.""" + self._ssh_fire( + "bash ~/run_remote.sh --restart", + "Restarting Jetson container (no code update)...", + ) + + def update_jetson(self): + """Git-pull the latest code, then restart. + Use this before a session to pick up new commits without a full image rebuild.""" + self._ssh_fire( + "bash ~/run_remote.sh --update", + "Pulling latest code and restarting Jetson...", + ) + + def rebuild_jetson(self): + """Pull the latest Docker image, git-pull code, then restart. + Use this after a Docker image has been pushed (e.g. new deps or base OS change).""" + self._ssh_fire( + "bash ~/run_remote.sh --rebuild", + "Pulling latest Docker image + code and rebuilding Jetson...", + ) + def get_residual_log(self, limit: int = 50) -> list[dict]: with self._lock: return list(self._residual_log[: max(0, limit)]) @@ -463,7 +510,6 @@ def get_residual_status(self) -> dict | None: def trigger_residual_revert(self) -> None: self.residual_revert_publisher.publish(Empty()) - def main(args=None): rclpy.init(args=args) diff --git a/src/autonomous_kart/autonomous_kart/params/system.yaml b/src/autonomous_kart/autonomous_kart/params/system.yaml index e172b17f..15b36d20 100644 --- a/src/autonomous_kart/autonomous_kart/params/system.yaml +++ b/src/autonomous_kart/autonomous_kart/params/system.yaml @@ -5,3 +5,5 @@ system_state: "IDLE" # "STOPPED", "IDLE", "MANUAL", "AUTONOMOUS" system_frequency: 60 # Speed of hot loop (Hertz) line_path: "/ws/data/racing_line/line6.csv" + jetson_ip: "evc-jetson.tail73145c.ts.net" + jetson_username: "evc"