Outdoor Monitor ESPHome Configuration

I think I’ve sorted out a functional ESPHome configuration for the outdoor monitor and figured I’d post in case it’s useful for others or if there are any suggested improvements.

Credit and big thanks to @spectrumjade for the patch which allows both UARTs to be used.

Some notable differences when compared to a similar configuration for the DIY Pro:

  • TPL5010 hardware watchdog (GPIO2) must be signaled periodically; if not, hardware reset is observed every ~450s

  • Logger uses hardware_uart: USB_SERIAL_JTAG which allows for logging over USB serial while avoiding interference with UART data

  • Values from the 2x PMS5003T modules are averaged, though there may be a more elegant way to do this

  • Uses esp-idf framework which is recommended for ESP32C3

Build versions (esphome==2023.4.4)

Processing airgradient-outdoor (board: lolin_c3_mini; framework: espidf; platform: platformio/espressif32 @ 5.3.0)
------------------------------------------------------------------------------------------------------------------
HARDWARE: ESP32C3 160MHz, 320KB RAM, 4MB Flash
 - framework-espidf @ 3.40404.0 (4.4.4) 
 - tool-cmake @ 3.16.4 
 - tool-ninja @ 1.7.1 
 - toolchain-esp32ulp @ 2.35.0-20220830 
 - toolchain-riscv32-esp @ 8.4.0+2021r2-patch5

airgradient-outdoor.yaml

substitutions:
  devicename: "airgradient-outdoor"

esphome:
  name: "${devicename}"
  platformio_options:
    board_build.flash_mode: dio

esp32:
  board: lolin_c3_mini
  framework:
    type: esp-idf

# Remove once this is merged: https://github.com/esphome/esphome/pull/4762
external_components:
  - source: github://spectrumjade/esphome@esp32s2_cdc_fix
    components: [ uart ]
    refresh: 0s

logger:
  hardware_uart: USB_SERIAL_JTAG
  level: INFO

api:

ota:
  password: !secret ota_update_password

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

switch:
  - platform: gpio
    id: watchdog_reset
    internal: true
    pin: GPIO2  # Pin 5

  - platform: restart
    name: "${devicename} Restart"

  - platform: safe_mode
    name: "${devicename} Flash Mode (Safe Mode)"

# Momentary button
binary_sensor:
  - platform: gpio
    id: button
    pin: GPIO9 # Pin 23

# Signal hardware watchdog every 60s
time:
  - platform: homeassistant
    id: ha_time
    on_time:
      - seconds: 0
        minutes: /1
        then:
          - lambda: !lambda |-
              id(watchdog_reset).turn_on();
              delay(20);
              id(watchdog_reset).turn_off();

i2c:
  sda: GPIO7 # Pin 21
  scl: GPIO6 # Pin 20
  scan: false

uart:
  - id: pm1_uart
    baud_rate: 9600
    rx_pin: GPIO20 # Pin 30 / RXD0
    tx_pin: GPIO21 # Pin 31 / TXD0
  - id: pm2_uart
    baud_rate: 9600
    rx_pin: GPIO0 # Pin 12
    tx_pin: GPIO1 # Pin 13

light:
  - platform: status_led
    name: "${devicename} Status LED"
    pin: GPIO10 # Pin 16

sensor:
  # Diagnostic sensors
  - platform: uptime
    name: "${devicename} Uptime"
    update_interval: 60s
  - platform: internal_temperature
    name: "${devicename} Internal ESP32 Temperature"
    unit_of_measurement: "°F"
    update_interval: 60s
    filters:
      - lambda: return x * (9.0/5.0) + 32.0;

  # PM1 raw values
  - platform: pmsx003
    type: PMS5003T
    uart_id: pm1_uart
    update_interval: 120s
    temperature:
      id: pm1_temperature
      accuracy_decimals: 1
      unit_of_measurement: "°F"
      filters:
        - lambda: return x * (9.0/5.0) + 32.0;
    humidity:
      id: pm1_humidity
      accuracy_decimals: 1
      unit_of_measurement: "%"
    pm_1_0:
      id: pm1_1_0
    pm_2_5:
      id: pm1_2_5
    pm_10_0:
      id: pm1_10_0
    pm_0_3um:
      id: pm1_0_3um
    pm_0_5um:
      id: pm1_0_5um
    pm_1_0um:
      id: pm1_1_0um
    pm_2_5um:
      id: pm1_2_5um

  # PM2 raw values
  - platform: pmsx003
    type: PMS5003T
    uart_id: pm2_uart
    update_interval: 120s
    temperature:
      id: pm2_temperature
      accuracy_decimals: 1
      unit_of_measurement: "°F"
      filters:
        - lambda: return x * (9.0/5.0) + 32.0;
    humidity:
      id: pm2_humidity
      accuracy_decimals: 1
      unit_of_measurement: "%"
    pm_1_0:
      id: pm2_1_0
    pm_2_5:
      id: pm2_2_5
    pm_10_0:
      id: pm2_10_0
    pm_0_3um:
      id: pm2_0_3um
    pm_0_5um:
      id: pm2_0_5um
    pm_1_0um:
      id: pm2_1_0um
    pm_2_5um:
      id: pm2_2_5um

  # PM1 + PM2 averaged values
  - platform: template
    id: temperature
    name: "${devicename} Temperature"
    icon: mdi:home-thermometer-outline
    accuracy_decimals: 1
    unit_of_measurement: "°F"
    lambda: return (id(pm1_temperature).state + id(pm2_temperature).state) / 2;
  - platform: template
    id: humidity
    name: "${devicename} Relative Humidity"
    icon: mdi:water-percent
    accuracy_decimals: 1
    unit_of_measurement: "%"
    lambda: return (id(pm1_humidity).state + id(pm2_humidity).state) / 2;
  - platform: template
    id: pm_1_0
    name: "${devicename} PM <1.0µm Concentration"
    icon: mdi:smoke
    accuracy_decimals: 0
    unit_of_measurement: µg/m³
    lambda: return (id(pm1_1_0).state + id(pm2_1_0).state) / 2;
  - platform: template
    id: pm_2_5
    name: "${devicename} PM <2.5µm Concentration"
    icon: mdi:smoke
    accuracy_decimals: 0
    unit_of_measurement: µg/m³
    lambda: return (id(pm1_2_5).state + id(pm2_2_5).state) / 2;
  - platform: template
    id: pm_10_0
    name: "${devicename} PM <10.0µm Concentration"
    icon: mdi:smoke
    accuracy_decimals: 0
    unit_of_measurement: µg/m³
    lambda: return (id(pm1_10_0).state + id(pm2_10_0).state) / 2;
  - platform: template
    id: pm_0_3um
    name: "${devicename} PM >0.3µm Count"
    icon: mdi:counter
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_0_3um).state + id(pm2_0_3um).state) / 2;
  - platform: template
    id: pm_0_5um
    name: "${devicename} PM >0.5µm Count"
    icon: mdi:counter
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_0_5um).state + id(pm2_0_5um).state) / 2;
  - platform: template
    id: pm_1_0um
    name: "${devicename} PM >1.0µm Count"
    icon: mdi:counter
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_1_0um).state + id(pm2_1_0um).state) / 2;
  - platform: template
    id: pm_2_5um
    name: "${devicename} PM >2.5µm Count"
    icon: mdi:counter
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_2_5um).state + id(pm2_2_5um).state) / 2;
2 Likes

