Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)

InputAction
Left stick up/downForward / backward
Left stick left/rightStrafe
Right stick left/rightYaw
Left triggerAscend
Right triggerDescend
ALow sensitivity (inputs scaled to 0.65)
BFull sensitivity
XToggle depth hold (publishes stabilization_toggle)
D-pad up/downClaw open/close (claw topic, Point.z)
Left / right bumperClaw 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:

ThrusterPico GPIO
Back left0
Middle right1
Front right2
Middle left3
Back right6
Front left7

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 /joy goes quiet for 0.5 s, topside sends a zero twist.
  • If the Jetson gets no twist for 0.5 s (twist_timeout parameter), 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

InputAction
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 triggerActuator forward while held
Left triggerActuator 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

OutputPico GPIORange sent
Servo 20GP200.42 to 0.54
Servo 01GP010.45 to 1.00
Actuator directionGP21 (actuator_direction_pin)1 forward, 0 back, 0.5 stop
Actuator speedGP28 (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

  1. Install Python 3.10 or newer and uv on the Jetson.
  2. With the Pico W disconnected, hold BOOTSEL while plugging it into USB. Copy the Pico W .uf2 from the repository’s assets/firmware/ folder onto the RPI-RP2 drive. The Pico will restart with MicroPython installed.
  3. Edit config/rov.json to match your wiring and output limits. Pin numbers are GPIO numbers: 12 means 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 }
  }
}
FieldMeaning
thruster_neutralStop value sent by arming and neutralizing. Between 0 and 1; currently 0.5. Match this to your ESC.
thruster_pulse_msPulse 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_msPulse widths in milliseconds for auxiliary servo values 0 and 1. Required even when no auxiliary servos are configured.
thrustersNamed 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.

FieldMeaning
Servo pinGPIO connected to the servo signal.
Servo min, maxAllowed movement range, each between 0 and 1. Firmware clamps servo commands to these limits.
Servo endPosition used at the end of the servo test. Keep it within min and max.
Actuator direction_pinDigital direction signal to the motor driver; the test switches it between 1 and 0.
Actuator speed_pinPWM speed signal to the motor driver; 0 stops it and 1 requests full power.
Actuator pwm_freqSpeed 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

DeviceFunctionConnectionPackageCategory
DWE exploreHD camerasVideoUSB, /dev/video*fish_camCommon
Blue Robotics Bar02 (MS5837-02BA)Depth (m), water temp (°C)I2C bus 1bar02_pubResearch
Adafruit BNO055 9-axis IMUOrientation, internal tempI2C, 0x28imu_publisher, internal_temp/ scriptCommon
ADS1115 ADCpH, TDS, turbidity probe voltagesI2C, 0x48sensor_bringup, ph_pubResearch
Blue Robotics Sonoptix EchoImaging sonarEthernet (RTSP)sonoptix_sonar (submodule)Research
Thrusters (6)PropulsionPico W, USB serialcontroller_node (drivetrain_node); see Driving the robot
ServosActuationPico W, USB serialcontroller_node (auxiliary_control); see Auxiliary control

ROS reference

Launch files

Launch fileRuns onStarts
controller_node topside_combined_launch.pyTopsidejoy_linux_node (20 Hz autorepeat), controller_node
controller_node jetson_combined_launch.pyJetsonTwo DWE cameras, drivetrain_node
controller_node auxiliary_control_launch.pyJetsonjoy_linux_node, auxiliary_control
sensor_bringup all_sensors.launch.pyJetsonEverything below, plus a rosbag
controller_node jetson_testing_launch.pyJetsonEverything 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.

ArgumentDefaultMeaning
serial_port, serial_baud, serial_timeout/dev/ttyACM0, 115200, 1.0Sensor Pico serial link
thruster_port/dev/ttyACM1Thruster Pico
camera_device0Video device index for dual_stream_camera
enable_ads1115_water_qualitytrueRun the ADS1115 publisher
ads1115_i2c_address72 (0x48)ADC address, as a decimal integer
ads1115_gain, ads1115_sample_period1.0, 0.5ADC gain, seconds between samples
tds_channel, turbidity_channel, ph_channel0, 1, 2ADC input for each probe
enable_sonarfalseAlso launch sonoptix_sonar echo.launch.py
enable_foxglovefalseAlso 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

TopicTypePublisherUsed by
joysensor_msgs/Joyjoy_linux_nodecontroller_node, auxiliary_control
twistgeometry_msgs/Twistcontroller_nodedrivetrain_node, stabilization_pub
clawgeometry_msgs/Pointcontroller_nodeNothing by default
stabilization_togglestd_msgs/Boolcontroller_nodestabilization_pub
stabilizationgeometry_msgs/Twiststabilization_pubdrivetrain_node
stabilization_statusstd_msgs/Int32stabilization_pub
bar02/depthstd_msgs/Float32bar02_pubstabilization_pub
bar02/tempstd_msgs/Float32bar02_pub
imusensor_msgs/Imuimu_pub
InternalTempDatastd_msgs/Int32imu_pub
pHstd_msgs/Float32ph_pub
Image, Image2sensor_msgs/ImageDWE_exploreHD_pub, DWE_exploreHD_pub2
/camera/image_rawsensor_msgs/Imagedual_stream_camera
/camera/image_low/compressedsensor_msgs/CompressedImagedual_stream_camera (640x480, 10 fps)
/sensors/serialleviathan_sensor_msgs/SerialReadingserial_json_publisher
/sensors/water_quality/voltagesleviathan_sensor_msgs/WaterQualityVoltagesads1115_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

SymptomLikely cause and fix
drivetrain_node fails with a serial error at startupWrong 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 portAdd the Jetson user to the dialout group, then log out and back in.
Thrusters never respondCheck 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 secondThe watchdog fired. Either /joy or twist stopped arriving. Check the gamepad and the network.
controller_node warns No joy inputGamepad unplugged, or joy_linux_node can’t see it. Check ros2 topic echo /joy.
Robot moves vertically on connectTrigger axes differ on your gamepad. Set left_trigger_axis and right_trigger_axis; see Driving the robot.
X button does nothingDepth hold needs bar02_pub and stabilization_pub, which jetson_combined_launch.py doesn’t start. Use jetson_testing_launch.py.
Thrusters don’t armWait the full 6 s after launch. Verify the Pico has compatible firmware and the ESCs have power.
auxiliary_control not foundRebuild: colcon build --symlink-install --packages-select controller_node, then source install/setup.bash.
Sonar launch failsThe sonoptix_sonar submodule is empty. Run git submodule update --init in the repository.
ADC readings missingCheck I2C wiring and that the address matches (i2cdetect -y 1; default 0x48).
Rebuild doesn’t pick up changesSource 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:

  1. Define a HoloOcean scenario with an imaging sonar, a camera, an IMU and a DVL, and record the data to ROS 2 bags.
  2. Convert the simulated sonar output to the message format of an existing sonar SLAM package. controller_node/sonar_cam_message_converter.py is a start on this. It publishes Oculus-style sonar pings and odometry.
  3. Generate a sonar point cloud, then color it with camera data using odometry.
  4. 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.