ESP32-S3 + Unihiker: Wireless Video Streaming & Snapshot System

0 27 Medium

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

HARDWARE LIST
1 Unihiker M10
1 ESP32-S3 AI Camera Module
STEP 1
Project structure

Your project directory should look like this:

CODE
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
STEP 2
Code

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.

CODE
#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)

CODE
#!/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

CODE
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
STEP 3
Upload code to devices

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

CODE
chmod +x wlan.sh
STEP 4
Demo

- 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.

CODE
# 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.

STEP 5
Annotations

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

License
All Rights
Reserved
licensBg
0