I also made a quick config last week. Its working flawlessly.

I do have a diferent approach for the hardware watchdog trigger and did not yet try out the uart debug trick. I do log all data and do the processing later on but overall its the same.

substitutions:
  devicename: "airgradientoutdoor"
  upper_devicename: "Airgradient Outdoor"
  ipaddress: ""

esphome:
  name: ${devicename}
  friendly_name: ${upper_devicename}

esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: arduino

# Enable logging
logger:
  baud_rate: 0 #disable uart serial output
  #tx_buffer_size: 256
  level: DEBUG #default
  #level: VERBOSE
  logs:
    #sensor: INFO
    #pmsx003: INFO
    #text_sensor: NONE
    homeassistant.sensor: NONE

# Enable Home Assistant API
api:
  encryption:
    key: ""

ota:
  password: ""

debug:
  update_interval: 10s

packages:
  wifi: !include .wifi.yaml

wifi:
  manual_ip:
    static_ip: ${ipaddress}

time:
  - platform: homeassistant
    id: homeassistant_time

web_server:
  port: 80
  include_internal: true

interval:
  - interval: 5min #Feed hardware watchdog so it doesn't reset while esp is running
    then:
      - output.turn_on: watchdog
      - delay: 20ms
      - output.turn_off: watchdog

i2c:
  sda: GPIO7
  scl: GPIO6
  frequency: 100khz
  scan: false

uart:
  - rx_pin: 20
    tx_pin: 21
    baud_rate: 9600
    id: pms5003t1_uart
    
  - rx_pin: 0
    tx_pin: 1
    baud_rate: 9600
    id: pms5003t2_uart

button:
  - platform: restart
    name: "Restart"
    disabled_by_default: true
    id: button_restart
  - platform: safe_mode
    name: "Restart (Safe Mode)"
    disabled_by_default: true
  - platform: shutdown
    name: "Shutdown"
    disabled_by_default: true
    id: button_shutdown

sensor:
  - platform: pmsx003
    type: PMS5003T
    uart_id: pms5003t1_uart
    pm_1_0_std:
      name: "PMS5003T1 PM <1.0µm Concentration Std"
    pm_2_5_std:
      name: "PMS5003T1 PM <2.5µm Concentration Std"
    pm_10_0_std:
      name: "PMS5003T1 PM <10.0µm Concentration Std"
    pm_1_0:
      name: "PMS5003T1 PM <1.0µm Concentration"
    pm_2_5:
      name: "PMS5003T1 PM <2.5µm Concentration"
    pm_10_0:
      name: "PMS5003T1 PM <10.0µm Concentration"
    pm_0_3um:
      name: "PMS5003T1 PM >0.3µm in 0.1L"
    pm_0_5um:
      name: "PMS5003T1 PM >0.5µm in 0.1L"
    pm_1_0um:
      name: "PMS5003T1 PM >1.0µm in 0.1L"
    pm_2_5um:
      name: "PMS5003T1 PM >2.5µm in 0.1L"
    temperature:
      name: "PMS5003T1 Temperature"
    humidity:
      name: "PMS5003T1 Humidity"
    update_interval: 3min # Sensor will go into sleep mode for extended operation lifetime

  - platform: pmsx003
    type: PMS5003T
    uart_id: pms5003t2_uart
    pm_1_0_std:
      name: "PMS5003T2 PM <1.0µm Concentration Std"
    pm_2_5_std:
      name: "PMS5003T2 PM <2.5µm Concentration Std"
    pm_10_0_std:
      name: "PMS5003T2 PM <10.0µm Concentration Std"
    pm_1_0:
      name: "PMS5003T2 PM <1.0µm Concentration"
    pm_2_5:
      name: "PMS5003T2 PM <2.5µm Concentration"
    pm_10_0:
      name: "PMS5003T2 PM <10.0µm Concentration"
    pm_0_3um:
      name: "PMS5003T2 PM >0.3µm in 0.1L"
    pm_0_5um:
      name: "PMS5003T2 PM >0.5µm in 0.1L"
    pm_1_0um:
      name: "PMS5003T2 PM >1.0µm in 0.1L"
    pm_2_5um:
      name: "PMS5003T2 PM >2.5µm in 0.1L"
    temperature:
      name: "PMS5003T2 Temperature"
    humidity:
      name: "PMS5003T2 Humidity"
    update_interval: 3min # Sensor will go into sleep mode for extended operation lifetime

  - platform: bme280
    temperature:
      name: "BME280 Temperature"
      id: temperature2
      oversampling: 1x
      filters:
        - offset: -0.8
    pressure:
      name: "BME280 Pressure"
      id: pressure
      oversampling: 1x
    humidity:
      name: "BME280 Humidity"
      id: humidity2
      oversampling: 1x
      filters:
        - offset: 6
    address: 0x76
    update_interval: 10s

  - platform: wifi_signal
    name: "Wifi Strength"
    id: wifirssi
    disabled_by_default: true
    update_interval: 10s

  - platform: uptime
    name: "Uptime Sensor"
    id: uptime_sensor
    update_interval: 60s

  - platform: debug
    free:
      name: "Heap Free"
      #disabled_by_default: true
    block:
      name: "Heap Max Block"
      #disabled_by_default: true
    loop_time:
      name: "Loop Time"
      #disabled_by_default: true

