AirGradient One - Customized MallocArray ESPHome Display

For anyone interested, I created a custom display for @MallocArray 's excellent Home Assistant integration for the AirGradient One v9: GitHub - MallocArray/airgradient_esphome: ESPHome definition for an AirGradient DIY device to send data to HomeAssistant and AirGradient servers

It looks more like the OOTB AirGradient One screen but uses Open Sans font and less space for TVOC and NOx.

Here’s what it looks like:

Here’s the code:

  - file: "gfonts://Open Sans"
    id: open_sans_14
    size: 14
    glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
  - file: "gfonts://Open Sans"
    id: open_sans_9
    size: 9
    glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'
  - file: "gfonts://Open Sans"
    id: open_sans_20
    size: 20
    glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/µ³'


pages:
      - id: custom
        lambda: |-
          if (id(display_in_f).state) {
            it.printf(0, 0, id(open_sans_14), "%.1f°F", id(temp).state*9/5+32);
          } else {
            it.printf(0, 0, id(open_sans_14), "%.1f°C", id(temp).state);
          }
          it.printf(128, 0, id(open_sans_14), TextAlign::TOP_RIGHT, "%.1f%%", id(humidity).state);
          it.line(0,17,128,17);
          it.printf(0,19, id(open_sans_9), "CO2");
          it.printf(0,27, id(open_sans_20), "%.0f", id(co2).state);
          it.printf(0,52, id(open_sans_9), "ppm");
          it.line(50,19,50,64);
          it.printf(54, 19, id(open_sans_9), "PM2.5");
          it.printf(54, 27, id(open_sans_20), "%.0f", id(pm_2_5).state);
          it.printf(54, 52, id(open_sans_9), "µg/m³");
          it.line(100,19,100,64);
          it.printf(104,18, id(open_sans_9), "TVOC");
          it.printf(104,29, id(open_sans_9), "%.0f", id(voc).state);
          it.printf(104,41, id(open_sans_9), "NOx");
          it.printf(104,52, id(open_sans_9), "%.0f", id(nox).state);
7 Likes

I love it. I’ll give it a try on my device and see about adding it to my list of pages.

1 Like

@CleanAir this looks great. Thank you for creating this!

1 Like

Man, fonts are hard!

I really like this layout, but this OLED has a tendency to make some letters lopsided, like the 0 in the middle of the screen is thicker on the left than the right, and the F on the top line is two lines thick on the vertical and for some reason that bugs me.

So I tried using the Poppins font in light weight that I used on my other screens and it fixed that issue, but didn’t look at crisp. So I’m trying the regular weight and it does get the characters to the same width on this display, but now the numbers are taller than letters, which may grate on me more.

image

1 Like

Thanks @CleanAir for the new screen! :raised_hands:

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 :pray:)

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)
1 Like

Yeah I struggled with the font - tried a few and wasn’t really happy with any of them. I’ll give Poppins a try. Thanks @MallocArray. I’m also working on some config modifications to get Apple Homekit to recognize a few of the sensors its not seeing from Home Assistant. I’ll post that config when I’m done.

Actually, @MallocArray , I noticed you made a lot of nice changes and reorganized the code into cleaner packages with remote references to your repo from the main yaml. I’m going to try that out first, but it means I can’t make changes unless I download the full config. Do you think you could make the following corrections:

Home Assistant requires that device_class be set for certain sensors to get recognized by the Apple HomeKit Bridge integration. CO2 seems to work without that attribute, but the other sensors don’t - in other words, Home Assistant recognizes them without an issue, but the Apple Home app does not.

The fix is to set one of these allowable device classes for each sensor platform. The only challenge is that for VOC and NOx, Homekit will report it as ug/m^3 because there is no “index” device class for these sensors. Technically, it’s more correct to not set the device class for these, but without it you don’t get the values reported at all to Homekit.

Details:

  • co2 sensor platform should have device_class: carbon_dioxide
  • pm_2_5 sensor platform should have device_class: pm25
  • pm_1_0 sensor platform should have device_class: pm1
  • pm_10_0 sensor platform should have device_class: pm10

Optionally, these device classes should be set, though it will report the incorrect measurement unit to Homekit, but interestingly enough it will still display correctly without a unit (as an index) within Home Assistant:

  • voc platform should have device_class: volatile_organic_compounds
  • nox platform should have device_class: nitrogen_dioxide

Thanks!

I’ll check those out. But if you want to validate it on your side as well, here are some instructions I shared on one of the Issues posts and I should add a simplified version of this to the README so people know how they can do their own modifications and still leverage most of the shared code:

You can copy the individual package from the repo, such as the https://github.com/MallocArray/airgradient_esphome/blob/main/packages/sensor_pms5003.yaml file and put it in your own ESPHome config folder (or a subfolder named packages for consistency) and then change the line to
pm_2.5: !include packages/sensor_pms5003.yaml and now it will use the file you have saved locally and you can make whatever modifications you would like, while all of the other packages continue to come directly from Github. So it can be flexible.

1 Like

It looks like currently the VOC and NOx device_classes are aqi, so does this need changed, or are you just pointing out that as it currently is, Homekit reports as ug/m3?

It doesn’t look like the PMS5003 is the only one that doesn’t have a device_class set, so I can open an Issue on the repo and see if the maintainer will consider adding those by default, but until then, I’ll try out adding them to my config and see if there is any impact to existing configs.

This worked, thanks! I created a local copy of sensor_pms5003.yaml and added device class for all the PM sensors. They show up correctly in Apple Home now, except for PM 1.0 and PM 0.3 which are not supported.

Question: I noticed that you added a PM 2.5 AQI index (separate from PM 2.5), but it shows up with an “unknown” value in the Home Assistant interface. Is that supposed to be populated?

It’s probably best to leave these alone. I might change them in my local copy but I don’t love that it exposes them with the wrong unit.

AQI is the correct class, but Apple HomeKit doesn’t support it. The only way to get HomeKit to recognize VOC and NOx is to set their device_class to “volatile_organic_compounds” and “nitrogen_dioxide,” respectively, which passes the correct value through to Apple HomeKit, but with an incorrect unit of ug/m^3. So, the choices are either to have no VOC or NOx reported to Apple, or to have them reported but with the incorrect unit.

I think this is a limitation of HomeKit - these are the only supported types and units listed in their docs. You’ll notice it excludes AQI, PM 0.3, and PM 1.0.