This tutorial demonstrates how to build a basic wireless video streaming system using the ESP32-S3 AI Camera and the Unihiker M10. The ESP32-S3 streams MJPEG video over a local WLAN AP (Access Point), while the Unihiker connects as an STA (WLAN client), displaying the stream and allowing snapshots via button press.
You will learn how to:
- Develop the code for ESP32-S3 AI Camera Module and Unihiker M10
- Establish a WLAN connection between both devices
- Stream live video and take snapshots using a physical button
Your project directory should look like this:
ESP32-S3-AI-CAM/
├── example.ino # Arduino sketch for ESP32-S3
├── main.py # Python script for Unihiker
└── wlan.sh # Bash script to connect Unihiker to ESP32-S3 AP
example.ino – ESP32 Arduino Sketch
This code:
- Initializes the camera and light sensor
- Sets up an MJPEG HTTP stream server
- Activates an IR LED in low light
- Creates a Wi-Fi Access Point (ESP32_CAM_AP)
Note: Do not modify the camera configuration unless you know the hardware details.
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include <DFRobot_LTR308.h>
// === Camera-Pin-Configuration ===
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 5
#define Y9_GPIO_NUM 4
#define Y8_GPIO_NUM 6
#define Y7_GPIO_NUM 7
#define Y6_GPIO_NUM 14
#define Y5_GPIO_NUM 17
#define Y4_GPIO_NUM 21
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 16
#define VSYNC_GPIO_NUM 1
#define HREF_GPIO_NUM 2
#define PCLK_GPIO_NUM 15
#define SIOD_GPIO_NUM 8
#define SIOC_GPIO_NUM 9
// === WLAN Access Point ===
const char *ap_ssid = "ESP32_CAM_AP";
const char *ap_password = "12345678";
WebServer server(80);
// === GPIOs for LED & IR ===
int led = 3; // Status-LED
int ir_led = 47; // IR-LED
// === Light Sensor ===
DFRobot_LTR308 light;
// === Streaming-Task ===
TaskHandle_t streamTaskHandle = NULL;
struct StreamParams {
WiFiClient client;
};
// === MJPEG-Streaming-Task ===
void streamVideo(void *pvParameters) {
StreamParams* params = (StreamParams*)pvParameters;
WiFiClient client = params->client;
delete params;
String response = "HTTP/1.1 200 OK\r\n";
response += "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n";
client.print(response);
while (client.connected()) {
camera_fb_t * fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Error Camera Frame.");
continue;
}
client.print("--frame\r\n");
client.print("Content-Type: image/jpeg\r\n\r\n");
client.write(fb->buf, fb->len);
client.print("\r\n");
esp_camera_fb_return(fb);
delay(80);
}
client.stop();
Serial.println("No Stream-Client.");
vTaskDelete(NULL);
}
// === Start Camera-Webserver ===
void startCameraServer() {
server.on("/", HTTP_GET, []() {
server.send(200, "text/html",
"<html><body><h2>ESP32 Camera Stream</h2><img src='/stream'></body></html>");
});
server.on("/stream", HTTP_GET, []() {
if (!server.client()) return;
StreamParams* params = new StreamParams();
params->client = server.client();
xTaskCreatePinnedToCore(
streamVideo, // Task-Function
"StreamTask", // Name
8192, // Stack-Size
params, // Parameter
1, // Priority
&streamTaskHandle, // Handle
1 // Core 1 für Streaming
);
});
server.begin();
Serial.println("MJPEG-Server ready.");
}
// === Setup ===
void setup() {
Serial.begin(115200);
Serial.setDebugOutput(true);
Serial.println();
pinMode(led, OUTPUT);
pinMode(ir_led, OUTPUT);
digitalWrite(ir_led, LOW);
// === Camera Setup ===
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.pixel_format = PIXFORMAT_JPEG;
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.jpeg_quality = 12;
config.fb_count = 1;
if (psramFound()) {
config.jpeg_quality = 12;
config.fb_count = 2;
config.grab_mode = CAMERA_GRAB_LATEST;
} else {
config.frame_size = FRAMESIZE_SVGA;
config.fb_location = CAMERA_FB_IN_DRAM;
}
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Error Camera Initialize: 0x%x\n", err);
return;
}
sensor_t *s = esp_camera_sensor_get();
if (s->id.PID == OV3660_PID) {
s->set_vflip(s, 1);
s->set_brightness(s, 1);
s->set_saturation(s, -2);
}
s->set_framesize(s, FRAMESIZE_QVGA); // 320x240
// === Initialize Light sensor (after Camera!) ===
while (!light.begin()) {
Serial.println("Error Sensor Initialize");
delay(1000);
}
Serial.println("Sensor ready.");
// === Start WLAN-AP ===
WiFi.softAP(ap_ssid, ap_password);
IPAddress IP = WiFi.softAPIP();
Serial.print("Access Point IP: ");
Serial.println(IP);
startCameraServer();
digitalWrite(led, HIGH);
Serial.println("System ready: http://" + IP.toString());
}
// === Loop ===
void loop() {
server.handleClient();
// === Read LUX ===
uint32_t raw = light.getData();
float lux = light.getLux(raw);
Serial.print("LUX: ");
Serial.println(lux);
if (lux < 100) {
digitalWrite(ir_led, HIGH);
} else {
digitalWrite(ir_led, LOW);
}
delay(1000);
}
wlan.sh – Bash Script for Unihiker M10
This script ensures:
- The Unihiker detects and connects to the ESP32-S3 AP
- Any conflicting connections are terminated
- The stream server IP is reachable (192.168.4.1)
#!/usr/bin/env bash
# define bash options
set -e # Abort script when a command exits with non-zero status
set -f # Filename expansion (globbing) disabled
set -u # Undefined variable forces an exit
# debug mode options
# set -v # Print each command to stdout before executing it
# set -x # Similar to -v, but expands commands
# set -n # Read commands in script, but do not execute them
# declare global magic variables
declare ACCESS_POINT='ESP32_CAM_AP'
declare ACCESS_POINT_PASSWORD='12345678'
declare IP_ADDRESS='192.168.4.1'
declare WLAN_INTERFACE='wlan0'
declare P2P_INTERFACE='p2p0'
declare -r -i SUCCESS=0
declare -r -i MISSING_AP=98
declare -r -i MISSING_CONNECTION=99
# script functions
# Function: msg
# Purpose : Simple logger that prints a message
function msg() {
printf "%s\n" "$1"
}
# Function: verify_access_point_available
# Purpose : Checks if the desired Access Point is visible on the interface.
# Exits : With $MISSING_AP if not found.
function verify_access_point_available() {
if nmcli device wifi list ifname "$WLAN_INTERFACE" | grep -q "$ACCESS_POINT"; then
msg "Access Point: $ACCESS_POINT found."
else
msg "Required Access Point: $ACCESS_POINT not found."
exit "$MISSING_AP"
fi
}
# Function: verify_connection_status
# Purpose : Disconnects unwanted connections and checks if $WLAN_INTERFACE is already connected to target AP.
# Returns : 0 if already connected, 1 if reconnection needed.
function verify_connection_status() {
local WLAN_CONN
local P2P_CONN
WLAN_CONN=$(nmcli -t -f DEVICE,STATE,CONNECTION device | grep "^$WLAN_INTERFACE:" | cut -d: -f3)
P2P_CONN=$(nmcli -t -f DEVICE,STATE,CONNECTION device | grep "^$P2P_INTERFACE:" | cut -d: -f3)
if [[ "$P2P_CONN" == "$ACCESS_POINT"* ]]; then
msg "Disconnect $P2P_INTERFACE."
nmcli device disconnect "$P2P_INTERFACE"
sleep 2
else
msg "Leave $P2P_INTERFACE as it is."
fi
if [[ "$WLAN_CONN" == "$ACCESS_POINT"* ]]; then
msg "The interface $WLAN_INTERFACE is connected to Access Point."
return 0
fi
if [[ "$WLAN_CONN" != "$ACCESS_POINT" && "$WLAN_CONN" != "" ]]; then
msg "Disconnect $WLAN_INTERFACE."
nmcli device disconnect "$WLAN_INTERFACE"
sleep 2
else
msg "Leave $WLAN_INTERFACE as it is."
fi
return 1
}
# Function: connect_to_ap
# Purpose : Connects to the Access Point via nmcli and pings target IP to verify connection.
# Exits : With $MISSING_CONNECTION if the connection fails.
function connect_to_ap() {
if nmcli device wifi connect "$ACCESS_POINT" password "$ACCESS_POINT_PASSWORD" ifname "$WLAN_INTERFACE"; then
msg "Successful connection to Access Point: $ACCESS_POINT via $WLAN_INTERFACE."
else
msg "Connection to $ACCESS_POINT could not be established."
exit "$MISSING_CONNECTION"
fi
sleep 3
if ping -c 1 -W 2 "$IP_ADDRESS" > /dev/null 2>&1; then
msg "Ping to $IP_ADDRESS successful."
else
msg "Warning: No ping response from $IP_ADDRESS."
fi
}
function main() {
msg 'Start WLAN configuration analysis.'
verify_access_point_available
if verify_connection_status; then
msg "No further action required. Exiting."
exit "$SUCCESS"
fi
msg 'Try to connect to Access Point.'
connect_to_ap
}
# call main function
main
# exit with success 0
exit "$SUCCESS"
main.py – Python Script for Unihiker M10
This Python app:
- Connects to the ESP32-S3 video stream
- Displays a fullscreen video window
- Listens for Button A presses to capture frames as .jpg images in a photos/ folder
from atexit import register
from sys import exit
from pinpong.board import Board, Pin
from pinpong.extension.unihiker import button_a
from pathlib import Path
from time import time
import cv2
VIDEO_URL: str = "http://192.168.4.1/stream"
def cleanup() -> None:
"""
Performs the cleanup process by releasing any resources and closing all
OpenCV windows. This function is designed to ensure the program
terminates resources safely and avoids memory leaks.
:return: None
"""
print("[INFO] Cleaning up...")
cap.release()
cv2.destroyAllWindows()
def btn_a_raising_handler(pin) -> None:
"""
Handles the button A press event for capturing and saving the current frame. The function saves
the captured frame as a .jpg image in the `photos` subdirectory within the script's directory.
If the `photos` folder does not exist, it will be created. The filename of the saved image is
based on the current timestamp.
:param pin: The GPIO pin associated with the button that triggers the handler.
:type pin: Any
:return: None
"""
global current_frame
_ = pin
if current_frame is not None:
script_dir = Path(__file__).resolve().parent
target_path = script_dir / 'photos'
target_path.mkdir(exist_ok=True)
filename = f"snapshot_{int(time())}.jpg"
target_file = target_path / filename
cv2.imwrite(str(target_file), current_frame)
print(f"[INFO] Frame saved to {target_file}")
if __name__ == '__main__':
register(cleanup)
current_frame = None
Board().begin()
button_a.irq(trigger=Pin.IRQ_RISING, handler=btn_a_raising_handler)
cap = cv2.VideoCapture(VIDEO_URL)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
cap.set(cv2.CAP_PROP_FPS, 15)
cv2.namedWindow('Stream', cv2.WND_PROP_FULLSCREEN)
cv2.setWindowProperty('Stream', cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
if not cap.isOpened():
print("[ERROR] No video stream")
exit(1)
while True:
ret, frame = cap.read()
if not ret:
print("[ERROR] No frame received")
break
rotated_image = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
current_frame = rotated_image.copy()
cv2.imshow('Stream', rotated_image)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
ESP32-S3 AI CAM:
- Connect ESP32-S3 AI Camera Module via USB with your system
- Open the Arduino IDE and copy/paste the example.ino code
- Select the correct board, port and configuration (see this wiki page for detailed information)
- Upload the code to the ESP32-S3 and verify via Serial Monitor the status
Unihiker M10:
- Copy main.py and wlan.sh to the Unihiker M10
- Use FTP, SMB or SCP as preferred (see this wiki page for detailed information)
- Make the shell script executable
chmod +x wlan.sh
- Power the ESP32-S3 – It will create a WLAN AP called ESP32_CAM_AP (the tiny LED indicates if all is running).
- Power the Unihiker M10 and connect as station to the ESP32-S3 AP (use the Bash script wlan.sh to prevent manual configuration).
- Start the Python script on Unihiker M10.
# change into directory
$ cd ESP32-S3-AI-CAM/
# scan WLAN (optional)
$ nmcli device wifi list
# verify current WLAN connections (optional)
$ nmcli device status
DEVICE TYPE STATE CONNECTION
wlan0 wifi connected wifi
p2p0 wifi connected ESP32_CAM_AP
...
# connect to AP
$ ./wlan.sh
# run Python script
$ python3 main.py
The stream should appear full screen. Press Button A (on Unihiker M10) to save a snapshot.
Note: The IR LEDs turn on automatically if LUX value is less 100. You cannot see them with your eyes! A simple mirror will help to identify the ON/OFF status.
Here are some ideas for expanding the project:
- Use STA mode to stream over your home WLAN (instead of AP mode)
- Send Frames to a Server (e.g. Flask or Node.js)
- Add Machine Learning on Unihiker M10 for real-time object detection
- Add a Web Interface (on Unihiker M10) to view saved snapshots remotely