binary_sensor:
  - platform: gpio
    name: "Button"
    pin: 
      number: GPIO9
      inverted: true
      mode: 
        input: true
        pullup: true
    on_click:
      min_length: 5s
      then:
        - button.press: button_restart

text_sensor:
  - platform: version
    name: "ESPHome version"
  - platform: debug
    device:
      name: "Device Info"
      #disabled_by_default: true
    reset_reason:
      name: "Reset Reason"

light:
  - platform: status_led
    name: "Status LED"
    pin: GPIO10

output:
  - platform: gpio
    id: watchdog
    pin: GPIO2

1 Like

@Hendrik @llamagecko
Many thanks for providing these! Happy to see that it works with ESPHome.

If you have any general feedback on the built process, the PCB or enclosure, please let me know.

Aha, you found my super-secret branch where I was working on a follow-up PR to fix the esphome compile error you get when USB-CDC is enabled :slight_smile:

My prior change to allow both UARTs to be used was merged a few days ago (and is already available in esphome 2023.05.0 actually), and I just submitted a PR for the CDC stuff based on the branch that you referenced in your post: Use Serial0 as the default UART in USB-CDC mode by spectrumjade · Pull Request #4869 · esphome/esphome · GitHub

Although note that both changes only apply to the Arduino platform - it’s my understanding that these particular issues don’t affect esphome when it’s using ESP-IDF (as in your example). I ran into other weird issues when I tried to use ESP-IDF, which is why I went back to Arduino. Hopefully once this second PR is merged, they’ll both work equally well with UARTs :slight_smile:

@Hendrik @llamagecko
I was in the middle of tracing connections in KiCad to confirm pinout for Open Air, when I found your configs, so thank you for saving bunch of my time! :slight_smile:
In order to hopefully save others time as well I combined your configs and issued a PR to Andrej Friesen’s repository with the AirGradient ESPHome configs, as this is (probably) the easiest place to find them from the Home Assistant perspective. Hope it’s ok with you.

3 Likes

Just got my outdoor module, and after some human error with a typo’d SSID, the combined file works great for me. Now to find a good dashboard setup for all of these sensors.

I added a couple usability comments to the pull request, in case you think someone else will find them helpful.

I spent some time playing around with the configs tonight, and reworked a few things:

  • Turned on the captive portal (fallback hotspot is kind of useless without it)
  • Included a section for Home Assistant API and encryption key (stored as a secret)
  • Separated AQI into pm2.5, pm10.0, and a generic AQI that matches whichever value is highest. Only the “best of” AQI values are sent to Home Assistant by default.
  • Set a bunch of the values to disabled_by_default: true so only the combined/average values show up by default in Home Assistant.
  • Extended the AQI calculations to the maximum of 500 if particle readings are REALLY bad (hello west coast wildfiles … I know some people who were caught in areas “above 500”).
  • Tried to clean up the AQI calculations (both numeric and text) to be easier to read/maintain
  • Made initial_aqi_delay a substitution variable because it was really annoying to change it in multiple spots while testing
  • Switched around a few of the icons:
    • mdi:blur seems to be a popular one for particle count
    • Home Assistant seems to use the plain mdi:thermometer icon for temperatures

(I’ve been updating this post as I do more cleanup)

… I think that’s it. Anyway, let me know what you think.

# Airgradient Open Air Outdoor v1.1 presoldered
# 
# @llamagecko, @Hendrik, @spectrumjade, and @ex-nerd from AirGradient forum
# did most of the work for this integration, see the following thread:
# https://forum.airgradient.com/t/outdoor-monitor-esphome-configuration/823
#

substitutions:
  id: "1"
  devicename: "airgradient-open-air"
  upper_devicename: "AirGradient Open Air"
  # Initial number of hours to delay AQI results
  initial_aqi_delay: "18"

esphome:
  name: "${devicename}-${id}"
  friendly_name: "${upper_devicename} ${id}"
  # Automatically add the mac address to the name
  # so you can use a single firmware for all devices
  # name_add_mac_suffix: true
  platform: esp32
  board: esp32-c3-devkitm-1

# Enable logging
logger:
  baud_rate: 0

# Enable Home Assistant API
api:
  encryption:
    key: !secret home_assistant_encryption_key

ota:
  password: !secret ota_password

