Air Gradient Pro with mmWave sensor (LD2410) and Touch Sensors for extra control options

Just wanted to share my modded version of the Air Gradient Pro. I added a tiny mmWave motion sensor and a couple touch sensors. One touch sensor is meant to control the screen (turn on/off for now) and the other is not defined yet so it does the same. The sensor is so sensitive that I don’t even have to touch the enclosure (where the logo is) as just getting close triggers it (I can control this by increasing/reducing the size of the copper pad on the inside of the enclosure).

Sorry for the terrible picture… my Samsung S20 Ultra is horrible for pictures!

The LD2410B is at the very top. Fortunately UART0 on the Wemos D1 Mini 32 (ESP-32 WROOM) was not used by the other sensors so I was able to integrate the mmwave sensor via uart instead of only using its GPIO output.

Firmware I am using is written for ESPHome.

substitutions:
  devicename: office-multi-sensor
  devicename_no_dashes: office_multi_sensor
  friendly_devicename: "Office Multi Sensor"
  device_description: "Office Multi Sensor"
  update_interval_s: "60s"
  #Only reason not to set it very long it for wifi troubleshooting
  update_interval_wifi: "120s"


esphome:
  name: ${devicename}
  comment: ${device_description}
  friendly_name: ${friendly_devicename}
  # Automatically add the mac address to the name
  # so you can use a single firmware for all devices
  # name_add_mac_suffix: true


esp32:
  board: wemos_d1_mini32
  framework:
    type: arduino
    version: recommended


logger:
  #Logging disabled as UART is already in use... but I still see it... why?
  baud_rate: 0
  level: INFO
#  logs:
#    sensor: INFO # DEBUG level with uart_target_output = overload!
#    binary_sensor: INFO
#    text_sensor: INFO


api: 
#  password: !secret api_pwd


ota:
  password: !secret ota_pwd


wifi:
  networks:
  - ssid: !secret iot_wifi_ssid
    password: !secret iot_wifi_password
  reboot_timeout: 15min


#Faster than DHCP. Also use if can't reach because of name change
  manual_ip:
    static_ip: 192.168.3.212
    gateway: 192.168.3.1
    subnet: 255.255.255.0
    dns1: 192.168.1.25
    dns2: 192.168.1.26

#Manually override what address to use to connect to the ESP.
#Defaults to auto-generated value. Example, if you have changed your
#static IP and want to flash OTA to the previously configured IP address.
  use_address: 192.168.3.212

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

web_server:
  port: 80
#  version: 2
#  include_internal: true
#  ota: false


#captive_portal:


# Sync time with Home Assistant
time:
  - platform: homeassistant
    id: ha_time


text_sensor:
  - platform: wifi_info
    ip_address:
      name: "IP"
      icon: "mdi:ip-outline"
      update_interval: ${update_interval_wifi}
    ssid:
      name: "SSID"
      icon: "mdi:wifi-settings"
      update_interval: ${update_interval_wifi}
    bssid:
      name: "BSSID"
      icon: "mdi:wifi-settings"
      update_interval: ${update_interval_wifi}
    mac_address:
      name: "MAC"
      icon: "mdi:network-outline"
    scan_results:
      name: "Wifi Scan"
      icon: "mdi:wifi-refresh"
      disabled_by_default: true


#https://esphome.io/guides/automations.html?highlight=restore_value#bonus-2-global-variables
globals: ##to set default reboot behavior
  # Wifi variables
  - id: wifi_connection
    type: bool
    restore_value: no
    initial_value: "false"

  - id: display_on_off
    type: bool
    restore_value: no
    initial_value: "true"

  - id: page_id
    type: int
    restore_value: no
    initial_value: "0"

  - id: last_page_id
    type: int
    restore_value: no
    initial_value: "3"

  - id: max_loops
    type: int
    restore_value: no
    initial_value: "3"

  - id: display_loops_counter
    type: int
    restore_value: no
    initial_value: "0"

  - id: debug_on_off
    type: bool
    restore_value: no
    initial_value: 'false'


