Quickstart
Clone, build, and bring up Lupin end-to-end, one command in Gazebo and one against the real MIRTE Master, covering the mdp-greenhouse install gotcha, the DDS-over-WiFi discovery-server env, the event-driven sentinel cascade, the twist_mux command bus, the AMCL-seed gate, and your first /mission/start.
This page takes you from a fresh clone to a robot that drives, maps, and runs a greenhouse mission, prerequisites, build, the one-command bring-up, and the first /mission/start. Lupin runs the same code in Gazebo and on the real MIRTE Master V2, only the deployment branch, a single drive topic, and the discovery-server env change. This page is the runbook for that first hour: do the steps in order, and the war stories woven through the callouts are the exact footguns that cost the team days so you skip them.
Prerequisites
Lupin is one ROS 2 package set that drops into a workspace alongside the MIRTE vendor stack, that shared infrastructure must be in place first. The robot README is the authoritative install guide; the essentials are:
- Ubuntu 22.04 (native or dual-boot, not WSL).
- ROS 2 Humble (
ros-humble-desktop), withsource /opt/ros/humble/setup.bashin your~/.bashrc. - A colcon top-level workspace,
~/ros2_ws/srcplus a.colcon_rootmarker and thecolcon-top-level-workspaceextension. - The MIRTE Master vendor packages, pulled into
~/ros2_ws/srcviavcstooland themirte.reposmanifest. See the MIRTE developer docs. - The
mdp-greenhouseenvironmental simulator (course-provided, on PyPI), this is the actual sensing modality, queried at AprilTag locations through a Python API, not a Gazebo plugin.
mdp-greenhouse ships with packaging gotchas: the wheel declares no runtime
deps and registers no console script. Install the deps it forgets and invoke
it as a module:
sudo apt install python3-tk
pip install mdp-greenhouse pyyaml matplotlib numpy
python -m greenhouse_sim.cli --read --list-tags # smoke testFull rationale lives in the robot README.md and on the
greenhouse bridge page.
Clone and pick a branch
Clone Lupin as a sibling of the vendor packages, never inside one of them. The repository uses three long-lived branches; check out the one that matches your target.
cd ~/ros2_ws/src
git clone <lupin-repo-url> lupin
cd lupin
git checkout sim # Gazebo bring-up, sim params, custom worldscd ~/ros2_ws/src
git clone <lupin-repo-url> lupin
cd lupin
git checkout hardware # real-robot bring-up, calibrated paramsmain holds code that is identical regardless of deployment target (messages, perception, planning, HMI logic); sim and hardware add their target-specific bring-up on top. This documentation site tracks the hardware branch. The design decisions page explains why the three-branch split exists.
Build
After cloning, rebuild the workspace so the lupin_* packages are indexed alongside the vendor stack:
cd ~/ros2_ws
rosdep install --from-paths src -y --ignore-src
colcon build --symlink-install
source install/setup.bashAdd the source ~/ros2_ws/install/setup.bash line to your ~/.bashrc so every shell sees the workspace. --symlink-install means edits to Python sources and configs take effect without a rebuild.
On hardware, the web HMI needs its Vite build artefact present. Build it once before the first bring-up:
cd ~/ros2_ws/src/lupin/lupin_web/web && npm install && npm run build
cd ~/ros2_ws && colcon build --symlink-installWhat environment must be set
The single biggest difference between a first sim run and a first hardware run is the environment, and the team's design goal was to make the answer lopsided: in sim you set nothing, on hardware you set the DDS discovery-server env once per laptop.
In simulation, greenhouse_sim.launch.py hardcodes GAZEBO_PLUGIN_PATH, GAZEBO_RESOURCE_PATH, GAZEBO_MODEL_PATH, and OGRE_RESOURCE_PATH for gazebo-classic-11 in-launch via AppendEnvironmentVariable, so you do not source /usr/share/gazebo/setup.sh. That sourcing was the recurring spawn_entity hangs / Scene shared_ptr null footgun every teammate hit on their first sim run, and baking the env into the launch is the deliberate fix.
On hardware, the laptop reaches the robot's nodes over WiFi through a FastDDS discovery server, not multicast, because most access points (the robot's own AP, hotspots, university APs) silently drop the multicast packets ROS 2 uses for discovery. Two flags wire it up, one on each side.
On the robot, enable the vendor discovery server and restart the service stack:
echo 'export MIRTE_FASTDDS=true' >> /home/mirte/.mirte_settings.sh
sudo systemctl restart mirte-ros.service lupin-onboard.service lupin-cameras.service
sudo ss -lnup | grep 11811 # expect fast-discovery-server on 0.0.0.0:11811On the laptop, run the setup script once. It writes the FastDDS XML profile and three env vars (FASTRTPS_DEFAULT_PROFILES_FILE, ROS_DISCOVERY_SERVER, RMW_IMPLEMENTATION=rmw_fastrtps_cpp) into ~/.config/lupin/ros-env.sh and wires ~/.bashrc to source it. Pass the robot's IP if it is not the AP default 192.168.42.1:
./lupin_bringup/scripts/setup-laptop-dds-env.sh # robot AP 192.168.42.1
./lupin_bringup/scripts/setup-laptop-dds-env.sh 10.42.0.1 # wired staticVerify from a fresh terminal that the data graph is actually shared. The topic count is the tell, two means you are still on multicast and blind:
ros2 daemon start && sleep 5
ros2 topic list | wc -l # expect 50+, NOT 2
ros2 topic echo /io/power/power_watcher --once # should print BatteryStateSymptom. The HMI loads, the connection pill says "LIVE", yet every panel
is blank and the robot will not drive. Cause. A node's DDS mode is fixed
at launch from its environment; if rosbridge_websocket was started from a
long-lived terminal (a tmux pane, a VS Code tab) opened before the env
existed, it is on multicast and can never join the discovery-server graph.
Fix. Read the truth from /proc/<pid>/environ, an empty
ROS_DISCOVERY_SERVER confirms it, then relaunch from a fresh shell. Full
walkthrough on the networking page.
hardware.launch.py mitigates this by self-sourcing: _apply_dds_env_from_ros_env_sh() parses the export FOO=bar lines of ~/.config/lupin/ros-env.sh into its own process env at launch time, so every child (rosbridge, Nav2, RViz, the twin) inherits a consistent discovery-server env even from a stale terminal. As a bonus, robot_ip is derived from ROS_DISCOVERY_SERVER, so the HMI camera-tab /_video proxy target follows the link automatically, AP 192.168.42.1 or wired 10.42.0.1, with no second place to edit when you go wired.
One-time robot-side services
The hardware entry point is laptop-side and runs on top of onboard systemd services that must be installed once per Mirte image, the stock image does not ship them. Without these the chassis has no twist_mux arbitration, the arm has no preset server, the gripper has no action bridge, and the cameras run at the vendor's heavy default rate.
# On the robot, from a Lupin checkout under ~/ros2_ws/src/lupin
sudo apt install ros-humble-twist-mux v4l-utils
cd ~/ros2_ws && colcon build --packages-up-to lupin_bringup --symlink-install
source ~/ros2_ws/install/setup.bash
sudo bash $(ros2 pkg prefix lupin_bringup)/share/lupin_bringup/scripts/install-onboard-systemd.sh
sudo bash $(ros2 pkg prefix lupin_bringup)/share/lupin_bringup/scripts/install-cameras-systemd.shBoth scripts are idempotent and accept --uninstall. The result is the robot/laptop split below: the robot runs only what must live close to the hardware, the laptop runs everything with CPU headroom, and a single discovery-server link on port 11811 joins them.
One-command bring-up
Top-level launches live in lupin_bringup. There is one entry point per target. Each composes the whole stack and prints a clickable HMI URL banner at the top of the log.
ros2 launch lupin_bringup sim_full.launch.pysim_full.launch.py composes the entire simulation mission stack in one shot: Gazebo plus the MIRTE in the generated greenhouse world, the greenhouse_bridge sensor oracle, the mission_orchestrator, lupin_twin, the perception nodes (tag_annotator plus perception_aggregator), the sim_flower_detector colour stand-in, rosbridge_websocket on :9090, the Lupin web HMI on :8090, seed_amcl_pose, a draining sim_battery_publisher, twist_mux, arm_library_server plus arm_preset_server, Xbox teleop, and RViz.
The banner prints http://localhost:8090 and http://<lan-ip>:8090 for the HMI.