wifi:
  networks:
    - ssid: !secret wifi_ssid
      password: !secret wifi_password
  reboot_timeout: 15min

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "${upper_devicename} Hotspot"
    password: !secret fallback_ssid_password

captive_portal:

# Used to support POST request to send data to AirGradient
# https://esphome.io/components/http_request.html
http_request:

web_server:
  port: 80
  include_internal: true

# captive_portal:

# Create a switch for safe_mode in order to flash the device
# Solution from this thread:
# https://community.home-assistant.io/t/esphome-flashing-over-wifi-does-not-work/357352/1
switch:
  - platform: safe_mode
    name: "Flash Mode (Safe Mode)"
    icon: "mdi:cellphone-arrow-down"

button:
  - platform: restart
    name: "Restart"
    disabled_by_default: true
    id: button_restart

output:
  - platform: gpio
    id: watchdog
    pin: GPIO2

# Reset hardware watchdog every 5 minutes
interval:
  - interval: 5min
    then:
      - output.turn_on: watchdog
      - delay: 20ms
      - output.turn_off: watchdog

  - interval: 5min
    # Send data to AirGradient API server
    # for more details have a look at sendToServer() function:
    # https://www.airgradient.com/open-airgradient/blog/airgradient-diy-pro-instructions/
    then:
      - http_request.post:
          # AirGradient URL with full MAC address in Hex format all lower case
          url: !lambda |-
            return "http://hw.airgradient.com/sensors/airgradient:" + get_mac_address() + "/measures";
          headers:
              Content-Type: application/json
          # "!lambda return to_string(id(pm2).state);" Converts sensor output from double to string
          body: !lambda |-
            String jsonString;
            StaticJsonDocument<1024> doc;
            doc["wifi"] = id(airgradient_wifi_signal).state;
            doc["pm01"] = to_string(id(pm_1_0).state);
            doc["pm02"] = to_string(id(pm_2_5).state);
            doc["pm10"] = to_string(id(pm_10_0).state);
            doc["pm003_count"] = to_string(id(pm_0_3um).state);
            doc["atmp"] = to_string(id(temperature).state);
            doc["rhum"] = to_string(id(humidity).state);
            doc["boot"] = "1";
            JsonObject channels = doc.createNestedObject("channels");
            JsonObject channels_1 = channels.createNestedObject("1");
            channels_1["pm01"] = to_string(id(pm1_1_0).state);
            channels_1["pm02"] = to_string(id(pm1_2_5).state);
            channels_1["pm10"] = to_string(id(pm1_10_0).state);
            channels_1["pm003_count"] = to_string(id(pm1_0_3um).state);
            channels_1["atmp"] = to_string(id(pm1_temperature).state);
            channels_1["rhum"] = to_string(id(pm1_humidity).state);
            JsonObject channels_2 = channels.createNestedObject("2");
            channels_2["pm01"] = to_string(id(pm2_1_0).state);
            channels_2["pm02"] = to_string(id(pm2_2_5).state);
            channels_2["pm10"] = to_string(id(pm2_10_0).state);
            channels_2["pm003_count"] = to_string(id(pm2_0_3um).state);
            channels_2["atmp"] = to_string(id(pm2_temperature).state);
            channels_2["rhum"] = to_string(id(pm2_humidity).state);
            // Serialize the JSON document into the string
            serializeJson(doc, jsonString);
            // Convert String to std::string
            std::string stdJsonString(jsonString.c_str());
            return stdJsonString;
light:
  - platform: status_led
    name: "Status LED"
    pin: GPIO10

binary_sensor:
  - platform: gpio
    name: "Button"
    pin: 
      number: GPIO9
      inverted: true
      mode: 
        input: true
        pullup: true
    on_click:
      min_length: 5s
      then:
        - button.press: button_restart

i2c:
  sda: GPIO7 # Pin 21
  scl: GPIO6 # Pin 20
  scan: false

uart:
  - id: uart_pm1
    baud_rate: 9600
    rx_pin: GPIO20 # Pin 30 / RXD0
    tx_pin: GPIO21 # Pin 31 / TXD0
  - id: uart_pm2
    baud_rate: 9600
    rx_pin: GPIO0 # Pin 12
    tx_pin: GPIO1 # Pin 13

text_sensor:
  - platform: template
    name: "${upper_devicename} PM <2.5 AQI Text"
    disabled_by_default: true
    icon: "mdi:air-filter"
    id: pm_2_5_aqi_text
    update_interval: 1h
    lambda: |-
      auto state = id(pm_2_5_aqi_text).state;
      return {
        state.length() > 0 ? state : "Calculating"
      };
    on_value:
      lambda: |-
        // copy higher value to combined AQI
        if (id(pm_2_5_aqi).state >= id(pm_10_0_aqi).state) {
          id(aqi_text).publish_state(x);
        }
  - platform: template
    name: "${upper_devicename} PM <10 AQI Text"
    disabled_by_default: true
    icon: "mdi:air-filter"
    id: pm_10_0_aqi_text
    update_interval: 1h
    lambda: |-
      auto state = id(pm_10_0_aqi_text).state;
      return {
        state.length() > 0 ? state : "Calculating"
      };
    on_value:
      lambda: |-
        // copy higher value to combined AQI
        if (id(pm_10_0_aqi).state >= id(pm_2_5_aqi).state) {
          id(aqi_text).publish_state(x);
        }
  - platform: template
    name: "${upper_devicename} AQI Text"
    icon: "mdi:air-filter"
    id: aqi_text
    update_interval: 1h
    lambda: |-
      auto state = id(aqi_text).state;
      return {
        state.length() > 0 ? state : "Calculating"
      };