i2c:
  sda: 21 #D2
  scl: 22 #D1

uart:
  - id: ld2410_uart
    tx_pin: 1
    rx_pin: 3
    baud_rate: 256000
    parity: NONE
    stop_bits: 1

  - id: co2_uart
    rx_pin: 16 #D4
    tx_pin: 17 #D3
    baud_rate: 9600

  - id: pms5003_uart
    rx_pin: 18 #D5
    tx_pin: 19 #D6
    baud_rate: 9600


ld2410:
  uart_id: ld2410_uart
  timeout: 150s
  max_move_distance : 6m
  max_still_distance: 0.75m
  g0_move_threshold: 10
  g0_still_threshold: 20
  g1_move_threshold: 10
  g1_still_threshold: 20
  g2_move_threshold: 20
  g2_still_threshold: 21
  g3_move_threshold: 30
  g3_still_threshold: 31
  g4_move_threshold: 40
  g4_still_threshold: 41
  g5_move_threshold: 50
  g5_still_threshold: 51
  g6_move_threshold: 60
  g6_still_threshold: 61
  g7_move_threshold: 70
  g7_still_threshold: 71
  g8_move_threshold: 80
  g8_still_threshold: 81


switch:
  - platform: restart
    name: "Restart"

  - platform: template
    name: "Calibrate CO2 Sensor"
    id : "calibrate_co2_sensor"
    disabled_by_default: true
    turn_on_action:
        - senseair.background_calibration: co2_sensor
        - logger.log: "CO2 Sensor Calibration Triggered! Must be done OUTDOORS!"
        
  - platform: template
    name: "CO2 Sensor Calibration Result"
    id : co2_sensor_calibration_result
    disabled_by_default: true
    turn_on_action:
        - senseair.background_calibration_result: co2_sensor

  # Source: https://github.com/airgradienthq/arduino/blob/master/AirGradient.cpp#L123
  - platform: template
    name: "PMS5003"
    id: pms_switch
    optimistic: true
    turn_on_action:
      - uart.write:
          id: pms5003_uart
          data: [0x42, 0x4D, 0xE4, 0x00, 0x01, 0x01, 0x74]
    turn_off_action:
      - uart.write:
          id: pms5003_uart
          data: [0x42, 0x4D, 0xE4, 0x00, 0x00, 0x01, 0x73]


button:
  - platform: safe_mode
    name: "Restart (Safe Mode)"


sensor:
  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: ${update_interval_wifi}
    device_class: signal_strength

  - platform: sht3xd
    temperature:
      id: temp
      name: Temperature
    humidity:
      id: humidity
      name: Humidity
    address: 0x44
    update_interval: 10s

  - platform: pmsx003
    type: PMSX003
    uart_id: pms5003_uart
    pm_1_0:
      id: pm1_0
      name: "Particulate <1.0µm"
    pm_2_5:
      id: pm2_5
      name: "Particulate <2.5µm"
    pm_10_0:
      id: pm10_0
      name: "Particulate <10.0µm"

  - platform: senseair
    id: co2_sensor
    uart_id: co2_uart
    co2:
      id: co2
      name: "CO2"
    update_interval: 60s

  - platform: ld2410
    moving_distance:
      name : Moving Distance
    still_distance:
      name: Still Distance
    moving_energy:
      name: Move Energy
    still_energy:
      name: Still Energy
    detection_distance:
      name: Detection Distance

binary_sensor:
  - platform: gpio
    pin:
      number: 26
      inverted: true
      mode:
        input: true
        pullup: true
    name: "Left Touch Sensor"
    id: touch_sensor_lx
    on_press:
      then:
        lambda: |-
          id(display_toggle).execute();

  - platform: gpio
    pin:
      number: 23
      inverted: true
      mode:
        input: true
        pullup: true
    name: "Right Touch Sensor"
    id: touch_sensor_rx
    on_press:
      then:
        lambda: |-
          id(display_toggle).execute();

  - platform: gpio
    name: "Occupancy Sensor"
    id: occupancy_sensor
    pin: 33
    device_class: occupancy

  - platform: ld2410
    has_target:
      name: Presence
    has_moving_target:
      name: Moving Target
    has_still_target:
      name: Still Target


