substitutions: device_name: esp32-h2c-camera friendly_name: "H2C Camera" esphome: name: ${device_name} friendly_name: ${friendly_name} esp32: board: esp32-s3-devkitc-1 framework: type: esp-idf # Required for ESP32 camera buffering. psram: mode: octal logger: api: encryption: key: !secret api_encryption_key ota: - platform: esphome password: !secret ota_password wifi: ssid: !secret wifi_iot_ssid password: !secret wifi_password manual_ip: static_ip: 192.168.69.90 gateway: 192.168.69.1 subnet: 255.255.255.0 ap: ssid: "${friendly_name} Fallback" password: !secret fallback_password # Prevents aggressive reconnection attempts power_save_mode: none # Use 'light' for battery devices # Slower but more reliable connection fast_connect: true # Handle connection failures gracefully on_connect: - logger.log: "Wi-Fi connected!" on_disconnect: - logger.log: "Wi-Fi disconnected!" # Reduce mDNS traffic mdns: disabled: true # Set to true if you use static IPs and don't need discovery # captive_portal: web_server: port: 80 esp32_camera: name: "${friendly_name}" external_clock: pin: GPIO5 # 10MHz (half the usual 20MHz) slows the sensor's line rate, so each of the # 1200 exposure lines below collects roughly twice the light. Costs # framerate, which does not matter for chamber monitoring. frequency: 10MHz i2c_pins: sda: GPIO8 scl: GPIO9 data_pins: [GPIO16, GPIO18, GPIO21, GPIO17, GPIO14, GPIO7, GPIO6, GPIO4] vsync_pin: GPIO1 href_pin: GPIO2 pixel_clock_pin: GPIO15 # Resolution & quality (ESP32-S3 with Octal PSRAM handles XGA easily) resolution: 1024X768 jpeg_quality: 10 max_framerate: 20 fps idle_framerate: 0.2 fps frame_buffer_count: 2 # Orientation vertical_flip: true horizontal_mirror: false # Image tuning for chamber monitoring through tinted door. # Low contrast + high brightness lifts the shadows; the LED strips are # allowed to blow out since the chamber is what matters. brightness: 2 contrast: -2 saturation: 0 # Exposure & gain are MANUAL on purpose. The two LED strips in frame kept the # auto-exposure meter satisfied, so AEC never lengthened the exposure and the # tinted-door scene stayed black regardless of gain ceiling or AE level. # Pinning both to max forces a bright chamber; the strips clip out entirely. aec_mode: manual aec_value: 1200 # max integration time (0-1200 lines) aec2: true ae_level: 2 # only consulted if AEC is flipped back to auto agc_mode: manual agc_value: 30 # max sensor gain (0-30) agc_gain_ceiling: 128X # only consulted if AGC is flipped back to auto wb_mode: auto # Camera endpoints: # Stream: http://:8080 # Snapshot: http://:8081 esp32_camera_web_server: - port: 8080 mode: stream - port: 8081 mode: snapshot switch: # DFRobot board IR illumination control pin. - platform: gpio name: "${friendly_name} IR LED" pin: GPIO47 restore_mode: ALWAYS_OFF # Flip exposure/gain back to automatic for A/B comparison without a reflash. # Off (the default) = manual exposure per the esp32_camera block above. - platform: template name: "${friendly_name} Auto Exposure" optimistic: true restore_mode: ALWAYS_OFF turn_on_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (!s) return; s->set_exposure_ctrl(s, 1); s->set_gain_ctrl(s, 1); turn_off_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (!s) return; s->set_exposure_ctrl(s, 0); s->set_gain_ctrl(s, 0); sensor: - platform: wifi_signal name: "${friendly_name} WiFi Signal" update_interval: 60s - platform: uptime name: "${friendly_name} Uptime" - platform: wifi_signal name: "${device_name} WiFi Signal" update_interval: 60s - platform: uptime name: "${device_name} Uptime" update_interval: 60s button: - platform: restart name: "${friendly_name} Restart" text_sensor: - platform: wifi_info ip_address: name: "${device_name} IP Address" ssid: name: "${device_name} Connected SSID" bssid: name: "${device_name} BSSID" # Watchdog to auto-reboot if things go wrong interval: - interval: 5min then: - if: condition: and: - lambda: 'return millis() > 300000;' # >5 min uptime - not: wifi.connected: then: - logger.log: "Wi-Fi not connected for 5 min, rebooting..." - delay: 10s - lambda: 'App.safe_reboot();' # Dynamic controls exposed to Home Assistant for real-time camera tuning number: # The two that actually drive image brightness in manual mode. - platform: template name: "${friendly_name} Exposure" min_value: 0 max_value: 1200 step: 25 initial_value: 1200 optimistic: true set_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (s) s->set_aec_value(s, (int)x); - platform: template name: "${friendly_name} Gain" min_value: 0 max_value: 30 step: 1 initial_value: 30 optimistic: true set_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (s) s->set_agc_gain(s, (int)x); - platform: template name: "${friendly_name} Brightness" min_value: -2 max_value: 2 step: 1 initial_value: 2 optimistic: true set_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (s) s->set_brightness(s, (int)x); - platform: template name: "${friendly_name} Contrast" min_value: -2 max_value: 2 step: 1 initial_value: -2 optimistic: true set_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (s) s->set_contrast(s, (int)x); - platform: template name: "${friendly_name} AE Level" min_value: -2 max_value: 2 step: 1 initial_value: 2 optimistic: true set_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (s) s->set_ae_level(s, (int)x); select: # Caps how far AGC may amplify. Higher = brighter but noisier; back this off # if the chamber image gets too grainy at 128X. - platform: template name: "${friendly_name} Gain Ceiling" optimistic: true initial_option: "128X" options: - "2X" - "4X" - "8X" - "16X" - "32X" - "64X" - "128X" set_action: - lambda: |- sensor_t *s = esp_camera_sensor_get(); if (!s) return; int idx = 6; if (x == "2X") idx = 0; else if (x == "4X") idx = 1; else if (x == "8X") idx = 2; else if (x == "16X") idx = 3; else if (x == "32X") idx = 4; else if (x == "64X") idx = 5; s->set_gainceiling(s, (gainceiling_t)idx);