sensor:
  - platform: pmsx003
    type: PMS5003T
    uart_id: uart_pm1
    update_interval: 3min # Sensor will go into sleep mode for extended operation lifetime
    pm_1_0:
      id: pm1_1_0
      name: "${upper_devicename} Particulate Matter <1.0µm Concentration (1)"
      disabled_by_default: true
    pm_2_5:
      id: pm1_2_5
      name: "${upper_devicename} Particulate Matter <2.5µm Concentration (1)"
      disabled_by_default: true
    pm_10_0:
      id: pm1_10_0
      name: "${upper_devicename} Particulate Matter <10.0µm Concentration (1)"
      disabled_by_default: true
    pm_0_3um:
      id: pm1_0_3um
      name: "${upper_devicename} Particulate Matter >0.3µm Count (1)"
      disabled_by_default: true
    pm_0_5um:
      id: pm1_0_5um
      name: "${upper_devicename} Particulate Matter >0.5µm Count (1)"
      disabled_by_default: true
    pm_1_0um:
      id: pm1_1_0um
      name: "${upper_devicename} Particulate Matter >1.0µm Count (1)"
      disabled_by_default: true
    pm_2_5um:
      id: pm1_2_5um
      name: "${upper_devicename} Particulate Matter >2.5µm Count (1)"
      disabled_by_default: true
    temperature:
      id: pm1_temperature
      name: "${upper_devicename} Temperature (1)"
      disabled_by_default: true
    humidity:
      id: pm1_humidity
      accuracy_decimals: 1
      name: "${upper_devicename} Relative Humidity (1)"
      disabled_by_default: true

  - platform: pmsx003
    type: PMS5003T
    uart_id: uart_pm2
    update_interval: 3min # Sensor will go into sleep mode for extended operation lifetime
    pm_1_0:
      id: pm2_1_0
      name: "${upper_devicename} Particulate Matter <1.0µm Concentration (2)"
      disabled_by_default: true
    pm_2_5:
      id: pm2_2_5
      name: "${upper_devicename} Particulate Matter <2.5µm Concentration (2)"
      disabled_by_default: true
    pm_10_0:
      id: pm2_10_0
      name: "${upper_devicename} Particulate Matter <10.0µm Concentration (2)"
      disabled_by_default: true
    pm_0_3um:
      id: pm2_0_3um
      name: "${upper_devicename} Particulate Matter >0.3µm Count (2)"
      disabled_by_default: true
    pm_0_5um:
      id: pm2_0_5um
      name: "${upper_devicename} Particulate Matter >0.5µm Count (2)"
      disabled_by_default: true
    pm_1_0um:
      id: pm2_1_0um
      name: "${upper_devicename} Particulate Matter >1.0µm Count (2)"
      disabled_by_default: true
    pm_2_5um:
      id: pm2_2_5um
      name: "${upper_devicename} Particulate Matter >2.5µm Count (2)"
      disabled_by_default: true
    temperature:
      id: pm2_temperature
      name: "${upper_devicename} Temperature (2)"
      disabled_by_default: true
    humidity:
      id: pm2_humidity
      accuracy_decimals: 1
      name: "${upper_devicename} Relative Humidity (2)"
      disabled_by_default: true

  # Calculate the average sensor values
  - platform: template
    id: temperature
    name: "${upper_devicename} Temperature"
    icon: mdi:thermometer
    device_class: temperature
    accuracy_decimals: 1
    unit_of_measurement: "°C"
    lambda: return (id(pm1_temperature).state + id(pm2_temperature).state) / 2;
  - platform: template
    id: humidity
    name: "${upper_devicename} Relative Humidity"
    icon: mdi:water-percent
    device_class: humidity
    accuracy_decimals: 1
    unit_of_measurement: "%"
    lambda: return (id(pm1_humidity).state + id(pm2_humidity).state) / 2;
  - platform: template
    id: pm_1_0
    name: "${upper_devicename} Particulate Matter <1.0µm Concentration"
    icon: mdi:chemical-weapon
    device_class: pm1
    accuracy_decimals: 0
    unit_of_measurement: µg/m³
    lambda: return (id(pm1_1_0).state + id(pm2_1_0).state) / 2;
  - platform: template
    id: pm_2_5
    name: "${upper_devicename} Particulate Matter <2.5µm Concentration"
    icon: mdi:chemical-weapon
    device_class: pm25
    accuracy_decimals: 0
    unit_of_measurement: µg/m³
    lambda: return (id(pm1_2_5).state + id(pm2_2_5).state) / 2;
  - platform: template
    id: pm_10_0
    name: "${upper_devicename} Particulate Matter <10.0µm Concentration"
    icon: mdi:chemical-weapon
    device_class: pm10
    accuracy_decimals: 0
    unit_of_measurement: µg/m³
    lambda: return (id(pm1_10_0).state + id(pm2_10_0).state) / 2;
  - platform: template
    id: pm_0_3um
    name: "${upper_devicename} Particulate Matter >0.3µm Count"
    icon: mdi:blur
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_0_3um).state + id(pm2_0_3um).state) / 2;
  - platform: template
    id: pm_0_5um
    name: "${upper_devicename} Particulate Matter >0.5µm Count"
    icon: mdi:blur
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_0_5um).state + id(pm2_0_5um).state) / 2;
  - platform: template
    id: pm_1_0um
    name: "${upper_devicename} Particulate Matter >1.0µm Count"
    icon: mdi:blur
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_1_0um).state + id(pm2_1_0um).state) / 2;
  - platform: template
    id: pm_2_5um
    name: "${upper_devicename} Particulate Matter >2.5µm Count"
    icon: mdi:blur
    accuracy_decimals: 0
    unit_of_measurement: /dL
    lambda: return (id(pm1_2_5um).state + id(pm2_2_5um).state) / 2;

  # https://forum.airgradient.com/t/on-device-pm2-5-to-aqi-conversion-with-esphome/750/4
  - platform: copy
    source_id: pm_2_5
    id: pm_2_5_24h
    name: "${upper_devicename} PM <2.5µm Average 24h"
    disabled_by_default: true
    device_class: pm25
    accuracy_decimals: 0
    filters:
    - sliding_window_moving_average:
        window_size: 480 #every 3 minutes for 24 hours
        send_every: 20 #hourly
        send_first_at: 20
    on_value:
      lambda: |-
        static int delay_hours = $initial_aqi_delay;
        if (delay_hours < 1) {
          int aqi = 0;
          int pm25 = id(pm_2_5_24h).state;
          // https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
          if (pm25 < 12.0) {
            aqi = (50.0 - 0.0) / (12.0 - 0.0) * (pm25 - 0.0) + 0.0;
          } else if (pm25 < 35.4) {
            aqi = (100.0 - 51.0) / (35.4 - 12.1) * (pm25 - 12.1) + 51.0;
          } else if (pm25 < 55.4) {
            aqi = (150.0 - 101.0) / (55.4 - 35.5) * (pm25 - 35.5) + 101.0;
          } else if (pm25 < 150.4) {
            aqi = (200.0 - 151.0) / (150.4 - 55.5) * (pm25 - 55.5) + 151.0;
          } else if (pm25 < 250.4) {
            aqi = (300.0 - 201.0) / (250.4 - 150.5) * (pm25 - 150.5) + 201.0;
          } else if (pm25 < 350.4) {
            aqi = (400.0 - 301.0) / (350.4 - 250.5) * (pm25 - 250.5) + 301.0;
          } else if (pm25 < 500.4) {
            aqi = (500.0 - 401.0) / (500.4 - 350.5) * (pm25 - 350.5) + 401.0;
          } else {
            // everything higher is just counted as 500
            aqi = 500;
          }
          id(pm_2_5_aqi).publish_state(aqi);
        } else {
          delay_hours--;
        }

  - platform: template
    name: "${upper_devicename} PM <2.5 AQI"
    disabled_by_default: true
    device_class: aqi
    icon: "mdi:air-filter"
    accuracy_decimals: 0
    id: pm_2_5_aqi
    on_value:
      lambda: |-
        if (x <= 50.0) {
          id(pm_2_5_aqi_text).publish_state("Good");
        } else if (x <= 100.0) {
          id(pm_2_5_aqi_text).publish_state("Moderate");
        } else if (x <= 150.0) {
          id(pm_2_5_aqi_text).publish_state("Unhealthy for Sensitive Groups");
        } else if (x <= 200.0) {
          id(pm_2_5_aqi_text).publish_state("Unhealthy");
        } else if (x <= 300.0) {
          id(pm_2_5_aqi_text).publish_state("Very Unhealthy");
        } else if (x <= 400.0) {
          id(pm_2_5_aqi_text).publish_state("Hazardous");
        } else if (x <= 500.0) {
          id(pm_2_5_aqi_text).publish_state("Hazardous"); // again
        } else {
          id(pm_2_5_aqi_text).publish_state("Hazardous"); // and again
        }
        id(aqi).publish_state(std::max(x, id(pm_10_0_aqi).state));

  - platform: copy
    source_id: pm_10_0
    id: pm_10_0_24h
    name: "${upper_devicename} PM <10µm Average 24h"
    disabled_by_default: true
    device_class: pm10
    accuracy_decimals: 0
    filters:
    - sliding_window_moving_average:
        window_size: 480 #every 3 minutes for 24 hours
        send_every: 20 #hourly
        send_first_at: 20
    on_value:
      lambda: |-
        static int delay_hours = $initial_aqi_delay;
        if (delay_hours < 1) {
          int aqi = 0;
          int pm10 = id(pm_10_0_24h).state;
          // https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
          if (pm10 < 54.0) {
            aqi = (50.0 - 0.0) / (54.0 - 0.0) * (pm10 - 0.0) + 0.0;
          } else if (pm10 < 154.0) {
            aqi = (100.0 - 51.0) / (154.0 - 55.0) * (pm10 - 55.0) + 51.0;
          } else if (pm10 < 254.0) {
            aqi = (150.0 - 101.0) / (254.0 - 155.0) * (pm10 - 155.0) + 101.0;
          } else if (pm10 < 354.0) {
            aqi = (200.0 - 151.0) / (354.0 - 255.0) * (pm10 - 255.0) + 151.0;
          } else if (pm10 < 424.0) {
            aqi = (300.0 - 201.0) / (424.0 - 355.0) * (pm10 - 355.0) + 201.0;
          } else if (pm10 < 504.0) {
            aqi = aqi = (400.0 - 301.0) / (504.0 - 425.0) * (pm10 - 425.0) + 301.0;
          } else if (pm10 < 604) {
            aqi = (500.0 - 401.0) / (604.0 - 505.0) * (pm10 - 505.0) + 401.0;
          } else {
            // everything higher is just counted as 500
            aqi = 500.0;
          }
          id(pm_10_0_aqi).publish_state(aqi);
        } else {
          delay_hours--;
        }

  - platform: template
    name: "${upper_devicename} PM <10 AQI"
    disabled_by_default: true
    device_class: aqi
    icon: "mdi:air-filter"
    accuracy_decimals: 0
    id: pm_10_0_aqi
    on_value:
      lambda: |-
        if (x <= 50.0) {
          id(pm_10_0_aqi_text).publish_state("Good");
        } else if (x <= 100.0) {
          id(pm_10_0_aqi_text).publish_state("Moderate");
        } else if (x <= 150.0) {
          id(pm_10_0_aqi_text).publish_state("Unhealthy for Sensitive Groups");
        } else if (x <= 200.0) {
          id(pm_10_0_aqi_text).publish_state("Unhealthy");
        } else if (x <= 300.0) {
          id(pm_10_0_aqi_text).publish_state("Very Unhealthy");
        } else if (x <= 400.0) {
          id(pm_10_0_aqi_text).publish_state("Hazardous");
        } else if (x <= 500.0) {
          id(pm_10_0_aqi_text).publish_state("Hazardous"); // again
        } else {
          id(pm_10_0_aqi_text).publish_state("Hazardous"); // and again
        }
        id(aqi).publish_state(std::max(id(pm_2_5_aqi).state, x));

  - platform: template
    name: "${upper_devicename} AQI"
    device_class: aqi
    icon: "mdi:air-filter"
    accuracy_decimals: 0
    id: aqi

  - platform: wifi_signal
    name: "Wifi Strength"
    id: airgradient_wifi_signal
    update_interval: 60s

  - platform: uptime
    name: "Uptime Sensor"
    id: uptime_sensor
    update_interval: 60s