font:
  # gfonts://family[@weight]
  - file: "gfonts://Roboto"
    id: roboto
    size: 12

  - file: "gfonts://Roboto"
    id: roboto_symbols
    size: 12
    glyphs: [
      "\U000000B5", #µ
      "\U00000067"  #g
      ]

  - file: "gfonts://Roboto"
    id: roboto_small
    size: 12

  - file: "gfonts://Roboto"
    id: roboto_medium
    size: 16
      
  - file: "gfonts://Roboto"
    id: roboto_large
    size: 32
 
  - file: "fonts/materialdesignicons-webfont.ttf"
    id: wifi_icon_font
    size: 12
    glyphs: [
      "\U000F05A9", #wifi
      "\U000F05AA"  #no wifi
      ]

  - file: "fonts/materialdesignicons-webfont.ttf"
    id: face_icon_font
    size: 48
    glyphs: [
      "\U000F01F5", #mdi-emoticon-happy-outline
      "\U000F01F6", #mdi-emoticon-neutral-outline
      "\U000F01F8"  #mdi-emoticon-sad-outline
      ]

# https://www.co2meter.com/blogs/news/co2-levels-at-home
# ~400 ppm 	background (normal) outdoor air levels
# 400- 1,000 ppm 	typical levels found in occupied spaces with good air exchange
# 1,000 – 2,000 ppm	levels associated with complaints of drowsiness and poor air
# 2,000 – 5,000 ppm	levels associated with headaches, sleepiness, and stagnant, stale, stuffy air,
# poor concentration, loss of attention, increased heart rate and slight nausea may also be present
# >5,000 ppm	Exposure may lead to serious oxygen deprivation symptoms

display:
  - platform: ssd1306_i2c
    id: device_display
    model: "SH1106 128x64"
    address: 0x3C
    rotation: 180
    flip_x: false
    flip_y: false
    offset_y: 0
    offset_x: 0
    external_vcc: true
    update_interval: 1s
    pages:
      - id: display_auto_off_warning
        lambda: |-
          it.rectangle(0, 0, 128, 64);
          it.printf(4, 4, id(roboto_small),  "Display will turn off");
          it.printf(4, 23, id(roboto_small), "automatically.");
          it.printf(4, 42, id(roboto_small), "Touch logo to turn on.");
      - id: page1
        lambda: |-
          //it.rectangle(0, 0, 128, 64);
          it.printf(  6,  8, id(roboto_medium), "CO2 ");
          it.printf( 92,  8, id(roboto_medium), TextAlign::TOP_RIGHT, "%5.0f", id(co2).state);
          it.printf(120, 11, id(roboto), TextAlign::TOP_RIGHT, "ppm");
          it.line( 0, 32, 128,  32);
          it.line(64, 32,  64, 128);
          it.printf(  4, 34, id(roboto), "C");
          it.printf( 68, 34, id(roboto), "RH");
          it.printf( 54, 40, id(roboto_medium), TextAlign::TOP_RIGHT, "%3.1f°", id(temp).state);
          it.printf(120, 40, id(roboto_medium), TextAlign::TOP_RIGHT, "%2.0f%%", id(humidity).state);
      - id: page2
        lambda: |-
          //it.rectangle(0, 0, 128, 64);
          it.printf(4, 4, id(roboto_medium), "PM 1: ");
          it.printf(105, 4, id(roboto_medium), TextAlign::TOP_RIGHT, "%4.0f", id(pm1_0).state);
          it.printf(124, 7, id(roboto_symbols), TextAlign::TOP_RIGHT, "µg");
          it.printf(4, 23, id(roboto_medium), "PM 2.5: ");
          it.printf(105, 23, id(roboto_medium), TextAlign::TOP_RIGHT, "%4.0f", id(pm2_5).state);
          it.printf(124, 26, id(roboto_symbols), TextAlign::TOP_RIGHT, "µg");
          it.printf(4, 42, id(roboto_medium), "PM 10: ");
          it.printf(105, 42, id(roboto_medium), TextAlign::TOP_RIGHT, "%4.0f", id(pm10_0).state);
          it.printf(124, 45, id(roboto_symbols), TextAlign::TOP_RIGHT, "µg");
      - id: page3
        lambda: |-
          //it.rectangle(0, 0, 128, 64);
          if ((id(co2).state <= 1000.0) && (id(pm2_5).state < 35 )) {
            it.printf(8, 8, id(face_icon_font), "%s", "\U000F01F5");   //mdi-emoticon-happy-outline
            it.printf(76, 14, id(roboto_medium), "ALL");
            it.printf(68, 34, id(roboto_medium), "GOOD");
          } else if ((id(co2).state > 1000.0 && id(co2).state < 2000.0) || (id(pm2_5).state >= 35 && id(pm2_5).state <= 50)) {
            it.printf(8, 8, id(face_icon_font), "%s", "\U000F01F6");   //mdi-emoticon-neutral-outline
            it.printf(74, 14, id(roboto_medium), "NOT");
            it.printf(68, 34, id(roboto_medium), "GOOD");
          } else {
            it.printf(8, 8, id(face_icon_font), "%s", "\U000F01F8");   //mdi-emoticon-sad-outline
            it.printf(72, 14, id(roboto_medium), "NOT");
            it.printf(68, 34, id(roboto_medium), "SAFE");
          }



