substitutions: #substitute your own values in this section internal_temp_sensor: sensor.meat_heater_current_temperature #entity from Home Assistant outside_temp_sensor: sensor.home_realfeel_temperature #entity from Home Assistant weather_entity: weather.home #entity from Home Assistant todays_forecast_high_entity: sensor.home_realfeel_temperature_max_day_0 #entity from Home Assistant todays_forecast_low_entity: sensor.home_realfeel_temperature_min_day_0 #entity from Home Assistant esphome: name: airqualitysensor-1 friendly_name: AirQualitySensor-1 on_boot: priority: -100 then: - light.turn_on: id: led brightness: 0.4 effect: "Rainbow Effect" esp32: board: esp32-s3-devkitc-1 cpu_frequency: 240MHz variant: esp32s3 flash_size: 16MB framework: type: esp-idf # Enable logging logger: # Enable Home Assistant API api: encryption: key: "gnQzjc98wQKUP3qX6+FeU9JchMPtjJwlKQejOB/mQDU=" ota: - platform: esphome password: "d4d36e1c98d4b7e2069295540a20a1aa" wifi: ssid: !secret wifi_ssid password: !secret wifi_password # manual_ip: # static_ip: 192.168.1.61 # gateway: 192.168.1.1 # subnet: 255.255.255.0 # Enable fallback hotspot (captive portal) in case wifi connection fails ap: ssid: "Airqualitysensor-1" password: "MpBryi70smN4" captive_portal: time: - platform: homeassistant id: homeassistant_time on_time: - seconds: 0 minutes: 0 hours: 6 # 6 AM then: - logger.log: "Automatic morning turn ON OLED" - switch.turn_on: oled_power - seconds: 0 minutes: 0 hours: 19 # 7 PM then: - logger.log: "Automatic evening turn OFF OLED" - switch.turn_off: oled_power script: # SCRIPT 1: Runs when the mode is changed from the select - id: update_led_state then: - lambda: |- if (!id(iaq_reading).has_state() || id(iaq_reading).state == "Booting...") { return; // Exit script, leave rainbow alone } std::string quality = id(iaq_reading).state; float lux = id(ambient_light).state; std::string mode = id(led_mode).state; float brightness = 0.15; float r = 0.0, g = 0.0, b = 0.0; std::string selected_effect = "None"; // --- 1. Determine desired color --- if (quality == "Excellent") { r = 0.0; g = 1.0; b = 0.0; } else if (quality == "Good") { r = 0.3; g = 0.6; b = 0.3; } else if (quality == "Moderate") { r = 1.0; g = 0.85; b = 0.38; } else if (quality == "Poor") { r = 1.0; g = 0.0; b = 0.0; } else if (quality == "Unhealthy") { r = 1.0; g = 0.0; b = 1.0; selected_effect = "Alert Flash"; // Alert overrides mode } // --- 2. Determine desired effect based on mode (if not alerting) --- if (selected_effect != "Alert Flash") { if (mode == "Breathing") { selected_effect = "Breathing"; } else if (mode == "Scanner") { selected_effect = "Scanner"; } } // --- 3. Apply changes --- auto call = id(led).turn_on(); call.set_rgb(r, g, b); // Always set color if (selected_effect == "None") { // We want a solid light (Auto/Manual) if (mode == "Auto") { if (isnan(lux)) lux = 0; brightness = 0.15 + (lux / 200.0) * (0.7 - 0.15); if (brightness > 0.7) brightness = 0.7; if (brightness < 0.15) brightness = 0.15; } else { // Manual brightness = id(iaq_led_brightness).state; } call.set_brightness(brightness); } // This script *always* sets the effect call.set_effect(selected_effect); call.perform(); # SCRIPT 2: Runs when IAQ *value* changes (every 10s) - id: update_led_color then: - lambda: |- if (!id(iaq_reading).has_state() || id(iaq_reading).state == "Booting...") { return; // Exit script, leave rainbow alone } std::string quality = id(iaq_reading).state; std::string mode = id(led_mode).state; float lux = id(ambient_light).state; float r = 0.0, g = 0.0, b = 0.0; // --- 1. Determine desired color --- if (quality == "Excellent") { r = 0.0; g = 1.0; b = 0.0; } else if (quality == "Good") { r = 0.3; g = 0.6; b = 0.3; } else if (quality == "Moderate") { r = 1.0; g = 0.85; b = 0.38; } else if (quality == "Poor") { r = 1.0; g = 0.0; b = 0.0; } else if (quality == "Unhealthy") { r = 1.0; g = 0.0; b = 1.0; // Force Alert Flash and EXIT id(led).turn_on().set_rgb(r, g, b).set_effect("Alert Flash").perform(); return; } // --- 2. If we are here, AQ is OK. Reset Effect! --- auto call = id(led).turn_on(); call.set_rgb(r, g, b); // Determine which effect to restore based on the Select Mode std::string restore_effect = "None"; if (mode == "Breathing") restore_effect = "Breathing"; else if (mode == "Scanner") restore_effect = "Scanner"; // Explicitly set the effect (This stops the Alert Flash) call.set_effect(restore_effect); // --- 3. Apply Brightness --- if (mode == "Auto") { float brightness = 0.15; if (isnan(lux)) lux = 0; brightness = 0.15 + (lux / 200.0) * (0.7 - 0.15); if (brightness > 0.7) brightness = 0.7; if (brightness < 0.15) brightness = 0.15; call.set_brightness(brightness); } else if (mode == "Manual") { call.set_brightness(id(iaq_led_brightness).state); } call.perform(); globals: - id: iaq_index type: int restore_value: no initial_value: '0' - id: computed_brightness type: float restore_value: no initial_value: '0.15' bluetooth_proxy: active: true connection_slots: 3 uart: tx_pin: GPIO17 rx_pin: GPIO18 baud_rate: 9600 light: - platform: esp32_rmt_led_strip rgb_order: GRB chipset: WS2812 pin: GPIO16 num_leds: 5 name: "LED" id: led icon: mdi:led-on default_transition_length: 0s disabled_by_default: False effects: - pulse: name: "Breathing" min_brightness: 0.15 max_brightness: 0.45 transition_length: 3000ms update_interval: 3000ms - addressable_scan: name: "Scanner" scan_width: 2 move_interval: 100ms - flicker: name: "Alert Flash" alpha: 95% intensity: 1.5% - addressable_rainbow: name: "Rainbow Effect" speed: 10 width: 50 i2c: sda: GPIO8 scl: GPIO9 scan: true id: bus_a frequency: 100kHz switch: - platform: template name: "OLED Power" id: oled_power optimistic: true restore_mode: RESTORE_DEFAULT_ON turn_on_action: - logger.log: "OLED turned ON" turn_off_action: - logger.log: "OLED turned OFF" select: - platform: template name: "LED Mode" id: led_mode options: - "Auto" - "Manual" - "Breathing" - "Scanner" optimistic: true restore_value: true on_value: then: - script.execute: update_led_state number: - platform: template name: "IAQ LED Brightness" id: iaq_led_brightness min_value: 0.15 max_value: 1.0 step: 0.01 optimistic: true restore_value: true sensor: - platform: uptime name: Uptime Sensor - platform: homeassistant id: inside_temperature entity_id: $internal_temp_sensor internal: true - platform: homeassistant id: outside_temperature entity_id: $outside_temp_sensor internal: true - platform: homeassistant id: todays_forecast_high entity_id: $todays_forecast_high_entity internal: true - platform: homeassistant id: todays_forecast_low entity_id: $todays_forecast_low_entity internal: true - platform: pmsx003 type: PMSX003 pm_1_0: id: pm1 name: "PM <1.0µm Concentration" pm_2_5: id: pm25 name: "PM <2.5µm Concentration" pm_10_0: id: pm10 name: "PM <10.0µm Concentration" - platform: bme680 temperature: name: "BME680 Temperature" id: bmetemp oversampling: 16x pressure: name: "BME680 Pressure" id: bmepressure humidity: name: "BME680 Humidity" id: bmehum gas_resistance: name: "BME680 Gas Resistance" id: bmegas address: 0x77 update_interval: 10s ## CO²/VOC Sensor - platform: ccs811 eco2: name: "CCS811 CO²" accuracy_decimals: 0 id: eco2 tvoc: name: "CCS811 T-VOC" accuracy_decimals: 0 id: tvoc address: 0x5A update_interval: 10s temperature: bmetemp humidity: bmehum ## After Calibration, Uncomment and change "baseline:" baseline: 0x9CB1 - platform: template name: "Humidity Sensor" id: humi unit_of_measurement: "%" accuracy_decimals: 1 lambda: |- return id(bmehum).state; update_interval: 10s - platform: wifi_signal name: AQ WiFi Signal update_interval: 60s - platform: veml7700 address: 0x10 update_interval: 10s # short variant of sensor definition: ambient_light: name: "Ambient Light" id: ambient_light filters: - sliding_window_moving_average: window_size: 5 send_every: 1 font: - file: "fonts/Roboto-Regular.ttf" id: robotto size: 10 - file: "fonts/Roboto-Regular.ttf" id: font2 size: 12 - file: "fonts/Poppins-Regular.ttf" id: font1 size: 10 - file: "fonts/Poppins-Regular.ttf" id: poppinslarger size: 12 - file: "fonts/Poppins-SemiBold.ttf" id: poppinsbold size: 10 - file: 'fonts/materialdesignicons-webfont.ttf' id: font3 size: 18 glyphs: - "\U000F13D5" #mdi:home-minus-outline - file: 'fonts/materialdesignicons-webfont.ttf' id: font4 size: 40 glyphs: - "\U000F0594" #"clear-night" - "\U000F0590" #"cloudy" - "\U000F0591" #"fog" - "\U000F0592" #"hail" - "\U000F0593" #"lightning" - "\U000F067E" #"lightning-rainy" - "\U000F0595" #"partlycloudy" - "\U000F0596" #"pouring" - "\U000F0597" #"rainy" - "\U000F0598" #"snowy" - "\U000F067F" #"snowy-rainy" - "\U000F0599" #"sunny" - "\U000F059D" #"windy" - "\U000F059E" #"windy-variant" display: - platform: ssd1306_i2c model: "SSD1306 128x64" address: 0x3C id: oled_display rotation: 0 i2c_id: bus_a pages: - id: page_air_quality lambda: |- if (id(oled_power).state) { // only draw if display is ON { // Combine the final string first char full_text[64]; if (id(iaq_reading).has_state()) { sprintf(full_text, "Air Quality: %s", id(iaq_reading).state.c_str()); } else { sprintf(full_text, "Air Quality: Booting..."); } int x, y, w, h; it.get_text_bounds(0, 0, full_text, id(poppinsbold), TextAlign::TOP_LEFT, &x, &y, &w, &h); int center_x = (128 - w) / 2; // OLED is 128px wide it.printf(center_x, 0, id(poppinsbold), "%s", full_text); } // --- Temp + Humidity on same line --- it.printf(0, 16, id(font1), "TEMP: %.1f°C", id(bmetemp).state); it.printf(70, 16, id(font1), "HUM: %.0f%%", id(bmehum).state); // --- Other sensor lines --- it.printf(0, 26, id(font1), "CO2: %.0f ppm", id(eco2).state); it.printf(0, 36, id(font1), "TVOC: %.0f ppb", id(tvoc).state); if (!isnan(id(pm25).state)) { it.printf(0, 46, id(font1), "PM2.5: %.0f", id(pm25).state); } else { it.printf(0, 46, id(font1), "PM2.5: ---"); } } - id: page_environment lambda: |- if (id(oled_power).state) { // only draw if display is ON { // Combine the final string first char full_text[64]; if (id(iaq_reading).has_state()) { sprintf(full_text, "Air Quality: %s", id(iaq_reading).state.c_str()); } else { sprintf(full_text, "Air Quality: Booting..."); } int x, y, w, h; it.get_text_bounds(0, 0, full_text, id(poppinsbold), TextAlign::TOP_LEFT, &x, &y, &w, &h); int center_x = (128 - w) / 2; // OLED is 128px wide it.printf(center_x, 0, id(poppinsbold), "%s", full_text); } // --- Temp + Humidity on same line --- it.printf(0, 16, id(font1), "TEMP: %.1f°C", id(bmetemp).state); it.printf(70, 16, id(font1), "HUM: %.0f%%", id(bmehum).state); // --- Other sensor lines --- it.printf(0, 26, id(font1), "CO2: %.0f ppm", id(ambient_light).state); } - id: page_weather lambda: |- if (id(weather_state).has_state()) { std::map weather_icon_map { {"clear-night", "\U000F0594"}, {"cloudy", "\U000F0590"}, {"fog", "\U000F0591"}, {"hail", "\U000F0592"}, {"lightning", "\U000F0593"}, {"lightning-rainy", "\U000F067E"}, {"partlycloudy", "\U000F0595"}, {"pouring", "\U000F0596"}, {"rainy", "\U000F0597"}, {"snowy", "\U000F0598"}, {"snowy-rainy", "\U000F067F"}, {"sunny", "\U000F0599"}, {"windy", "\U000F059D"}, {"windy-variant", "\U000F059E"}, }; it.printf(0, it.get_height(), id(font4), TextAlign::BASELINE_LEFT, weather_icon_map[id(weather_state).state.c_str()].c_str()); } // Print time in HH:MM format it.strftime(0, 0, id(font1), TextAlign::TOP_LEFT, "%H:%M %a", id(homeassistant_time).now()); //Print day of week //it.strftime(40, 0, id(font1), TextAlign::TOP_LEFT, "%a", id(homeassistant_time).now()); it.line(0, 20, it.get_width(), 20); //Print home icon //it.printf(70, 0, id(font3), "\U000F13D5"); // Print inside temperature (from homeassistant sensor) if (id(inside_temperature).has_state()) { it.printf(it.get_width(), 0, id(font1), TextAlign::TOP_RIGHT , "%7.1f°", id(inside_temperature).state); } // Print outside temperature (from homeassistant sensor) if (id(outside_temperature).has_state()) { it.printf(42, 32, id(font2), "%.1f°", id(outside_temperature).state); } // Print Forecast High if (id(todays_forecast_high).has_state()) { it.printf(it.get_width(), 32, id(font1), TextAlign::TOP_RIGHT, "%7.1f°", id(todays_forecast_high).state); } // Print Forecast Low if (id(todays_forecast_low).has_state()) { it.printf(it.get_width(), 48, id(font1), TextAlign::TOP_RIGHT, "%7.1f°", id(todays_forecast_low).state); } text_sensor: - platform: homeassistant id: weather_state name: "Current Weather Icon" entity_id: $weather_entity internal: true - platform: template name: "PM 2.5 Air Quality" icon: mdi:air-filter id: aq_reading lambda: |- if (id(pm25).state <= 12) { return {"Good"}; } else if ((id(pm25).state >= 12.1) && (id(pm25).state <= 35.4)) { return {"Moderate"}; } else if ((id(pm25).state >= 35.5) && (id(pm25).state <= 55.4)) { return {"Unhealthy(SG)"}; } else if ((id(pm25).state >= 55.5) && (id(pm25).state <= 150.4)) { return {"Unhealthy"}; } else if ((id(pm25).state >= 150.5) && (id(pm25).state <= 250.4)) { return {"Very Unhealthy"}; } else if (id(pm25).state >= 250.5) { return {"Hazardous"}; } return {}; update_interval: 30s - platform: template name: "PM 10 Air Quality" icon: mdi:air-filter id: aq_10_reading lambda: |- if (id(pm10).state <= 54) { return {"Good"}; } else if ((id(pm10).state >= 55) && (id(pm10).state <= 154)) { return {"Moderate"}; } else if ((id(pm10).state >= 155) && (id(pm10).state <= 254)) { return {"Unhealthy(SG)"}; } else if ((id(pm10).state >= 255) && (id(pm10).state <= 354)) { return {"Unhealthy"}; } else if ((id(pm10).state >= 355) && (id(pm10).state <= 424)) { return {"Very Unhealthy"}; } else if (id(pm10).state >= 425) { return {"Hazardous"}; } return {}; update_interval: 30s - platform: template name: "Livingroom IAQ" icon: "mdi:air-filter" id: iaq_reading lambda: |- // 1. Safety Check: Ensure all sensors have valid numbers if (isnan(id(humi).state) || isnan(id(pm25).state) || isnan(id(eco2).state) || isnan(id(tvoc).state)) { return {"Booting..."}; } id(iaq_index) = 0; if (id(humi).state < 10 or id(humi).state > 90) { id(iaq_index) += 1; } else if (id(humi).state < 20 or id(humi).state > 80) { id(iaq_index) += 2; } else if (id(humi).state < 30 or id(humi).state > 70) { id(iaq_index) += 3; } else if (id(humi).state < 40 or id(humi).state > 60) { id(iaq_index) += 4; } else if (id(humi).state >= 40 and id(humi).state <= 60) { id(iaq_index) += 5; } if (id(pm25).state <= 12) { id(iaq_index) += 6; } else if ((id(pm25).state >= 12.1) && (id(pm25).state <= 35.4)) { id(iaq_index) += 5; } else if ((id(pm25).state >= 35.5) && (id(pm25).state <= 55.4)) { id(iaq_index) += 4; } else if ((id(pm25).state >= 55.5) && (id(pm25).state <= 150.4)) { id(iaq_index) += 3; } else if ((id(pm25).state >= 150.5) && (id(pm25).state <= 250.4)) { id(iaq_index) += 2; } else if (id(pm25).state >= 250.5) { id(iaq_index) += 1; } if (id(eco2).state <= 600) { id(iaq_index) += 5; } else if (id(eco2).state <= 800) { id(iaq_index) += 4; } else if (id(eco2).state <= 1500) { id(iaq_index) += 3; } else if (id(eco2).state <= 1800) { id(iaq_index) += 2; } else if (id(eco2).state > 1800) { id(iaq_index) += 1; } if (id(tvoc).state <= 65) { id(iaq_index) += 5; } else if (id(tvoc).state <= 220) { id(iaq_index) += 4; } else if (id(tvoc).state <= 660) { id(iaq_index) += 3; } else if (id(tvoc).state <= 2200) { id(iaq_index) += 2; } else if (id(tvoc).state > 2200) { id(iaq_index) += 1; } ESP_LOGD("main", "Current IAQ index %d", id(iaq_index)); if (id(iaq_index) <= 11) { return {"Unhealthy"}; } else if (id(iaq_index) <= 14) { return {"Poor"}; } else if (id(iaq_index) <= 17) { return {"Moderate"}; } else if (id(iaq_index) <= 19) { return {"Good"}; } else if (id(iaq_index) > 19) { return {"Excellent"}; } return {}; update_interval: 10s on_value: then: - script.execute: update_led_color - platform: template name: "Livingroom IAQ Calculation" icon: "mdi:air-filter" id: iaq_reading_calculation lambda: |- id(iaq_index) = 0; if (id(humi).state < 10 or id(humi).state > 90) { id(iaq_index) += 1; } else if (id(humi).state < 20 or id(humi).state > 80) { id(iaq_index) += 2; } else if (id(humi).state < 30 or id(humi).state > 70) { id(iaq_index) += 3; } else if (id(humi).state < 40 or id(humi).state > 60) { id(iaq_index) += 4; } else if (id(humi).state >= 40 and id(humi).state <= 60) { id(iaq_index) += 5; } if (id(pm25).state <= 12) { id(iaq_index) += 6; } else if ((id(pm25).state >= 12.1) && (id(pm25).state <= 35.4)) { id(iaq_index) += 5; } else if ((id(pm25).state >= 35.5) && (id(pm25).state <= 55.4)) { id(iaq_index) += 4; } else if ((id(pm25).state >= 55.5) && (id(pm25).state <= 150.4)) { id(iaq_index) += 3; } else if ((id(pm25).state >= 150.5) && (id(pm25).state <= 250.4)) { id(iaq_index) += 2; } else if (id(pm25).state >= 250.5) { id(iaq_index) += 1; } if (id(eco2).state <= 600) { id(iaq_index) += 5; } else if (id(eco2).state <= 800) { id(iaq_index) += 4; } else if (id(eco2).state <= 1500) { id(iaq_index) += 3; } else if (id(eco2).state <= 1800) { id(iaq_index) += 2; } else if (id(eco2).state > 1800) { id(iaq_index) += 1; } if (id(tvoc).state <= 65) { id(iaq_index) += 5; } else if (id(tvoc).state <= 220) { id(iaq_index) += 4; } else if (id(tvoc).state <= 660) { id(iaq_index) += 3; } else if (id(tvoc).state <= 2200) { id(iaq_index) += 2; } else if (id(tvoc).state > 2200) { id(iaq_index) += 1; } ESP_LOGD("main", "Current IAQ index %d", id(iaq_index)); return std::to_string(id(iaq_index)); update_interval: 10s