Thanks @CleanAir for the new screen!
I received the AirGradient One earlier this week, and I like its minimal single-page OOTB screen. Very cool to have it again with this awesome HA integration (thanks a lot @MallocArray btw )
Also, I implemented MallocAlloc’s version with the Poppins font in Regular weight. I agree, the large numbers look better in Poppins.
It was my first time playing with ESPHome, and I wish I had this full YAML file as an example, so here is the code for other beginners:
# AirGradient ONE - Board v9
# https://www.airgradient.com/open-airgradient/instructions/overview/
# Needs ESPHome 2023.7.0 or later
# Reference for substitutions: https://github.com/ajfriesen/ESPHome-AirGradient/blob/main/air-gradient-pro-diy.yaml
substitutions:
devicename: "airgradient-one"
upper_devicename: "AirGradient One"
ag_esphome_config_version: 0.2.1
esphome:
name: "${devicename}"
friendly_name: "${upper_devicename}"
name_add_mac_suffix: true # Set to false if you don't want part of the MAC address in the name
on_boot:
priority: 200 # Network connections setup
then:
if:
condition:
switch.is_on: upload_airgradient
then:
- http_request.post:
# Return wifi signal -50 as soon as device boots to show activity on AirGradient Dashboard site
# Using -50 instead of actual value as the wifi_signal sensor has not reported a value at this point in boot process
url: !lambda |-
return "http://hw.airgradient.com/sensors/airgradient:" + get_mac_address() + "/measures";
headers:
Content-Type: application/json
json:
wifi: !lambda return to_string(-50);
esp32:
board: esp32-c3-devkitm-1
# Disable logging
# https://esphome.io/components/logger.html
logger:
baud_rate: 0 # Must disable serial logging as ESP32-C3 only has 2 hardware UART and both are in use
logs:
component: ERROR # Hiding warning messages about component taking a long time https://github.com/esphome/issues/issues/4717
# Enable Home Assistant API
api:
ota:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
# The captive portal is a fallback mechanism for when connecting to the configured WiFi fails.
# https://esphome.io/components/captive_portal.html
captive_portal:
# web_server: # Please note that enabling this component will take up a lot of memory and may decrease stability, especially on ESP8266.
http_request:
# Used to support POST request to send data to AirGradient
# https://esphome.io/components/http_request.html
switch:
# 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
- platform: safe_mode
name: "Flash Mode (Safe Mode)"
icon: "mdi:cellphone-arrow-down"
- platform: template
name: "Display Temperature in °F"
icon: "mdi:thermometer"
id: display_in_f
restore_mode: RESTORE_DEFAULT_OFF
optimistic: True
- platform: template
name: "Upload to AirGradient Dashboard"
id: upload_airgradient
restore_mode: RESTORE_DEFAULT_ON
optimistic: True
uart:
# https://esphome.io/components/uart.html#uart
- rx_pin: GPIO0 # Pin 12
tx_pin: GPIO1 # Pin 13
baud_rate: 9600
id: senseair_s8_uart
- rx_pin: GPIO20 # Pin 30 or RX
tx_pin: GPIO21 # Pin 31 or TX
baud_rate: 9600
id: pms5003_uart
i2c:
# https://esphome.io/components/i2c.html
sda: GPIO7
scl: GPIO6
frequency: 400kHz # 400kHz eliminates warnings about components taking a long time other than SGP40 component: https://github.com/esphome/issues/issues/4717
sensor:
# Default interval of updating every second, but using an average over the last 30 seconds/readings
- platform: pmsx003
# PMS5003 https://esphome.io/components/sensor/pmsx003.html
type: PMSX003
uart_id: pms5003_uart
pm_2_5:
name: "PM 2.5"
id: pm_2_5
filters:
- sliding_window_moving_average:
window_size: 30
send_every: 30
pm_1_0:
name: "PM 1.0"
id: pm_1_0
filters:
- sliding_window_moving_average:
window_size: 30
send_every: 30
pm_10_0:
name: "PM 10.0"
id: pm_10_0
filters:
- sliding_window_moving_average:
window_size: 30
send_every: 30
pm_0_3um:
name: "PM 0.3"
id: pm_0_3um
filters:
- sliding_window_moving_average:
window_size: 30
send_every: 30
# Depends on another sensor providing an ID of pm_2_5 such as a pms5003
- platform: template
name: "PM 2.5 AQI"
id: pm_2_5_aqi
update_interval: 5 min
unit_of_measurement: "AQI"
icon: "mdi:air-filter"
accuracy_decimals: 0
filters:
- skip_initial: 10 # Need valid data from PM 2.5 sensor before able to calculate
lambda: |-
// https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
// Borrowed from https://github.com/kylemanna/sniffer/blob/master/esphome/sniffer_common.yaml
if (id(pm_2_5).state <= 12.0) {
// good
return((50.0 - 0.0) / (12.0 - 0.0) * (id(pm_2_5).state - 0.0) + 0.0);
} else if (id(pm_2_5).state <= 35.4) {
// moderate
return((100.0 - 51.0) / (35.4 - 12.1) * (id(pm_2_5).state - 12.1) + 51.0);
} else if (id(pm_2_5).state <= 55.4) {
// usg
return((150.0 - 101.0) / (55.4 - 35.5) * (id(pm_2_5).state - 35.5) + 101.0);
} else if (id(pm_2_5).state <= 150.4) {
// unhealthy
return((200.0 - 151.0) / (150.4 - 55.5) * (id(pm_2_5).state - 55.5) + 151.0);
} else if (id(pm_2_5).state <= 250.4) {
// very unhealthy
return((300.0 - 201.0) / (250.4 - 150.5) * (id(pm_2_5).state - 150.5) + 201.0);
} else if (id(pm_2_5).state <= 350.4) {
// hazardous
return((400.0 - 301.0) / (350.4 - 250.5) * (id(pm_2_5).state - 250.5) + 301.0);
} else if (id(pm_2_5).state <= 500.4) {
// hazardous 2
return((500.0 - 401.0) / (500.4 - 350.5) * (id(pm_2_5).state - 350.5) + 401.0);
} else {
return(500);
}
- platform: senseair
# SenseAir S8 https://esphome.io/components/sensor/senseair.html
# https://senseair.com/products/size-counts/s8-lp/
co2:
name: "SenseAir S8 CO2"
id: co2
filters:
- skip_initial: 2
- clamp:
min_value: 400 # 419 as of 2023-06 https://gml.noaa.gov/ccgg/trends/global.html
on_value:
- if:
condition:
light.is_on: led_strip
then:
- if:
condition:
lambda: 'return id(co2).state < 800;'
then:
- light.turn_on:
id: led_strip
brightness: !lambda 'return id(led_brightness).state / 100.0;'
red: 0%
green: 100%
blue: 0%
- if:
condition:
lambda: 'return id(co2).state >= 800 && id(co2).state < 1000;'
then:
- light.turn_on:
id: led_strip
brightness: !lambda 'return id(led_brightness).state / 100.0;'
red: 100%
green: 100%
blue: 0%
- if:
condition:
lambda: 'return id(co2).state >= 1000 && id(co2).state < 1500;'
then:
- light.turn_on:
id: led_strip
brightness: !lambda 'return id(led_brightness).state / 100.0;'
red: 100%
green: 70%
blue: 0%
- if:
condition:
lambda: 'return id(co2).state >= 1500 && id(co2).state < 2000;'
then:
- light.turn_on:
id: led_strip
brightness: !lambda 'return id(led_brightness).state / 100.0;'
red: 100%
green: 0%
blue: 0%
- if:
condition:
lambda: 'return id(co2).state >= 2000 && id(co2).state < 3000;'
then:
- light.turn_on:
id: led_strip
brightness: !lambda 'return id(led_brightness).state / 100.0;'
red: 60%
green: 0%
blue: 60%
- if:
condition:
lambda: 'return id(co2).state >= 3000 && id(co2).state < 10000;'
then:
- light.turn_on:
id: led_strip
brightness: !lambda 'return id(led_brightness).state / 100.0;'
red: 40%
green: 0%
blue: 0%
id: senseair_s8
uart_id: senseair_s8_uart
- platform: sht4x
# SHT40 https://esphome.io/components/sensor/sht4x.html
temperature:
name: "Temperature"
id: temp
humidity:
name: "Humidity"
id: humidity
address: 0x44
# - platform: sht3xd
# # SHT30 https://esphome.io/components/sensor/sht3xd.html
# temperature:
# name: "Temperature"
# id: temp
# humidity:
# name: "Humidity"
# id: humidity
# address: 0x44
# heater_enabled: false # Only enable if in conditions that may have high condensation
- platform: wifi_signal
name: "WiFi Signal"
id: wifi_dbm
update_interval: 60s
- platform: uptime
name: "Uptime"
id: device_uptime
update_interval: 10s
- platform: sgp4x
# SGP41 https://esphome.io/components/sensor/sgp4x.html
voc:
name: "VOC Index"
id: voc
nox:
name: "NOx Index"
id: nox
compensation: # Remove this block if no temp/humidity sensor present for compensation
temperature_source: temp
humidity_source: humidity
font:
# Font to use on the display
- file:
type: gfonts
family: Poppins
weight: light
id: poppins_light
size: 14
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
- file:
type: gfonts
family: Poppins
weight: light
id: poppins_light_12
size: 12
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
- file:
type: gfonts
family: Poppins
weight: regular
id: poppins_regular_9
size: 9
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
- file:
type: gfonts
family: Poppins
weight: regular
id: poppins_regular_14
size: 14
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
- file:
type: gfonts
family: Poppins
weight: regular
id: poppins_regular_20
size: 20
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
- file: "gfonts://Ubuntu Mono"
id: ubuntu
size: 22
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
display:
# https://esphome.io/components/display/ssd1306.html
# Formatting reference: https://www.tutorialspoint.com/c_standard_library/c_function_printf.htm
- platform: ssd1306_i2c
model: "SH1106 128x64"
id: oled_display
address: 0x3C
lambda: |-
if (id(display_in_f).state) {
it.printf(0, 0, id(poppins_regular_14), "%.1f°F", id(temp).state*9/5+32);
} else {
it.printf(0, 0, id(poppins_regular_14), "%.1f°C", id(temp).state);
}
it.printf(128, 0, id(poppins_regular_14), TextAlign::TOP_RIGHT, "%.1f%%", id(humidity).state);
it.line(0,17,128,17);
it.printf(0,19, id(poppins_regular_9), "CO2");
it.printf(0,27, id(poppins_regular_20), "%.0f", id(co2).state);
it.printf(0,52, id(poppins_regular_9), "ppm");
it.line(50,19,50,64);
it.printf(54, 19, id(poppins_regular_9), "PM2.5");
it.printf(54, 27, id(poppins_regular_20), "%.0f", id(pm_2_5).state);
it.printf(54, 52, id(poppins_regular_9), "µg/m³");
it.line(100,19,100,64);
it.printf(104,18, id(poppins_regular_9), "TVOC");
it.printf(104,29, id(poppins_regular_9), "%.0f", id(voc).state);
it.printf(104,41, id(poppins_regular_9), "NOx");
it.printf(104,52, id(poppins_regular_9), "%.0f", id(nox).state);
button:
# https://github.com/esphome/issues/issues/2444
- platform: template
name: SenseAir S8 Calibration
id: senseair_s8_calibrate_button
on_press:
then:
- senseair.background_calibration: senseair_s8
- delay: 70s
- senseair.background_calibration_result: senseair_s8
- platform: template
name: SenseAir S8 Enable Automatic Calibration
id: senseair_s8_enable_calibrate_button
on_press:
then:
- senseair.abc_enable: senseair_s8
- platform: template
name: SenseAir S8 Disable Automatic Calibration
id: senseair_s8_disable_calibrate_button
on_press:
then:
- senseair.abc_disable: senseair_s8
- platform: template
name: SenseAir S8 Show Calibration Interval
id: senseair_s8_show_calibrate_interval
on_press:
then:
- senseair.abc_get_period: senseair_s8
binary_sensor:
# Binary sensor to perform action when physical config button is pressed
# https://esphome.io/components/binary_sensor/index.html?highlight=on_multi_click
- platform: gpio
pin:
number: GPIO9
mode: INPUT_PULLUP
inverted: true
internal: true # Hide from displaying in HomeAssistant
name: "Option Button"
id: option_button
on_multi_click:
- timing: # Short Click
- ON for at most 1s
- OFF for at least 0.5s
then:
- logger.log: "Toggling display betwen C and F"
- switch.toggle: display_in_f
- timing: # Press and hold up to 5 seconds
- ON for 1s to 5s
- OFF for at least 0.5s
then:
- logger.log: "Starting manual CO2 calibration"
- senseair.background_calibration: senseair_s8
- delay: 70s
- senseair.background_calibration_result: senseair_s8
light:
# https://esphome.io/components/light/esp32_rmt_led_strip.html
- platform: esp32_rmt_led_strip
rgb_order: GRB
pin: GPIO10 # Pin 16
num_leds: 11
rmt_channel: 0
chipset: ws2812
name: "LED Strip"
id: led_strip
restore_mode: RESTORE_DEFAULT_OFF
number:
# https://esphome.io/components/number/template.html
- platform: template
name: "LED Brightness %"
icon: "mdi:lightbulb"
id: led_brightness
min_value: 0
max_value: 100
step: 1
initial_value: 100
optimistic: true
restore_value: true
mode: slider
output:
# Pin to notify external watchdog that activity is present
- platform: gpio
id: watchdog
pin: GPIO2
interval:
- interval: 30s
# Notify watchdog device is still alive
then:
- output.turn_on: watchdog
- delay: 20ms
- output.turn_off: watchdog
- interval: 2.5min
# Send data to AirGradient API server
then:
if:
condition:
switch.is_on: upload_airgradient
then:
- http_request.post:
# https://api.airgradient.com/public/docs/api/v1/
# AirGradient URL with the MAC address 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(pm_2_5).state);" Converts sensor output from double to string
json:
wifi: !lambda return to_string(id(wifi_dbm).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);
rco2: !lambda return to_string(id(co2).state);
atmp: !lambda return to_string(id(temp).state);
rhum: !lambda return to_string(id(humidity).state);
tvoc_index: !lambda return to_string(id(voc).state);
nox_index: !lambda return to_string(id(nox).state);
Disclaimer => Changes from the original GitHub:
- Celsius (°C) is the default (instead of °F, you can still toggle it from HA)
- Pagination was removed entirely (display pages + output interval => to keep it minimal)
- Added Wifi secrets (hardcoded)