I also noticed in the logs that the post to the airgradient servers doesn’t seem to be configured properly:

HTTP Request failed; URL: http://hw.airgradient.com/sensors/airgradient:<REDACTED>/measures; Code: 400; Duration: 941 ms

Or maybe that’s because I never actually set it up with the official firmware?

You might get a 400 if you did not setup the monitor on our server and thus the sensor id is not recognized.

What’s the best way to do that after the fact? I’m happy to contribute data (especially since there is so little on your map here in my area of the US).

And as a webdev nitpick, that should probably be a 401 error to indicate that the device is not authorized to use the service. A 400 suggests something like malformed JSON.

I’m getting ready to rework my config for DIY and Pro so I can have the device name as part of the sensor like you are doing. In the http_request.post section, I was able to do it with just a json block that was pretty straight forward. Your body with a lambda looks more involved. Is there functionality you are adding or a particular advantage to that method?

Here is what I’m using:
airgradient_esphome/airgradient.yml at main · MallocArray/airgradient_esphome (github.com)

minor note: I haven’t looked into the documentation, but I personally ran into watchdog related errors when using the recommended 5min interval:

interval:
  - interval: 5min
    then:
      - output.turn_on: watchdog
      - delay: 20ms
      - output.turn_off: watchdog
[20:36:13]E (187079) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
[20:36:13]E (187079) task_wdt:  - loopTask (CPU 0)
[20:36:13]E (187079) task_wdt: Tasks currently running:
[20:36:13]E (187079) task_wdt: CPU 0: IDLE
[20:36:13]E (187079) task_wdt: Aborting.

