مسیر یادگیری ROS 2  ·  کتاب آموزشی تصویری

فصل سوم: ارتباطات عملی ARCHO

از مفهوم به کد واقعی — Publisher، Service، Action و Launch
پیش‌نیاز: فصل ۱ و ۲
پروژه پیوسته: ربات ARCHO
زبان: Python (rclpy)
زمان مطالعه: ۱۰۰ تا ۱۳۰ دقیقه
در این فصل چه می‌خوانیم ۳.۱از یک Node ساکت به یک تیم پرسروصدا ۳.۲Publisher و Subscriber واقعی ۳.۳ساخت یک Message سفارشی ۳.۴Service در عمل: Reset Encoder ۳.۵Action در عمل: برو به قفسه ۳.۶Parameter در عمل ۳.۷یک Launch File که همه را روشن می‌کند ۳.۸جمع‌بندی، واژه‌نامه و تمرین‌ها

۳.۱از یک Node ساکت به یک تیم پرسروصدا

در فصل دوم، simple_node فقط یک جمله چاپ کرد و ساکت ماند. این برای یاد گرفتن ساختار خوب بود، اما یک ربات واقعی مثل ARCHO این‌طور کار نمی‌کند. باتری‌اش باید هر چند ثانیه وضعیتش را اعلام کند، کسی باید بتواند از موتور بخواهد Encoderها را صفر کند، و وقتی فرمان «برو به قفسه ۱۲» می‌آید، ربات باید واقعاً حرکت کند و گزارش پیشرفت بدهد. در این فصل، دقیقاً همان چهار ابزار ارتباطی فصل اول — Topic، Service، Action و Parameter — را برای اولین بار به کد واقعی تبدیل می‌کنیم.

