Add ROS2 Action client and server support#325
Conversation
Adds C# Action client and server classes that work with the patched ROS-TCP-Endpoint (comoc/ROS-TCP-Endpoint, main-ros2 branch) to bridge ROS2 Actions through the TCP connection. SysCommand.cs: - 6 new command constants (__action_client, __action_send_goal, __action_get_result, __action_cancel_goal, __action_server, __action_publish_feedback) - 3 new param structs (SysCommand_ActionRegistration, SysCommand_ActionGoalOp, SysCommand_ActionFeedback) ROSActionClient.cs: - ROSActionClient<TGoal, TResult, TFeedback> with async SendGoal() returning ActionGoalHandle - ActionGoalHandle with async GetResult(), Cancel(), and FeedbackReceived event - RegisterFeedbackSubscription<TFeedbackMessage>() for wiring up goal-specific feedback routing via SubscribeByMessageName - GetResultRequestProxy and CancelGoalRequestProxy internal messages - Handles the endpoint's UUID-prepend convention on SendGoal response - Skips int8 status + alignment padding in GetResult response ROSActionServer.cs: - ROSActionServer<TGoal, TResult, TFeedback> with GoalReceived event - ActionServerGoalHandle with PublishFeedback() and SetResult() - Goal routing via __request/__response reusing existing service infrastructure ROSConnection.cs: - AllocateServiceRequest() internal helper for thread-safe srv_id allocation without exposing private fields - QueueRawMessage() internal helper for sending data frames without triggering Publish()'s publisher-registration check - CreateActionClient<>() and CreateActionServer<>() factory methods - TryRouteToActionServer() for __request dispatch to action servers - Action server routing integrated into ReceiveSysCommand's m_SpecialIncomingMessageHandler - FindObjectOfType -> FindAnyObjectByType (deprecation fix) Hand-written Fibonacci message classes for testing: - FibonacciGoal, FibonacciResult, FibonacciFeedback, FibonacciFeedbackMessage (with dual MessageRegistry registration for short and /action/ type name variants) Verified end-to-end: - Action client: Unity -> ROS2 fibonacci_action_server, all 9 feedback messages + result [0,1,1,2,3,5,8,13,21,34,55] - Action server: ros2 action send_goal -> Unity, 4 feedback messages + result [0,1,1,2,3,5] with SUCCEEDED status - Reconnect after endpoint restart works correctly
|
|
There was a problem hiding this comment.
Pull request overview
Adds ROS2 Action client/server support to the ROS TCP Connector by introducing new C# action abstractions and integrating them into ROSConnection’s existing syscommand + request/response plumbing (intended to work with the patched ROS-TCP-Endpoint changes).
Changes:
- Add new Action syscommands + JSON param structs to support action registration, goal ops, and feedback publishing.
- Add
ROSActionClient/ROSActionServer(and goal-handle helpers) plusROSConnectionfactory + routing helpers. - Add handwritten Fibonacci action message types used for end-to-end validation.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| com.unity.robotics.ros-tcp-connector/Runtime/TcpConnector/SysCommand.cs | Adds action-related syscommand constants and param structs. |
| com.unity.robotics.ros-tcp-connector/Runtime/TcpConnector/ROSConnection.cs | Adds action factories, raw-frame enqueue helper, and __request routing to action servers; updates instance lookup API. |
| com.unity.robotics.ros-tcp-connector/Runtime/TcpConnector/ROSActionServer.cs | New Unity-side action server + per-goal handle for feedback/result. |
| com.unity.robotics.ros-tcp-connector/Runtime/TcpConnector/ROSActionServer.cs.meta | Unity asset metadata for new file. |
| com.unity.robotics.ros-tcp-connector/Runtime/TcpConnector/ROSActionClient.cs | New Unity-side action client + per-goal handle for feedback/result/cancel. |
| com.unity.robotics.ros-tcp-connector/Runtime/TcpConnector/ROSActionClient.cs.meta | Unity asset metadata for new file. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces.meta | Adds new message package folder metadata. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action.meta | Adds new action/ folder metadata. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciGoal.cs | Adds Fibonacci action Goal message type. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciGoal.cs.meta | Unity asset metadata for new file. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciResult.cs | Adds Fibonacci action Result message type. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciResult.cs.meta | Unity asset metadata for new file. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciFeedback.cs | Adds Fibonacci action Feedback message type. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciFeedback.cs.meta | Unity asset metadata for new file. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciFeedbackMessage.cs | Adds Fibonacci FeedbackMessage wrapper (goal_id + feedback) and registers alt typename. |
| com.unity.robotics.ros-tcp-connector/Runtime/Messages/ActionTutorialsInterfaces/action/FibonacciFeedbackMessage.cs.meta | Unity asset metadata for new file. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| { | ||
| // Prefer to use the ROSConnection in the scene, if any | ||
| _instance = FindObjectOfType<ROSConnection>(); | ||
| _instance = FindAnyObjectByType<ROSConnection>(); | ||
| if (_instance != null) | ||
| return _instance; |
There was a problem hiding this comment.
FindAnyObjectByType<T>() is not available in Unity 2020.2 (the package’s minimum Unity version in package.json). This will cause compile errors for supported Unity versions; use FindObjectOfType<ROSConnection>() or wrap the newer API behind version-dependent #if guards with an older fallback.
| internal bool TryRouteToActionServer(int srvId, string destination, byte[] data) | ||
| { | ||
| if (m_ActionServers.TryGetValue(destination, out var serverObj)) | ||
| { | ||
| // serverObj is ROSActionServer<,,> but we stored it as object. | ||
| // Use reflection to call OnGoalRequest. | ||
| var method = serverObj.GetType().GetMethod("OnGoalRequest", | ||
| System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); | ||
| method?.Invoke(serverObj, new object[] { srvId, data }); | ||
| return true; |
There was a problem hiding this comment.
TryRouteToActionServer returns true as soon as the destination matches, even if reflection fails to find OnGoalRequest (or if invocation throws). That can silently drop incoming __request messages. Consider caching a typed delegate/interface instead of reflection, and only returning true after a successful dispatch (otherwise log and fall back to normal service routing).
| void EnsureRegistered() | ||
| { | ||
| if (m_Registered) | ||
| return; | ||
| m_Registered = true; | ||
|
|
||
| m_Connection.QueueSysCommand( | ||
| SysCommand.k_SysCommand_ActionClient, | ||
| new SysCommand_ActionRegistration | ||
| { | ||
| action_name = m_ActionName, | ||
| action_type = m_ActionType | ||
| }); | ||
| } |
There was a problem hiding this comment.
Action client registration is only sent once per ROSActionClient instance (m_Registered), but ROSConnection re-registers topics/services on reconnect. If the endpoint restarts, this client may never re-send __action_client, potentially breaking subsequent action calls/feedback. Consider resetting m_Registered on connection loss or integrating with ROSConnection’s reconnect path to re-register action clients.
| readonly Dictionary<string, ActionGoalHandle<TResult, TFeedback>> m_GoalHandles = | ||
| new Dictionary<string, ActionGoalHandle<TResult, TFeedback>>(); | ||
|
|
There was a problem hiding this comment.
m_GoalHandles entries are added for accepted goals but never removed (not after GetResult() completes and not after Cancel() succeeds). Over time this can grow unbounded and keep routing feedback to stale handles; consider removing the entry when a goal reaches a terminal state (result received / cancel acknowledged).
| public void EnsureRegistered() | ||
| { | ||
| if (m_Registered) | ||
| return; | ||
| m_Registered = true; | ||
|
|
||
| m_Connection.QueueSysCommand( | ||
| SysCommand.k_SysCommand_ActionServer, | ||
| new SysCommand_ActionRegistration | ||
| { | ||
| action_name = m_ActionName, | ||
| action_type = m_ActionType | ||
| }); | ||
| } |
There was a problem hiding this comment.
Action server registration is only sent once (m_Registered), but ROSConnection will reconnect and re-register topics/services after an endpoint restart. If the endpoint loses state, Unity’s action server may never re-send __action_server, so ROS clients won’t be able to send goals until restart. Consider resetting m_Registered on connection loss or hooking into ROSConnection’s reconnect path to re-register action servers.
| public void SetResult(TResult result) | ||
| { | ||
| // Send __response{srv_id} + CDR Result body. | ||
| // QueueSysCommand is public on ROSConnection. | ||
| m_Connection.QueueSysCommand( | ||
| SysCommand.k_SysCommand_ServiceResponse, | ||
| new SysCommand_Service { srv_id = m_SrvId }); | ||
| m_Connection.QueueRawMessage(m_ActionName, result); | ||
| } |
There was a problem hiding this comment.
ROSActionServer tracks active goals (m_ActiveGoals) and even has RemoveGoal, but ActionServerGoalHandle.SetResult() never calls back to remove the goal. This leaves completed goals in the dictionary indefinitely; either remove the tracking entirely or ensure the goal is removed when a terminal result is set (or on cancel).
| public async Task<TResult> GetResult() | ||
| { | ||
| var (srvId, pauser) = m_Connection.AllocateServiceRequest(); | ||
|
|
||
| m_Connection.QueueSysCommand( | ||
| SysCommand.k_SysCommand_ActionGetResult, | ||
| new SysCommand_ActionGoalOp { action_name = m_ActionName, srv_id = srvId }); | ||
|
|
||
| var request = new GetResultRequestProxy { goal_id = GoalId }; | ||
| m_Connection.QueueRawMessage(m_ActionName, request); | ||
|
|
||
| byte[] responseBytes = (byte[])await pauser.PauseUntilResumed(); | ||
|
|
||
| if (responseBytes == null || responseBytes.Length == 0) | ||
| return default; | ||
|
|
||
| // GetResult_Response CDR layout: | ||
| // [4 bytes CDR header (00 01 00 00)] | ||
| // [1 byte int8 status] | ||
| // [3 bytes alignment padding to 4-byte boundary] | ||
| // [Result CDR body (WITHOUT its own CDR header)] | ||
| // | ||
| // We need to strip the CDR header + status + padding, then | ||
| // prepend a fresh CDR header so the deserializer sees a | ||
| // well-formed CDR stream for TResult. | ||
| const int headerSize = 4; // CDR encapsulation header | ||
| const int statusSize = 1; // int8 status | ||
| const int padSize = 3; // alignment to 4-byte boundary | ||
| int skipBytes = headerSize + statusSize + padSize; | ||
|
|
||
| if (responseBytes.Length <= skipBytes) | ||
| return default; | ||
|
|
||
| // Build a new byte array: CDR header + Result body | ||
| byte[] resultCdr = new byte[4 + (responseBytes.Length - skipBytes)]; | ||
| resultCdr[0] = 0x00; resultCdr[1] = 0x01; // CDR_LE | ||
| resultCdr[2] = 0x00; resultCdr[3] = 0x00; | ||
| Array.Copy(responseBytes, skipBytes, resultCdr, 4, | ||
| responseBytes.Length - skipBytes); | ||
|
|
||
| var deserializer = new MessageDeserializer(); | ||
| return deserializer.DeserializeMessage<TResult>(resultCdr); | ||
| } |
There was a problem hiding this comment.
This PR introduces new action-specific behaviors (goal request/response framing, GetResult byte-skipping, feedback subscription routing, and __request dispatch to action servers) but there are no corresponding unit/integration tests. Since the package already has Runtime tests, please add coverage for at least the protocol parsing/packing logic and routing behavior (e.g., verifying GetResult payload handling and that __request is routed correctly when an action server is registered).
Proposed change(s)
Add C# Action client and server classes that bridge ROS2 Actions through the TCP connection. Works with a patched ROS-TCP-Endpoint (comoc/ROS-TCP-Endpoint PR #178) that adds the corresponding Python-side syscommands.
Changes
New classes:
ROSConnection integration:
SysCommand additions:
Protocol details:
Useful links
Types of change(s)
Testing and Verification
Tested end-to-end with action_tutorials_interfaces/Fibonacci action:
Action client (Unity -> ROS2 fibonacci_action_server): send_goal accepted, 9 feedback messages received with partial_sequence growing correctly, final result [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
Action server (ros2 action send_goal -> Unity): goal received, 4 feedback messages published at 1-second intervals, result [0, 1, 1, 2, 3, 5] returned with status SUCCEEDED
Reconnect: after endpoint restart, feedback subscription re-registers correctly
Test Configuration:
Checklist
Notes
This PR requires the companion endpoint changes in PR #178. The implementation is backwards-compatible — existing topic/service functionality is completely unaffected. The new Action classes are opt-in; projects that don't use Actions will see no changes in behavior.
The Fibonacci message classes included are hand-written for testing. A future enhancement would add .action file support to the MessageGeneration tool.