ros2 launch lupin_bringup hardware.launch.py mission:=truehardware.launch.py is the laptop-side entry point. It runs on the operator's laptop on top of the robot's onboard systemd services (mirte-ros, lupin-onboard, lupin-cameras) and talks to the real MIRTE over the discovery-server link. It brings up the Lupin web HMI, slam_toolbox, Nav2, lupin_twin, perception, the real yolo_detector, and RViz; mission:=true adds the greenhouse bridge, mission orchestrator, and the AMCL seed.
The web include pins mode:=preview tls:=true rosbridge:=true video:=false leds:=false. Video is false on purpose: web_video_server runs on the robot so raw frames never cross WiFi, and the Vite /_video proxy is retargeted at the robot's :8091. The banner advertises https://<lan>:8090 for the HMI and wss://<lan>:8090/_ros for rosbridge, the browser reaches rosbridge through the Vite same-origin proxy, not :9090 directly, which is what keeps the self-signed cert and mixed-content story clean.
Laptop to robot over WiFi needs the FastDDS discovery-server setup above done once, or the HMI loads "LIVE" but every panel is blank. The robot must also be single-homed, a robot still running its AP while wired advertises an unroutable locator and the data channel silently fails one-way. See networking.

The event-driven bring-up cascade
The spine of every bring-up is a cascade that is event-driven, not delay-driven. Phase-1 nodes fire at t=0 and forget (Gazebo or the robot, the HMI, rosbridge, the orchestrator, the seed), then a chain of tiny ros2 topic echo --once sentinel processes gates each upstream-dependent stage: each sentinel exits on its first message, and that OnProcessExit event kicks the next stage. The why is diagnostic, a stalled stage is visible as the next stage never fired rather than buried under a flood of retry warnings you have to learn to ignore.
The sim chain is wait_for_scan then slam_toolbox then wait_for_map then Nav2. Hardware inserts one extra stage, wait_for_tf (a wait_for_tf sentinel with a 30 s budget on odom→base_link) between /map and Nav2, because a cold DDS-over-WiFi /tf subscription on the laptop takes ~5-10 s to populate, and Nav2's local_costmap activation does a single canTransform() with a short retry budget and bails with Invalid frame ID base_link if TF is not hot yet, leaving the lifecycle stuck.
Symptom. A sentinel for a latched topic never fires even though the topic
publishes. Cause. /map is RELIABLE + TRANSIENT_LOCAL, and a default
ros2 topic echo subscribes RELIABLE + VOLATILE, so the QoS negotiation
silently never connects. Fix. The wait_for_map sentinel passes
--qos-reliability reliable --qos-durability transient_local explicitly. The
same trap fakes "no data" for BEST_EFFORT topics like /scan, treat any
laptop "no data" as inconclusive and match the QoS.
Key launch arguments
Each subsystem sits behind a boolean (or scalar) launch argument, so you can opt in or out without editing files. Override as arg:=value on the ros2 launch line.
| Argument | Sim default | Hardware default | What it controls |
|---|---|---|---|
web / enable_web | true | true | Web HMI + rosbridge. Set false for a headless smoke test. |
slam | (auto) | true | slam_toolbox, owns /map and the map→odom TF. |
nav2 | (auto) | true | Nav2 stack in slam mode; waits for /map (and a hot odom→base_link TF on hardware) before activating. |
twin | (always) | true | lupin_twin aggregator → /twin/state. |
mission | (always) | false | Greenhouse bridge + orchestrator + AMCL seed bundle. Turn on for mission runs. |
perception | (always) | true | tag_annotator (AprilTag) + perception_aggregator. |
yolo | n/a (HSV stand-in) | true | Real YOLO flower/anomaly detector (heavy; needs ultralytics + numpy<2). |
rviz | true | true | RViz with the persistent full_bringup_viz.rviz config. |
joystick | true | true | Xbox controller teleop. Set false on hosts without a pad, joy_node logs noisily otherwise. |
seed_amcl | true | (via mission) | Runs seed_amcl_pose, the continuous /amcl_pose republisher that clears the orchestrator's PREPARE.LOCALIZING gate (slam mode has no AMCL). |
dependency_timeout_s | 120.0 | 120.0 | How long the orchestrator waits for Nav2 + bridge before going to FAULT. |
In sim, spawn_x / spawn_y / spawn_yaw (2.0 / 1.5 / 1.5708) set the MIRTE's pose in the greenhouse world, the default puts it in the south aisle facing the tables. On hardware, discovery_goal (5) sets how many distinct tags an ExplorationMission discovers before switching to the monitoring loop.
Why no YOLO in sim. best.pt is trained on real dahlias and fires on
nothing in Gazebo, so sim_flower_detector is an HSV colour stand-in that
publishes the same /yolo/detections contract. The
perception_aggregator, twin, and HMI are then identical sim vs hardware,
the detector is the only swap. On the laptop the real yolo_detector needs
ultralytics installed carefully: a plain pip install ultralytics drags in
numpy 2 and pip's opencv-python, both of which shadow the system packages
and break cv_bridge. Pin numpy<2 and pip uninstall opencv-python after.
Headless smoke test (no HMI, no RViz window) on either target:
# sim
ros2 launch lupin_bringup sim_full.launch.py enable_web:=false rviz:=false
# hardware
ros2 launch lupin_bringup hardware.launch.py web:=false rviz:=falseThe chassis command bus
Before you drive, know what actually moves the wheels, because the drive topic is the classic sim-vs-hardware trap: a node that drives perfectly in Gazebo can publish into a void on the real Mirte. The reason is that the terminal controller topic differs, sim's clearpath controller listens on /mirte_base_controller/cmd_vel_unstamped, the real Mirte firmware wires /mirte_base_controller/cmd_vel with no _unstamped remap, and the unstamped topic simply does not exist on hardware.
The fix is to never publish at the controller directly. Every drive source goes through twist_mux, which arbitrates by priority and timeout, the Xbox dead-man on /cmd_vel_joy (100) always wins, the web HMI joystick on /cmd_vel_manual (50) is the remote-operator override, and Nav2's velocity smoother on /cmd_vel_auto (10) loses to either human. The mux's output is the per-branch terminal topic, so you publish through the bus and arbitration handles the difference for you.
Symptom. Nothing drives, not the HMI joystick, not even a raw terminal
ros2 topic pub. Cause. The HMI boots with the e-stop engaged and
publishes a 20 Hz zero Twist heartbeat to /cmd_vel_manual; at priority
50 those zeros out-vote a raw publish, and estopAutoOnFocusLoss re-latches
the e-stop when you alt-tab away to test. Fix. Click Reset E-stop
before driving; isolate the controller layer with priority-100
/cmd_vel_joy first. See web HMI and teleop.
Once it's up
Wait for the chain to settle, Gazebo loaded (or the robot's /scan live), mirte_base_controller active, Nav2 lifecycle reporting Managed nodes are active, and the orchestrator printing Dependencies up. Orchestrator READY.

Open the HMI at the banner URL.
- Simulation:
http://localhost:8090 - Hardware:
https://<laptop-ip>:8090(accept the self-signed cert once)
The rosbridge pill in the top bar goes green within a couple of seconds.
Drive a short loop. Open the Teleop tab and nudge the robot around (left joystick = linear x/y, right = angular z). slam_toolbox needs a few scans of context before Nav2 will plan, its global costmap won't extend beyond the explored region.
In simulation only, do a one-time per-browser setting first: gear icon → Drive → set the cmd_vel topic to /cmd_vel_manual. That feeds the web joystick into twist_mux instead of racing Nav2 on the controller topic.
Start a mission. Call /mission/start with a mission type and (for InspectionMission) a tag sequence:
ros2 service call /mission/start lupin_msgs/srv/StartMission \
"{mission_type: 'InspectionMission', tag_sequence: ['1','2']}"The orchestrator transitions READY → PREPARE and replies with accepted and a mission_id (or an error_message on reject). For an ExplorationMission, tag_sequence is ignored, pass mission_type: 'ExplorationMission' and optionally a discovery_goal.
In sim, prefer tags near the spawn aisle (['1','2']) for your first
end-to-end run, the P3D plugin's pose drift accumulates over long runs. See
navigation.
Watch progress. /mission/state publishes a MissionState snapshot at a fixed 5 Hz whenever the orchestrator is alive, and every reading lands on /floranova/observations:
ros2 topic echo /mission/state lupin_msgs/msg/MissionState
ros2 topic echo /floranova/observations lupin_msgs/msg/ObservationMissionState carries lifecycle_state (BOOT/READY/PREPARE/EXPLORING/INSPECTING/MONITORING/RETURNING/DONE/FAULT), mission_phase, the targets_total/targets_completed/targets_failed counts, and estop_engaged / paused flags.
The AMCL-seed gate, and why the seed is continuous
The orchestrator gates PREPARE.LOCALIZING on a low-covariance /amcl_pose (diagonal covariance below 0.25) before it will navigate, exactly as it does against real AMCL on hardware. The sim has no AMCL, slam_toolbox owns map→odom, so seed_amcl_pose is AMCL's stand-in: at 10 Hz it looks up the live map→base_link TF and republishes it as a tight-covariance PoseWithCovarianceStamped (0.01 on x, y, yaw).
It is a continuous node, not a one-shot publish-then-exit, and that distinction is a real distributed-systems lesson the team learned the hard way. The original seed published once and exited; that was a bug. TRANSIENT_LOCAL latching only persists while the publisher is alive, and the orchestrator subscribes minutes later when a mission starts, so the late subscriber got nothing, localization never converged, and the orchestrator dropped to FAULT. The second reason a static seed fails is the drift gate: each approach leg re-checks AMCL drift, and a fixed origin pose would make the moving robot look wildly drifted, so the seed must track the real map→base_link pose to keep both the observations and the gate honest.
The full state machine and the per-leg drift gate live on the mission page.
Operator controls
These std_srvs/srv/Trigger services act on a running mission at any time:
ros2 service call /mission/pause std_srvs/srv/Trigger
ros2 service call /mission/resume std_srvs/srv/Trigger
ros2 service call /mission/abort std_srvs/srv/Trigger
ros2 service call /mission/skip_current std_srvs/srv/TriggerThe web HMI's permanent E-STOP bar also preempts the mission. Releasing E-STOP clears /e_stop_state, but the mission stays paused until you call /mission/resume.
Hardware mission caveats, by design. hardware.launch.py prints a loud
banner that require_visual_confirmation=false, readings come from the
greenhouse bridge oracle even though /perception/confirm_tag exists, so
the camera is not gating the readings. Separately, on the deployed
hardware-branch onboard the HMI Arm tab Enable and sliders are gated
off because /lupin/arm/set_torque is absent there; Xbox arm control still
works because arm_teleop bypasses the strict arming gate. Use the pad as
the arm fallback until the onboard branch is redeployed.
See also
Overview
Lupin is an autonomous greenhouse robot, Nav2 autonomy, AprilTag + YOLO perception, a live digital twin, and a voice-controlled mission console, all on ROS 2.
Architecture
How Lupin's nine ROS 2 packages fit together, the lupin_msgs interface root, the Explore Navigate Perceive Twin HMI pipeline, the twist_mux command bus with priorities, the mission orchestrator FSM, the e-stop and battery safety lane, the per-tag approach-pose geometry, the latched-snapshot QoS contract, and the sim vs hardware drive-topic boundary.