RoboControl turns a Raspberry Pi (or a Jetson Nano) and a RaspiRobot Board V3 into a small robot car you drive from a web browser. Point your phone at the Pi's IP address and you get a live camera feed, a D-pad, pan/tilt controls, and basic telemetry — no app to install, no cables, nothing to solder beyond the motor board itself.
Under the hood it's a FastAPI app with a small hardware abstraction layer, so the same codebase runs the real thing on a Pi or Jetson and a fully simulated version in Docker when you just want to poke at the dashboard. It's a one-person hobby project, not a commercial product, and the README below tries to reflect that: what it actually does, what it doesn't do yet, and what you'd need to check yourself before trusting it with real hardware.
| Front | Top | Angle |
|---|---|---|
![]() |
![]() |
![]() |
If you don't write code, here's what this actually is: a small robot car — motors, wheels, a camera, a distance sensor — that you drive from a web page, like you'd use any app. No special software to install. You open a browser, you see what the car sees, and you drive it.
What makes it worth a second look isn't the robot itself — plenty of hobby kits do that. It's why it's built the way it is. A NASA rover on Mars can't be joysticked in real time: a radio signal takes between 3 and 22 minutes to cross the distance from Earth to Mars, one way, no matter how good your hardware is — that's the speed of light, not an engineering limitation. So the rover gets one batch of commands a day, executes them mostly on its own, and has to make its own calls about what's a hazard and what isn't. This robot, on the same WiFi network as the phone driving it, has a round-trip of well under a second — but it still has to answer a smaller version of the same question every real remote system faces: what does it do when it can't reach you? If the sonar sees an obstacle, it stops itself before a human can react. If it can't find a known WiFi network, it opens its own and asks for help instead of going dark. Different scale, same underlying idea.
You don't need a NASA budget to start thinking this way — you need about $100 in parts and a free weekend. That's really the point of this project: the ideas behind serious remote systems are learnable, not exclusive, and a Raspberry Pi is a perfectly good place to start practicing them.
If you want to see it running before reading any further, jump to Section 5 (Using the Controller) for screenshots, or docker/README.md to try the dashboard yourself with no hardware at all.
- What You Need
- Install
- WiFi Setup
- First Login
- Using the Controller
- Internet Access — Optional
- Day-to-day Commands
- Configuration Reference
- API Reference
- Security
- Monitoring
- Developer Guide
| Part | Details |
|---|---|
| Raspberry Pi 3B+ / 4 / 5, or Jetson Nano | Any RAM size. GPIO/camera backend is auto-detected — see Supported Hardware |
| RaspiRobot Board V3 (RRB3) | Motor driver HAT — plugs directly onto the GPIO header |
| HC-SR04 ultrasonic sensor | Plugs into the RRB3 sonar header |
| 2× DC gear motors + chassis | Any TT-motor compatible pair |
| 7.4V LiPo battery (2S) | Powers the motors via the RRB3 |
| MicroSD card (16 GB+) | For Raspberry Pi OS |
| Part | What it adds |
|---|---|
| Pi Camera Module (CSI) or USB webcam | Live video stream while driving |
| 2× SG90 servos + pan/tilt bracket | Aim the camera remotely |
| Passive buzzer | Audio alerts on obstacles |
Raspberry Pi GPIO header
│
RaspiRobot Board V3
├── Motor A terminals → Left motor
├── Motor B terminals → Right motor
├── Sonar header → HC-SR04
└── Power input → 7.4V LiPo
GPIO 12 → Pan servo
GPIO 13 → Tilt servo
GPIO 18 → Buzzer
CSI → Pi Camera ribbon (if using)
USB → USB webcam (if using)
Power the Pi from its own micro-USB supply. Sharing power with the motors causes reboots.
app/hardware/ auto-detects your board at runtime — no config flag to set, the same install works unmodified across boards:
| Board | GPIO backend | Camera backend |
|---|---|---|
| Raspberry Pi 3B+ / 4 | RPi.GPIO |
picamera2 (CSI) |
| Raspberry Pi 5 | rpi-lgpio (drop-in RPi.GPIO replacement for the RP1 chip) |
picamera2 (CSI) |
| Jetson Nano / other Jetson | Jetson.GPIO |
OpenCV (opencv-python-headless) — USB webcam or V4L2 |
| Anything else (USB webcam, no GPIO) | — (drives disable themselves) | OpenCV |
scripts/install.sh reads /proc/device-tree/model and installs the matching GPIO package automatically; app/hardware/gpio_compat.py then picks up whichever backend is importable. app/hardware/camera_driver.py tries picamera2 first and falls back to OpenCV.
Moving the RRB3 board to a Jetson Nano: the motor driver in app/hardware/rrb3_driver.py talks to the RRB3 over the same BCM pin numbers it uses on a Raspberry Pi, via whichever GPIO backend was detected — no code changes needed. What I can't verify for you is the physical wiring: Jetson Nano's 40-pin header matches a Raspberry Pi's layout closely enough for Jetson.GPIO's BCM-compatible mode to work for most digital I/O, but check your specific pins against a Jetson pinout diagram with a multimeter before powering the board — a wrong assumption here is the kind of mistake that damages hardware, not just software.
WiFi setup (Section 3) depends on NetworkManager being the active network manager. That's the Raspberry Pi OS Bookworm default, matching what this whole hardware layer assumes; JetPack's Ubuntu-based images typically ship it too, but I can't confirm that for your specific Jetson image — check with systemctl status NetworkManager before relying on it.
Testing without any hardware at all: see docker/README.md — docker compose -f docker/docker-compose.yml up --build runs the full dashboard with simulated motors/sonar/camera so you can try changes before touching a real board.
Download Raspberry Pi Imager and flash Raspberry Pi OS Lite (64-bit, Bookworm). In the imager's advanced settings, enable SSH and set a username/password before writing.
Insert the card, power on the Pi, and find its IP address from your router's device list. Then open a terminal:
ssh pi@<pi-ip-address>git clone https://github.com/ZioGuillo/robocar.git ~/robocontrol
cd ~/robocontrolcp .env.example .envGenerate a secret key and paste it into .env:
python3 -c "import secrets; print(secrets.token_hex(32))"Open .env with nano .env and set:
SESSION_SECRET_KEY=<paste the key here>Save and close (Ctrl+O, Enter, Ctrl+X).
bash scripts/install.shThis will:
- Create the Python virtual environment and install all dependencies
- Detect your board (
/proc/device-tree/model) and install the matching GPIO backend — see Supported Hardware - Install the camera driver (
picamera2on a Pi; otherwise nothing — install the OpenCV fallback yourself if this board has a non-CSI camera) - Register and start the
robocontrolsystemd service (auto-starts on every boot)
At the end you will see something like:
Local URL : http://192.168.x.x:8000
Public URL : not configured (local-only mode)
On any device connected to the same WiFi, open a browser and go to:
http://<pi-ip-address>:8000
Step 1 already got you online once, using the WiFi credentials you set in Raspberry Pi Imager's advanced settings. This section is for everything after that: moving the rover to a different network, setting up a second one somewhere you don't have Imager access, or connecting it without a screen or SD card reader at all.
The deterministic way. Before boot, place a wifi.txt file on the boot partition (/boot/firmware/ on Bookworm):
SSID=NetworkName
PASSWORD=NetworkPasswordwifi-provision.service reads it on boot and deletes the file once connected, so the password doesn't sit on the SD card indefinitely. To switch networks later, drop a new wifi.txt on the boot partition and reboot.
If the device has no working network after a short wait on boot, wifi-connect-fallback.service opens its own WiFi network — default SSID RoboCar-Setup, no passphrase — with a captive portal:
- Connect your phone or laptop to
RoboCar-Setup. - Your device should open the setup page on its own (captive portal detection); if it doesn't, open any URL and it'll redirect there.
- Pick the real network from the scanned list and enter its password.
The portal shuts down as soon as it connects, and robocontrol becomes reachable at its usual URL on the new network. Set PORTAL_SSID / PORTAL_PASSPHRASE in .env if you want the setup network renamed or passphrase-protected — worth doing if you're setting up somewhere the real WiFi password shouldn't cross an open network, even briefly.
This uses WiFi Connect (Apache-2.0), installed automatically by scripts/install.sh. It only ever offers to help: if wifi.txt or a previously-saved network already connects, the portal never appears. It also isn't available until scripts/install.sh has run once — it can't help with the very first boot, only every one after.
Default credentials:
| Username | Password |
|---|---|
admin |
admin |
Change the password immediately — go to the Settings tab → Change password.
| Control | What it does |
|---|---|
| Camera preview | Live stream while you drive |
| D-pad | Forward / reverse / turn left / turn right / stop (red centre) |
| Speed slider | Motor power 0–100% (default 75%) |
When the sonar detects an obstacle closer than 20 cm, the car stops automatically and the UI shows a warning.
Full-size live stream. The 3×3 grid below it nudges the pan/tilt servos; the centre button re-centres both.
Detection order: CSI camera → USB webcam → CAMERA_STREAM_URL → placeholder image.
| Mission | Description |
|---|---|
| Path Following | Drive a pre-defined waypoint route automatically |
| Visual Search | Roam until the camera finds a target image, then alert |
| Auto-Drive | Free-roam with sonar-based obstacle avoidance |
All missions are currently coming soon.
Live stats for the current session (resets on restart):
| Metric | Description |
|---|---|
| Battery | Whether the motor driver board is detected (the RRB3 has no voltage-sensing circuit, so this isn't a live voltage reading) |
| Uptime | Time since the server started |
| Last CMD Latency | Round-trip time of the last motor command |
| Commands Sent | Total motor commands this session |
| Obstacles Hit | Times the sonar triggered an automatic stop |
| Est. Distance | Approximate distance driven |
- User management — approve or revoke GitHub OAuth users
- GitHub OAuth — paste your GitHub app credentials to enable social login
- Change password — update the admin password
By default the rover is only reachable on your local network. This section is for when you want to control it from anywhere in the world using a permanent public URL like https://rover01.yourdomain.com.
Skip this entirely if local control is enough — the rover works fine without it.
- A free Cloudflare account
- A domain added to that account (works with any registrar)
The install script runs cloudflared_provision.py which:
- Reads your Cloudflare credentials
- Auto-detects your domain (or you can specify one)
- Creates a tunnel and assigns the next free
roverXXsubdomain - Adds the DNS record automatically
- Saves everything — the tunnel starts on every boot from then on
On your computer — create a cloudflared.env file:
CF_API_TOKEN=your_cloudflare_api_token
CF_ACCOUNT_ID=your_cloudflare_account_id
# Optional — auto-detected if omitted
# CF_ZONE_ID=your_zone_id
# CF_ZONE_NAME=mydomain.com| Variable | Required | Where to find it |
|---|---|---|
CF_API_TOKEN |
Yes | Dashboard → My Profile → API Tokens → Create Token. Permissions: Tunnel:Edit + DNS:Edit + Zone:Read |
CF_ACCOUNT_ID |
Yes | Dashboard → any domain page → right sidebar |
CF_ZONE_ID |
No | Dashboard → your domain → right sidebar. Auto-detected if omitted. |
CF_ZONE_NAME |
No | e.g. mydomain.com. Picks a specific domain when your account has more than one. |
Recommended — encrypt the file before putting it on the SD card:
# On your Mac / Linux machine:
bash scripts/encrypt_env.sh cloudflared.env
# → produces cloudflared.env.enc (safe to share)Copy cloudflared.env.enc to the SD card boot partition (/boot/firmware/). Then add the passphrase to ~/robocontrol/.env on the Pi:
CLOUDFLARED_ENV_PASSPHRASE=<the passphrase you chose>If you prefer plain text (trusted environment only), place cloudflared.env directly on the boot partition and skip the passphrase step.
Run provisioning:
python3 scripts/cloudflared_provision.pyOn success:
============================================================
rover01 is LIVE
============================================================
Public URL : https://rover01.yourdomain.com
Local URL : http://192.168.x.x:8000
The script queries your Cloudflare account for existing roverXX tunnels and picks the first free slot. Rover01 already set up? The next one becomes rover02 automatically.
WiFi connects before any of this runs — see Section 3, WiFi Setup if the rover isn't on your network yet. Once it is, the rest of the boot chain looks like this:
wifi-provision ← connects WiFi from wifi.txt (skipped if absent)
↓
wifi-connect-fallback ← no connection after ~20s? opens the setup portal (skipped once connected)
↓
cloudflared-provision ← provisions tunnel (skipped if no credentials or already done)
↓
cloudflared ← tunnel is live
↓
robocontrol ← app is live at :8000 regardless of tunnel
journalctl -u wifi-provision -n 20 # WiFi provisioning log
journalctl -u wifi-connect-fallback -n 20 # Captive-portal WiFi setup log
journalctl -u cloudflared-provision -n 20 # Tunnel provisioning log
sudo systemctl status cloudflared # Tunnel runtime status
journalctl -u robocontrol -f # App live logsrm ~/.cloudflared/config.yml ~/.cloudflared/rover_id ~/.cloudflared/rover_url
sudo systemctl restart cloudflared-provisionTo let team members log in with their GitHub accounts:
- Go to GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App
- Set the callback URL to
https://rover01.yourdomain.com/auth/callback - Copy the Client ID and generate a Client Secret
- In RoboControl: Settings tab → GitHub OAuth — paste and enable
- Set
BASE_URL=https://rover01.yourdomain.comin.envso redirects match
New GitHub users land in pending until you approve them in the Settings tab.
sudo systemctl status robocontrol # is it running?
sudo systemctl restart robocontrol # restart after a config change
journalctl -u robocontrol -f # live logs
journalctl -u robocontrol -n 50 # last 50 linesmake help # list all commands
make deploy # push latest code to Pi and restart
make restart # restart without deploying
make logs # stream live logs
make status # check if running
make open # open the web UI in your browser
make connect # SSH into the Pi
make set-password ADMIN_PASS=NewPass # change admin passwordOverride the Pi address for one command:
make deploy PI_HOST=pi@192.168.1.50All settings live in ~/robocontrol/.env:
| Variable | Default | Description |
|---|---|---|
SESSION_SECRET_KEY |
(auto-generated) | Signs session cookies — 32+ random chars. Setting it explicitly is still recommended; if omitted, a random key is generated once and persisted to data_dir/session_secret_key |
PORT |
8000 |
HTTP port |
BASE_URL |
(empty) | Public URL when behind Cloudflare or a proxy — required for GitHub OAuth |
CAMERA_STREAM_URL |
(empty) | Fallback MJPEG URL if no local camera is detected |
MOTOR_SPEED_DEFAULT |
0.75 |
Default motor power (0.0–1.0) |
OBSTACLE_THRESHOLD_CM |
20 |
Sonar distance (cm) that triggers auto-stop |
PAN_SERVO_PIN |
12 |
GPIO pin for pan servo |
TILT_SERVO_PIN |
13 |
GPIO pin for tilt servo |
BUZZER_PIN |
18 |
GPIO pin for buzzer |
MOTOR_RATE_LIMIT |
20 |
Max motor commands/second per IP (0 = unlimited) |
CLOUDFLARED_ENV_PASSPHRASE |
(empty) | Decrypts cloudflared.env.enc on the boot partition |
CF_API_TOKEN |
(empty) | Cloudflare API token — leave blank for local-only |
CF_ACCOUNT_ID |
(empty) | Cloudflare Account ID |
CF_ZONE_ID |
(empty) | Zone ID — auto-detected if omitted |
CF_ZONE_NAME |
(empty) | Domain name — selects a zone when account has multiple |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/ping |
— | Liveness check — returns {"ok": true} |
GET |
/api/status |
Yes | Hardware availability (motors, camera, servos) |
GET |
/api/camera/stream |
Yes | Live MJPEG stream |
POST |
/api/camera/{action} |
Yes | Pan/tilt: up down left right center |
POST |
/api/motors/{action} |
Yes | forward reverse left right stop |
POST |
/api/motors/auto |
Yes | Not implemented (501) |
POST |
/api/missions/path |
Yes | Not implemented (501) |
POST |
/api/missions/search |
Yes | Not implemented (501) |
Motor commands accept an optional body: {"speed": 0.75} (0.0–1.0).
This isn't an audited product, it's a hobby project that happens to control a physical thing over the internet, so it gets treated more carefully than a typical side project. Here's what's actually in place, plainly:
Passwords and sessions. Local passwords are hashed with PBKDF2-HMAC-SHA256 (260,000 iterations, random salt per user) — not bcrypt, in case you go looking for it in the code. A session is a random 32-byte token stored in SQLite, referenced by a signed cookie (itsdangerous); the cookie can't be forged without the server's secret key, and that key is either the one you set in .env or, if you didn't set one, a random value generated on first run and saved to disk rather than falling back to a value baked into the source. Sessions expire after 7 days.
Roles. Users are admin, approved, pending, or revoked. Revoking someone kills their active session immediately — that wasn't always true (a role change used to leave existing sessions valid until they expired on their own), and it's the kind of bug that matters more here than in most apps, since a revoked user could otherwise still be driving the car.
GitHub OAuth, if you turn it on, uses a signed state parameter to stop the login-CSRF trick where someone else's OAuth callback gets completed in your browser. New GitHub logins land in pending until an admin approves them.
Rate limiting applies to login attempts (5/second per IP) and to motor commands (configurable, default 20/second per IP) — mostly to keep a stuck client or runaway script from hammering the motors, not as a defense against a serious attacker.
Everything else that's just good hygiene: all SQL is parameterized (nothing string-built), API request bodies are validated with Pydantic, secrets live in .env and are never committed, and the app only talks HTTPS at all if you put it behind the Cloudflare Tunnel — on your local network it's plain HTTP, same as most home IoT devices.
What this doesn't claim to be: independently audited, resistant to a determined attacker with local network access, or built against a specific compliance framework. If you find something wrong, open an issue.
RoboControl exports a Prometheus-compatible /metrics endpoint that any Prometheus server can scrape.
GET http://<pi-ip>:8000/metrics
The path requires no authentication so Prometheus can scrape without a session token. Metrics are refreshed every 5 seconds by the background telemetry thread.
scrape_configs:
- job_name: robocar
static_configs:
- targets: ['<pi-ip>:8000']
metrics_path: /metrics
scrape_interval: 5s| Category | Metric | Type | Description |
|---|---|---|---|
| System | robocar_cpu_percent |
Gauge | CPU usage (0–100) |
robocar_cpu_temperature_celsius |
Gauge | SoC temperature in °C (Pi only — 0 on other hw) | |
robocar_ram_used_bytes |
Gauge | RAM used in bytes | |
robocar_ram_total_bytes |
Gauge | Total RAM in bytes | |
robocar_disk_used_bytes |
Gauge | Disk used bytes (/) | |
robocar_disk_total_bytes |
Gauge | Disk total bytes (/) | |
robocar_uptime_seconds |
Gauge | Server uptime in seconds | |
| Hardware | robocar_hardware_available{component} |
Gauge | Component status — 1=up, 0=down; labels: motors, camera, servo |
| Motors | robocar_motor_commands_total{action} |
Counter | Motor commands dispatched; labels: forward, reverse, left, right, stop |
robocar_motor_blocked_total |
Counter | Forward commands blocked by obstacle detection | |
robocar_estimated_distance_meters |
Gauge | Estimated distance driven this session in meters | |
| Sensor | robocar_sonar_distance_cm |
Gauge | Last ultrasonic reading in cm (0 = no reading yet) |
robocar_obstacles_total |
Counter | Obstacle detections triggering auto-stop | |
| Camera | robocar_camera_frames_total |
Counter | MJPEG frames captured by the camera driver |
| HTTP | robocar_http_requests_total{method,path,status} |
Counter | HTTP requests handled |
robocar_http_request_duration_seconds{path} |
Histogram | Request latency; buckets 5 ms → 2.5 s | |
| Auth | robocar_login_attempts_total{result} |
Counter | Login attempts; labels: success, failure |
robocar_rate_limit_hits_total{endpoint} |
Counter | Rate limit rejections; labels: login, motors |
|
robocar_active_sessions_total |
Gauge | Currently active (non-expired) user sessions | |
| Build | robocar_build_info |
Info | Static labels: version="2.0", hardware="rpi4" |
# CPU usage live
robocar_cpu_percent
# HTTP request rate (last 5 min)
rate(robocar_http_requests_total[5m])
# P95 request latency
histogram_quantile(0.95, rate(robocar_http_request_duration_seconds_bucket[5m]))
# Motor command breakdown
rate(robocar_motor_commands_total[5m])
# Obstacle detection rate
rate(robocar_obstacles_total[5m])
# Active sessions
robocar_active_sessions_total
All metric definitions live in app/metrics.py. The /metrics route is registered in app/main.py.
sudo apt install -y python3-picamera2
sudo raspi-config nonint do_camera 0
sudo rebootUSB webcams work out of the box — just plug in and restart the service.
The app uses route auto-discovery — drop a new file in app/routes/ and it's live on restart, no changes to main.py needed.
cp app/routes/_template.py app/routes/lights.py
# edit lights.py: set prefix and add endpoints
sudo systemctl restart robocontrolapp/routes/
├── _template.py ← start here
├── camera.py
├── missions.py
├── motors.py
├── settings.py
├── status.py
└── telemetry.py
from app.hardware import rrb3_driver # set_motors(), get_distance(), available
from app.hardware import servo_driver # move(), center(), available
from app.hardware import camera_driver # get_frame(), start(), stop(), available
from app.config import settings # all .env values
from app import telemetry # record_command()No hardware required — drivers degrade gracefully when GPIO/camera backends are unavailable:
pytest -vOnly app/hardware/ is hardware-specific. Everything else is agnostic. Raspberry Pi 3/4/5 and Jetson Nano are supported out of the box — see Supported Hardware.
Other boards (not auto-detected — add a case to app/hardware/gpio_compat.py):
| Board | Change |
|---|---|
| Orange Pi / Banana Pi | Use OPi.GPIO or wiringOP |
| BeagleBone Black | Use Adafruit_BBIO |
Alternative motor drivers:
| Driver | Notes |
|---|---|
| L298N | Direct RPi.GPIO PWM — no extra library |
| Adafruit Motor HAT | adafruit-circuitpython-motorkit |
| Cytron MDD3A / MDD10A | PWM + direction pins, same pattern as L298N |
Sensors and extras:
| Component | Library |
|---|---|
| Encoder wheels | RPi.GPIO interrupt — accurate odometry |
| IMU (MPU-6050) | mpu6050-raspberrypi |
| LIDAR (RPLidar A1) | rplidar-roboticia — 2D mapping |
| NeoPixel LEDs | rpi_ws281x |
| GPS module | gpsd + gps3 |
- Auto-drive — obstacle avoidance free-roam
- Path following — execute a waypoint route
- Visual search — roam until the camera matches a target image
- Buzzer feedback — audio confirmation for commands
- Light control — toggle RRB3 onboard LEDs
- Battery monitoring — live voltage display