flowchart RL A["Publisher/Subscriber
battery_monitor"] --> B["Message سفارشی
archo_interfaces"] B --> C["Service
reset_encoder"] C --> D["Action
move_to_shelf"] D --> E["Parameter YAML"] E --> F["Launch File واحد
archo_bringup.launch.py"] style A fill:#eef0ff,stroke:#3d4bf5,color:#211f1a style F fill:#eafaf3,stroke:#0e9e6e,color:#211f1a,font-weight:bold
🌍 نقشه امروز تیم ARCHO

امروز سه Node جدید می‌سازیم: battery_monitor (منتشرکننده وضعیت باتری)، dashboard (مشترک ساده که وضعیت را نمایش می‌دهد)، و motor_controller (سروری که هم Service می‌دهد و هم Action اجرا می‌کند). در پایان فصل، همه این‌ها با یک دستور واحد روشن می‌شوند.

۳.۲Publisher و Subscriber واقعی

اول ساده‌ترین حالت را می‌سازیم: یک Node که هر یک ثانیه وضعیت باتری ARCHO را منتشر می‌کند. با پیام استاندارد std_msgs/Float32 شروع می‌کنیم تا بعد در بخش بعد ببینیم چرا در دنیای واقعی این کافی نیست.

# archo_bringup/battery_monitor.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import Float32
import random


class BatteryMonitor(Node):
    def __init__(self):
        super().__init__('battery_monitor')
        self.publisher_ = self.create_publisher(Float32, 'battery_level', 10)
        self.timer = self.create_timer(1.0, self.publish_battery)
        self.level = 100.0

    def publish_battery(self):
        self.level = max(0.0, self.level - random.uniform(0.05, 0.2))
        msg = Float32()
        msg.data = self.level
        self.publisher_.publish(msg)
        self.get_logger().info(f'Battery: {self.level:.1f}%')


def main(args=None):
    rclpy.init(args=args)
    rclpy.spin(BatteryMonitor())
    rclpy.shutdown()


if __name__ == '__main__':
    main()

سه چیز اینجا تازه است نسبت به فصل قبل:

خطمعنی
create_publisher(Float32, 'battery_level', 10)یک Publisher روی Topic به نام battery_level با نوع پیام Float32 می‌سازد؛ عدد ۱۰ یعنی اندازه صف پیام‌های نگه‌داشته‌شده (QoS queue depth)
create_timer(1.0, self.publish_battery)تابع publish_battery را هر ۱ ثانیه به‌صورت خودکار صدا می‌زند
self.publisher_.publish(msg)پیام را واقعاً روی شبکه ROS 2 منتشر می‌کند

حالا Subscriber را می‌سازیم — یک داشبورد ساده که به همین Topic گوش می‌دهد:

# archo_bringup/dashboard.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import Float32


class Dashboard(Node):
    def __init__(self):
        super().__init__('dashboard')
        self.subscription = self.create_subscription(
            Float32, 'battery_level', self.battery_callback, 10)

    def battery_callback(self, msg):
        if msg.data < 20.0:
            self.get_logger().warning(f'Low battery: {msg.data:.1f}%')
        else:
            self.get_logger().info(f'Dashboard sees: {msg.data:.1f}%')


def main(args=None):
    rclpy.init(args=args)
    rclpy.spin(Dashboard())
    rclpy.shutdown()


if __name__ == '__main__':
    main()
🧠 نکته کلیدی: هیچ اتصال مستقیمی وجود ندارد

دقت کن که battery_monitor.py و dashboard.py هیچ‌جا اسم همدیگر را نمی‌دانند. هیچ‌کدام نمی‌داند دیگری وجود دارد. تنها چیزی که آن‌ها را به هم وصل می‌کند، توافق روی یک اسم مشترک است: battery_level. این دقیقاً همان استقلال Node که در فصل اول توضیح دادیم — می‌توانی dashboard را ببندی، دوباره بازش کنی، یا ده نسخه از آن اجرا کنی، بدون اینکه battery_monitor اصلاً متوجه شود.

dev@archo:~$ ros2 run archo_bringup battery_monitor [INFO] [battery_monitor]: Battery: 99.9% [INFO] [battery_monitor]: Battery: 99.7% dev@archo:~$ ros2 topic echo /battery_level data: 99.7 --- data: 99.5 --- dev@archo:~$ ros2 topic hz /battery_level average rate: 1.001
تمرین آسان

هر دو Node را در دو ترمینال جدا اجرا کن، سپس با ros2 topic echo /battery_level در ترمینال سوم بررسی کن که پیام‌ها واقعاً منتشر می‌شوند. بعد dashboard را ببند و دوباره اجرا کن — آیا battery_monitor باید دوباره اجرا شود؟ چرا؟

۳.۳ساخت یک Message سفارشی

یک عدد خام مثل Float32 برای باتری کافی نیست. تیم ARCHO می‌خواهد هم‌زمان درصد باتری، ولتاژ، و اینکه آیا در حال شارژ است یا نه را بفرستد. اینجاست که یک Message سفارشی می‌سازیم — دقیقاً همان‌طور که در فصل اول با sensor_msgs/LaserScan آشنا شدیم، اما این‌بار قالب خودمان را طراحی می‌کنیم.

📖 چرا Message سفارشی در یک Package جدا؟

قانون رایج در ROS 2 این است که تعریف Messageها، Serviceها و Actionهای سفارشی را در یک Package مجزا قرار دهیم — معمولاً با پسوند _interfaces — تا هم Package منطق (archo_bringup) و هم هر Package دیگری که بخواهد از همین قالب استفاده کند، بتوانند آن را وارد کنند بدون وابستگی غیرضروری به کد اجرایی.

archo_ws/src/
├── archo_interfaces/
│   ├── msg/
│   │   └── BatteryStatus.msg
│   ├── srv/
│   │   └── ResetEncoder.srv
│   ├── action/
│   │   └── MoveToShelf.action
│   ├── CMakeLists.txt
│   └── package.xml
└── archo_bringup/

فایل BatteryStatus.msg ساختار پیام را به زبان ساده تعریف می‌کند:

# archo_interfaces/msg/BatteryStatus.msg
float32 percentage
float32 voltage
bool is_charging

این فایل فقط یک فرم است، نه کد اجرایی. وقتی این Package را Build کنی، ROS 2 به‌طور خودکار کلاس‌های پایتون و C++ متناظر با آن را می‌سازد. بعد از Build، در کد پایتون می‌توانی این‌طور وارد و استفاده‌اش کنی:

from archo_interfaces.msg import BatteryStatus

msg = BatteryStatus()
msg.percentage = 87.5
msg.voltage = 24.1
msg.is_charging = False
self.publisher_.publish(msg)
⚠️ اشتباه رایج

بعد از اضافه‌کردن یا تغییر یک فایل .msg، فراموش نکن دوباره Build کنی: colcon build --packages-select archo_interfaces و سپس source install/setup.bash. تا این کار را نکنی، پایتون نمی‌تواند archo_interfaces.msg را پیدا کند و خطای Import می‌دهد.

تمرین متوسط

به BatteryStatus.msg یک فیلد string battery_health اضافه کن (مثلاً مقادیر "good"، "warning"، "replace") و battery_monitor.py را طوری تغییر بده که وقتی درصد باتری زیر ۲۰ باشد، battery_health را "warning" بفرستد.

۳.۴Service در عمل: Reset Encoder

یادت هست در فصل اول گفتیم Service برای کارهایی است که فقط یک‌بار و بر اساس درخواست انجام می‌شوند؟ «صفر کردن Encoder چرخ‌ها» دقیقاً یکی از این کارهاست. اول قالب Service را تعریف می‌کنیم:

# archo_interfaces/srv/ResetEncoder.srv
---
bool success
string message

خط --- Request را از Response جدا می‌کند. اینجا Request خالی است (فقط فراخوانی لازم است، داده‌ای نمی‌فرستیم)، اما Response دو فیلد دارد: آیا موفق بود، و یک پیام توضیحی.

سمت Server

# archo_bringup/motor_controller.py (بخش Service)
import rclpy
from rclpy.node import Node
from archo_interfaces.srv import ResetEncoder


class MotorController(Node):
    def __init__(self):
        super().__init__('motor_controller')
        self.encoder_ticks = 15420
        self.srv = self.create_service(
            ResetEncoder, 'reset_encoder', self.handle_reset)

    def handle_reset(self, request, response):
        self.get_logger().info(f'Resetting encoder from {self.encoder_ticks} ticks')
        self.encoder_ticks = 0
        response.success = True
        response.message = 'Encoder reset to zero'
        return response

سمت Client (از خط فرمان، برای تست سریع)

dev@archo:~$ ros2 service list /reset_encoder dev@archo:~$ ros2 service call /reset_encoder archo_interfaces/srv/ResetEncoder "{}" response: archo_interfaces.srv.ResetEncoder_Response(success=True, message='Encoder reset to zero')

و همین درخواست از داخل یک Node پایتون دیگر (مثلاً یک ابزار نگهداری):

client = self.create_client(ResetEncoder, 'reset_encoder')
while not client.wait_for_service(timeout_sec=1.0):
    self.get_logger().info('Waiting for reset_encoder service...')

request = ResetEncoder.Request()
future = client.call_async(request)
🔧 دید مهندسی: چرا call_async؟

فراخوانی Service در پایتون معمولاً به‌صورت Async انجام می‌شود تا Node حین منتظرماندن برای پاسخ، قفل نشود و بتواند هم‌زمان به Topicهای دیگر هم گوش دهد. این یکی از تفاوت‌های مهم بین نوشتن یک اسکریپت ساده و نوشتن یک Node واقعی چندوظیفه‌ای است.

تمرین متوسط

یک Service جدید به نام emergency_stop طراحی کن (فایل .srv و پیاده‌سازی سمت Server). Request می‌تواند خالی باشد؛ Response باید بگوید آیا توقف موفق بوده یا نه.

۳.۵Action در عمل: برو به قفسه

حالا به سراغ کاری می‌رویم که واقعاً طول می‌کشد: فرمان «برو به قفسه ۱۲». قالب Action سه بخش دارد — Goal، Feedback و Result — و همه در یک فایل با همین ترتیب نوشته می‌شوند:

# archo_interfaces/action/MoveToShelf.action
int32 shelf_number
---
bool success
string final_message
---
float32 distance_remaining
string status

سمت Action Server

# archo_bringup/motor_controller.py (بخش Action)
import time
from rclpy.action import ActionServer
from archo_interfaces.action import MoveToShelf


class MotorController(Node):
    def __init__(self):
        super().__init__('motor_controller')
        # ... کد Service قبلی هم اینجا می‌ماند ...
        self._action_server = ActionServer(
            self, MoveToShelf, 'move_to_shelf', self.execute_move)

    def execute_move(self, goal_handle):
        target = goal_handle.request.shelf_number
        self.get_logger().info(f'Moving to shelf {target}')
        distance = 18.0
        feedback = MoveToShelf.Feedback()

        while distance > 0:
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                result = MoveToShelf.Result()
                result.success = False
                result.final_message = 'Cancelled mid-route'
                return result

            distance -= 3.0
            feedback.distance_remaining = max(distance, 0.0)
            feedback.status = 'moving'
            goal_handle.publish_feedback(feedback)
            time.sleep(0.5)

        goal_handle.succeed()
        result = MoveToShelf.Result()
        result.success = True
        result.final_message = f'Arrived at shelf {target}'
        return result
sequenceDiagram participant C as Client (Mission Manager) participant S as Action Server (motor_controller) C->>S: Goal: shelf_number = 12 S-->>C: Feedback: distance_remaining = 15.0 S-->>C: Feedback: distance_remaining = 9.0 S-->>C: Feedback: distance_remaining = 3.0 Note over C,S: در هر لحظه Client می‌تواند Cancel Goal بفرستد S-->>C: Result: success = true, "Arrived at shelf 12"

و از خط فرمان، برای تست سریع بدون نوشتن هیچ کد Clientی:

dev@archo:~$ ros2 action send_goal /move_to_shelf archo_interfaces/action/MoveToShelf "{shelf_number: 12}" --feedback Feedback: distance_remaining: 15.0, status: moving Feedback: distance_remaining: 9.0, status: moving Feedback: distance_remaining: 3.0, status: moving Result: success: True, final_message: 'Arrived at shelf 12'
🌍 چرا این طراحی برای انبار مهم است

در فصل بعد، وقتی Nav2 را روی ARCHO سوار کنیم، دقیقاً همین الگو — Goal/Feedback/Result با قابلیت Cancel — زیرساخت اصلی حرکت خودکار ربات خواهد بود؛ Action رسمی Nav2 به نام NavigateToPose از همین ساختار پیروی می‌کند، فقط با Goal و Feedback واقعی‌تر (موقعیت هدف، فاصله باقی‌مانده واقعی از روی نقشه).

تمرین سخت‌تر

سناریویی بنویس که در آن Client یک MoveToShelf Goal می‌فرستد، اما وسط راه (وقتی distance_remaining به ۹ می‌رسد) درخواست Cancel می‌دهد. بگو Result نهایی چه مقداری خواهد داشت و چرا.

۳.۶Parameter در عمل

حالا سرعت پیش‌فرض حرکت ARCHO را به‌جای نوشتن ثابت در کد، به یک Parameter تبدیل می‌کنیم:

class MotorController(Node):
    def __init__(self):
        super().__init__('motor_controller')
        self.declare_parameter('max_speed', 0.8)
        self.declare_parameter('wheel_radius', 0.05)

    def get_max_speed(self):
        return self.get_parameter('max_speed').get_parameter_value().double_value

و فایل YAML متناظر که در Launch بارگذاری می‌شود:

# config/motor_params.yaml
motor_controller:
  ros__parameters:
    max_speed: 0.8
    wheel_radius: 0.05
dev@archo:~$ ros2 param list /motor_controller: max_speed wheel_radius dev@archo:~$ ros2 param set /motor_controller max_speed 0.5 Set parameter successful
تمرین آسان

یک Parameter به نام low_battery_threshold (پیش‌فرض ۲۰.۰) به battery_monitor اضافه کن و به‌جای عدد ثابت ۲۰ در تمرین بخش ۳.۳، از این Parameter استفاده کن.

۳.۷یک Launch File که همه را روشن می‌کند

حالا سه Node، فایل Parameter و همه‌چیز را با یک فایل Launch به هم وصل می‌کنیم:

# launch/archo_comms.launch.py
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch_ros.actions import Node


def generate_launch_description():
    params_file = os.path.join(
        get_package_share_directory('archo_bringup'),
        'config', 'motor_params.yaml')

    return LaunchDescription([
        Node(
            package='archo_bringup',
            executable='battery_monitor',
        ),
        Node(
            package='archo_bringup',
            executable='dashboard',
        ),
        Node(
            package='archo_bringup',
            executable='motor_controller',
            parameters=[params_file],
        ),
    ])
ros2 launch archo_bringup archo_comms.launch.py

با یک دستور، هر سه Node بالا می‌آیند: battery_monitor شروع به انتشار می‌کند، dashboard گوش می‌دهد، و motor_controller هم Service reset_encoder و هم Action move_to_shelf را با Parameterهای بارگذاری‌شده از YAML آماده می‌کند.

archo_comms.launch.py battery_monitor Publisher dashboard Subscriber motor_controller Service + Action /battery_level motor_params.yaml → Parameters
شکل ۳.۱ — یک Launch File، سه Node مستقل که با Topic، Service/Action و Parameter به هم وصل می‌شوند.
تمرین سخت‌تر

یک آرگومان Launch به نام use_sim (پیش‌فرض false) اضافه کن که وقتی true باشد، یک Node اضافه به نام fake_battery_drain هم اجرا شود. (راهنمایی: DeclareLaunchArgument و condition=IfCondition(...))

۳.۸جمع‌بندی فصل سوم

از فصل اول تا اینجا یک مسیر کامل طی شد: مفاهیم انتزاعی Node، Topic، Service، Action و Parameter را دیدی؛ در فصل دوم اولین Node ساکت را ساختی؛ و در همین فصل، برای اولین بار سه Node واقعی ARCHO — battery_monitor، dashboard و motor_controller — را نوشتی که واقعاً با هم حرف می‌زنند: یکی منتشر می‌کند، یکی گوش می‌دهد، یکی به درخواست سریع پاسخ می‌دهد و یکی عملیات طولانی را با گزارش زنده اجرا می‌کند.

✅ نقطه بازبینی یادگیری
  • می‌توانم یک Publisher و یک Subscriber واقعی با rclpy بنویسم.
  • می‌دانم چرا Messageهای سفارشی را در یک Package _interfaces جدا تعریف می‌کنیم.
  • می‌توانم یک Service با Request/Response سفارشی پیاده‌سازی کنم.
  • می‌توانم یک Action Server با Goal/Feedback/Result و پشتیبانی از Cancel بنویسم.
  • می‌دانم چطور Parameter را در کد declare کنم و از YAML بارگذاری کنم.
  • می‌توانم چند Node و فایل Parameter را با یک Launch File واحد اجرا کنم.

ارتباط با پروژه اصلی

پروژه ARCHO حالا یک زیرساخت ارتباطی کامل و واقعی دارد. هنوز ربات هیچ بدنه فیزیکی یا چرخی ندارد — این دقیقاً همان چیزی است که در فصل بعد می‌سازیم.

فصل بعد چه چیزی اضافه می‌کند

در فصل چهارم، برای اولین بار به ARCHO یک بدنه واقعی می‌دهیم: شاسی، دو چرخ محرک، یک چرخ هرزگرد، و محل نصب LiDAR و IMU — همه با زبان توصیف ربات ROS 2 به نام URDF، و نسخه هوشمندتر آن، Xacro.

واژه‌نامه فصل سوم

create_publisher / create_subscription
توابعی در کلاس Node برای ساخت Publisher و Subscriber روی یک Topic مشخص.
Timer
مکانیزمی در rclpy که یک تابع را با فاصله زمانی ثابت به‌طور خودکار اجرا می‌کند.
Interfaces Package
Packageای که فقط تعریف Message، Service و Action سفارشی را نگه می‌دارد، بدون منطق اجرایی.
ActionServer / call_async
ابزارهای rclpy برای پیاده‌سازی سمت سرور یک Action و فراخوانی ناهمزمان یک Service.
declare_parameter
تابعی که یک Parameter را با مقدار پیش‌فرض در Node ثبت می‌کند.
DeclareLaunchArgument
ابزار Launch برای گرفتن آرگومان‌های ورودی از خط فرمان و استفاده شرطی از آن‌ها.

خطاهای رایج فصل سوم — جمع‌بندی