Community project
Underwater ROV Thruster Controller

This guide builds a three-axis underwater ROV thruster controller powered by an ESP32 microcontroller. The system combines vectored thrust control via three 30A ESCs, real-time depth and distance sensing from a Ping sonar module, and AI-powered object detection through a HuskyLens camera—all coordinated through a single command interface.
Readers will receive a complete wiring diagram showing power distribution from the 3S LiPo battery through the buck converter to logic and thruster circuits, a full parts list with watertight housing specifications, Arduino firmware with UART communication handlers for both sonar and camera sensors, and step-by-step assembly instructions including electrical checkout and pressure testing procedures.
Wiring diagram
Interactive · read-only
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| 30A ESC (Electronic Speed Controller)30 A, bidirectional | 1 | A 30A Electronic Speed Controller (ESC) used to drive brushless (BLDC) outrunner or inrunner motors. On the motor side, three wires connect to the three phases of the brushless motor. On the power side, two wires (VCC and GND) connect to a LiPo battery (typically 2S–4S). A three-wire signal harness (signal, +5V, GND) interfaces with the microcontroller and provides a Battery Eliminator Circuit (BEC) that outputs regulated 5V to optionally power the Arduino. Control is achieved via a standard 50 Hz PWM signal with pulse widths ranging from 1000 µs (minimum/stop) to 2000 µs (maximum speed), identical to servo motor control. The ESC requires an arming sequence at startup (throttle at minimum) and supports calibration of high/low throttle endpoints. |
| 30A ESC (Electronic Speed Controller)30 A, bidirectional | 1 | A 30A Electronic Speed Controller (ESC) used to drive brushless (BLDC) outrunner or inrunner motors. On the motor side, three wires connect to the three phases of the brushless motor. On the power side, two wires (VCC and GND) connect to a LiPo battery (typically 2S–4S). A three-wire signal harness (signal, +5V, GND) interfaces with the microcontroller and provides a Battery Eliminator Circuit (BEC) that outputs regulated 5V to optionally power the Arduino. Control is achieved via a standard 50 Hz PWM signal with pulse widths ranging from 1000 µs (minimum/stop) to 2000 µs (maximum speed), identical to servo motor control. The ESC requires an arming sequence at startup (throttle at minimum) and supports calibration of high/low throttle endpoints. |
| 30A ESC (Electronic Speed Controller)30 A, bidirectional | 1 | A 30A Electronic Speed Controller (ESC) used to drive brushless (BLDC) outrunner or inrunner motors. On the motor side, three wires connect to the three phases of the brushless motor. On the power side, two wires (VCC and GND) connect to a LiPo battery (typically 2S–4S). A three-wire signal harness (signal, +5V, GND) interfaces with the microcontroller and provides a Battery Eliminator Circuit (BEC) that outputs regulated 5V to optionally power the Arduino. Control is achieved via a standard 50 Hz PWM signal with pulse widths ranging from 1000 µs (minimum/stop) to 2000 µs (maximum speed), identical to servo motor control. The ESC requires an arming sequence at startup (throttle at minimum) and supports calibration of high/low throttle endpoints. |
| Blue Robotics Ping Sonar (Ping1D underwater echosounder)Underwater 1D sonar | 1 | Pressure-rated single-beam underwater echosounder. TTL UART uses 3.3 V logic and reports target distance/confidence using Ping Protocol. |
| MP1584 Buck Converter5 V, 3 A | 1 | Adjustable buck (step-down) DC-DC converter, 4.5-28 V in -> 0.8-20 V out, ~3 A. Configured to 5 V to step a 9 V / 12 V supply or battery pack down to the board's 5 V rail. |
| 3S 11.1 V LiPo propulsion battery with balance lead3S 11.1 V, 5000 mAh minimum | 1 | High-discharge 3-cell LiPo pack for the three ROV thrusters and logic regulator. Use a capacity and continuous-discharge rating appropriate for the selected thrusters. |
| HuskyLens K210 AI Vision Sensor (SEN0305)Object-recognition AI camera | 1 | Self-contained AI vision camera used in Object Recognition mode. Learns target objects on its built-in screen and reports recognized object IDs, bounding-box position, and size to the ESP32 over UART. Mount it in a pressure-rated, optically clear camera housing for underwater use. |
Assembly
6 stepsDisconnect propulsion power
Remove the 3S battery connection and keep all propellers removed while adding the camera wiring.
- Tip: This prevents an accidental motor start while working on the electronics.
- ⚠ Do not work on the wiring with the propulsion battery connected.
Mount the AI camera in a watertight optical housing
Secure huskylens_camera behind a flat, clear optical window in the ROV enclosure. Keep the window clean, scratch-free, and close to the lens; point the camera forward and avoid trapping air bubbles in front of it.
- Tip: A flat window is preferable to a curved dome for a simple forward-looking setup.
- Tip: Use a pressure-rated enclosure and cable gland appropriate for the intended depth.
- ⚠ HuskyLens itself is not waterproof. Water ingress will damage it.
- ⚠ Object recognition is affected by underwater turbidity, colour loss, reflections, and low light; add external ROV lighting if necessary.
Power the camera from the regulated 5 V logic rail
Connect huskylens_camera VCC to the 5 V VOUT of logic_buck and connect huskylens_camera GND to the common logic ground. The buck regulator must have sufficient spare output current for the ESP32, sonar, and camera.
- Tip: Use short power leads and a solid common-ground connection.
- Tip: The existing 5 V / 3 A regulator is adequate for this low-power camera alongside the controller electronics when correctly wired.
- ⚠ Never connect the 3S battery directly to the camera.
Connect the UART data pair
Cross the UART wires: connect huskylens_camera TX to ESP32 GPIO32 and huskylens_camera RX to ESP32 GPIO33. Leave the existing Ping sonar on GPIO16 and GPIO17 unchanged.
- Tip: Keep the TX/RX data leads away from high-current ESC and thruster power wires.
- Tip: Use twisted pairs or shielded cable for long internal runs in a noisy ROV enclosure.
- ⚠ A shared ground is required for UART communication.
- ⚠ Do not swap the camera and sonar UART pins.
Set the camera’s local recognition mode
Before closing the enclosure, power the 5 V logic rail and use the HuskyLens built-in screen and controls to select Object Recognition mode. Teach each desired target; the camera assigns it an object ID.
- Tip: Train using targets under lighting and water conditions similar to the actual mission.
- Tip: The firmware sends each detected target as OBJECT id,x,y,width,height over USB serial.
- ⚠ Do not rely on camera detection alone for navigation, collision avoidance, or safety-critical control.
Complete the ROV electrical and watertightness check
Verify the three ESC signal connections, Ping sonar wiring, camera wiring, and every common ground before reconnecting the 3S battery. Perform a dry electronics check with propellers removed, then pressure/leak-test the sealed enclosure before water use.
- Tip: Use the firmware STATUS command to see CAMERA=READY or CAMERA=OFFLINE.
- Tip: Use the Deploy button in Schematik to compile and flash the controller.
- ⚠ Fit the existing propulsion fuse/disconnect as specified in the ROV build.
- ⚠ Never place hands near installed propellers while the battery is connected.
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| EXT | esc_left VCC → 3S 11.1 V LiPo propulsion battery with balance lead BAT+ | power |
| EXT | esc_left GND → 3S 11.1 V LiPo propulsion battery with balance lead BAT- | ground |
| EXT | esc_right VCC → 3S 11.1 V LiPo propulsion battery with balance lead BAT+ | power |
| EXT | esc_right GND → 3S 11.1 V LiPo propulsion battery with balance lead BAT- | ground |
| EXT | esc_vertical VCC → 3S 11.1 V LiPo propulsion battery with balance lead BAT+ | power |
| EXT | esc_vertical GND → 3S 11.1 V LiPo propulsion battery with balance lead BAT- | ground |
| EXT | logic_buck VIN → 3S 11.1 V LiPo propulsion battery with balance lead BAT+ | power |
| EXT | logic_buck GND → 3S 11.1 V LiPo propulsion battery with balance lead BAT- | ground |
| VIN | logic_buck VOUT | power |
| GPIO 25 | esc_left PWM | pwm |
| GPIO 26 | esc_right PWM | pwm |
| GPIO 27 | esc_vertical PWM | pwm |
| VIN | ping_sonar VIN | power |
| GND | ping_sonar GND | ground |
| GPIO 16 | ping_sonar TX | uart |
| GPIO 17 | ping_sonar RX | uart |
| VIN | huskylens_camera VCC | power |
| GND | huskylens_camera GND | ground |
| GPIO 32 | huskylens_camera TX | uart |
| GPIO 33 | huskylens_camera RX | uart |
Firmware
ESP32#include <Arduino.h>
#include <ESP32Servo.h>
#include <HUSKYLENS.h>
// Forward declarations
int throttleToPulse(int throttle);
void writeThrusters(int left, int right, int vertical);
void stopThrusters();
void printHelp();
void processCommand(String line);
void readConsole();
void forwardSonarFrames();
void reportCameraObjects();
constexpr int ESC_LEFT_PIN = 25;
constexpr int ESC_RIGHT_PIN = 26;
constexpr int ESC_VERTICAL_PIN = 27;
constexpr int SONAR_RX_PIN = 16;
constexpr int SONAR_TX_PIN = 17;
constexpr int CAMERA_RX_PIN = 32;
constexpr int CAMERA_TX_PIN = 33;
constexpr int PWM_NEUTRAL_US = 1500;
constexpr int PWM_MIN_US = 1100;
constexpr int PWM_MAX_US = 1900;
constexpr uint32_t COMMAND_TIMEOUT_MS = 500;
constexpr uint32_t STATUS_INTERVAL_MS = 250;
constexpr uint32_t CAMERA_POLL_INTERVAL_MS = 100;
Servo leftEsc;
Servo rightEsc;
Servo verticalEsc;
HardwareSerial sonarSerial(2);
HardwareSerial cameraSerial(1);
HUSKYLENS huskylens;
int leftCommand = 0;
int rightCommand = 0;
int verticalCommand = 0;
bool cameraReady = false;
uint32_t lastCommandMs = 0;
uint32_t lastStatusMs = 0;
uint32_t lastCameraPollMs = 0;
String commandLine;
int throttleToPulse(int throttle) { return map(constrain(throttle, -100, 100), -100, 100, PWM_MIN_US, PWM_MAX_US); }
void writeThrusters(int left, int right, int vertical) { leftEsc.writeMicroseconds(throttleToPulse(left)); rightEsc.writeMicroseconds(throttleToPulse(right)); verticalEsc.writeMicroseconds(throttleToPulse(vertical)); }
void stopThrusters() { leftCommand = 0; rightCommand = 0; verticalCommand = 0; writeThrusters(0, 0, 0); }
void printHelp() {
Serial.println("Commands: T left right vertical (-100..100), STOP, STATUS, HELP");
Serial.println("Vision: set HuskyLens to Object Recognition and teach object IDs on its screen.");
Serial.println("Detection output: OBJECT id,x,y,width,height.");
}
void processCommand(String line) {
line.trim(); line.toUpperCase();
if (line == "STOP") { stopThrusters(); lastCommandMs = millis(); Serial.println("ACK STOP"); return; }
if (line == "HELP") { printHelp(); return; }
if (line == "STATUS") { Serial.printf("STATUS L=%d R=%d V=%d CAMERA=%s\n", leftCommand, rightCommand, verticalCommand, cameraReady ? "READY" : "OFFLINE"); return; }
int l, r, v;
if (sscanf(line.c_str(), "T %d %d %d", &l, &r, &v) == 3) {
leftCommand = constrain(l, -100, 100); rightCommand = constrain(r, -100, 100); verticalCommand = constrain(v, -100, 100);
writeThrusters(leftCommand, rightCommand, verticalCommand); lastCommandMs = millis();
Serial.printf("ACK T %d %d %d\n", leftCommand, rightCommand, verticalCommand);
} else Serial.println("ERR. Type HELP.");
}
void readConsole() {
while (Serial.available()) {
char c = static_cast<char>(Serial.read());
if (c == '\n' || c == '\r') { if (commandLine.length()) { processCommand(commandLine); commandLine = ""; } }
else if (commandLine.length() < 80) commandLine += c;
}
}
void forwardSonarFrames() {
static uint8_t frame[128]; static size_t length = 0;
while (sonarSerial.available()) {
uint8_t b = static_cast<uint8_t>(sonarSerial.read());
if (length < sizeof(frame)) frame[length++] = b; else length = 0;
if (length >= 4 && frame[0] == 0x42 && frame[1] == 0x52) {
uint16_t payloadLength = static_cast<uint16_t>(frame[2]) | (static_cast<uint16_t>(frame[3]) << 8);
size_t frameLength = static_cast<size_t>(payloadLength) + 8;
if (frameLength <= sizeof(frame) && length == frameLength) {
Serial.print("PING_RAW "); for (size_t i = 0; i < length; ++i) Serial.printf("%02X", frame[i]); Serial.println(); length = 0;
} else if (frameLength > sizeof(frame)) length = 0;
} else if (length >= 2) { frame[0] = frame[length - 1]; length = 1; }
}
}
void reportCameraObjects() {
if (!cameraReady || millis() - lastCameraPollMs < CAMERA_POLL_INTERVAL_MS) return;
lastCameraPollMs = millis();
if (!huskylens.request()) { Serial.println("CAMERA_ERROR request failed"); cameraReady = false; return; }
if (!huskylens.isLearned()) { Serial.println("CAMERA_NOT_LEARNED"); return; }
if (!huskylens.available()) { Serial.println("NO_OBJECT"); return; }
do {
HUSKYLENSResult result = huskylens.read();
if (result.command == COMMAND_RETURN_BLOCK) Serial.printf("OBJECT %d,%d,%d,%d,%d\n", result.ID, result.xCenter, result.yCenter, result.width, result.height);
} while (huskylens.available());
}
void setup() {
Serial.begin(115200);
sonarSerial.begin(115200, SERIAL_8N1, SONAR_RX_PIN, SONAR_TX_PIN);
cameraSerial.begin(9600, SERIAL_8N1, CAMERA_RX_PIN, CAMERA_TX_PIN);
leftEsc.setPeriodHertz(50); rightEsc.setPeriodHertz(50); verticalEsc.setPeriodHertz(50);
leftEsc.attach(ESC_LEFT_PIN, 1000, 2000); rightEsc.attach(ESC_RIGHT_PIN, 1000, 2000); verticalEsc.attach(ESC_VERTICAL_PIN, 1000, 2000);
stopThrusters(); delay(2500);
cameraReady = huskylens.begin(cameraSerial);
lastCommandMs = millis();
Serial.println("ROV controller ready; all thrusters neutral.");
Serial.printf("HuskyLens camera: %s. Set it to Object Recognition and teach target IDs.\n", cameraReady ? "READY" : "OFFLINE");
printHelp();
}
void loop() {
readConsole(); forwardSonarFrames(); reportCameraObjects();
if (millis() - lastCommandMs > COMMAND_TIMEOUT_MS && (leftCommand || rightCommand || verticalCommand)) { stopThrusters(); Serial.println("FAILSAFE: command timeout; thrusters neutral."); }
if (millis() - lastStatusMs >= STATUS_INTERVAL_MS) { lastStatusMs = millis(); Serial.printf("STATUS L=%d R=%d V=%d CAMERA=%s\n", leftCommand, rightCommand, verticalCommand, cameraReady ? "READY" : "OFFLINE"); }
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.