Getting started? Jump to the installation instructions.
DRYV Bot is a digital twin designed for hospital logistics. In a hospital, medications, equipment, meals, and documents must circulate between departments — sometimes urgently, always through spaces shared with staff, patients, and visitors. A delivery is not just a trip between two rooms: it is a mission that evolves in a living environment.
A mobile robot can transport a payload and navigate using its sensors, but it remains limited by what it perceives locally. Its onboard resources are already dedicated to safety, perception, motors, navigation, and communication. Its LiDAR detects nearby surroundings, but it cannot know the global state of the hospital — whether a distant corridor is blocked or in what order multiple deliveries should be handled. In short, the robot knows how to transport, but it lacks a global vision to decide.
DRYV Bot fills that gap. Rather than adding more computation on the robot itself, the system follows an edge–fog architecture: the robot stays on the ground handling local execution (movement, obstacle avoidance), while the digital twin runs on a nearby external machine. The twin continuously receives data from the robot and the environment, updates a representation of the situation, and sends back the decisions needed to carry out the mission.
The system relies on two complementary components:
- Safe and fast navigation : adapts the robot's trajectory based on the dynamic state of the environment (people, obstacles, congested areas). It answers: which path to take to reach the destination safely and efficiently?
- Delivery order optimization : organizes orders to determine the sequence of deliveries, taking into account priorities and estimated durations. It answers: which delivery to handle first, and in what order to continue the round?
Separately, each component addresses part of the problem. Together, they form a complete loop: observe the situation, organize the missions, guide the robot, then readjust decisions based on field feedback. DRYV Bot does not just simulate a robot or display a map — it orchestrates the mission.
About the name: DRYV Bot takes its initials from the team — Dounia, Ramses, Yanis, and Vasilis. The "Y" is not a typo, it's our signature. And for a robot meant to intelligently drive hospital deliveries, the wordplay was too good to pass up. The digital twin receives real-time data from the robot and the environment, computes the optimal path, and sends back motion commands. This creates a closed-loop feedback system (boucle de rétroaction) detailed in Documentation/pages/architecture.md.
Since we do not have a physical robot, Gazebo Classic 11 simulates the robot navigating a hospital environment. The simulation represents what would be a real robot connected via a 5G local network to the cloud.
For the full system architecture, feedback loop, and data flow diagrams, see Documentation/pages/architecture.md
For the navigation logic layer (crowd avoidance, room commands, voice control), see Documentation/pages/navigation_logic.md
For the delivery optimizer logic and ML travel-time model, see Documentation/pages/delivery_optimization.md
For the Foxglove user interface (layout, panels, topic visualisation), see Documentation/pages/foxglove.md
For the network map (which IP/port each system opens and connects to), see Documentation/pages/network_connections.md
For known issues and their solutions, see Documentation/pages/troubleshooting.md
The project is organised in the following ROS2 packages:
robot_simulation: World files (.world), saved maps, and the obstacle spawner that injects dynamic human models into the simulation. In a real deployment, this package would be replaced by the physical robot and its sensors.digital_twin: The core of the project. Cloud-side intelligence that the robot cannot run onboard. Contains Nav2 configuration, hospital map, launch files, and the navigation logic layer (crowd monitor, room interpreter, speech node).bcr_bot: The simulated robot. A differential drive robot with 2D lidar, camera, and IMU. Used in Gazebo Classic mode.
| Component | Technology | Role |
|---|---|---|
| Framework | ROS2 Humble | Robotics middleware |
| Simulation | Gazebo Classic 11 | Robot and environment simulation |
| Robot | bcr_bot | Differential drive with lidar and camera |
| Navigation | Nav2 (DWB local planner) | Path planning and obstacle avoidance |
| Mapping | slam_toolbox | SLAM for map generation |
| Visualisation | Foxglove (web) | Real-time monitoring and user interface |
| Speech | faster-whisper | Local speech-to-text for voice commands |
- Ubuntu 22.04
- ROS2 Humble (installation guide)
# Gazebo Classic 11 + ROS2 bridge
sudo apt install ros-humble-gazebo-ros-pkgs ros-humble-gazebo-ros
# Navigation and SLAM
sudo apt install ros-humble-navigation2 \
ros-humble-nav2-bringup \
ros-humble-slam-toolbox
# Foxglove bridge
sudo apt install ros-humble-foxglove-bridge
# ROS2 tools
sudo apt install python3-colcon-common-extensions \
ros-humble-teleop-twist-keyboard \
ros-humble-topic-tools \
ros-humble-tf2-ros
# Audio (for speech node)
sudo apt install libportaudio2 portaudio19-devNote: Gazebo Classic and Gazebo Harmonic (gz-tools2) cannot coexist. If Harmonic is installed, remove it first:
sudo apt remove gz-harmonic gz-tools2 ros-humble-ros-gzharmonic*
pip install faster-whisper sounddevice numpy pyyaml websockets --break-system-packagesgit clone https://github.com/vskarleas/DRYVbot
cd DRYVbot/CodeIf bcr_bot is not already in src/:
cd src
git clone https://github.com/blackcoffeerobotics/bcr_bot.git
cd ..Install ROS dependencies:
sudo apt install python3-rosdep
sudo rosdep init # skip if already initialised
rosdep update
rosdep install --from-paths src --ignore-src -r -yXacro needs to be installed for this repo. Check the installation process depending on your system.
Build:
cd ~/Documents/DRYVbot/Code
colcon build
source install/setup.bashAdd to ~/.bashrc if not already there:
source /opt/ros/humble/setup.bashSimply open a terminal on your Ubuntu computer and navigate to rge repo of the project. Then you only need to launch the simulation.launch.py file as shown below :
cd ~/Documents/DRYVbot/Code
source install/setup.bash
ros2 launch digital_twin simulation.launch.pyStarts Gazebo with the hospital world, bcr_bot, Nav2 navigation, obstacle spawner, digital twin logic, RViz, cmd_vel relay, initial pose publisher, and Foxglove bridge:
cd ~/Documents/DRYVbot/Code
source install/setup.bash
ros2 launch digital_twin simulation.launch.py \
obstacle_mode:=random \
random_obstacle_scenario:=normalWait approximately 30 seconds for everything to initialise.
The launch argument is still named random_obstacle_scenario for compatibility,
but the current human scenarios are deterministic and controlled.
The default obstacle mode remains fixed for backward compatibility.
obstacle_mode:=fixedlaunches the originalobstacle_spawner.py.obstacle_mode:=randomlaunches the controlled moving-human spawner.obstacle_mode:=disabledlaunches the simulation without obstacles.enable_obstacles:=falsealso disables obstacle spawning.
For controlled moving humans, use:
ros2 launch digital_twin simulation.launch.py \
obstacle_mode:=random \
random_obstacle_scenario:=normalros2 launch digital_twin simulation.launch.py \
obstacle_mode:=random \
random_obstacle_scenario:=crowdros2 launch digital_twin simulation.launch.py \
obstacle_mode:=random \
random_obstacle_scenario:=emergencyAvailable controlled scenarios are:
normal: 10 humans follow predefined patrol loops through the hospital.crowd: 18 humans follow predefined patrol loops with wider coverage.emergency: 8 humans follow fixed emergency routes. Afteremergency_start_timeseconds, they move to a separated gathering area. The return phase begins afteremergency_durationseconds, and a new emergency can start after complete dispersal whenemergency_loopis enabled.
The controlled spawner parameters are in
robot_simulation/config/random_obstacles_params.yaml. This file configures the
map, wall/robot/person safety distances, Gazebo update rate, spawn timeout, and
emergency timing. It no longer generates random trajectories.
The spawner publishes /people_positions for the crowd monitor and
/people_markers for RViz. The full simulation remaps the enabled Nav2
MarkerArray display to /people_markers, showing each person as a small
coloured circle.
The standalone spawner command, with Gazebo already running, is:
ros2 run robot_simulation random_obstacle_spawner.py --ros-args \
--params-file $(ros2 pkg prefix robot_simulation)/share/robot_simulation/config/random_obstacles_params.yaml \
-p scenario:=normalIf you only want Gazebo, bcr_bot, Nav2, the obstacle mode selected inhospital.launch.py, and Foxglove bridge, launch:
cd ~/Documents/DRYVbot/Code
source install/setup.bash
ros2 launch digital_twin hospital.launch.pysimulation.launch.py already includes this layer. Launch it separately only when using hospital.launch.py directly.
Starts the crowd monitor (dynamic map overlay), room interpreter (text commands), and speech node (voice commands):
cd ~/Documents/DRYVbot/Code
source install/setup.bash
ros2 launch digital_twin logic.launch.pyTo enable the simulation logger (records departure/arrival data per room command to simulation_logs/):
ros2 launch digital_twin logic.launch.py enable_sim_logger:=trueEach simulation run produces a JSON file in simulation_logs/ named simulation_<YYYY-MM-DD_HH-MM-SS>.json. The file contains one record per room command:
{
"simulation_id":"2026-06-18_14-32-10",
"started_at":"2026-06-18T14:32:10.123Z",
"records":[
{
"record_id":1,
"raw_command":"Go to room 101",
"room_id":"salle_101",
"room_display_name":"salle 101",
"target_position":{
"x":-8.1,
"y":-6.62
},
"departure":{
"wall_time":"2026-06-18T14:32:15.456Z",
"sim_time_s":120.345,
"robot_position":{"x":0.0,"y":1.95,"z":0.0}
},
"arrival":{
"wall_time":"2026-06-18T14:32:38.789Z",
"sim_time_s":143.678,
"robot_position":{"x":-8.09,"y":-6.60,"z":0.0}
},
"duration_s":23.333,
"position_error_m":0.0224,
"status":"succeeded",
"feedback":"Navigating to salle 101 (x=-8.1, y=-6.62)"
}
]
}Field descriptions:
target_position— theoretical coordinates fromroom_registry.yamldeparture.robot_position— where the robot was when the command was receivedarrival.robot_position— where the robot actually stoppedduration_s— simulation time elapsed between departure and arrivalposition_error_m— Euclidean distance betweentarget_positionandarrival.robot_position, representing the navigation accuracy errorstatus— outcome of the navigation:succeeded,aborted,canceled,interrupted(new command received before arrival), orshutdown(node stopped mid-navigation)
The ws_command_bridge node runs a WebSocket server on port 9090 (configurable via ws_port launch argument). Any computer on the network can connect without ROS being installed. To disable the WebSocket bridge or change its port on the server side:
ros2 launch digital_twin logic.launch.py enable_ws_bridge:=false
ros2 launch digital_twin logic.launch.py ws_port:=8080Connection:
ws://<ROS_MACHINE_IP>:9090
On connect, the server immediately sends the current navigation state.
Send the robot to a room:
{"type": "room_command", "room": "salle 101"}The room field accepts any name or alias from room_registry.yaml (e.g. "room 101", "cuisine", "pharmacy", "charging station"). The command is validated against the registry before being forwarded to the robot — invalid rooms return an error.
List available rooms:
{"type": "list_rooms"}Query current navigation state:
{"type": "get_status"}Acknowledgement (sent to the requesting client after a valid room command):
{
"type": "ack",
"command": "room_command",
"room": "salle 101",
"resolved_room_id": "salle_101",
"target_position": {"x": -8.1, "y": -6.62}
}Navigation status (broadcast to all connected clients on state changes):
{"type": "status", "state": "navigating", "target": "salle_101",
"target_position": {"x": -8.1, "y": -6.62}}{"type": "status", "state": "arrived", "target": "salle_101",
"robot_position": {"x": -8.09, "y": -6.60, "z": 0.0},
"position_error_m": 0.0224, "duration_s": 23.333}{"type": "status", "state": "aborted", "target": "salle_101"}{"type": "status", "state": "idle", "target": null}Possible state values: idle, navigating, arrived, aborted, canceled.
Feedback (forwarded from the room interpreter):
{"type": "feedback", "text": "Navigating to salle 101 (x=-8.1, y=-6.62)"}Room list (response to list_rooms):
{
"type": "rooms",
"rooms": {
"salle_101": {"x": -8.1, "y": -6.62, "aliases": ["room 101", "salle 101", "chambre 101"]},
"salle_cuisine": {"x": -8.66, "y": -27.77, "aliases": ["cuisine", "salle cuisine", "kitchen"]}
}
}Error (invalid room, malformed message, etc.):
{"type": "error", "message": "Room not found: \"xyz\". Available: [\"salle_101\", ...]"}Here is an example of a ready to run client:
import argparse
import asyncio
import json
import sys
try:
import websockets
except ImportError:
print('Install websockets: pip install websockets')
sys.exit(1)
async def main(host: str, port: int):
uri = f'ws://{host}:{port}'
print(f'Connecting to {uri} ...')
try:
async with websockets.connect(uri) as ws:
print(f'Connected to {uri}')
print()
print('Commands:')
print(' Type a room name or command to send the robot')
print(' "rooms" — list available rooms')
print(' "status" — query current navigation state')
print(' "quit" — disconnect')
print()
# Task to receive and print server messages
async def receiver():
try:
async for raw in ws:
data = json.loads(raw)
msg_type = data.get('type', '')
if msg_type == 'status':
state = data.get('state', '?')
target = data.get('target', '')
print(f'\n [STATUS] {state}', end='')
if target:
print(f' target={target}', end='')
if data.get('robot_position'):
pos = data['robot_position']
print(
f' robot=({pos["x"]}, {pos["y"]})',
end='',
)
if data.get('position_error_m') is not None:
print(
f' error={data["position_error_m"]}m',
end='',
)
if data.get('duration_s') is not None:
print(
f' duration={data["duration_s"]}s',
end='',
)
print()
elif msg_type == 'feedback':
print(f' [FEEDBACK] {data.get("text", "")}')
elif msg_type == 'ack':
room = data.get('room', '')
resolved = data.get('resolved_room_id', '')
pos = data.get('target_position', {})
print(
f' [ACK] "{room}" → {resolved} '
f'(x={pos.get("x")}, y={pos.get("y")})'
)
elif msg_type == 'rooms':
print('\n Available rooms:')
for rid, info in data.get('rooms', {}).items():
aliases = ', '.join(info.get('aliases', []))
print(
f' {rid:20s} '
f'x={info["x"]:8.2f} '
f'y={info["y"]:8.2f} '
f'aliases: {aliases}'
)
print()
elif msg_type == 'error':
print(
f' [ERROR] {data.get("message", "unknown")}'
)
else:
print(f' [MSG] {json.dumps(data)}')
# Re-show prompt
print('> ', end='', flush=True)
except websockets.exceptions.ConnectionClosed:
print('\nConnection closed by server.')
recv_task = asyncio.create_task(receiver())
# Input loop (run in executor so it doesn't block)
loop = asyncio.get_event_loop()
try:
while True:
print('> ', end='', flush=True)
line = await loop.run_in_executor(
None, sys.stdin.readline
)
line = line.strip()
if not line:
continue
if line.lower() == 'quit':
break
elif line.lower() == 'rooms':
await ws.send(json.dumps({
'type': 'list_rooms',
}))
elif line.lower() == 'status':
await ws.send(json.dumps({
'type': 'get_status',
}))
else:
await ws.send(json.dumps({
'type': 'room_command',
'room': line,
}))
except (KeyboardInterrupt, EOFError):
pass
finally:
recv_task.cancel()
except ConnectionRefusedError:
print(f'Could not connect to {uri} — is ws_command_bridge running?')
sys.exit(1)
except Exception as e:
print(f'Connection error: {e}')
sys.exit(1)
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description='WebSocket client for DRYVbot robot commands',
)
parser.add_argument(
'--host', default='localhost',
help='IP or hostname of the ROS machine (default: localhost)',
)
parser.add_argument(
'--port', type=int, default=9090,
help='WebSocket port (default: 9090)',
)
args = parser.parse_args()
asyncio.run(main(args.host, args.port))To run it, type a room name to navigate, rooms to list destinations, status to poll, quit to disconnect. The client prints all status updates as they arrive, so you know when the robot has reached its destination before sending the next command.
python3 ws_robot_client.py --host 192.168.1.42 --port 9090- Open https://app.foxglove.dev in a browser
- Connect to
ws://<ip>:8765(uselocalhostif on the same machine, or the digital twin's IP if remote) - Install the Room Command panel extension (
.foxefile) for voice/text destination commands - Send a navigation goal from the 3D panel or the Room Command panel
You can also send a goal from the terminal:
ros2 topic pub --once /goal_pose geometry_msgs/PoseStamped \
"{header: {frame_id: 'map'}, pose: {position: {x: 3.0, y: 0.0, z: 0.0}, orientation: {w: 1.0}}}"The hospital map was generated using slam_toolbox. To recreate it or create a map for a different world:
-
Launch the robot in the world:
export GAZEBO_MODEL_PATH=$HOME/Documents/DRYVbot/Code/src/robot_simulation/models:$GAZEBO_MODEL_PATH ros2 launch bcr_bot gazebo.launch.py \ two_d_lidar_enabled:=True \ camera_enabled:=True \ world_file:=$HOME/Documents/DRYVbot/Code/src/robot_simulation/worlds/hospital.world \ position_x:=0.0 \ position_y:=5.0
-
Launch slam_toolbox (new terminal):
source /opt/ros/humble/setup.bash ros2 run slam_toolbox async_slam_toolbox_node --ros-args \ -p use_sim_time:=true \ -r scan:=/bcr_bot/scan -
Launch teleop to drive the robot (new terminal):
source /opt/ros/humble/setup.bash ros2 run teleop_twist_keyboard teleop_twist_keyboard --ros-args \ -r cmd_vel:=/bcr_bot/cmd_vel -
Visualize in RViz: set Fixed Frame to
odom, add/mapand/bcr_bot/scantopics. -
Drive the robot through the entire environment.
-
Save the map:
cd ~/path_to_save_map ros2 run nav2_map_server map_saver_cli -f hospital_map
| Version | Details |
|---|---|
| V0.1.0 | Repo initialisation with Doxygen configuration |
| V0.1.1 | Tested Doxygen |
| V1.0.1 | Created test Gazebo world and launch script |
| V1.1.0 | Started building the robot_simulation package |
| V2.1.1 | Created kick_off package for centralised launch |
| V2.1.2 | Renamed kick_off to launch_project |
| V2.2.1 | Created the digital_twin package |
| V2.3.0 | Updated setup.py for the digital_twin package |
| V2.3.1 | Created the visualisation package |
| V2.3.2 | Modified Gazebo world for sun/lighting. Updated launch file for Gazebo server |
| V2.3.3 | Created map using SLAM toolbox |
| V3.0.0 | Migration to Gazebo Harmonic + bcr_bot + small_warehouse. Nav2 integration. Foxglove bridge. Removed launch_project |
| V3.1.0 | Removed AMCL startup, increased acceleration and speed |
| V3.2.0 | Custom warehouse for better navigation, applied planning |
| V4.0.0 | Changed to Gazebo Classic from Gazebo Harmonic, hospital world with bcr_bot |
| V4.0.1 | Added goal_pose relay for Foxglove timestamp fix |
| V4.1.0 | Added bcr_bot to project tree, first version of people spawner |
| V4.1.1 | Updated human spawner logic, fixed non-moving cylinders |
| V4.1.2 | Changed cylinder SDF to Scrub person model |
| V4.1.3 | Updated Nav2 params for narrow doors |
| V4.1.4 | Added /people_positions publisher to obstacle spawner |
| V5.0.0 | YAML registry files for intersections and rooms |
| V6.0.1 | Smart automatic re-navigation based on crowd affluence data |
| V6.0.2 | Foxglove layout V1 saved |
| V6.1.0 | Custom Foxglove panel for voice/text room commands |
| V6.1.1 | Released version 1.0.0 of Foxglove panel |
| V6.2.1 | hospital.launch.py updated to include obstacle_spawner |
| V6.2.2 | Speech node for voice commands, integrated into logic.launch.py and Foxglove panel |
| V6.3.0 | Created websocket to receive command from a no ROS system (do not like the idea) and added support to save a simulation in a JSON format |
| V7.0.0 | Included the LGBM AI model and Laravel UI on the project |
| V7.0.1 | Changed minor issues on the Web App laravel side |
- Spawn robot and verify sensors
- Implement teleop for manual driving
- Run SLAM to generate map
- Simulate people detection using Gazebo Fuel models
- Obstacle spawner for dynamic people at known positions
- Create the digital_twin package
- Integrate Nav2 for path planning
- Navigation with obstacle avoidance via Nav2 costmaps
- Dynamic map overlay for crowd-aware replanning
- Room interpreter for text/voice destination commands
- Speech-to-text node with Whisper
- Add simulated 5G latency on robot ↔ digital twin communication
- Local safety controller for emergency braking
- Demo scenarios: nominal navigation, dynamic obstacle avoidance
- Connect Foxglove via websocket bridge
- Send navigation goals from Foxglove
- Real-time display of updated path, crowd density, and robot status
- Custom Foxglove panel for room commands (text + voice)
- Display robot coordinates, goal, and planning status
Code/src/delivery_optimization is a web application, not a ROS 2 package.
It is the Laravel React Starter Kit, more precisely a Laravel 13 back end with a React 19 + Inertia front end, used as a management / supervision dashboard for the delivery robot. It connects to the running digital twin through the DT_SOCKET_* settings in its .env (the ROS side exposes a WebSocket bridge on port 9090, see the WebSocket section above).
A
COLCON_IGNOREfile is placed in this folder socolcon buildskips it
| Component | Requirement | Notes |
|---|---|---|
| PHP | 8.4+ (required bycomposer.lock) |
withmbstring xml curl zip gd sqlite3 bcmath intl |
| Composer | latest | PHP package manager |
| Node.js / npm | Node 20+, npm 10+ | front-end build (Vite) |
| Database | SQLite (default) | file atdatabase/database.sqlite |
| Laravel Reverb | bundled | WebSocket server for real-time UI updates |
Notable PHP packages: inertiajs/inertia-laravel (v3), laravel/fortify,laravel/reverb, laravel/wayfinder, maatwebsite/excel (Excel import/export),
smalot/pdfparser (PDF parsing). Front-end: React 19, Inertia, TailwindCSS 4, Radix UI, Recharts, laravel-echo + pusher-js (Reverb client).
You can connect on the different accounts using the test credential below by opening the http://localhost:8000 :
- Pharmacy
- Username : pharmacien@dryvbot.com
- Password : password
- Manager
- Username : manager@dryvbot.com
- Password : password
Please note that the Web app communictes with the digital twin via a WebSocket that is opened from ROS2 in order to receive the different comands of delivery in ther order that was decided from the delivery optimizer using the LGBM. The websocket is open by default on the port 9090 of then the IP of the machine running ROS2. If everything is running on the same machine, in that case is localhost.
mbstring xml curl zip gd sqlite3 bcmath intl
Two helper scripts at the repository root automate everything described above.
Installs (and skips anything already present):
- ROS 2 Humble system packages (Gazebo, Nav2, SLAM, Foxglove bridge, tools)
- Python AI-layer packages (
faster-whisper,sounddevice,numpy,pyyaml,websockets) rosdepdependencies and thebcr_botpackage (cloned intoCode/src/)- PHP 8.4 + extensions (via
ppa:ondrej/php) and Composer - The web app's Composer/npm dependencies,
.env, SQLite database, and migrations
./installation.shIt does not install ROS 2 Humble itself. If ROS 2 is missing, follow the installation guide first
Prompts for the obstacle scenario, starts the web app in the background with its proccesses (logs to logs/web.log, stopped automatically on Ctrl+C), then builds the ROS 2
workspace and launches the simulation.
./start.sh # interactive scenario menu (emergency / normal / crowd)
./start.sh emergency # pick the scenario directly: emergency | normal | crowd
NO_WEB=1 ./start.sh # ROS simulation only, skip the web app