Skip to content

Commit e43c464

Browse files
authored
Create Dummy ROS2 Nodes for MoveAbovePlate, Integrates into App (#34)
Implemented and Tested MoveAbovePlate - Implemented and tested the MoveAbovePlate dummy ROS action - Integrated it with the web app and thoroughly tested it - Made the Footer take in paused/setPaused and callback functions from the page it is on - Updated README
1 parent 7eafc79 commit e43c464

File tree

16 files changed

+681
-150
lines changed

16 files changed

+681
-150
lines changed

feeding_web_app_ros2_msgs/package.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<version>0.0.0</version>
66
<description>Defines the message types used by `feeding_web_app_ros2_test`.</description>
77
<maintainer email="[email protected]">Amal Nanavati</maintainer>
8-
<license>TODO: License declaration</license>
8+
<license>BSD-3-Clause</license>
99

1010
<buildtool_depend>ament_cmake</buildtool_depend>
1111
<test_depend>ament_lint_auto</test_depend>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env python3
2+
from ada_feeding_msgs.action import MoveTo
3+
from feeding_web_app_ros2_test.MoveToDummy import MoveToDummy
4+
import rclpy
5+
from rclpy.executors import MultiThreadedExecutor
6+
7+
8+
def main(args=None):
9+
rclpy.init(args=args)
10+
11+
move_above_plate = MoveToDummy("MoveAbovePlate", MoveTo)
12+
13+
# Use a MultiThreadedExecutor to enable processing goals concurrently
14+
executor = MultiThreadedExecutor()
15+
16+
rclpy.spin(move_above_plate, executor=executor)
17+
18+
19+
if __name__ == "__main__":
20+
main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
#!/usr/bin/env python3
2+
import rclpy
3+
from rclpy.action import ActionServer, CancelResponse, GoalResponse
4+
from rclpy.node import Node
5+
import threading
6+
import time
7+
8+
9+
class MoveToDummy(Node):
10+
def __init__(
11+
self,
12+
name,
13+
action_class,
14+
send_feedback_hz=10,
15+
dummy_plan_time=2.5,
16+
dummy_motion_time=10.0,
17+
):
18+
"""
19+
Initialize the MoveToDummy action node.
20+
21+
Parameters
22+
----------
23+
name: The name of the action server.
24+
action_class: The action class to use for the action server.
25+
send_feedback_hz: The target frequency at which to send feedback.
26+
dummy_plan_time: How many seconds this dummy node should spend in planning.
27+
dummy_motion_time: How many seconds this dummy node should spend in motion.
28+
"""
29+
super().__init__(name)
30+
31+
self.send_feedback_hz = send_feedback_hz
32+
self.dummy_plan_time = dummy_plan_time
33+
self.dummy_motion_time = dummy_motion_time
34+
self.action_class = action_class
35+
36+
self.active_goal_request = None
37+
38+
self._action_server = ActionServer(
39+
self,
40+
action_class,
41+
name,
42+
self.execute_callback,
43+
goal_callback=self.goal_callback,
44+
cancel_callback=self.cancel_callback,
45+
)
46+
47+
def goal_callback(self, goal_request):
48+
"""
49+
Accept a goal if this action does not already have an active goal,
50+
else reject.
51+
52+
Parameters
53+
----------
54+
goal_request: The goal request message.
55+
"""
56+
self.get_logger().info("Received goal request")
57+
if self.active_goal_request is None:
58+
self.get_logger().info("Accepting goal request")
59+
self.active_goal_request = goal_request
60+
return GoalResponse.ACCEPT
61+
self.get_logger().info("Rejecting goal request")
62+
return GoalResponse.REJECT
63+
64+
def cancel_callback(self, goal_handle):
65+
"""
66+
Always accept client requests to cancel the active goal. Note that this
67+
function should not actually impelement the cancel; that is handled in
68+
`execute_callback`
69+
70+
Parameters
71+
----------
72+
goal_handle: The goal handle.
73+
"""
74+
self.get_logger().info("Received cancel request, accepting")
75+
return CancelResponse.ACCEPT
76+
77+
def plan(self, plan, success):
78+
"""
79+
A dummy thread for planning to the target position. This thread
80+
will sleep for `self.dummy_plan_time` sec and then set the plan to None.
81+
82+
Parameters
83+
----------
84+
plan: A mutable object, which will contain the plan once the thread has
85+
finished. For this dummy thread, it contains None.
86+
success: A mutable object, which will contain the success status once
87+
the thread has finished.
88+
"""
89+
time.sleep(self.dummy_plan_time)
90+
plan.append(None)
91+
success[0] = True
92+
93+
def move(self, plan, success):
94+
"""
95+
A dummy thread for moving the robot arm along the plan. This thread
96+
will sleep for `self.dummy_motion_time` sec and then return success.
97+
98+
Parameters
99+
----------
100+
plan: Contains the plan.
101+
success: A mutable object, which will contain the success status once
102+
the thread has finished.
103+
"""
104+
time.sleep(self.dummy_motion_time)
105+
success[0] = True
106+
107+
async def execute_callback(self, goal_handle):
108+
"""
109+
First, plan to the target position. Then, move to that position.
110+
As a "dummy node," this specific node will spend `self.dummy_plan_time`
111+
sec in planning and `self.dummy_motion_time` sec in motion.
112+
113+
NOTE: In the actual (not dummy) implementation, we will be calling a
114+
ROS action defined by MoveIt to do both planning and execution. Because
115+
actions are non-blocking, we won't need to break off separate threads.
116+
Using separate threads is an artiface of using time.sleep() in this
117+
dummy implementation.
118+
"""
119+
self.get_logger().info("Executing goal...%s" % (goal_handle,))
120+
121+
# Load the feedback parameters
122+
feedback_rate = self.create_rate(self.send_feedback_hz)
123+
feedback_msg = self.action_class.Feedback()
124+
125+
# Start the planning thread
126+
plan = []
127+
plan_success = [False]
128+
planning_thread = threading.Thread(
129+
target=self.plan, args=(plan, plan_success), daemon=True
130+
)
131+
planning_thread.start()
132+
planning_start_time = self.get_clock().now()
133+
is_planning = True
134+
135+
# Create (but don't yet start) the motion thread
136+
is_moving = False
137+
motion_success = [False]
138+
motion_thread = threading.Thread(
139+
target=self.move, args=(plan, motion_success), daemon=True
140+
)
141+
142+
# Monitor the planning and motion threads, and send feedback
143+
while rclpy.ok() and (is_planning or is_moving):
144+
# Check if there is a cancel request
145+
if goal_handle.is_cancel_requested:
146+
self.get_logger().info("Goal canceled")
147+
goal_handle.canceled()
148+
result = self.action_class.Result()
149+
result.status = result.STATUS_CANCELED
150+
self.active_goal_request = None # Clear the active goal
151+
return result
152+
153+
# Check if the planning thread has finished
154+
if is_planning:
155+
if not planning_thread.is_alive():
156+
is_planning = False
157+
if plan_success[0]: # Plan succeeded
158+
self.get_logger().info(
159+
"Planning succeeded, proceeding to motion"
160+
)
161+
# Start the motion thread
162+
motion_thread.start()
163+
motion_start_time = self.get_clock().now()
164+
is_moving = True
165+
continue
166+
else: # Plan failed
167+
self.get_logger().info("Planning failed, aborting")
168+
# Abort the goal
169+
goal_handle.abort()
170+
result = self.action_class.Result()
171+
result.status = result.STATUS_PLANNING_FAILED
172+
self.active_goal_request = None # Clear the active goal
173+
return result
174+
175+
# Check if the motion thread has finished
176+
if is_moving:
177+
if not motion_thread.is_alive():
178+
is_moving = False
179+
if motion_success[0]:
180+
self.get_logger().info("Motion succeeded, returning")
181+
# Succeed the goal
182+
goal_handle.succeed()
183+
result = self.action_class.Result()
184+
result.status = result.STATUS_SUCCESS
185+
self.active_goal_request = None # Clear the active goal
186+
return result
187+
else:
188+
self.get_logger().info("Motion failed, aborting")
189+
# Abort the goal
190+
goal_handle.abort()
191+
result = self.action_class.Result()
192+
result.status = result.STATUS_MOTION_FAILED
193+
self.active_goal_request = None # Clear the active goal
194+
return result
195+
196+
# Send feedback
197+
feedback_msg.is_planning = is_planning
198+
if is_planning:
199+
feedback_msg.planning_time = (
200+
self.get_clock().now() - planning_start_time
201+
).to_msg()
202+
elif is_moving:
203+
# TODO: In the actual (not dummy) implementation, this should
204+
# return the distance (not time) the robot has yet to move.
205+
feedback_msg.motion_initial_distance = self.dummy_motion_time
206+
elapsed_time = self.get_clock().now() - motion_start_time
207+
elapsed_time_float = elapsed_time.nanoseconds / 1.0e9
208+
feedback_msg.motion_curr_distance = (
209+
self.dummy_motion_time - elapsed_time_float
210+
)
211+
self.get_logger().info("Feedback: %s" % feedback_msg)
212+
goal_handle.publish_feedback(feedback_msg)
213+
214+
# Sleep for the specified feedback rate
215+
feedback_rate.sleep()
216+
217+
# If we get here, something went wrong
218+
self.get_logger().info("Unknown error, aborting")
219+
goal_handle.abort()
220+
result = self.action_class.Result()
221+
result.status = result.STATUS_UNKNOWN
222+
self.active_goal_request = None # Clear the active goal
223+
return result

feeding_web_app_ros2_test/package.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<version>0.0.0</version>
66
<description>A minimal ROS publisher, subscriber, service, and action to use to test the web app.</description>
77
<maintainer email="[email protected]">Amal Nanavati</maintainer>
8-
<license>TODO: License declaration</license>
8+
<license>BSD-3-Clause</license>
99

1010
<test_depend>ament_copyright</test_depend>
1111
<test_depend>ament_flake8</test_depend>

feeding_web_app_ros2_test/setup.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
maintainer="Amal Nanavati",
1616
maintainer_email="[email protected]",
1717
description="A minimal ROS publisher, subscriber, service, and action to use to test the web app.",
18-
license="TODO: License declaration",
18+
license="BSD-3-Clause",
1919
tests_require=["pytest"],
2020
entry_points={
2121
"console_scripts": [
22+
# Scripts for the main app
23+
"MoveAbovePlate = feeding_web_app_ros2_test.MoveAbovePlate:main",
24+
# Scripts for the "TestROS" component
2225
"listener = feeding_web_app_ros2_test.subscriber:main",
2326
"reverse_string = feeding_web_app_ros2_test.reverse_string_service:main",
2427
"sort_by_character_frequency = feeding_web_app_ros2_test.sort_by_character_frequency_action:main",

feedingwebapp/README.md

+20-4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The overall user flow for this robot can be seen below.
1414
- [Node.js](https://nodejs.org/en/download/package-manager)
1515
- [ROS2 Humble](https://docs.ros.org/en/humble/Installation.html)
1616
- [PRL fork of rosbridge_suite](https://github.com/personalrobotics/rosbridge_suite). This fork enables rosbridge_suite to communicate with ROS2 actions.
17+
- [ada_feeding (branch: ros2-devel)](https://github.com/personalrobotics/ada_feeding/tree/ros2-devel).
1718

1819
## Getting Started in Computer
1920

@@ -31,14 +32,29 @@ The overall user flow for this robot can be seen below.
3132
- Note that if you're not running the robot code alongside the app, set [`debug = true` in `App.jsx`](https://github.com/personalrobotics/feeding_web_interface/tree/main/feedingwebapp/src/App.jsx#L17) to be able to move past screens where the app is waiting on the robot. Since the robot is not yet connected, the default is `debug = true`
3233
3. Use a web browser to navigate to `localhost:3000` to see the application.
3334

35+
#### Launching Dummy Nodes
36+
This repository includes several dummy nodes that match the interface that the robot nodes will use. By running the dummy nodes alongside the app, we can test the app's communication with the robot even without actual robot code running.
37+
38+
The below instructions are for `MoveAbovePlate`; we will add to the instructions as more dummy nodes get implemented.
39+
1. Navigate to your ROS2 workspace: `cd {path/to/your/ros2/workspace}`
40+
2. Build your workspace: `colcon build`
41+
3. Launch rosbridge: `source install/setup.bash; ros2 launch rosbridge_server rosbridge_websocket_launch.xml`
42+
4. In another terminal, run the MoveAbovePlate action: `source install/setup.bash; ros2 run feeding_web_app_ros2_test MoveAbovePlate`
43+
5. In another terminal, navigate to the web app folder: `cd {path/to/feeding_web_interface}/feedingwebapp`
44+
6. Start the app: `npm start`
45+
7. Use a web browser to navigate to `localhost:3000`.
46+
47+
You should now see that the web browser is connected to ROS. Further, you should see that when the `MoveAbovePlate` page starts, it should call the action (exactly once), and render feedback. "Pause" should cancel the action, and "Resume" should re-call it. Refreshing the page should cancel the action. When the action returns success, the app should automatically transition to the next page.
48+
3449
### Usage (Test ROS)
3550
There is a special page in the app intended for developers to: (a) test that their setup of the app and ROS2 enables the two to communicate as expected; and (b) gain familiarity with the library of ROS helper functions we use in the web app (see [TechDocumentation.md](https://github.com/personalrobotics/feeding_web_interface/tree/main/feedingwebapp/TechDocumentation.md)). Below are instructions to use this page:
3651
1. Navigate to your ROS2 workspace: `cd {path/to/your/ros2/workspace}`
3752
2. Build your workspace: `colcon build`
38-
3. Launch rosbridge: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml`
39-
4. In another terminal, navigate to the web app folder: `cd {path/to/feeding_web_interface}/feedingwebapp`
40-
5. Start the app: `npm start`
41-
6. Use a web browser to navigate to `localhost:3000/test_ros`.
53+
3. Source your workspace: `source install/setup.bash`
54+
4. Launch rosbridge: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml`
55+
5. In another terminal, navigate to the web app folder: `cd {path/to/feeding_web_interface}/feedingwebapp`
56+
6. Start the app: `npm start`
57+
7. Use a web browser to navigate to `localhost:3000/test_ros`.
4258

4359
The following are checks to ensure the app is interacting with ROS as expected. If any of them fails, you'll have to do additional troubleshooting to get the web app and ROS2 smoothly communicating with each other.
4460
1. First, check if the page says `Connected` at the top of the screen. If not, the web app is not communicating with ROS2.

feedingwebapp/TechDocumentation.md

+8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ All functions to interact with ROS are defined in [`ros_helpers.jsx`](https://gi
1919

2020
Although the web app's settings menu seem parameter-esque it is not a good idea to update settings by getting/setting ROS parameters. That is because ROS2 parameters are owned by individual nodes, and do not persist beyond the lifetime of a node. Further, we likely want to run downstream code after updating any setting (e.g., writing it to a file as backup), which cannot easily be done when setting ROS parameters. Therefore, **updating settings should be done by ROS service calls, not by getting/setting ROS parameters**. Hence, `ros_helpers.jsx` does not even implement parameter getting/setting.
2121

22+
### Using ROS Services, Actions, etc. Within React
23+
24+
React, by default, will re-render a component any time local state changes. In other words, it will re-run all code in the function that defines that component. However, this doesn't work well for ROS Services, Actions, etc. because: (a) we don't want to keep re-calling the service/action each time the UI re-renders; and (b) we don't want to keep re-creating a service/action client each time the UI re-renders. Therefore, it is crucial to use React Hooks intentionally and to double and triple check that the service/action is only getting once, to avoid bugs.
25+
26+
These resources that can help you understand React Hooks in general:
27+
- https://medium.com/@guptagaruda/react-hooks-understanding-component-re-renders-9708ddee9928
28+
- https://stackoverflow.com/a/69264685
29+
2230
## Frequently Asked Questions (FAQ)
2331

2432
Q: Why are we using global state as opposed to a [`Router`](https://www.w3schools.com/react/react_router.asp) to decide which app components to render?

feedingwebapp/src/Pages/Constants.js

+26
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { MEAL_STATE } from './GlobalState'
2+
23
// The RealSense's default video stream is 640x480
34
export const REALSENSE_WIDTH = 640
45
export const REALSENSE_HEIGHT = 480
@@ -21,3 +22,28 @@ FOOTER_STATE_ICON_DICT[MEAL_STATE.R_MovingToStagingLocation] = '/robot_state_img
2122
FOOTER_STATE_ICON_DICT[MEAL_STATE.R_MovingToMouth] = '/robot_state_imgs/move_to_mouth_position.svg'
2223
FOOTER_STATE_ICON_DICT[MEAL_STATE.R_StowingArm] = '/robot_state_imgs/stowing_arm_position.svg'
2324
export { FOOTER_STATE_ICON_DICT }
25+
26+
// For states that call ROS actions, this dictionary contains
27+
// the action name and the message type
28+
let ROS_ACTIONS_NAMES = {}
29+
ROS_ACTIONS_NAMES[MEAL_STATE.R_MovingAbovePlate] = {
30+
actionName: 'MoveAbovePlate',
31+
messageType: 'ada_feeding_msgs/action/MoveTo'
32+
}
33+
export { ROS_ACTIONS_NAMES }
34+
35+
// The meaning of the status that motion actions return in their results.
36+
// These should match the action definition(s)
37+
export const MOTION_STATUS_SUCCESS = 0
38+
export const MOTION_STATUS_PLANNING_FAILED = 1
39+
export const MOTION_STATUS_MOTION_FAILED = 2
40+
export const MOTION_STATUS_CANCELED = 3
41+
export const MOTION_STATUS_UNKNOWN = 99
42+
43+
// The meaning of ROS Action statuses.
44+
// https://docs.ros2.org/latest/api/rclpy/api/actions.html#rclpy.action.server.GoalEvent
45+
export const ROS_ACTION_STATUS_EXECUTE = '1'
46+
export const ROS_ACTION_STATUS_CANCEL_GOAL = '2'
47+
export const ROS_ACTION_STATUS_SUCCEED = '3'
48+
export const ROS_ACTION_STATUS_ABORT = '4'
49+
export const ROS_ACTION_STATUS_CANCELED = '5'

0 commit comments

Comments
 (0)