Topside Docker setup
Run these commands on the above-water laptop. This is the competition setup; it does not install or require HoloOcean.
Docker and Docker Compose must already be installed. Check:
docker --version
docker compose version
On Ubuntu, install Docker Engine and the Docker Compose plugin. Your user needs permission to run Docker; follow the Linux post-installation steps. If you add your user to the Docker group, log out and back in before continuing.
Start the container
From the 2026-27WaterCode repository root:
cd docker
bash setup.sh
bash enter.sh
This starts the leviathan container and opens a terminal inside it.
Build and launch topside
Inside the container:
source /opt/ros/humble/setup.bash
cd /home/ubuntu/2026-27WaterCode/fishROS_ws
colcon build --symlink-install --packages-select controller_node
source install/setup.bash
ros2 launch controller_node topside_combined_launch.py
Plug the gamepad into the laptop. Connect the laptop to the Jetson through the tether network. Start the Jetson hardware launch separately.
Stop the container
Press Ctrl+C to stop topside, then run exit. From the host repository root:
bash docker/stop.sh
For HoloOcean, use the separate simulation setup.
Jetson startup and Pico control
The topside laptop runs the gamepad code in Docker. The Jetson runs the cameras and thruster-control ROS node. These commands run on the Jetson, outside the laptop’s container.
Requirements
- ROS 2 Humble and colcon installed on the Jetson.
- A copy of this repository, with its ROS and Python dependencies installed
(the drivetrain uses
pyserial). - The laptop and Jetson connected through the tether network.
- The thruster Pico connected to the Jetson by USB, with compatible firmware already flashed, and the cameras connected.
- The Jetson user has permission to access the Pico’s serial device.
For firmware upload and standalone hardware tests, see Pico W thruster control.
Connect and build
From the laptop, use the Jetson’s current address and username:
ssh ubuntu@JETSON_IP
On the Jetson, replace the repository path with its actual location:
source /opt/ros/humble/setup.bash
cd /path/to/2026-27WaterCode/fishROS_ws
colcon build --symlink-install
source install/setup.bash
If you use the sonar, run git submodule update --init in the repository first;
sonoptix_sonar is a submodule.
Skip the build on later starts unless the code has changed. Source ROS and
install/setup.bash in each new terminal.
Start the hardware code
On the Jetson, identify the thruster Pico’s USB serial path:
ls -l /dev/serial/by-id/
Use its actual path in the launch command:
ros2 launch controller_node jetson_combined_launch.py \
thruster_port:=/dev/serial/by-id/YOUR_THRUSTER_PICO
This launch starts two cameras and the drivetrain. It sends neutral commands to arm the thrusters. Keep the gamepad neutral and the thruster area clear. Depth, stabilization, pH, and Foxglove are currently disabled in this launch.
To run the sensors (depth, ADC probes, serial JSON, camera) and record a bag, use
sensor_bringup’s all_sensors.launch.py instead. It also starts the drivetrain,
so don’t run both. See the ROS reference for its arguments.
For the servos and actuator, see Auxiliary control. If
something doesn’t work, see Troubleshooting.
Start the topside code on the laptop to send driving commands.
The gamepad map lists the controls. The Jetson stops the thrusters
if commands stop arriving for 0.5 s.
Press Ctrl+C in the Jetson launch terminal to stop.
Driving the robot
The topside laptop reads the gamepad (joy_linux_node), and controller_node
turns it into a twist message at 50 Hz. The Jetson’s drivetrain_node turns
that twist into thruster commands for the Pico. See
Topside Docker setup and
Jetson startup for launching both sides.
Gamepad map (Xbox layout)
| Input | Action |
|---|---|
| Left stick up/down | Forward / backward |
| Left stick left/right | Strafe |
| Right stick left/right | Yaw |
| Left trigger | Ascend |
| Right trigger | Descend |
| A | Low sensitivity (inputs scaled to 0.65) |
| B | Full sensitivity |
| X | Toggle depth hold (publishes stabilization_toggle) |
| D-pad up/down | Claw open/close (claw topic, Point.z) |
| Left / right bumper | Claw rotate (claw topic, Point.y) |
Depth hold needs bar02_pub and stabilization_pub. The default
jetson_combined_launch.py leaves them out; jetson_testing_launch.py starts
them. Without them, X does nothing useful. The claw topic is published but nothing in the default launch files
uses it. The bumpers also toggle the servos if you run
auxiliary control.
Trigger axes rest at 1 and read -1 when fully pulled. controller_node
ignores a trigger until it has moved once, so a pad that reports 0 at rest does
not drive the robot on connect. If your gamepad numbers its triggers differently,
set left_trigger_axis and right_trigger_axis on controller_node.
Thruster layout
Six thrusters on the thruster Pico:
| Thruster | Pico GPIO |
|---|---|
| Back left | 0 |
| Middle right | 1 |
| Front right | 2 |
| Middle left | 3 |
| Back right | 6 |
| Front left | 7 |
The four corner thrusters handle forward, strafe, and yaw. The two middle thrusters handle vertical motion (and roll, if a twist asks for it).
Safety behavior
- Stick inputs under 0.05 are treated as zero.
- Thruster output is scaled to 0.8 of full range. If several inputs combine to more than full power, the thrusters are scaled down together, keeping their ratio.
- If
/joygoes quiet for 0.5 s, topside sends a zero twist. - If the Jetson gets no twist for 0.5 s (
twist_timeoutparameter), it zeros every thruster. This covers a dropped tether or a crashed topside launch. - On startup the drivetrain holds neutral for about 6 s while the ESCs arm. Keep the gamepad neutral and the area clear.
- If the Pico stops reading, the drivetrain drops frames instead of blocking. If it is unplugged, the drivetrain tries to reopen the port.
- On shutdown (Ctrl+C) the drivetrain sends neutral before closing the port.
Auxiliary control (servos and actuator)
auxiliary_control drives the servos and the linear actuator from the gamepad.
It talks to a second Pico, separate from the thruster Pico. Run it on the
Jetson, with joy messages arriving from topside.
ros2 launch controller_node auxiliary_control_launch.py \
port:=/dev/serial/by-id/YOUR_AUX_PICO
The default port is /dev/ttyACM0 and the baud rate is 115200.
The launch file starts its own joy_linux_node. If topside is already
publishing joy over the tether, run the executable directly instead so there
is a single joy source:
ros2 run controller_node auxiliary_control --ros-args -p port:=/dev/serial/by-id/YOUR_AUX_PICO
Controls
| Input | Action |
|---|---|
| Left bumper (button 4) | Toggle servo on GP20 between its min and max |
| Right bumper (button 5) | Toggle servo on GP01 between its min and max |
| Right trigger | Actuator forward while held |
| Left trigger | Actuator back while held |
A trigger counts as pressed when its axis drops below trigger_pressed_threshold
(default 0.5). Pressing both stops the actuator. The triggers also drive the
vertical thrusters through controller_node, so the robot moves vertically when
you move the actuator.
Limits and wiring
| Output | Pico GPIO | Range sent |
|---|---|---|
Servo 20 | GP20 | 0.42 to 0.54 |
Servo 01 | GP01 | 0.45 to 1.00 |
| Actuator direction | GP21 (actuator_direction_pin) | 1 forward, 0 back, 0.5 stop |
| Actuator speed | GP28 (actuator_speed_pin) | actuator_speed (default 1.0), 0 stop |
The servo limits are constants in auxiliary_control.py. Change them there if
the mechanism needs a different range.
Commands are sent as z<pin><value>x lines, for example z200.54x: a
two-digit GPIO number, then a value from 0 to 1. The actuator is stopped when the
node shuts down.
Pico W thruster control
Use pico-w-thruster-control to upload Pico firmware and test thrusters, servos, and an actuator from the Jetson over USB. Run these commands on the Jetson, outside Docker. Close any ROS launch or serial tool using the Pico first.
Setup
- Install Python 3.10 or newer and
uvon the Jetson. - With the Pico W disconnected, hold BOOTSEL while plugging it into USB.
Copy the Pico W
.uf2from the repository’sassets/firmware/folder onto theRPI-RP2drive. The Pico will restart with MicroPython installed. - Edit
config/rov.jsonto match your wiring and output limits. Pin numbers are GPIO numbers:12means GP12. The current configuration has one thruster on GP12; servos and the actuator are optional.
Configure outputs
Edit config/rov.json in pico-w-thruster-control. The same file configures
the diagnostic menu and, after uploading, the Pico. config/rov.schema.json
defines the allowed fields; you normally only need to edit rov.json.
The current configuration is:
{
"thruster_neutral": 0.5,
"thruster_pulse_ms": { "min": 1.1, "max": 1.9 },
"servo_pulse_ms": { "min": 0.7, "max": 2.2 },
"thrusters": {
"thruster_1": { "pin": 12 }
}
}
| Field | Meaning |
|---|---|
thruster_neutral | Stop value sent by arming and neutralizing. Between 0 and 1; currently 0.5. Match this to your ESC. |
thruster_pulse_ms | Pulse widths in milliseconds for thruster values 0 (min) and 1 (max). With the current limits, 0.5 gives a 1.5 ms pulse. |
servo_pulse_ms | Pulse widths in milliseconds for auxiliary servo values 0 and 1. Required even when no auxiliary servos are configured. |
thrusters | Named thrusters, each with a GPIO pin. Add one entry per thruster. An optional position string is a label; it does not change motor behavior. |
Names such as thruster_1 are your choice and appear in the menu. GPIO numbers
are integers from 0 to 29 in the schema; choose pins available on your board.
Output values between 0 and 1 are relative settings, not milliseconds.
Choose pulse limits supported by your ESCs and servos, with min below max.
Optional servos and actuator
Add these fields alongside thrusters only if you use them. These are example
pins and limits; adjust them for your hardware:
"aux_servos": {
"gripper": { "pin": 14, "min": 0.2, "max": 0.8, "end": 0.5 }
},
"actuator": {
"direction_pin": 10,
"speed_pin": 11,
"pwm_freq": 1000
}
Add a comma after the existing thrusters object before inserting this fragment.
| Field | Meaning |
|---|---|
Servo pin | GPIO connected to the servo signal. |
Servo min, max | Allowed movement range, each between 0 and 1. Firmware clamps servo commands to these limits. |
Servo end | Position used at the end of the servo test. Keep it within min and max. |
Actuator direction_pin | Digital direction signal to the motor driver; the test switches it between 1 and 0. |
Actuator speed_pin | PWM speed signal to the motor driver; 0 stops it and 1 requests full power. |
Actuator pwm_freq | Speed PWM frequency in Hz, a positive integer supported by your driver. |
Leave unused aux_servos and actuator fields out, or set them to null.
aux_servos can also be {}. Use distinct GPIOs for each signal. PWM outputs
that share a hardware channel conflict, and outputs sharing a PWM slice must
use the same frequency. Thrusters and servos use 50 Hz; an actuator speed output
at another frequency needs a separate slice.
The upload and diagnostics commands check the schema and PWM conflicts before
running. Unknown fields are rejected. Keep servo min <= end <= max and pulse
min < max; these ordering rules are not checked by the schema.
Robot wiring
The drivetrain_node expects six thrusters on these GPIOs. A rov.json for the
robot, in the same format as above:
{
"thruster_neutral": 0.5,
"thruster_pulse_ms": { "min": 1.1, "max": 1.9 },
"servo_pulse_ms": { "min": 0.7, "max": 2.2 },
"thrusters": {
"back_left": { "pin": 0 },
"middle_right": { "pin": 1 },
"front_right": { "pin": 2 },
"middle_left": { "pin": 3 },
"back_right": { "pin": 6 },
"front_left": { "pin": 7 }
}
}
The drivetrain sends one line per thruster in the form z<GPIO><value>x, for
example z0000.50x for GP0 at neutral, with the value running from 0.00 to
1.00. Your firmware must accept this format. Servos and the actuator use the
same format from auxiliary control, which runs on a
separate Pico, so its GPIOs can overlap with the thruster Pico’s.
Upload and test
From the pico-w-thruster-control repository folder:
uv run upload --port /dev/ttyACM0
uv run diagnostics --port /dev/ttyACM0
Dependencies install automatically. Change the port to match your Pico.
On Linux, ls -l /dev/serial/by-id/ can help identify it. Close diagnostics before
uploading. Upload again after changing the configuration.
Use Arm / sound test to send neutral to the selected thrusters, then Spin test for a short forward/reverse test. Servo and actuator tests appear only when configured. Use Neutralize thrusters (or Neutralize thrusters / stop actuator) to stop those outputs.
To try the menu without hardware:
uv run diagnostics --dry-run
Keep the thruster area clear during tests. Disconnecting USB does not guarantee the motors stop.
Devices
| Device | Function | Connection | Package | Category |
|---|---|---|---|---|
| DWE exploreHD cameras | Video | USB, /dev/video* | fish_cam | Common |
| Blue Robotics Bar02 (MS5837-02BA) | Depth (m), water temp (°C) | I2C bus 1 | bar02_pub | Research |
| Adafruit BNO055 9-axis IMU | Orientation, internal temp | I2C, 0x28 | imu_publisher, internal_temp/ script | Common |
| ADS1115 ADC | pH, TDS, turbidity probe voltages | I2C, 0x48 | sensor_bringup, ph_pub | Research |
| Blue Robotics Sonoptix Echo | Imaging sonar | Ethernet (RTSP) | sonoptix_sonar (submodule) | Research |
| Thrusters (6) | Propulsion | Pico W, USB serial | controller_node (drivetrain_node); see Driving the robot | |
| Servos | Actuation | Pico W, USB serial | controller_node (auxiliary_control); see Auxiliary control |
ROS reference
Launch files
| Launch file | Runs on | Starts |
|---|---|---|
controller_node topside_combined_launch.py | Topside | joy_linux_node (20 Hz autorepeat), controller_node |
controller_node jetson_combined_launch.py | Jetson | Two DWE cameras, drivetrain_node |
controller_node auxiliary_control_launch.py | Jetson | joy_linux_node, auxiliary_control |
sensor_bringup all_sensors.launch.py | Jetson | Everything below, plus a rosbag |
controller_node jetson_testing_launch.py | Jetson | Everything in jetson_combined_launch.py plus bar02_pub, stabilization_pub, ph_pub and the Foxglove bridge |
jetson_combined_launch.py takes thruster_port (default /dev/ttyACM1).
Pass a /dev/serial/by-id/ path, since ttyACM numbers change with USB order.
all_sensors.launch.py
Starts the serial JSON publisher, the dual-stream camera, bar02_pub, the
ADS1115 water-quality publisher, stabilization_pub, and drivetrain_node. It
also records a bag with ros2 bag record -a.
| Argument | Default | Meaning |
|---|---|---|
serial_port, serial_baud, serial_timeout | /dev/ttyACM0, 115200, 1.0 | Sensor Pico serial link |
thruster_port | /dev/ttyACM1 | Thruster Pico |
camera_device | 0 | Video device index for dual_stream_camera |
enable_ads1115_water_quality | true | Run the ADS1115 publisher |
ads1115_i2c_address | 72 (0x48) | ADC address, as a decimal integer |
ads1115_gain, ads1115_sample_period | 1.0, 0.5 | ADC gain, seconds between samples |
tds_channel, turbidity_channel, ph_channel | 0, 1, 2 | ADC input for each probe |
enable_sonar | false | Also launch sonoptix_sonar echo.launch.py |
enable_foxglove | false | Also launch the Foxglove bridge |
bag_output | ~/rosbags/leviathan_<timestamp> | Bag directory |
The bag records every topic, including raw camera images, so it grows fast. Check free disk space on the Jetson before a long run, and delete old bags you don’t need.
Topics
| Topic | Type | Publisher | Used by |
|---|---|---|---|
joy | sensor_msgs/Joy | joy_linux_node | controller_node, auxiliary_control |
twist | geometry_msgs/Twist | controller_node | drivetrain_node, stabilization_pub |
claw | geometry_msgs/Point | controller_node | Nothing by default |
stabilization_toggle | std_msgs/Bool | controller_node | stabilization_pub |
stabilization | geometry_msgs/Twist | stabilization_pub | drivetrain_node |
stabilization_status | std_msgs/Int32 | stabilization_pub | |
bar02/depth | std_msgs/Float32 | bar02_pub | stabilization_pub |
bar02/temp | std_msgs/Float32 | bar02_pub | |
imu | sensor_msgs/Imu | imu_pub | |
InternalTempData | std_msgs/Int32 | imu_pub | |
pH | std_msgs/Float32 | ph_pub | |
Image, Image2 | sensor_msgs/Image | DWE_exploreHD_pub, DWE_exploreHD_pub2 | |
/camera/image_raw | sensor_msgs/Image | dual_stream_camera | |
/camera/image_low/compressed | sensor_msgs/CompressedImage | dual_stream_camera (640x480, 10 fps) | |
/sensors/serial | leviathan_sensor_msgs/SerialReading | serial_json_publisher | |
/sensors/water_quality/voltages | leviathan_sensor_msgs/WaterQualityVoltages | ads1115_water_quality_publisher |
WaterQualityVoltages carries tds_voltage, turbidity_voltage and ph_voltage
(float32), plus the matching _raw ADC counts (int32). SerialReading carries
the raw line and its parsed JSON: numeric_keys/numeric_values,
string_keys/string_values, parse_ok and error.
Network
- The Jetson Orin Nano’s address is
10.50.2.103(ssh ubuntu@10.50.2.103). Confirm it before a run, since it can change. - The laptop and Jetson talk over the tether network. ROS 2 discovery needs both on the same subnet, and the topside container uses host networking.
- To view data, run the Foxglove bridge on the Jetson (it was faster there) and connect with Foxglove Studio from the laptop.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
drivetrain_node fails with a serial error at startup | Wrong port. ttyACM0/ttyACM1 swap with USB order. Run ls -l /dev/serial/by-id/ and pass that path as thruster_port. |
| Permission denied on the serial port | Add the Jetson user to the dialout group, then log out and back in. |
| Thrusters never respond | Check the Jetson log for Receiving twist, thrusters live. If it only says No twist, topside isn’t reaching the Jetson. Check the tether, subnet, and that topside_combined_launch.py is running. |
| Thrusters stop after about half a second | The watchdog fired. Either /joy or twist stopped arriving. Check the gamepad and the network. |
controller_node warns No joy input | Gamepad unplugged, or joy_linux_node can’t see it. Check ros2 topic echo /joy. |
| Robot moves vertically on connect | Trigger axes differ on your gamepad. Set left_trigger_axis and right_trigger_axis; see Driving the robot. |
| X button does nothing | Depth hold needs bar02_pub and stabilization_pub, which jetson_combined_launch.py doesn’t start. Use jetson_testing_launch.py. |
| Thrusters don’t arm | Wait the full 6 s after launch. Verify the Pico has compatible firmware and the ESCs have power. |
auxiliary_control not found | Rebuild: colcon build --symlink-install --packages-select controller_node, then source install/setup.bash. |
| Sonar launch fails | The sonoptix_sonar submodule is empty. Run git submodule update --init in the repository. |
| ADC readings missing | Check I2C wiring and that the address matches (i2cdetect -y 1; default 0x48). |
| Rebuild doesn’t pick up changes | Source install/setup.bash in each new terminal, and rebuild after adding an entry point. |
HoloOcean simulation setup
Warning: untested. This HoloOcean setup has not been tested by building or running the simulation containers.
Simulation is separate from competition. Its containers are holoocean-ros and
leviathan-sim; the latter runs robot-side adapters and mounts HoloOcean interfaces.
Prerequisites
Install Docker Engine and Docker Compose. Keep the external repositories beside this repository:
uw/
2026-27WaterCode/
holoocean-ros/
HoloOcean/
The setup needs holoocean-ros/docker/dev, holoocean-ros/holoocean_interfaces,
holoocean-ros/config, and HoloOcean/client. Install the Ocean world into
~/.local/share/holoocean. Follow the external repositories’ setup instructions.
On Ubuntu, install Docker using the competition prerequisites. The simulation installer also needs Git, curl, GnuPG, sudo, and Python’s venv module:
sudo apt install git curl gnupg ca-certificates python3-venv
Then bash docker/simulation/install.sh clones the external repositories beside
this repo, installs the HoloOcean client into its own virtual environment, and
downloads Ocean. It also configures the NVIDIA container toolkit when NVIDIA
drivers are detected.
A graphical desktop is needed for the existing display mounts. The NVIDIA option
requires NVIDIA drivers and the NVIDIA container toolkit. -N omits the NVIDIA
runtime; whether rendering works depends on your machine and HoloOcean.
Start simulation
From the repository root, choose one:
bash docker/simulation/setup.sh -H # NVIDIA (default)
bash docker/simulation/setup.sh -N # without NVIDIA runtime
These start containers; they do not automatically launch a simulation scenario. Enter HoloOcean:
bash docker/simulation/enter.sh
Use the scenario and ROS launch instructions from holoocean-ros inside that
container. To enter the robot adapter container:
bash docker/simulation/enter.sh -R
Inside the adapter container, source ROS and build the packages you need:
source /opt/ros/humble/setup.bash
cd /home/ubuntu/2026-27WaterCode/fishROS_ws
colcon build --symlink-install --packages-select holoocean_interfaces controller_node
source install/setup.bash
The reconstruction converter additionally requires sonar_oculus, which this
setup does not install. The existing HoloOcean experiment files remain in
controller_node; this change only separates Docker setup.
Competition and simulation share the repository’s build directories. Stop the other workflow before switching and rebuilding. Both use host networking, so avoid launching real robot controllers and simulation controllers together.
Stop the simulation containers from the host:
bash docker/simulation/stop.sh
Ideas not implemented
Status: not built. Nothing here has been tested on the robot.
3D reconstruction with imaging sonar and cameras in HoloOcean
The goal was to build 3D reconstructions of underwater scenes in simulation, by combining an imaging sonar with camera images.
The rough plan:
- Define a HoloOcean scenario with an imaging sonar, a camera, an IMU and a DVL, and record the data to ROS 2 bags.
- Convert the simulated sonar output to the message format of an existing sonar
SLAM package.
controller_node/sonar_cam_message_converter.pyis a start on this. It publishes Oculus-style sonar pings and odometry. - Generate a sonar point cloud, then color it with camera data using odometry.
- Compare against HoloOcean ground truth.
Approaches considered: use an existing sonar SLAM package, implement a visual-inertial-sonar method, or add sonar to a visual SLAM pipeline. Neural rendering methods that fuse camera and sonar data were also looked at, but were judged too heavy for this project.
The sonar_cam_message_converter entry point exists, but the converter also
needs sonar_oculus, which the simulation setup does not
install. The full original plan is in this file’s git history.