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