Solar Powered Weather Temp / Humidity / Air Quality Sensor w/ deep sleep

Thought I’d share my [self-inflicted pain] project since it was far less straightforward than I thought it would be. The mission was, what I thought, fairly trivial: use I2C to connect a BME680, PMSA003i, and INA226 together and control it all with a Seeed Xiao ESP32-C6 powered by a solar panel and a 10,000mAh LiPo (spicy pillow). An attempt to replicate something similar to a PurpleAir monitor, but solar-powered… and in ESPHome.

It was all working well together until I decided to add deep sleep so I could keep the battery from dying in about two days. There are some non-standard delays in this one so that I2C initialization is held off until the PMS (Particulate Matter Sensor) completes its requisite three seconds of wake time before it can be detected on the bus. I also implemented an MQTT topic for an “OTA mode” so I could tell the device not to sleep while pushing new code to it, or when I just want more contiguous readings for debugging.

The other problem I had to solve was the Xiao ESP32-C6 not holding GPIO LOW to sleep the PMS. The PMS can’t be slept via I2C — it requires setting a pin LOW to get it to sleep correctly, and HIGH to wake. If the pin is floating or HIGH, it will start right back up on you. Unfortunately, the second the Xiao went into deep sleep, the GPIO pin floated and the PMS would wake back up. A few manual ESP-IDF lambda calls in the config helped with that, and for now, things sleep and wake in the right order.

Hope this helps someone shortcut a few headaches if they go this route as well! Happy building!

Wiring Diagram:

Config:

esphome:
  name: esp32-c6-xaio-temppms-sol-1
  friendly_name: 'Solar Weather 1 - PMS⁄680'

  on_boot:
  - priority: 800
    then: #Start turning on the PMS so it can have required wake duration before the i2c is initialized
      - lambda: |-
          gpio_hold_dis(GPIO_NUM_17);
          gpio_set_direction(GPIO_NUM_17, GPIO_MODE_OUTPUT);
          gpio_set_level(GPIO_NUM_17, 1);    // HIGH = SET high = PMS awake
      - logger.log: "PMS woken via direct GPIO"
  
  - priority: -100
    then:
      - light.turn_on: light_esp    #Show it's awake
      - output.turn_off: rf_switch_enable   #Enable antenna switching
      - delay: 100ms    #Pause for antenna switching enablement
      - switch.turn_on: enable_external_wifi_antenna    #Switch to external antenna
      - logger.log: "Peripherals configured"

  - priority: -150
    then:   #Extra sleep required so the PMS can boot for 3 seconds from sleep before I2C connection
      - lambda: |-
          ESP_LOGI("pms", "Blocking wait for PMS I2C readiness...");
          vTaskDelay(pdMS_TO_TICKS(4000));  // Blocking 4s delay
          ESP_LOGI("pms", "PMS should be ready now");

  on_shutdown: 
    then:   #Set Pin D7/GPIO17 LOW and _hold it LOW_ during sleep - otherwise it floats and PMS turns back on instantly at ESP sleep
      - lambda: |-
          gpio_set_direction(GPIO_NUM_17, GPIO_MODE_OUTPUT);
          gpio_set_level(GPIO_NUM_17, 0);    // LOW = SET low = PMS sleep
          gpio_hold_en(GPIO_NUM_17);         // Latch pin state through deep sleep
      - logger.log: "PMSA003i sleeping, GPIO17 held for deep sleep"

esp32:
  variant: ESP32C6
  board: seeed_xiao_esp32c6
  framework:
    type: esp-idf

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "<REDACTED>"

ota:
  - platform: esphome
    password: "<REDACTED>"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Esp32-C6-Xaio-Temppms-Sol-1"
    password: "<REDACTED>"

captive_portal:

#   MQTT Config to enable the detection of the "OTA Mode helper" so the device doesn't deep sleep while attempting to upload OTA payloads
mqtt:
  broker: "homeassistant.local"
  username: !secret mqtt_backyard_weather_usr
  password: !secret mqtt_backyard_weather_pwd
  topic_prefix: null    #Don't publish and therefore duplicate sensors in Home Assistant ESPHOME
  on_message:
    - topic: devices/ota_mode
      payload: 'on'
      then:
        - deep_sleep.prevent: deep_sleep_1
        - binary_sensor.template.publish:
            id: ota_detected
            state: True
    - topic: devices/ota_mode
      payload: 'off'
      then:
        - deep_sleep.allow: deep_sleep_1
        - binary_sensor.template.publish: 
            id: ota_detected
            state: False


deep_sleep:
  id: deep_sleep_1
  run_duration: 93s     # More headroom for 3 stable PMS readings as PMS needs 30 seconds to really be good to go
  sleep_duration: 90s

