├── .gitignore ├── web_socket_server ├── .gitignore ├── cleanup.py ├── config.py ├── static │ ├── app.css │ ├── index.html │ └── app.js ├── server.py ├── app.py ├── ws_handlers.py └── video_utils.py ├── esp32_rc_cars.ino ├── wiring.png ├── car_photo.jpeg ├── screenshot.png ├── full-wiring.png ├── config.h ├── SteeringServo.h ├── SteeringServo.cpp ├── Esc.h ├── ServoControl.h ├── LICENSE ├── Esc.cpp ├── ServoControl.cpp ├── bluetooth_control.h ├── README.md └── web_control.h /.gitignore: -------------------------------------------------------------------------------- 1 | secrets.h 2 | .venv 3 | -------------------------------------------------------------------------------- /web_socket_server/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /esp32_rc_cars.ino: -------------------------------------------------------------------------------- 1 | // #include "web_control.h" 2 | #include "bluetooth_control.h" -------------------------------------------------------------------------------- /wiring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattsroufe/esp32_rc_cars/HEAD/wiring.png -------------------------------------------------------------------------------- /car_photo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattsroufe/esp32_rc_cars/HEAD/car_photo.jpeg -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattsroufe/esp32_rc_cars/HEAD/screenshot.png -------------------------------------------------------------------------------- /full-wiring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattsroufe/esp32_rc_cars/HEAD/full-wiring.png -------------------------------------------------------------------------------- /web_socket_server/cleanup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | 4 | async def cleanup(app): 5 | logging.info("Shutting down resources...") 6 | app['shutdown_event'].set() 7 | 8 | # Cancel background tasks 9 | for task in asyncio.all_tasks(): 10 | if task is not asyncio.current_task(): 11 | task.cancel() 12 | 13 | app['thread_pool'].shutdown(wait=False, cancel_futures=True) 14 | logging.info("Cleanup finished.") 15 | -------------------------------------------------------------------------------- /web_socket_server/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from multiprocessing import cpu_count 3 | 4 | # Server 5 | HOST = os.getenv("HOST", "0.0.0.0") 6 | PORT = int(os.getenv("PORT", "8080")) 7 | 8 | # Video 9 | FRAME_RATE_FPS = float(os.getenv("FRAME_RATE", "30")) 10 | FRAME_RATE = 1.0 / FRAME_RATE_FPS 11 | MAX_EXPECTED_CLIENTS = int(os.getenv("MAX_EXPECTED_CLIENTS", "8")) 12 | 13 | # Thread pool 14 | MAX_THREADS = max(2, min(MAX_EXPECTED_CLIENTS, cpu_count() * 2)) 15 | -------------------------------------------------------------------------------- /config.h: -------------------------------------------------------------------------------- 1 | #ifndef CONFIG_H 2 | #define CONFIG_H 3 | 4 | // Global constants for servo configuration 5 | constexpr int SERVO_DEFAULT_MIN_ANGLE = 0; 6 | constexpr int SERVO_DEFAULT_MAX_ANGLE = 180; 7 | constexpr int SERVO_MIN_ANGLE = 30; 8 | constexpr int SERVO_MAX_ANGLE = 130; 9 | constexpr int SERVO_CENTER = 90; 10 | constexpr int SERVO_DEADZONE = 5; 11 | 12 | // Hardware pin definitions 13 | constexpr int STEERING_SERVO_PIN = 12; 14 | 15 | // You can add more reusable values here: 16 | // - ESC min/max 17 | // - pin assignments 18 | // - command timeouts 19 | // - camera config 20 | // - etc. 21 | 22 | #endif 23 | -------------------------------------------------------------------------------- /SteeringServo.h: -------------------------------------------------------------------------------- 1 | #ifndef STEERINGSERVO_H 2 | #define STEERINGSERVO_H 3 | 4 | #include 5 | #include "config.h" 6 | 7 | class SteeringServo 8 | { 9 | public: 10 | SteeringServo(int pin, 11 | int minAngle = SERVO_MIN_ANGLE, 12 | int maxAngle = SERVO_MAX_ANGLE, 13 | int deadZone = SERVO_DEADZONE); 14 | 15 | void control(int position); 16 | 17 | private: 18 | int _pin; 19 | int _minAngle; 20 | int _maxAngle; 21 | int _deadZone; 22 | static constexpr int _centerPos = SERVO_CENTER; 23 | 24 | Servo _servo; 25 | 26 | int mapSteering(int input); 27 | }; 28 | 29 | #endif 30 | -------------------------------------------------------------------------------- /web_socket_server/static/app.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body, html { 8 | height: 100%; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | font-family: Arial, sans-serif; 13 | } 14 | 15 | h3 { 16 | margin-bottom: 8px; 17 | } 18 | 19 | .container { 20 | display: flex; 21 | height: 100vh; 22 | width: 100vw; 23 | } 24 | 25 | .side-panels { 26 | width: 20%; 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: space-between; 30 | } 31 | 32 | .panel { 33 | flex: 1; 34 | margin: 8px; 35 | padding: 12px; 36 | background-color: #ccc; 37 | border-radius: 5px; 38 | } 39 | 40 | .video-section { 41 | width: 60%; 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | background-color: #000; 46 | } 47 | 48 | .video-section img { 49 | width: 100%; 50 | height: 100%; 51 | object-fit: contain; 52 | } -------------------------------------------------------------------------------- /SteeringServo.cpp: -------------------------------------------------------------------------------- 1 | #include "SteeringServo.h" 2 | #include "config.h" 3 | 4 | SteeringServo::SteeringServo(int pin, int minAngle, int maxAngle, int deadZone) 5 | : _pin(pin), _minAngle(minAngle), _maxAngle(maxAngle), _deadZone(deadZone) 6 | { 7 | _servo.attach(_pin); 8 | _servo.write(_centerPos); 9 | } 10 | 11 | int SteeringServo::mapSteering(int input) 12 | { 13 | int angle = constrain(input, SERVO_DEFAULT_MIN_ANGLE, SERVO_DEFAULT_MAX_ANGLE); 14 | 15 | if (abs(input - _centerPos) < _deadZone) 16 | { 17 | return _centerPos; 18 | } 19 | 20 | if (input < _centerPos) 21 | { 22 | angle = map(input, SERVO_DEFAULT_MIN_ANGLE, _centerPos, _minAngle, _centerPos); 23 | } 24 | else 25 | { 26 | angle = map(input, _centerPos, SERVO_DEFAULT_MAX_ANGLE, _centerPos, _maxAngle); 27 | } 28 | 29 | return angle; 30 | } 31 | 32 | void SteeringServo::control(int position) 33 | { 34 | int angle = mapSteering(position); 35 | _servo.write(angle); 36 | } 37 | -------------------------------------------------------------------------------- /Esc.h: -------------------------------------------------------------------------------- 1 | // Esc.h 2 | #pragma once // Prevents double inclusion of this file 3 | 4 | #include // Include the Servo library to control the ESC 5 | 6 | class Esc 7 | { 8 | public: 9 | // Constructor that takes the pin number for the ESC 10 | Esc(int pin = 13); 11 | 12 | // Method to initialize the ESC 13 | void initialize(); 14 | 15 | // Method to control the ESC with a throttle value 16 | void control(int throttle); 17 | 18 | private: 19 | int _pin; // Pin for the ESC 20 | Servo _esc; // Servo object to control the ESC 21 | int smoothedMotorSpeed = 0; // Variable for smoothing throttle input 22 | const float MOTOR_SMOOTHING_FACTOR = 0.6; // Smoothing factor for motor speed 23 | const int MOTOR_DEAD_ZONE = 5; // Dead zone threshold 24 | const int MIN_SPEED_MS = 1000; // 1000; 25 | const int NEUTRAL_SPEED_MS = 1500; // 1500; 26 | const int MAX_SPEED_MS = 2000; // 2000; 27 | }; 28 | -------------------------------------------------------------------------------- /ServoControl.h: -------------------------------------------------------------------------------- 1 | #ifndef SERVOCONTROL_H 2 | #define SERVOCONTROL_H 3 | 4 | #include 5 | 6 | class ServoControl 7 | { 8 | public: 9 | // Constructor to initialize the servo with pin, min/max angle, and dead zone 10 | ServoControl(int pin = 12, int minAngle = 25, int maxAngle = 130, int deadZone = 5); 11 | 12 | // Initialize the servo 13 | void initialize(); 14 | 15 | // Control the servo based on the input position 16 | void control(int position); 17 | 18 | private: 19 | int _pin; // Pin to which the servo is connected 20 | int _minAngle; // Minimum angle for the servo (left limit) 21 | int _maxAngle; // Maximum angle for the servo (right limit) 22 | int _deadZone; // Dead zone for the servo control 23 | 24 | Servo _servo; // Servo object to control the physical servo 25 | const int _centerPos = 90; // Center position (90 degrees) 26 | 27 | // Map the input value (0-180) to the servo angle range with proper limits 28 | int mapSteering(int input); 29 | }; 30 | 31 | #endif 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matt Sroufe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Esc.cpp: -------------------------------------------------------------------------------- 1 | // Esc.cpp 2 | #include "Esc.h" // Include the header file 3 | 4 | // Constructor that initializes the ESC with a given pin 5 | Esc::Esc(int pin) 6 | { 7 | _pin = pin; 8 | _esc.attach(_pin); // Attach the ESC to the specified pin 9 | } 10 | 11 | // Method to initialize the ESC (sets it to 0 speed initially) 12 | void Esc::initialize() 13 | { 14 | _esc.writeMicroseconds(NEUTRAL_SPEED_MS); // Start motor at 0 speed 15 | delay(1000); // Wait for ESC initialization 16 | } 17 | 18 | // Method to control the ESC based on the throttle input 19 | void Esc::control(int throttle) 20 | { 21 | if (abs(throttle) < MOTOR_DEAD_ZONE) 22 | { 23 | throttle = 0; // Ignore small values within dead zone 24 | } 25 | 26 | // Smooth the throttle value 27 | smoothedMotorSpeed = smoothedMotorSpeed + MOTOR_SMOOTHING_FACTOR * (throttle - smoothedMotorSpeed); 28 | 29 | // Map the smoothed throttle value from -255 to 255 into a PWM signal range (1000 to 2000 microseconds) 30 | int pwmValue = map(smoothedMotorSpeed, -255, 255, MIN_SPEED_MS, MAX_SPEED_MS); 31 | 32 | // Set the PWM signal to the ESC 33 | _esc.writeMicroseconds(pwmValue); 34 | 35 | // Optionally, print the PWM value for debugging 36 | // Serial.print(" - PWM Value: "); 37 | // Serial.println(pwmValue); 38 | } 39 | -------------------------------------------------------------------------------- /web_socket_server/server.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from video_utils import process_frame_canvas 3 | from ws_handlers import websocket_handler 4 | import asyncio 5 | import cv2 6 | 7 | async def index(request): 8 | return web.FileResponse('./static/index.html') 9 | 10 | 11 | async def generate_frames(request): 12 | shutdown_event = request.app['shutdown_event'] 13 | loop = asyncio.get_event_loop() 14 | pool = request.app['thread_pool'] 15 | 16 | while not shutdown_event.is_set(): 17 | async with request.app['frame_lock']: 18 | frame_queues = dict(request.app['video_frames']) 19 | 20 | canvas = await loop.run_in_executor(pool, process_frame_canvas, frame_queues) 21 | _, jpeg_frame = cv2.imencode('.jpg', canvas) 22 | yield jpeg_frame.tobytes() 23 | await asyncio.sleep(request.app['frame_rate']) 24 | 25 | 26 | async def video_feed(request): 27 | response = web.StreamResponse( 28 | status=200, 29 | reason="OK", 30 | headers={ 31 | "Content-Type": "multipart/x-mixed-replace; boundary=frame", 32 | "Cache-Control": "no-cache" 33 | } 34 | ) 35 | await response.prepare(request) 36 | 37 | try: 38 | async for frame in generate_frames(request): 39 | await response.write(b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") 40 | except asyncio.CancelledError: 41 | return 42 | 43 | return response 44 | -------------------------------------------------------------------------------- /web_socket_server/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ESP32 RC 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |

Gamepad 1

16 |

Axes:

17 |

Buttons:

18 |
19 |
20 |

Gamepad 3

21 |

Axes:

22 |

Buttons:

23 |
24 |
25 |
26 | Video Stream 27 | 28 |
29 |
30 |
31 |

Gamepad 2

32 |

Axes:

33 |

Buttons:

34 |
35 |
36 |

Gamepad 4

37 |

Axes:

38 |

Buttons:

39 |
40 |
41 |
42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /web_socket_server/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from aiohttp import web 3 | from server import index, video_feed, websocket_handler 4 | from cleanup import cleanup 5 | from config import HOST, PORT, MAX_THREADS, FRAME_RATE 6 | from concurrent.futures import ThreadPoolExecutor 7 | 8 | async def create_application(): 9 | app = web.Application() 10 | app['shutdown_event'] = asyncio.Event() 11 | app['video_frames'] = {} 12 | app['control_commands'] = {} 13 | app['frame_lock'] = asyncio.Lock() 14 | app['thread_pool'] = ThreadPoolExecutor(max_workers=MAX_THREADS) 15 | app['frame_rate'] = FRAME_RATE 16 | 17 | # Routes 18 | app.router.add_get("/", index) 19 | app.router.add_get("/video", video_feed) 20 | app.router.add_get("/ws", websocket_handler) 21 | 22 | # Static files 23 | app.router.add_static("/static", path="./static", name="static") 24 | 25 | # Cleanup 26 | app.on_cleanup.append(cleanup) 27 | return app 28 | 29 | 30 | async def main(): 31 | app = await create_application() 32 | runner = web.AppRunner(app) 33 | await runner.setup() 34 | site = web.TCPSite(runner, HOST, PORT) 35 | await site.start() 36 | print(f"Server started at http://{HOST}:{PORT}") 37 | 38 | try: 39 | await asyncio.Event().wait() 40 | except KeyboardInterrupt: 41 | print("Ctrl+C received, shutting down...") 42 | except asyncio.exceptions.CancelledError: 43 | pass 44 | finally: 45 | await app.shutdown() 46 | await app.cleanup() 47 | await runner.cleanup() 48 | 49 | 50 | if __name__ == "__main__": 51 | asyncio.run(main()) 52 | -------------------------------------------------------------------------------- /ServoControl.cpp: -------------------------------------------------------------------------------- 1 | #include "ServoControl.h" 2 | 3 | // Constructor to initialize the servo with pin, min/max angles, and dead zone 4 | ServoControl::ServoControl(int pin, int minAngle, int maxAngle, int deadZone) 5 | { 6 | _pin = pin; 7 | _minAngle = minAngle; // Minimum angle (left limit) 8 | _maxAngle = maxAngle; // Maximum angle (right limit) 9 | _deadZone = deadZone; 10 | } 11 | 12 | // Initialize the servo by attaching it to the specified pin and setting the default position 13 | void ServoControl::initialize() 14 | { 15 | _servo.attach(_pin); // Attach the servo to the specified pin 16 | _servo.write(_centerPos); // Set the servo to the center position (90 degrees) 17 | } 18 | 19 | // Map the input value (0 to 180) to the servo's range (left and right travel based on min/max angles) 20 | int ServoControl::mapSteering(int input) 21 | { 22 | int angle = constrain(input, 0, 180); // Ensure the input is within 0 to 180 23 | 24 | // Apply dead zone to the input (ignore small values near neutral) 25 | if (abs(input - _centerPos) < _deadZone) 26 | { 27 | angle = _centerPos; // Neutral position 28 | } 29 | 30 | if (input < _centerPos) 31 | { // Left steering (input < 90) 32 | // Map the input range (0-90) to the left angle range (_minAngle to _centerPos) 33 | angle = map(input, 0, _centerPos, _minAngle, _centerPos); 34 | } 35 | else 36 | { // Right steering (input >= 90) 37 | // Map the input range (90-180) to the right angle range (_centerPos to _maxAngle) 38 | angle = map(input, _centerPos, 180, _centerPos, _maxAngle); 39 | } 40 | 41 | return angle; 42 | } 43 | 44 | // Control the servo based on the position, applying dead zone 45 | void ServoControl::control(int position) 46 | { 47 | // Map the input position (0-180) to the appropriate servo angle 48 | int angle = mapSteering(position); 49 | 50 | // Write the calculated angle to the servo 51 | _servo.write(angle); 52 | } 53 | -------------------------------------------------------------------------------- /web_socket_server/ws_handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from aiohttp import WSMsgType, web 4 | from time import time 5 | from collections import deque 6 | from video_utils import calculate_frame_rate 7 | 8 | async def handle_text_message(msg: WSMsgType, request: web.Request, ws: web.WebSocketResponse): 9 | if msg.data == 'close': 10 | await ws.close() 11 | return 12 | try: 13 | request.app['control_commands'].update(json.loads(msg.data)) 14 | except json.JSONDecodeError: 15 | logging.warning(f"Invalid JSON from client {request.remote}: {msg.data}") 16 | return 17 | 18 | video_info = { 19 | client_ip: {"fps": client_data["fps"], "frame_count": client_data["frame_count"]} 20 | for client_ip, client_data in request.app['video_frames'].items() 21 | } 22 | await ws.send_json(video_info) 23 | 24 | async def handle_binary_message(msg: WSMsgType, client_ip: str, request: web.Request, ws: web.WebSocketResponse): 25 | frame_queue = request.app['video_frames'].setdefault( 26 | client_ip, {"frames": deque(maxlen=10), "fps": 0.0, "frame_count": 0} 27 | ) 28 | timestamp = time() 29 | frame_queue["frames"].append((msg.data, timestamp)) 30 | fps = calculate_frame_rate(frame_queue["frames"]) 31 | frame_queue["fps"] = fps 32 | frame_queue["frame_count"] = len(frame_queue["frames"]) 33 | 34 | if client_ip in request.app['control_commands']: 35 | command = request.app['control_commands'][client_ip] 36 | await ws.send_str(f"CONTROL:{command[0]}:{command[1]}") 37 | 38 | async def websocket_handler(request: web.Request): 39 | ws = web.WebSocketResponse() 40 | await ws.prepare(request) 41 | client_ip = request.remote or "unknown" 42 | logging.info(f"Client connected: {client_ip}") 43 | 44 | try: 45 | async for msg in ws: 46 | if msg.type == WSMsgType.TEXT: 47 | await handle_text_message(msg, request, ws) 48 | elif msg.type == WSMsgType.BINARY: 49 | await handle_binary_message(msg, client_ip, request, ws) 50 | elif msg.type == WSMsgType.ERROR: 51 | logging.error(f"WebSocket error: {ws.exception()}") 52 | finally: 53 | logging.info(f"Client disconnected: {client_ip}") 54 | 55 | return ws 56 | -------------------------------------------------------------------------------- /web_socket_server/video_utils.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | from collections import deque 4 | from typing import Dict, Deque, Tuple, Any 5 | 6 | FRAME_WIDTH = 320 7 | FRAME_HEIGHT = 240 8 | 9 | FrameQueue = Deque[Tuple[bytes, float]] 10 | VideoFrames = Dict[str, Dict[str, Any]] # {"frames": FrameQueue, "fps": float, "frame_count": int} 11 | 12 | def calculate_grid_dimensions(num_clients: int) -> Tuple[int, int]: 13 | cols = int(np.ceil(np.sqrt(num_clients))) 14 | rows = int(np.ceil(num_clients / cols)) 15 | return rows, cols 16 | 17 | def get_offsets(index: int, cols: int) -> Tuple[int, int]: 18 | return (index % cols) * FRAME_WIDTH, (index // cols) * FRAME_HEIGHT 19 | 20 | def calculate_frame_rate(frame_queue: FrameQueue) -> float: 21 | timestamps = [ts for _, ts in frame_queue] 22 | if len(timestamps) > 1: 23 | return round((len(timestamps) - 1) / (timestamps[-1] - timestamps[0]), 1) 24 | return 0.0 25 | 26 | def process_frame_canvas(frame_queues: VideoFrames) -> np.ndarray: 27 | """ 28 | Combine all client frames into a single canvas. 29 | Handles empty frames or missing data gracefully. 30 | """ 31 | num_clients = len(frame_queues) 32 | if num_clients == 0: 33 | return np.zeros((FRAME_HEIGHT, FRAME_WIDTH, 3), dtype=np.uint8) 34 | 35 | rows, cols = calculate_grid_dimensions(num_clients) 36 | canvas = np.zeros((rows * FRAME_HEIGHT, cols * FRAME_WIDTH, 3), dtype=np.uint8) 37 | 38 | for i, (client_ip, client_data) in enumerate(frame_queues.items()): 39 | frame_queue = client_data.get("frames", deque()) 40 | if not frame_queue: 41 | continue 42 | 43 | compressed_frame, _ = frame_queue[-1] # latest frame 44 | if compressed_frame is None: 45 | continue 46 | 47 | frame_array = np.frombuffer(compressed_frame, dtype=np.uint8) 48 | frame = cv2.imdecode(frame_array, cv2.IMREAD_COLOR) 49 | if frame is None or frame.shape[0] == 0 or frame.shape[1] == 0: 50 | continue 51 | 52 | x_offset, y_offset = get_offsets(i, cols) 53 | # Resize frame if needed to match expected dimensions 54 | if frame.shape[1] != FRAME_WIDTH or frame.shape[0] != FRAME_HEIGHT: 55 | frame = cv2.resize(frame, (FRAME_WIDTH, FRAME_HEIGHT)) 56 | 57 | canvas[y_offset:y_offset + FRAME_HEIGHT, x_offset:x_offset + FRAME_WIDTH] = frame 58 | 59 | return canvas 60 | -------------------------------------------------------------------------------- /bluetooth_control.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include "soc/rtc_cntl_reg.h" // Prevent brownout 3 | #include "config.h" 4 | #include "SteeringServo.h" 5 | #include "Esc.h" 6 | 7 | // Create steering and ESC objects 8 | SteeringServo steeringServo(STEERING_SERVO_PIN); 9 | Esc esc; 10 | 11 | ControllerPtr myController = nullptr; 12 | 13 | void onConnectedController(ControllerPtr ctl) 14 | { 15 | if (myController != nullptr) 16 | { 17 | Serial.println("CALLBACK: A controller is already connected. Rejecting new controller."); 18 | ctl->disconnect(); // Reject extra controller 19 | return; 20 | } 21 | 22 | Serial.println("CALLBACK: Controller connected."); 23 | ControllerProperties properties = ctl->getProperties(); 24 | Serial.printf("Controller model: %s, VID=0x%04x, PID=0x%04x\n", 25 | ctl->getModelName().c_str(), 26 | properties.vendor_id, 27 | properties.product_id); 28 | 29 | myController = ctl; 30 | } 31 | 32 | void onDisconnectedController(ControllerPtr ctl) 33 | { 34 | if (myController == ctl) 35 | { 36 | Serial.println("CALLBACK: Controller disconnected."); 37 | myController = nullptr; 38 | } 39 | } 40 | 41 | void controlMotor(ControllerPtr ctl) 42 | { 43 | int throttle = map(ctl->axisY(), -511, 511, -255, 255); 44 | esc.control(-throttle); 45 | } 46 | 47 | void controlServo(ControllerPtr ctl) 48 | { 49 | int servoPos = map(ctl->axisRX(), -511, 511, 0, 180); 50 | steeringServo.control(servoPos); 51 | } 52 | 53 | void processGamepad(ControllerPtr ctl) 54 | { 55 | controlMotor(ctl); 56 | controlServo(ctl); 57 | } 58 | 59 | void processController() 60 | { 61 | if (myController && myController->isConnected() && myController->hasData()) 62 | { 63 | if (myController->isGamepad()) 64 | { 65 | processGamepad(myController); 66 | } 67 | else 68 | { 69 | Serial.println("Unsupported controller"); 70 | } 71 | } 72 | } 73 | 74 | void setup() 75 | { 76 | WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); 77 | Serial.begin(115200); 78 | 79 | Serial.printf("Firmware: %s\n", BP32.firmwareVersion()); 80 | const uint8_t *addr = BP32.localBdAddress(); 81 | Serial.printf("BD Addr: %2X:%2X:%2X:%2X:%2X:%2X\n", 82 | addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]); 83 | 84 | BP32.setup(&onConnectedController, &onDisconnectedController); 85 | BP32.forgetBluetoothKeys(); 86 | 87 | esc.initialize(); // Still required if ESC needs pin setup 88 | } 89 | 90 | void loop() 91 | { 92 | bool dataUpdated = BP32.update(); 93 | if (dataUpdated) 94 | { 95 | processController(); 96 | } 97 | 98 | delay(30); 99 | } 100 | -------------------------------------------------------------------------------- /web_socket_server/static/app.js: -------------------------------------------------------------------------------- 1 | const ws = new WebSocket('ws://localhost:8080/ws'); 2 | const GAMEPAD_POLLING_INTERVAL = 30; // ms for polling gamepad status 3 | let clients = []; 4 | 5 | // Holds FPS and frame count for each client 6 | let clientStats = {}; 7 | 8 | ws.onmessage = function (event) { 9 | const json = JSON.parse(event.data); 10 | 11 | // Update the client list 12 | clients = Object.keys(json); 13 | 14 | // Update the FPS and frame count for each client 15 | let i = 0; 16 | Object.keys(json).forEach(clientIp => { 17 | const stats = json[clientIp]; 18 | clientStats[clientIp] = stats; 19 | 20 | // Update the client's gamepad panel with FPS and frame count 21 | updateGamepadPanel(i, stats.fps, stats.frame_count); 22 | i++; 23 | }); 24 | }; 25 | 26 | function sendMessage() { 27 | ws.send(message); 28 | } 29 | 30 | ws.onclose = function () { 31 | // Handle WebSocket closure 32 | }; 33 | 34 | const gamepads = {}; 35 | 36 | // Configuration object for joystick ranges 37 | const controllerConfig = { 38 | "default": { 39 | rightJoystickRange: { min: -1, max: 1 } // Default range: -1 to 1 40 | }, 41 | "057e-2009-Pro Controller": { // Replace with actual gamepad.id of your special controller 42 | rightJoystickRange: { min: -1.0, max: 0.0 } // Special range: -1.0 to 0.0 43 | } 44 | }; 45 | 46 | // Send gamepad data to the server 47 | function updateGamepadInfo() { 48 | const connectedGamepads = navigator.getGamepads(); 49 | 50 | // Collect the status of each connected gamepad 51 | const gamepadData = connectedGamepads.map((gamepad) => { 52 | if (!gamepad) return; 53 | 54 | let gamepadAxes = [ 55 | -gamepad.axes[1].toFixed(1), // Left joystick axis Y 56 | gamepad.axes[2].toFixed(1) // Right joystick axis X 57 | ]; 58 | 59 | // Check for special configuration based on gamepad.id 60 | const controllerId = gamepad.id; 61 | const config = controllerConfig[controllerId] || controllerConfig["default"]; // Default config if no special config 62 | 63 | // Remap the right joystick axis based on the controller's config 64 | if (config.rightJoystickRange) { 65 | gamepadAxes[1] = transformAxisToStandardRange(gamepad.axes[2], config.rightJoystickRange.min, config.rightJoystickRange.max, -1, 1).toFixed(1); 66 | } 67 | 68 | gamepadButtons = gamepad.buttons.map(button => +button.pressed); 69 | 70 | requestAnimationFrame(() => { 71 | // Get the gamepad info container 72 | const gamepadInfoContainer = document.getElementById(`gamepad-info-${gamepad.index}`); 73 | 74 | // Selectively update the text content of specific child elements 75 | const axesContainer = gamepadInfoContainer.querySelector(".axes"); 76 | const buttonsContainer = gamepadInfoContainer.querySelector(".buttons"); 77 | 78 | // Only update what's changed 79 | axesContainer.textContent = gamepadAxes.join(", "); 80 | buttonsContainer.textContent = gamepadButtons.join(", "); 81 | }); 82 | 83 | // Map the axes values to desired ranges 84 | const result = [ 85 | Math.round(-255 + (gamepadAxes[0] - -1) * (255 - -255) / (1 - -1)), 86 | Math.round(0 + (gamepadAxes[1] - -1) * (180 - 0) / (1 - -1)) 87 | ]; 88 | 89 | return result; 90 | }); 91 | 92 | // Send the gamepad data to the server if WebSocket is open 93 | if (ws && ws.readyState === WebSocket.OPEN) { 94 | json = {} 95 | clients.forEach((ip, i) => json[ip] = gamepadData[i]) 96 | ws.send(JSON.stringify(json)); 97 | } 98 | } 99 | 100 | // Transform axis from a custom range to the standard -1 to 1 range 101 | function transformAxisToStandardRange(value, min, max, newMin, newMax) { 102 | if (min === -1 && max === 1) { 103 | return value; // No transformation needed for normal range 104 | } 105 | 106 | // Adjust the value before applying the transformation for special controllers 107 | return ((value - min) * (newMax - newMin)) / (max - min) + newMin; 108 | } 109 | 110 | // Poll for gamepad status every 30ms 111 | function pollGamepads() { 112 | setInterval(updateGamepadInfo, GAMEPAD_POLLING_INTERVAL); 113 | } 114 | 115 | // Start polling when gamepads are connected 116 | window.addEventListener("gamepadconnected", (event) => { 117 | const gamepad = event.gamepad; 118 | gamepads[gamepad.index] = gamepad; 119 | console.log("Gamepad connected:", gamepad.id); 120 | }); 121 | 122 | // Handle gamepad disconnection 123 | window.addEventListener("gamepaddisconnected", (event) => { 124 | const gamepad = event.gamepad; 125 | delete gamepads[gamepad.index]; 126 | console.log("Gamepad disconnected:", gamepad.id); 127 | }); 128 | 129 | // Start the polling loop 130 | pollGamepads(); 131 | 132 | // Update the gamepad panel with FPS and frame count information 133 | function updateGamepadPanel(clientIp, fps, frameCount) { 134 | const gamepadPanel = document.getElementById(`gamepad-info-${clientIp}`); 135 | if (gamepadPanel) { 136 | let statsContainer = gamepadPanel.querySelector('.stats'); 137 | if (!statsContainer) { 138 | statsContainer = document.createElement('div'); 139 | statsContainer.classList.add('stats'); 140 | gamepadPanel.appendChild(statsContainer); 141 | } 142 | 143 | // Update FPS and frame count 144 | statsContainer.innerHTML = `FPS: ${fps}
Frames: ${frameCount}`; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESP32 RC Cars 2 | 3 | ![](car_photo.jpeg) 4 | 5 | ![](screenshot.png) 6 | 7 | ## Demo video 8 | 9 | https://youtu.be/OubYFXmvA1E 10 | 11 | This project demonstrates an ESP32-based remote-controlled camera system capable of transmitting live video streams over WebSockets and controlling motors and servos. A Python server application manages WebSocket communication and provides a web interface to view and control the ESP32 devices. 12 | 13 | ## Features 14 | 15 | - Live video streaming from an ESP32-CAM to a web server. 16 | - Remote control of a motor and a servo via WebSocket commands. 17 | - Automatic timeout to reset motor and servo to default states. 18 | - Dynamic multi-client video feed canvas on the server. 19 | 20 | --- 21 | 22 | ## Hardware Requirements 23 | 24 | - ESP32-CAM (AI Thinker module or compatible board). 25 | - Motor and servo connected to appropriate GPIO pins. 26 | - Stable 5V power supply for the ESP32-CAM. 27 | - Optional SD card (if required for other functionalities). 28 | - Wi-Fi network for communication. 29 | 30 | --- 31 | 32 | ## Materials 33 | 34 | I bought everything for this kit from aliexpress: 35 | 36 | - Car chassis: https://s.click.aliexpress.com/e/_opUxSdp 37 | - Electronic speed control: https://s.click.aliexpress.com/e/_oF12WIj 38 | I used the 30 amp version because it had a better BEC output - 5v at 3 amps. Seems to be plenty of current to power 39 | both the esc and servo. 40 | - Battery holder: https://s.click.aliexpress.com/e/_onDYLjZ 41 | - 2 18650 3.6v cells or 7.4v battery pack (I have used both, cells are more flexible for other projects) 42 | - ESP32-CAM: https://www.aliexpress.com/item/1005001468076374.html (Very important to get one with external antenna and 43 | and I used the 170 degree fisheye camera) 44 | - Bluetooth Gamepad (I used a PS4 controller) 45 | 46 | ___ 47 | 48 | ## Wiring 49 | 50 | I took the 3-pin jst adapter off the servo that came with the car chassis and moved them into a 4-pin jst plug to plug into the esp32. I then spliced the postive and negative leads on the servo and added a 3-pin female jst plug using a crimping tool. Finally, i connected the (white) esc control wire to the 4-pin jst plug and 3-pin female plug. It looks like this: 51 | 52 | ![](wiring.png) ![](full-wiring.png) 53 | 54 | ___ 55 | 56 | ## Software Requirements 57 | 58 | ### ESP32 Code 59 | 60 | #### Libraries 61 | 62 | - `WiFi.h` for Wi-Fi connectivity. 63 | - `ArduinoWebsockets.h` for WebSocket communication. 64 | - `esp_camera.h` for ESP32-CAM camera control. 65 | - `ServoControl.h` and `Esc.h` for controlling the servo and motor. 66 | - `Arduino.h` for standard Arduino functions. 67 | 68 | ### Python Server 69 | 70 | #### Dependencies 71 | 72 | Install the following Python libraries: 73 | 74 | ```bash 75 | sudo apt install python3-aiohttp python3-opencv python3-numpy 76 | ``` 77 | 78 | --- 79 | 80 | ## Configuration 81 | 82 | ### ESP32 Firmware 83 | 84 | 1. Modify the `secrets.h` file to include your Wi-Fi credentials and WebSocket server URL: 85 | 86 | ```cpp 87 | #define WIFI_SSID "YourWiFiSSID" 88 | #define WIFI_PASSWORD "YourWiFiPassword" 89 | #define WS_SERVER_URL "ws://YourServerIP:Port" 90 | ``` 91 | 92 | 2. Ensure the GPIO pins for the camera module, motor, and servo match your hardware setup: 93 | 94 | - Camera GPIO pins are pre-configured for the AI Thinker ESP32-CAM board. 95 | - Update motor and servo pins if necessary. 96 | 97 | ### Python Server 98 | 99 | 1. Place the server script in a directory with an `index.html` file for the web interface. 100 | 2. Start the server: 101 | 102 | ```bash 103 | python3 app.py 104 | ``` 105 | 106 | The server will be accessible on `http://localhost:8080/`. 107 | 108 | --- 109 | 110 | ## Usage 111 | 112 | ### ESP32 113 | 114 | 1. Upload the provided sketch to your ESP32-CAM using the Arduino IDE or a compatible platform. 115 | 2. Monitor the serial output to ensure successful connection to Wi-Fi and the WebSocket server. 116 | 117 | ### Server 118 | 119 | 1. Run the Python server script. 120 | 2. Open the web interface in a browser to view the live video streams. 121 | 3. Send control commands via the WebSocket connection. 122 | 123 | ### WebSocket Commands 124 | 125 | - `MOTOR:`: Set motor speed (-255 to 255). 126 | - `SERVO:`: Set servo angle (0 to 180). 127 | - `CONTROL::`: Control both motor speed and servo angle simultaneously. 128 | 129 | --- 130 | 131 | ## Technical Details 132 | 133 | ### ESP32 Initialization 134 | 135 | - **Wi-Fi**: Connects to the specified Wi-Fi network. 136 | - **Camera**: Configures the ESP32-CAM with the appropriate settings for video streaming. 137 | - **WebSocket**: Establishes a WebSocket connection with the server. 138 | 139 | ### Timeout Handling 140 | 141 | If no control commands are received within a predefined timeout period, the motor speed resets to `0`, and the servo angle resets to `90`. 142 | 143 | ### Python Server 144 | 145 | - Handles WebSocket communication with multiple ESP32 clients. 146 | - Processes incoming video frames and dynamically arranges them in a grid. 147 | - Streams the grid of video frames to the web interface. 148 | 149 | --- 150 | 151 | ## Troubleshooting 152 | 153 | - **Connection Issues**: 154 | - Verify Wi-Fi credentials in `secrets.h`. 155 | - Check that the WebSocket server is running and accessible. 156 | 157 | - **Video Stream Issues**: 158 | - Ensure proper power supply to the ESP32-CAM. 159 | - Verify camera initialization settings. 160 | 161 | --- 162 | 163 | ## License 164 | 165 | This project is open-source and available under the MIT License. 166 | 167 | --- 168 | 169 | ## Contribution 170 | 171 | Feel free to submit issues or pull requests to improve the application! 172 | -------------------------------------------------------------------------------- /web_control.h: -------------------------------------------------------------------------------- 1 | #include "secrets.h" 2 | #include 3 | #include 4 | #include 5 | #include "soc/rtc_cntl_reg.h" 6 | #include "esp_camera.h" 7 | #include 8 | #include "ServoControl.h" 9 | #include "Esc.h" 10 | 11 | // configuration for AI Thinker Camera board 12 | #define PWDN_GPIO_NUM 32 13 | #define RESET_GPIO_NUM -1 14 | #define XCLK_GPIO_NUM 0 15 | #define SIOD_GPIO_NUM 26 16 | #define SIOC_GPIO_NUM 27 17 | #define Y9_GPIO_NUM 35 18 | #define Y8_GPIO_NUM 34 19 | #define Y7_GPIO_NUM 39 20 | #define Y6_GPIO_NUM 36 21 | #define Y5_GPIO_NUM 21 22 | #define Y4_GPIO_NUM 19 23 | #define Y3_GPIO_NUM 18 24 | #define Y2_GPIO_NUM 5 25 | #define VSYNC_GPIO_NUM 25 26 | #define HREF_GPIO_NUM 23 27 | #define PCLK_GPIO_NUM 22 28 | 29 | // Motor and servo pins 30 | #define DUMMY_PIN -1 // Pin for servo control 31 | 32 | using namespace websockets; 33 | WebsocketsClient client; 34 | 35 | // Create two dummy instances of the Servo class to increment the pwm channels since we're using the camera 36 | ServoControl dummyServo1(DUMMY_PIN); 37 | ServoControl dummyServo2(DUMMY_PIN); 38 | ServoControl steeringServo; 39 | Esc esc; 40 | 41 | // Time tracking variables 42 | unsigned long lastCommandTime = 0; 43 | const int COMMAND_TIMEOUT = 20; // command timeout ms 44 | 45 | void onMessageCallback(WebsocketsMessage message) 46 | { 47 | lastCommandTime = millis(); 48 | String command = message.data(); 49 | // Serial.print("Got Message: "); 50 | // Serial.println(command); 51 | 52 | if (command.startsWith("MOTOR:")) 53 | { 54 | // Control motor speed (0-255) 55 | } 56 | else if (command.startsWith("SERVO:")) 57 | { 58 | // Control servo angle (0-180) 59 | } 60 | else if (command.startsWith("CONTROL:")) 61 | { 62 | // Control servo angle (0-180) 63 | String commands_str = command.substring(8); 64 | int colonIndex = commands_str.indexOf(":"); // Find the index of the colon 65 | int speed = commands_str.substring(0, colonIndex).toInt(); 66 | speed = constrain(speed, -255, 255); 67 | int angle = commands_str.substring(colonIndex + 1).toInt(); 68 | angle = constrain(angle, 0, 180); 69 | esc.control(speed); 70 | steeringServo.control(angle); 71 | // xQueueOverwrite(controlQueue, &angle); 72 | } 73 | } 74 | 75 | void onEventsCallback(WebsocketsEvent event, String data) 76 | { 77 | if (event == WebsocketsEvent::ConnectionOpened) 78 | { 79 | Serial.println("Connnection Opened"); 80 | } 81 | else if (event == WebsocketsEvent::ConnectionClosed) 82 | { 83 | Serial.println("Connnection Closed"); 84 | } 85 | else if (event == WebsocketsEvent::GotPing) 86 | { 87 | Serial.println("Got a Ping!"); 88 | } 89 | else if (event == WebsocketsEvent::GotPong) 90 | { 91 | Serial.println("Got a Pong!"); 92 | } 93 | } 94 | 95 | esp_err_t init_camera() 96 | { 97 | camera_config_t config; 98 | config.ledc_channel = LEDC_CHANNEL_0; 99 | config.ledc_timer = LEDC_TIMER_0; 100 | config.pin_d0 = Y2_GPIO_NUM; 101 | config.pin_d1 = Y3_GPIO_NUM; 102 | config.pin_d2 = Y4_GPIO_NUM; 103 | config.pin_d3 = Y5_GPIO_NUM; 104 | config.pin_d4 = Y6_GPIO_NUM; 105 | config.pin_d5 = Y7_GPIO_NUM; 106 | config.pin_d6 = Y8_GPIO_NUM; 107 | config.pin_d7 = Y9_GPIO_NUM; 108 | config.pin_xclk = XCLK_GPIO_NUM; 109 | config.pin_pclk = PCLK_GPIO_NUM; 110 | config.pin_vsync = VSYNC_GPIO_NUM; 111 | config.pin_href = HREF_GPIO_NUM; 112 | config.pin_sscb_sda = SIOD_GPIO_NUM; 113 | config.pin_sscb_scl = SIOC_GPIO_NUM; 114 | config.pin_pwdn = PWDN_GPIO_NUM; 115 | config.pin_reset = RESET_GPIO_NUM; 116 | config.xclk_freq_hz = 20000000; 117 | config.pixel_format = PIXFORMAT_JPEG; 118 | 119 | // parameters for image quality and size 120 | config.frame_size = FRAMESIZE_QVGA; // FRAMESIZE_ + QVGA|CIF|VGA|SVGA|XGA|SXGA|UXGA 121 | config.jpeg_quality = 10; // 0-63 lower number means higher quality 122 | config.fb_count = 2; 123 | 124 | // Camera init 125 | esp_err_t err = esp_camera_init(&config); 126 | 127 | if (err != ESP_OK) 128 | { 129 | Serial.printf("camera init FAIL: 0x%x", err); 130 | return err; 131 | } 132 | 133 | Serial.println("camera init OK"); 134 | 135 | return ESP_OK; 136 | }; 137 | 138 | esp_err_t init_wifi() 139 | { 140 | WiFi.begin(WIFI_SSID, WIFI_PASSWORD); 141 | Serial.println("Wifi init "); 142 | while (WiFi.status() != WL_CONNECTED) 143 | { 144 | delay(500); 145 | Serial.print("."); 146 | } 147 | Serial.println(""); 148 | Serial.println("WiFi OK"); 149 | Serial.println("connecting to WS: "); 150 | client.onMessage(onMessageCallback); 151 | client.onEvent(onEventsCallback); 152 | while (!client.connect(WS_SERVER_URL)) 153 | { 154 | delay(500); 155 | Serial.print("."); 156 | } 157 | Serial.println("WS OK"); 158 | // client.send("hello from ESP32 camera stream!"); 159 | return ESP_OK; 160 | }; 161 | 162 | void setup() 163 | { 164 | WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); 165 | 166 | Serial.begin(115200); 167 | Serial.setDebugOutput(true); 168 | 169 | // Now, disable the SD card to free up pins 170 | SD.end(); 171 | Serial.println("SD Card disabled. Pins freed!"); 172 | 173 | // xTaskCreate(ServoTask, "Servo", 1000, NULL, 1, NULL); 174 | 175 | init_camera(); 176 | init_wifi(); 177 | steeringServo.initialize(); 178 | esc.initialize(); 179 | } 180 | 181 | void loop() 182 | { 183 | if (millis() - COMMAND_TIMEOUT >= lastCommandTime) 184 | { 185 | esc.control(0); // Start motor at 0 speed 186 | steeringServo.control(90); 187 | // Serial.println("Throttle reset to 0 due to timeout."); 188 | } 189 | 190 | if (client.available()) 191 | { 192 | camera_fb_t *fb = esp_camera_fb_get(); 193 | 194 | if (!fb) 195 | return; 196 | 197 | client.sendBinary((const char *)fb->buf, fb->len); 198 | 199 | esp_camera_fb_return(fb); 200 | 201 | client.poll(); 202 | } 203 | } 204 | --------------------------------------------------------------------------------