Romeo ESP32-S3 Robot Development Board With The Devastator Tank Mobile Robot Platform
John.Winters 2026-04-01 13:09:07 310 Views4 Replies I am trying to make a web based FPV tank with the Devastator Tank Mobile Robot Platform(R0B0112) and Romeo ESP32-S3 Robot Development Board(DFR0994).
My code works when not streaming video (I comment out line 155). But when I try to stream the control buttons do not work, until I close my browser then the tank tries to runaway. Any ideas?
— Code ----
#include <HTTP_Method.h>
#include <Middlewares.h>
#include <Uri.h>
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include "DFRobot_AXP313A.h"
// --- Camera Pinout for Romeo S3 (standard OV2640) ---
#define CAMERA_MODEL_DFRobot_Romeo_ESP32S3
#include "camera_pins.h"
DFRobot_AXP313A axp;
const char* ssid = "Devastator_FPV";
const char* password = "password123";
WebServer server(80);
WiFiServer streamServer(81);
// Motor Pins
const int M1_PWM = 12; // Adjust based on your Romeo S3 version
const int M1_DIR = 13;
const int M2_PWM = 14;
const int M2_DIR = 21;
void setup() {
Serial.begin(115200);
while (axp.begin() !=0){
Serial.println("init error");
delay(1000);
}
axp.enableCameraPower(axp.eOV2640);
// 1. Camera Config
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.frame_size = FRAMESIZE_UXGA;
config.pixel_format = PIXFORMAT_JPEG; // for streaming
//config.pixel_format = PIXFORMAT_RGB565; // for face detection/recognition
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.jpeg_quality = 32;
config.fb_count = 1;
// if PSRAM IC present, init with UXGA resolution and higher JPEG quality
// for larger pre-allocated frame buffer.
if (config.pixel_format == PIXFORMAT_JPEG) {
if (psramFound()) {
config.jpeg_quality = 10;
config.fb_count = 2;
config.grab_mode = CAMERA_GRAB_LATEST;
} else {
// Limit the frame size when PSRAM is not available
config.frame_size = FRAMESIZE_SVGA;
config.fb_location = CAMERA_FB_IN_DRAM;
}
} else {
// Best option for face detection/recognition
config.frame_size = FRAMESIZE_240X240;
#if CONFIG_IDF_TARGET_ESP32S3
config.fb_count = 2;
#endif
}
#if defined(CAMERA_MODEL_ESP_EYE)
pinMode(13, INPUT_PULLUP);
pinMode(14, INPUT_PULLUP);
#endif
// camera init
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed with error 0x%x", err);
return;
}
sensor_t *s = esp_camera_sensor_get();
// initial sensors are flipped vertically and colors are a bit saturated
if (s->id.PID == OV3660_PID) {
s->set_vflip(s, 1); // flip it back
s->set_brightness(s, 1); // up the brightness just a bit
s->set_saturation(s, -2); // lower the saturation
}
// drop down frame size for higher initial frame rate
if (config.pixel_format == PIXFORMAT_JPEG) {
s->set_framesize(s, FRAMESIZE_QVGA);
}
// 2. WiFi & Motors
pinMode(M1_PWM, OUTPUT); pinMode(M1_DIR, OUTPUT);
pinMode(M2_PWM, OUTPUT); pinMode(M2_DIR, OUTPUT);
WiFi.softAP(ssid, password);
Serial.println("AP Started. IP: 192.168.4.1");
// 3. Routes
server.on("/", handleRoot);
server.on("/action", handleAction);
server.begin();
streamServer.begin();
}
void loop() {
server.handleClient();
handleStream();
}
// --- Video Streaming Logic ---
void handleStream() {
WiFiClient client = streamServer.available();
if (client) {
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: multipart/x-mixed-replace; boundary=frame");
client.println();
while (client.connected()) {
camera_fb_t * fb = esp_camera_fb_get();
if (!fb) break;
client.printf("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", fb->len);
client.write(fb->buf, fb->len);
client.print("\r\n");
esp_camera_fb_return(fb);
}
}
}
// --- Web UI ---
void handleRoot() {
String html = "<html><body style='text-align:center; background:#222; color:white; font-family:sans-serif;'>";
html += "<h1>Devastator FPV</h1>";
// The Image source points to the stream on port 81
//html += "<img src='http://192.168.4.1:81' style='width:320px; border:2px solid orange;'><br><br>";
html += "<H2>";
html += "<button style='width:100px;height:50px' onmousedown=\"fetch('/action?dir=F')\" onmouseup=\"fetch('/action?dir=S')\">FORWARD</button><br>";
html += "<button style='width:100px;height:50px' onmousedown=\"fetch('/action?dir=L')\" onmouseup=\"fetch('/action?dir=S')\">LEFT</button>";
html += "<button style='width:100px;height:50px' onmousedown=\"fetch('/action?dir=R')\" onmouseup=\"fetch('/action?dir=S')\">RIGHT</button><br>";
html += "<button style='width:100px;height:50px' onmousedown=\"fetch('/action?dir=B')\" onmouseup=\"fetch('/action?dir=S')\">BACK</button>";
html += "</H2>";
server.send(200, "text/html", html);
}
void handleAction() {
String dir = server.arg("dir");
if (dir == "F") move(150, 150, 1, 0);
else if (dir == "B") move(150, 150, 0, 1);
else if (dir == "L") move(200, 200, 0, 0);
else if (dir == "R") move(200, 200, 1, 1);
else move(0, 0, 0, 0);
server.send(200);
}
void move(int sL, int sR, bool dL, bool dR) {
digitalWrite(M1_DIR, dL); digitalWrite(M2_DIR, dR);
analogWrite(M1_PWM, sL); analogWrite(M2_PWM, sR);
}
That got it! Thanks again. I just asked gemini to add delays so that the code would work and it eventually got it. This worked
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include "DFRobot_AXP313A.h"
// --- Camera Pinout for Romeo S3 ---
#define CAMERA_MODEL_DFRobot_Romeo_ESP32S3
#include "camera_pins.h"
DFRobot_AXP313A axp;
const char* ssid = "Devastator_FPV";
const char* password = "password123";
WebServer server(80);
WiFiServer streamServer(81);
// Motor Pins
const int M1_PWM = 12;
const int M1_DIR = 13;
const int M2_PWM = 14;
const int M2_DIR = 21;
void setup() {
Serial.begin(115200);
// 1. Power Management - Critical for Romeo S3
if (axp.begin() != 0) {
Serial.println("AXP313A init error");
}
axp.enableCameraPower(axp.eOV2640);
// 2. Camera Configuration
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.frame_size = FRAMESIZE_QVGA;
config.pixel_format = PIXFORMAT_JPEG;
config.grab_mode = CAMERA_GRAB_LATEST;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.jpeg_quality = 12;
config.fb_count = 2;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed: 0x%x", err);
}
// 3. Motor Pin Setup
pinMode(M1_PWM, OUTPUT);
pinMode(M1_DIR, OUTPUT);
pinMode(M2_PWM, OUTPUT);
pinMode(M2_DIR, OUTPUT);
WiFi.softAP(ssid, password);
// 4. Routes
server.on("/", handleRoot);
server.on("/action", handleAction);
server.begin();
streamServer.begin();
Serial.println("System Ready. Connect to Devastator_FPV");
}
void loop() {
// Handle web server requests (Motor Commands)
server.handleClient();
// Handle streaming (Video)
handleStream();
// CRITICAL: Small delay to prevent watchdog triggers
// and allow the background WiFi task to process actions.
delay(1);
}
void handleAction() {
String dir = server.arg("dir");
Serial.print("Command Received: "); Serial.println(dir);
if (dir == "F") move(180, 180, 1, 0);
else if (dir == "B") move(180, 180, 0, 1);
else if (dir == "L") move(200, 200, 0, 0);
else if (dir == "R") move(200, 200, 1, 1);
else move(0, 0, 0, 0); // Stop
server.send(200, "text/plain", "OK");
}
void move(int sL, int sR, bool dL, bool dR) {
digitalWrite(M1_DIR, dL);
digitalWrite(M2_DIR, dR);
analogWrite(M1_PWM, sL);
analogWrite(M2_PWM, sR);
}
void handleStream() {
static WiFiClient client;
// If no client is connected, check for a new one
if (!client || !client.connected()) {
client = streamServer.available();
if (client) {
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: multipart/x-mixed-replace; boundary=frame");
client.println();
}
}
// If a client IS connected, send ONE frame then exit to allow loop() to continue
if (client && client.connected()) {
camera_fb_t * fb = esp_camera_fb_get();
if (fb) {
client.printf("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", fb->len);
client.write(fb->buf, fb->len);
client.print("\r\n");
esp_camera_fb_return(fb);
}
}
}
void handleRoot() {
String html = "<html><head><meta name='viewport' content='width=device-width, initial-scale=1'></head>";
html += "<body style='text-align:center; background:#222; color:white;'>";
html += "<h2>Romeo S3 Control</h2>";
html += "<img src='http://192.168.4.1:81' style='width:320px;'><br><br>";
// Script-based fetch is faster than standard links
html += "<button style='width:80px;height:80px' onmousedown=\"fetch('/action?dir=F')\" onmouseup=\"fetch('/action?dir=S')\" ontouchstart=\"fetch('/action?dir=F')\" ontouchend=\"fetch('/action?dir=S')\">FWD</button><br>";
html += "<button style='width:80px;height:80px' onmousedown=\"fetch('/action?dir=L')\" onmouseup=\"fetch('/action?dir=S')\" ontouchstart=\"fetch('/action?dir=L')\" ontouchend=\"fetch('/action?dir=S')\">L</button>";
html += "<button style='width:80px;height:80px' onmousedown=\"fetch('/action?dir=R')\" onmouseup=\"fetch('/action?dir=S')\" ontouchstart=\"fetch('/action?dir=R')\" ontouchend=\"fetch('/action?dir=S')\">R</button><br>";
html += "<button style='width:80px;height:80px' onmousedown=\"fetch('/action?dir=B')\" onmouseup=\"fetch('/action?dir=S')\" ontouchstart=\"fetch('/action?dir=B')\" ontouchend=\"fetch('/action?dir=S')\">REV</button>";
html += "</body></html>";
server.send(200, "text/html", html);
}
John.Winters One possible problem could be: CPU Blocking. The primary reason the tank's controls fail when video starts is the while(client.connected()) loop inside the handleStream() function.
The Trap: Once a browser connects to the video stream (Port 81), the code enters that while loop and stays there indefinitely to keep sending JPEG frames.
The Consequence: Because the CPU is stuck in that loop, it never returns to loop() to run server.handleClient(). This is why the control buttons (Port 80) stop working.
The "Runaway" Effect: When the browser is closed, the connection breaks and the loop finally ends. However, if the last command received was "Forward," the motor pins stay HIGH because the "Stop" command (usually sent on mouseup) was never processed.
R2D2C3PO Thank you so much for your answer. I think you are right.
I will look into fixing this.
My other thought was the battery did not have enough amps, I am using a 4S battery 850 mah. I got a splitter and UBEC to use it to power the motors.
https://www.youtube.com/shorts/k8GCJCmK-U0

