RichyHBM

Software engineer with a focus on game development and scalable backend development

Using Esp32-Cam Devices for CCTV

I had wanted to get a couple of cameras to watch over my house and make sure everything was as I expected. A couple of requirements I had when looking were being able to use them without any cloud connection, and being compatible with self-hosted solutions, which usually means having RTSP support. Looking at some commercial products, it seemed hit or miss as to whether they would support any custom firmware which would mean needing advanced networking setup to cut them out from the internet but also allowing local networking, for these reasons I decided for now to give making my own a go…

When looking online for cheap camera hardware that can run custom code, there are several options available; however, I wanted to try out something cheap to begin with, as I don’t really need professional-level CCTV, just something to be able to check the house over with. The most popular options online seem to be a RPi-based camera running full Linux or an ESP32-based camera running some custom code. As I didn’t want this to be overly complex and I already run a couple devices for other smart home applications, I ended up going with the ESP32.

To start with, I found a few open source firmwares that supported RTSP and could easily be installed on the ESP32; however, the more I tinkered with them, the more I wanted a unified platform to just manage all of my ESP devices, so I decided to go with EspHome, with the added benefit that it makes it much easier to apply newer versions of the EspHome SDK than other firmwares I had found.

A lot of the config is fairly standard across other ESP devices; apart from the camera section, the EspHome SDK provides the camera output in MJpeg format, which generally means lower quality and framerate. I don’t really mind this, as I mainly want a minimum of 4 fps just to be able to get an idea of what is happening. Likewise, I am happy to give up some resolution in exchange for image quality; these are all things that can be configured, and thanks to a few examples by other people, I also managed to add controls for the lights and LEDs on the board as well. The full configuration for EspHome is here:

esphome:
  name: kitchen-espcam
  friendly_name: kitchen-espcam

esp32:
  board: esp32dev
  framework:
    type: arduino

web_server:
  port: 80

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: !secret kitchen_espcam_api_password
  services:  # change camera parameters on-the-fly
  - service: camera_set_param
    variables:
      name: string
      value: int
    then:
      - lambda: |-
          bool state_return = false;
          if (("contrast" == name) && (value >= -2) && (value <= 2)) { id(espcam).set_contrast(value); state_return = true; }
          if (("brightness" == name) && (value >= -2) && (value <= 2)) { id(espcam).set_brightness(value); state_return = true; }
          if (("saturation" == name) && (value >= -2) && (value <= 2)) { id(espcam).set_saturation(value); state_return = true; }
          if (("special_effect" == name) && (value >= 0U) && (value <= 6U)) { id(espcam).set_special_effect((esphome::esp32_camera::ESP32SpecialEffect)value); state_return = true; }
          if (("aec_mode" == name) && (value >= 0U) && (value <= 1U)) { id(espcam).set_aec_mode((esphome::esp32_camera::ESP32GainControlMode)value); state_return = true; }
          if (("aec2" == name) && (value >= 0U) && (value <= 1U)) { id(espcam).set_aec2(value); state_return = true; }
          if (("ae_level" == name) && (value >= -2) && (value <= 2)) { id(espcam).set_ae_level(value); state_return = true; }
          if (("aec_value" == name) && (value >= 0U) && (value <= 1200U)) { id(espcam).set_aec_value(value); state_return = true; }
          if (("agc_mode" == name) && (value >= 0U) && (value <= 1U)) { id(espcam).set_agc_mode((esphome::esp32_camera::ESP32GainControlMode)value); state_return = true; }
          if (("agc_value" == name) && (value >= 0U) && (value <= 30U)) { id(espcam).set_agc_value(value); state_return = true; }
          if (("agc_gain_ceiling" == name) && (value >= 0U) && (value <= 6U)) { id(espcam).set_agc_gain_ceiling((esphome::esp32_camera::ESP32AgcGainCeiling)value); state_return = true; }
          if (("wb_mode" == name) && (value >= 0U) && (value <= 4U)) { id(espcam).set_wb_mode((esphome::esp32_camera::ESP32WhiteBalanceMode)value); state_return = true; }
          if (("test_pattern" == name) && (value >= 0U) && (value <= 1U)) { id(espcam).set_test_pattern(value); state_return = true; }
          if (true == state_return) {
            id(espcam).update_camera_parameters();
          }
          else {
            ESP_LOGW("esp32_camera_set_param", "Error in name or data range");
          }          