interval:
  - interval: 10s
    then:
      - if:
          condition:
            lambda: 'return id(display_on_off) == true;' 
          then:
            - display.page.show: !lambda |-
                ESP_LOGD("DEBUG", "page_id: %d", id(page_id));
                ESP_LOGD("DEBUG", "display_loops_counter: %d", id(display_loops_counter));
                ESP_LOGD("DEBUG", "max_loops: %d", id(max_loops));
                switch (id(page_id)) {
                  case 0:
                    return id(display_auto_off_warning);
                    break;
                  case 1:
                    return id(page1);
                    break;
                  case 2:
                    return id(page2);
                    break;
                  case 3:
                    return id(page3);
                    break;
                  default:
                    return (id(page1));
                    break;
                  }
            - component.update: device_display
            - lambda: |-
                if(id(display_on_off)){
                  id(page_id) += 1;
                  if(id(page_id) == id(last_page_id) + 1) {
                    id(page_id) = 1;
                    id(display_loops_counter) += 1;
                  }
                  if(id(display_loops_counter) >= id(max_loops)) {
                      id(display_loops_counter) = 1;
                      id(device_display).turn_off();
                      id(display_on_off) = false;
                      ESP_LOGD("DEBUG", "Reached max loops. Display turned off.");
                  } 
                }

            

  - interval: 20s
    then:
      if:
        condition:
          wifi.connected:
        then:
          - globals.set:
              id: wifi_connection
              value: "true"
        else:
          - globals.set:
              id: wifi_connection
              value: "false"

  - interval: 150s
    # Two-minute interval to extend the life span of the PMS5003 sensor
    then:
      - switch.turn_on: pms_switch
      - delay: 30s
      - switch.turn_off: pms_switch

script:
  - id: display_toggle
    then:
      - lambda: |-
          if(id(display_on_off)) {
            id(device_display).turn_off();
            id(display_on_off) = false;
            ESP_LOGD("DEBUG", "Display turned off.");
          } else {
            id(page_id) = 1;
            id(display_loops_counter) = 0;
            id(display_on_off) = true;
            id(display_first_page).execute();
            id(device_display).turn_on();
            ESP_LOGD("DEBUG", "Display turned on.");
          }

  - id: display_first_page
    then:
      - display.page.show: page1
      - component.update: device_display

2 Likes

Very nice mod. Thank you for sharing!