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"