web_server:
  port: 80

substitutions:
  switch_id: "solar_01"

output:
  - platform: gpio
    id: rf_switch_enable    #Seeed ESP32C6 External Antenna Switch Enabler
    pin: GPIO3

  - platform: gpio
    id: light_output
    pin: GPIO15
    inverted: True

switch:
  - platform: gpio
    restore_mode: ALWAYS_OFF
    id: enable_external_wifi_antenna #Seeed ESP32C6 External Antenna actual switch (requries being enabled)
    name: "Enable External Antenna"
    pin: GPIO14     #High = External Antenna, Low = Onboard/SMB Antenna

light:
  - platform: binary
    id: light_esp
    name: "Awake LED"  #Easier to see when the project box is open vs looking at debugs/logs
    output: light_output


i2c:
  setup_priority: 600
  sda: GPIO22
  scl: GPIO23
  scan: true
  id: bus_a

button:
  - platform: restart
    name: "Remote Restart"

# setup i2c config for BME680
bme68x_bsec2_i2c:
  id: bme680_sensor
  address: 0x77
  model: bme680
  operating_age: 28d
  sample_rate: LP
  supply_voltage: 3.3V
  temperature_offset: 2.555 #!!!Adjustment for temperature deviation YMMV!!!


sensor:
#   Default definitions for the BME680 Temperature/Humidity/Pressure/IAQ sensor
  - platform: bme68x_bsec2
    bme68x_bsec2_id: bme680_sensor
    temperature:
      name: "BME680 Temperature"
    pressure:
      name: "BME680 Pressure"
    humidity:
      name: "BME680 Humidity"
    iaq:
      name: "Air Quality Index - BME"
      id: iaq
    co2_equivalent:
      name: "BME680 CO2 Equivalent"
    breath_voc_equivalent:
      name: "BME680 Breath VOC Equivalent"

#   Battery monitoring courtesy of INA226 over i2c to make sure the 10000mah Lipo doesn't go too low
  - platform: ina226
    id: ina226_sensor
    current: 
      name: "Power: Current"
    power:
      name: "Power: Wattage"
    bus_voltage: 
      name: "Power: Voltage"
      id: battery_voltage
    shunt_voltage: 
      name: "Power: Shunt Voltage"
    
  - platform: template
    name: "Power: Battery Percentage"
    id: battery_percentage
    unit_of_measurement: "%"
    device_class: battery
    accuracy_decimals: 0
    icon: "mdi:battery"
    update_interval: 30s
    lambda: |-
      float voltage = id(battery_voltage).state;
      
      // LiPo discharge lookup table (voltage -> percent)
      // Based on typical 1S LiPo curve at moderate discharge rate
      float v[] =   {3.30, 3.50, 3.60, 3.67, 3.71, 3.75, 3.79, 3.83, 3.87, 3.92, 3.97, 4.10, 4.20};
      float pct[] = {0.0,  5.0,  10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 95.0, 100.0};
      int n = 13;
      
      // Clamp to range
      if (voltage <= v[0]) return 0.0f;
      if (voltage >= v[n-1]) return 100.0f;
      
      // Linear interpolation between nearest points
      for (int i = 0; i < n - 1; i++) {
        if (voltage >= v[i] && voltage <= v[i+1]) {
          float ratio = (voltage - v[i]) / (v[i+1] - v[i]);
          return pct[i] + ratio * (pct[i+1] - pct[i]);
        }
      }
      return 0.0f;
      
#   Outdoor sensor so wifi data is handy for debug
  - platform: wifi_signal
    name: "${switch_id} WiFi Signal Sensor"
    update_interval: 60s

#   Default setup for PMSA003i i2c Particulate Mass Laser Air Quality Sensor
  - platform: pmsa003i
    setup_priority: -200
    update_interval: 30s #3 readings per cycle
    pm_1_0:
      id: pm1_sensor
      name: "Particulate Matter <1.0µm Concentration"
    pm_2_5:
      id: pm2_5_sensor
      name: "Particulate Matter <2.5µm Concentration"
    pm_10_0:
      id: pm10_sensor
      name: "Particulate Matter <10.0µm Concentration"
    pmc_0_3:
      id: pmc_03_sensor
      name: "PMC >0.3µm"
    pmc_0_5:
      id: pmc_05_sensor
      name: "PMC >0.5µm"
    pmc_1_0:
      id: pmc_1_sensor
      name: "PMC >1µm"
    pmc_2_5:
      id: pmc_2_5_sensor
      name: "PMC >2.5µm"
    pmc_5_0:
      id: pmc_5_sensor
      name: "PMC >5µm"
    pmc_10_0:
      id: pmc_10_sensor
      name: "PMC >10µm"

  - platform: aqi
    name: "Air Quality Index - PMS"
    id: pms_aq_sensor
    pm_2_5: pm2_5_sensor
    pm_10_0: pm10_sensor
    calculation_type: aqi


