Learning Objectives
- 說明要跑很久的工作為什麼用 action,不用 service
- 說出 goal、feedback、result、cancel 的角色
- 用 ActionServer、ActionClient 寫伺服器與用戶端
- 說明 execute 回呼阻塞時,同一個節點的訂閱為什麼收不到,以及怎麼解決
- 正確處理取消:先讓機器人停下來,再回報已取消
「走到教室門口」不能用 service
叫機器人走到教室門口要花 30 秒。如果用 service:
- 用戶端這 30 秒什麼都不知道:走到哪了、卡住了嗎、還是機器人根本沒收到?
- 走到一半有人擋路,想喊停也沒辦法,因為只有「問」和「答」兩個動作。
action 就是為了這種工作設計的。官方的說法是「長時間的遠端呼叫,有回饋,也可以取消」。它分成四個部分:
| 部分 | 方向 | 例子 |
|---|---|---|
| goal(目標) | 用戶端 → 伺服器 | 走到 (3, 1) |
| feedback(回饋) | 伺服器 → 用戶端,可以很多次 | 還剩 2.4 公尺 |
| result(結果) | 伺服器 → 用戶端,一次 | 到了/失敗 |
| cancel(取消) | 用戶端 → 伺服器 | 不要走了 |
定義檔長這樣,用兩條 --- 分成三段(goal、result、feedback):
int32 order
---
int32[] sequence
---
int32[] sequence
官方的例子是算 Fibonacci 數列,這裡先用它看流程。伺服器故意每一步停一下,模擬要跑很久的工作。
一步一步看 action 的順序
這次用同一個節點同時當伺服器與用戶端,也不用 sleep,專心看順序:先拿到「接受了」,再用它要結果。注意最後的輸出:回饋和結果走不同的通道,不保證誰先到。這個範例伺服器一下子就做完,回饋反而比結果晚被處理;上面的練習伺服器每一步都停一下,所以回饋都先到。
自己寫一個「往前走 N 公尺」
現在做一個真的會動的 action:收到距離,往前走,回報剩餘距離,走到就停。
先借用 nav2_msgs/action/NavigateToPose 這個訊息型別:goal 裡的 pose 有 x,可以放距離;feedback 有 distance_remaining。這樣就不用先建自訂的 action 型別(真實專案通常會在自己的 package 裡寫一個 .action 檔)。真正的導航堆疊要到第 19 課才用。
看起來最直接的寫法是:訂閱 /odom 更新 self.x,在 execute 裡一邊發速度指令、一邊 time.sleep(0.1)、一邊檢查 self.x。這樣寫有問題,先執行看看。
多執行緒與回呼群組
剛才照官方範例改了兩件事:
- MultiThreadedExecutor:用多條執行緒跑回呼,同時可以有好幾個回呼在進行。預設的單執行緒執行器一次只跑一個。
- 回呼群組(callback group):決定哪些回呼可以同時跑。每個節點預設只有一個互斥群組,同一群組裡的回呼不會同時跑。
ReentrantCallbackGroup表示這個群組裡的回呼可以同時跑。
多執行緒也有代價:多個回呼可能同時碰到同一個變數。這裡 on_odom 寫、execute 讀一個浮點數問題不大,如果是好幾個欄位要一起更新,就要加鎖,否則可能讀到改到一半的資料。
另一個選擇:把工作拆開。 第 3 課的做法在這裡也成立,規則是能短就短;真的要等很久的回呼才需要這一節的工具。rclpy 也支援 async def 的回呼,用 await 把執行權讓出來,官方範例的 execute_callback 就是這樣宣告的。
(真實環境和模擬環境都依這套規則運作,但模擬環境裡的回呼其實是輪流跑的:只有在 time.sleep 或 await 的時候才會換人,所以不會真的碰到同時改資料的問題。真實環境會。)
取消:要對方同意,也要真的停下來
用戶端呼叫 goal_handle.cancel_goal_async() 只是請求取消,伺服器要做三件事才算處理好:
- 同意:
ActionServer(..., cancel_callback=...)註冊的函數回傳CancelResponse.ACCEPT。沒有註冊的話,rclpy 的預設行為是一律拒絕。 - 注意到:
execute的迴圈要自己檢查goal_handle.is_cancel_requested,系統不會把你的迴圈中斷掉。 - 收尾:讓機器人停下來,呼叫
goal_handle.canceled(),回傳一個結果。
第 3 步的順序不是形式。cmd_vel 指令發出去之後,機器人會照最後一筆指令繼續走(模擬世界就是這樣;很多真實的底盤驅動會在一段時間沒收到指令後自己停,但不能假設)。所以取消的時候要主動送一筆速度為 0 的指令。有人站在機器人前面按取消,他要的是機器人停下來,不是系統狀態變成「已取消」。
同樣的邏輯也適用於拒絕 goal:goal_callback 可以在開始之前就拒絕不合理的目標(例如距離是負的、超出地圖),回傳 GoalResponse.REJECT。
確認一下
機器人手臂要花 20 秒把杯子拿過來,過程中要顯示進度,也要能中止。應該用什麼?
ActionServer 沒有傳 cancel_callback。用戶端呼叫 cancel_goal_async() 會怎樣?
execute 裡有 while 迴圈配 time.sleep,並讀取訂閱回呼更新的 self.x。節點用預設的 rclpy.spin。會發生什麼?
下面哪一個是處理取消的正確順序?
為什麼用戶端的程式全部寫成回呼(send_goal_async 接 add_done_callback),不要寫成一行一行等?
重點整理
- action 給要跑很久的工作用:goal、多次 feedback、一個 result,還能 cancel。
- 伺服器回傳之前要宣告
succeed、abort或canceled。 execute阻塞時,單執行緒執行器輪不到同一個節點的其他回呼。需要在execute中途讀感測器時,用MultiThreadedExecutor加ReentrantCallbackGroup。- 預設會拒絕取消;要接受得註冊
cancel_callback,並在execute裡檢查is_cancel_requested。 - 取消或失敗時,先讓機器人停下來,再回報狀態。
到這裡 ROS 2 的三種溝通方式都看過了。下一課處理一個實際問題:節點多了怎麼一次啟動,以及怎麼用 TF 管理一堆座標系。
(Fibonacci 範例改寫自 ROS 2 官方文件 Writing an action server and client(Python),CC BY 4.0,有修改。)