and changing to 3 min (arbitrary value decision)

interval:
  - interval: 3min
    then:
      - output.turn_on: watchdog
      - delay: 20ms
      - output.turn_off: watchdog

has so far resolved my issue.

again, not sure why this is the case, but sharing in case anyone else encounters this.

Thanks for putting this together!

One question (for now) – why 18hrs?


substitutions:
  id: "1"
  devicename: "airgradient-open-air"
  upper_devicename: "AirGradient Open Air"
  # Initial number of hours to delay AQI results
  initial_aqi_delay: "18"

(sorry if this is an obvious answer, I just haven’t run into this)

It was in the original. My best guess is it was an arbitrary decision to get enough data collected and be close enough to the 24h recommended window for AQI calculations. Personally, I set mine down to 2h and just be aware that the first day is “settling in” … It might be acceptable to drop this delay down to 1h and call it good, and just make sure users are aware that it won’t be “true AQI” for a day. From what I’ve seen in other forum posts, this is what PurpleAir does with some of their units (I think they use 4h).

I do plan to adjust this further and replace the Calculating status string with something like Delay #h (once I come up with a better word for that).

1 Like

I had just copied the original, but decided to test out yours tonight (still new to esphome, learning as I go along). The main difference seems to be the inclusion of nested channels data, since the outdoor sensor has 2 plus the “combined” sensor data.

I transposed the lambda code into JSON yaml as such:

          json:
            wifi: id(airgradient_wifi_signal).state
            pm01: !lambda return to_string(id(pm_1_0).state);
            pm02: !lambda return to_string(id(pm_2_5).state);
            pm10: !lambda return to_string(id(pm_10_0).state);
            pm003_count: !lambda return to_string(id(pm_0_3um).state);
            atmp: !lambda return to_string(id(temp).state);
            rhum: !lambda return to_string(id(humidity).state);
            boot: "1"
            channels:
              "1": 
                pm01: !lambda return to_string(id(pm1_1_0).state);
                pm02: !lambda return to_string(id(pm1_2_5).state);
                pm10: !lambda return to_string(id(pm1_10_0).state);
                pm003_count: !lambda return to_string(id(pm1_0_3um).state);
                atmp: !lambda return to_string(id(pm1_temperature).state);
                rhum: !lambda return to_string(id(pm1_humidity).state);
              "2":
                pm01: !lambda return to_string(id(pm2_1_0).state);
                pm02: !lambda return to_string(id(pm2_2_5).state);
                pm10: !lambda return to_string(id(pm2_10_0).state);
                pm003_count: !lambda return to_string(id(pm2_0_3um).state);
                atmp: !lambda return to_string(id(pm2_temperature).state);
                rhum: !lambda return to_string(id(pm2_humidity).state);

However, that results in a compile error: Must be string, got <class 'esphome.helpers.OrderedDict'>. did you forget putting quotes around the value?. … presumably around the object with 1 and 2 keys. I noticed that the original URL was sending the whole MAC address rather than the last 6 characters like yours did, so I’ve fixed that in my debug code. If that still doesn’t work, I’ll check to see if channels is actually intended to be an array rather than object.