binary_sensor:
#   Binary sensor to say whether or not the MQTT OTA Update Mode is detected so the device won't sleep while OTA update pushed
  - platform: template
    device_class: running
    id: ota_detected
    name: "OTA Detected"

text_sensor:
  - platform: bme68x_bsec2
    iaq_accuracy: 
      name: "BME680 IAQ Accuracy"
  
#   Pretty Print the Particulate Mass Sensor's Detected Air Quality Status
  - platform: template
    name: "PMS AIQ Classification"
    icon: "mdi:checkbox-marked-circle-outline"
    lambda: |-
      if (int(id(pms_aq_sensor).state) <= 50) {
        return {"Excellent"};
      } else if (int(id(pms_aq_sensor).state) >= 51 && int(id(pms_aq_sensor).state) <= 100) {
        return {"Good"};
      } else if (int(id(pms_aq_sensor).state) >= 101 && int(id(pms_aq_sensor).state) <= 150) {
        return {"Unhealthy for Sensitive Groups"};
      } else if (int(id(pms_aq_sensor).state) >= 151 && int(id(pms_aq_sensor).state) <= 200) {
        return {"Unhealthy"};
      } else if (int(id(pms_aq_sensor).state) >= 201 && int(id(pms_aq_sensor).state) <= 250) {
        return {"Very Unhealthy"};
      } else if (int(id(pms_aq_sensor).state) >= 251 && int(id(pms_aq_sensor).state) <= 350) {
        return {"Severely polluted"};
      } else if (int(id(pms_aq_sensor).state) >= 351 && int(id(pms_aq_sensor).state) <= 500) {
        return {"Hazardous"};
      } else {
        return {"Error"};
      }

#   Pretty Print the BME680's Detected Air Quality status
  - platform: template
    name: "BME680 IAQ Classification"
    icon: "mdi:checkbox-marked-circle-outline"
    lambda: |-
      if (int(id(iaq).state) <= 50) {
        return {"Excellent"};
      } else if (int(id(iaq).state) >= 51 && int(id(iaq).state) <= 100) {
        return {"Good"};
      } else if (int(id(iaq).state) >= 101 && int(id(iaq).state) <= 150) {
        return {"Lightly polluted"};
      } else if (int(id(iaq).state) >= 151 && int(id(iaq).state) <= 200) {
        return {"Moderately polluted"};
      } else if (int(id(iaq).state) >= 201 && int(id(iaq).state) <= 250) {
        return {"Heavily polluted"};
      } else if (int(id(iaq).state) >= 251 && int(id(iaq).state) <= 350) {
        return {"Severely polluted"};
      } else if (int(id(iaq).state) >= 351 && int(id(iaq).state) <= 500) {
        return {"Extremely polluted"};
      } else {
        return {"Error"};
      }
      
#   Wifi Information
  - platform: wifi_info
    ip_address:
      name: "${switch_id} IP Address"
    ssid:
      name: "${switch_id} Connected SSID"
    bssid:
      name: "${switch_id} Connected BSSID"
    mac_address:
      name: "${switch_id} Mac Wifi Address"
1 Like

Interesting setup. What sort of charging circuit is that? What solar panel are you using? What sort of latitude are you at?
Can you show a picture of the project box.

I did stay away from specifics on the charging solution and project box as I figure those are mostly ‘standards’ available out there (as far as standards in this stuff go). I will provide mine here, but hope anyone else would gather what works for them. As far as latitude goes I’m in San Francisco at ~37.72 N. I’ve also been playing with the wake/sleep timing for power to work though occasional cloudy days as well as the hungry PMSA003i sensors. Currently I’m up to a deep sleep of 5m while maintaining my 93s runtimes. YMMV.

Here’s a picture of my ‘real world is less pretty than diagrams’ setup:

As far as a hardware list goes I used the following:
Solar panel: AUNMAS 20W Solar Panel (usb micro removed as it instantly rusted outside and replaced with 2 pin DuPont connectors)
Solar Power Manager: Waveshare Solar Power Manager Module (D)
Stevenson screen: La Crosse Technology 925-1418 Sensor Protection Shield (long term viability with PMS/BME TBD)

Nice


Is that green wire I have highlighted stretching out to the sensor cover. I would be concerned water would slip down it into the enclosure. Do you have a drip loop on it? Water has a way of getting in.

At 37 degrees you are not going to have to worry about short days in winter nor sub zero temps affecting the lithium cell.