ota:
  password: !secret kitchen_espcam_ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true
  manual_ip:
    static_ip: 192.168.1.x
    gateway: 192.168.1.x
    subnet: 255.255.255.0

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Kitchen-Espcam Fallback Hotspot"
    password: !secret kitchen_espcam_eap_password

captive_portal:

esp32_camera_web_server:
  - port: 8080
    mode: stream
  - port: 8081
    mode: snapshot

esp32_camera:
  id: espcam
  name: esp-cam
  external_clock:
    pin: GPIO0
    frequency: 10MHz
  i2c_pins:
    sda: GPIO26
    scl: GPIO27
  data_pins: [GPIO5, GPIO18, GPIO19, GPIO21, GPIO36, GPIO39, GPIO34, GPIO35]
  vsync_pin: GPIO25
  href_pin: GPIO23
  pixel_clock_pin: GPIO22
  power_down_pin: GPIO32
  resolution: 800x600
  jpeg_quality: 24  # max. 63
  max_framerate: 4.0fps
  idle_framerate: 0.2fps
  vertical_flip: false
  horizontal_mirror: false
  brightness: 2 # -2 to 2
  contrast: 1 # -2 to 2
  special_effect: none
  # exposure settings
  aec_mode: auto
  aec2: false
  ae_level: 0
  aec_value: 300
  # gain settings
  agc_mode: auto
  agc_gain_ceiling: 2x
  agc_value: 0
  # white balance setting
  wb_mode: auto
output:
# white LED
  - platform: ledc
    channel: 2
    pin: GPIO4
    id: espCamLED
# red status light
  - platform: gpio
    pin:
      number: GPIO33
      inverted: True
    id: gpio_33
light:
  - platform: monochromatic
    output: espCamLED
    name: esp-cam light
  - platform: binary
    output: gpio_33
    name: esp-cam led
switch:
  - platform: restart
    name: esp-cam restart
binary_sensor:
  - platform: status
    name: esp-cam status

sensor:
  - platform: uptime
    name: "ESP32 Camera Uptime Sensor"
    
  - platform: wifi_signal
    name: "ESP32 Camera WiFi Signal"
    update_interval: 60s

While the boards do seem to heat up somewhat, they don’t get excessively hot and continue to operate within the required parameters. The main thing is to not keep the flash light on for too long.

Now most things seem to expect the output as an RTSP h264 format, which would be a little too much processing for the little ESP device, so instead I use a self-hosted service that takes in the MJpeg stream and transcodes it to h264. This also allows it to act as a proxy, so clients just connect to the service rather than directly to the ESP. This service is Go2RTC; it internally uses ffmpeg, so you can provide it with custom arguments if required. This is needed for the ESP device as its framerate can change quite a bit, so you will need to tell ffmpeg to ignore any missing frames, etc. The configuration for this can be found here:

streams:
  kitchen: 
    - http://192.168.1.x:8080
    - ffmpeg:kitchen#video=h264#hardware=vaapi#raw=-avoid_negative_ts make_zero -fflags nobuffer -flags low_delay -strict experimental -fflags +genpts+discardcorrupt -use_wallclock_as_timestamps 1

Lastly, there is a way to view the stream and detect things happening in it. For this, I am using Frigate NVR, which uses AI to detect objects, and then getting this into Home Assistant to automate notifications and other things. Much like with Go2RTC, I set ffmpeg to expect a low-quality stream just to be sure that if anything happens, Frigate will still receive some data. The resolution and fps should match what you have set in the EspHome config; the full config for it can be found here:

  kitchen:
    ffmpeg:
      output_args: 
        record: preset-record-generic
      inputs:
        - path: rtsp://go2rtc:8554/kitchen
          input_args: -avoid_negative_ts make_zero -fflags nobuffer -flags low_delay -strict experimental -fflags +genpts+discardcorrupt -use_wallclock_as_timestamps 1
          roles:
            - detect
            - record
            - snapshot
    rtmp:
      enabled: False
    detect:
      enabled: True
      width: 800
      height: 600
      fps: 4
    record:
      enabled: True
    snapshots:
      enabled: True
      timestamp: True

This all finally sets up Frigate with a camera feed, and then you can use the integrations to hook it up to Home Assistant in order to, for example, turn lights on or receive a notification if someone is detected while you are out of the house.