No harm in lowering the interval. The watchdog needs to be activated within a certain time depending on the resistor connected. I calculated its about 8min but yours could be shorter. As long as you feed it in time it will not reset.

1 Like

Update on the JSON stuff: I tracked down the official AirGradient Outdoor code and confirmed the JSON format is indeed sending an object with keys of 1 and 2. And as far as I can tell, that Must be a string error is a “feature” of espHome itself: it just doesn’t supported nested properties via the json option.

The code also showed that it should be sending the full MAC address in the URL, so I’ve restored that in my copy. I’ll find some time this weekend to fork the repo and get my changes onto github rather than lazily relying on my clipboard history as version control. I also have some updates for the DIY Pro sensor firmware that I want to get in place.

I’m not so sure on the full MAC. It looks like the ESP.getChipId() function returns the last 3 bytes in HEX and not the full MAC
arduino-esp32/GetChipID.ino at master · espressif/arduino-esp32 · GitHub

I haven’t played with ESPHome with a JSON that has multiple keys, but there may be some YAML formatting to have it format more as expected. I haven’t tried any of this, but in YAML, it would be something like

            channels:
              - 1: 
                  pm01: !lambda return to_string(id(pm1_1_0).state);
                  pm02: !lambda return to_string(id(pm1_2_5).state);
                  pm10: !lambda return to_string(id(pm1_10_0).state);
                  pm003_count: !lambda return to_string(id(pm1_0_3um).state);
                  atmp: !lambda return to_string(id(pm1_temperature).state);
                  rhum: !lambda return to_string(id(pm1_humidity).state);
              - 2:
                  pm01: !lambda return to_string(id(pm2_1_0).state);
                  pm02: !lambda return to_string(id(pm2_2_5).state);
                  pm10: !lambda return to_string(id(pm2_10_0).state);

Or maybe not naming the keys, but just making it a list:

            channels:
              -  pm01: !lambda return to_string(id(pm1_1_0).state);
                 pm02: !lambda return to_string(id(pm1_2_5).state);
                 pm10: !lambda return to_string(id(pm1_10_0).state);
                 pm003_count: !lambda return to_string(id(pm1_0_3um).state);
                 atmp: !lambda return to_string(id(pm1_temperature).state);
                 rhum: !lambda return to_string(id(pm1_humidity).state);
              -  pm01: !lambda return to_string(id(pm2_1_0).state);
                 pm02: !lambda return to_string(id(pm2_2_5).state);
                 pm10: !lambda return to_string(id(pm2_10_0).state);

Oh, that was totally an assumption on my part that it would return the whole value. Thanks for catching that.

Yeah, it’s not the YAML itself that’s problematic, but the validator for the json property is set as “strict string”: esphome/config_validation.py at 8bb4c65272b41ccc6fa25a9e39096617c8c041c5 · esphome/esphome · GitHub

I spent way too much time poking at this, but it’s been a good leaning project for ESPHome. My version of the file is now on github (link at the end of this post). I haven’t yet submitted an upstream pull request because I’m waiting a couple days for AQI and NowCast to show up and make sure they’re consistent and the system is stable.

Major rewrite: AQI + NowCast

I now have a better understanding if AQI, and see why it’s something
that needs to be a “once/day” calculation. To that end, I’ve added
NowCast calculations, which require keeping track of the hourly
averages. This actually paves the way for storing the values in
flash so they can be restored across reboots and speed up
re-calculation (though we’ll need to track the hours that each avg
comes from so we can age out too-old values).

  • Rewrite AQI calculation based on official standards.
    • No more separate 2.5 and 10 variants. AQI is AQI, and there is no
      reason to provide the separate pseudo-AQI calculations. Plus, it
      was a lot of duplcated code for little benefit.
    • Document why we use 18 hours not 24: the official spec allows for
      calculations once we hit 75% data availability.
    • Note: This calculation still differs from the official AQI , which
      is apparently calculated midnight-to-midnight rather than a 24h
      rolling average.
  • Add NowCast. This is a 12h version of AQI weighted to the most recent
    hour, and is a better fit for what a lot of people want from AQI: a
    measurement of air quality during short-term quality issues like
    wildfire smoke.
  • Add sensors to report on the number of minutes remaining before AQI
    and NowCast are available.
  • Update some sensors to report 1 accuracy decimal instead of 0.
  • Changes around a few icons.
  • Fixes some math and timing values.
  • More inline comments/documentation.

Also includes previous work:

  • Make initial_aqi_delay configurable. I’ve arbitrarily chosen 4h here, and
    have some ideas how to make it better in the future because I like seeing
    numbers and can trust myself to know it’s not particularly accurate before
    24h.
  • Turned on the captive portal (fallback hotspot is kind of useless without
    it)
  • Included a section for Home Assistant API and encryption key (stored as a
    secret)
  • Separated AQI into pm2.5, pm10.0, and a generic AQI that matches whichever
    value is highest. Only the “best of” AQI values are sent to Home Assistant
    by default.
  • Set a bunch of the values to disabled_by_default: true so only the
    combined/average values show up by default in Home Assistant.
  • Extended the AQI calculations to the maximum of 500 if particle readings
    are REALLY bad (hello west coast wildfiles … I know some people who were
    caught in areas “above 500”).
  • Tried to clean up the AQI calculations (both numeric and text) to be
    easier to read/maintain.
  • Switched around a few of the icons:
    • mdi:blur seems to be a popular one for particle count
    • Home Assistant seems to use the plain mdi:thermometer icon for
      temperatures
    • Use mdi:weather-windy-variant for AQI, which matches Home Assistant’s
      use of it for the air_quality integration.
3 Likes