diff --git a/README.md b/README.md index 4f6d17b..42c715c 100644 --- a/README.md +++ b/README.md @@ -1,317 +1,14 @@ -# SuperSensor v2.x +# Repository Unification -The SuperSensor is an all-in-one voice, motion, presence, temperature/humidity/air -quality, and light sensor, built on an ESP32 with ESPHome, and inspired -heavily by the EverythingSmartHome Everything Presence One sensor and the -HomeAssistant "$13 Voice Assistant" project. +For future expandability to make my life a bit easier, I'm deprecating this repo +and moving the SuperSensor 2.x code back to the original https://github.com/joshuaboniface/supersensor +repository in the branch `v2.x`. Sorry for the inconvenience! -Use SuperSensors around your house to provide HomeAssistant Voice Assist -interfaces with wake word detection, as well as other sensor detection options -as you want them. +Please update your package configuration URLs to the following: +``` +packages: + joshuaboniface.supersensor: github://joshuaboniface/supersensor/supersensor.yaml@v2.x +``` -Assist feedback is provided by a pair of common-cathode RGB LED. No speakers -or annoying TTS feedback here! With [an optional 3D Printed case and a clear -diffuser cover](/case), the LEDs can be turned into a sleek light bar on the bottom -of the unit for quick and easy confirmation of voice actions, or just use -it bare if you like the "PCB on a wall" aesthetic. - -To Use: - - * Install the ESPHome configuration `supersensor.yaml` to a compatible ESP32 devkit (below). - * Install the ESP32 and sensors into the custom PCB. - * Power up the SuperSensor, connect to the WiFi AP, and connect it to your network. - * Install the SuperSensor somewhere that makes sense. - * Add/adopt the SuperSensor to HomeAssistant using the automatic name. - * Tune the SuperSensor values to your needs. - -For more details, please [see my first blog post on the original SuperSensor project](https://www.boniface.me/posts/the-supersensor/) -and [my update post on version 2.0](https://www.boniface.me/posts/the-supersensor-2.0). - -**NOTE: For those with v1.x hardware, see [the repository for that code instead](https://github.com/joshuaboniface/supersensor).** - -## Major Changes from 1.x - -1. Replaced the Bosch BME680 with the Sensirion SHT45 and Sensirion SGP41. - - The BME680 proved to be woefully unreliable in my testing. Temperature was fairly accurate (internal heating and offset notwithstanding), - but humidity was wildly off of what other thermometers/hydrometers would report. In addition, the AQ functionality of the sensor was a - source of much frustration and I was never able to get it to work reliably, either with the official BSEC library or with my own attempts - at self-configuration. - - Thus, this sensor has been replaced with two Sensirion sensors which in my experience so far have been much more reliable and consistent. - There is a slight cost increase due to these sensors, but not signfigant enough to outweigh the benefit of reliable monitoring they confer. - -2. Replaced the SR602 PIR sensor with the AM312 PIR sensor. - - The SR602 was, in my experience, prone to constant false misfirings and hard to enclose due to its shape. In addition its orientation is - awkward (pins on the left or right side) which made building around it difficult. Thus, this sensor has been replaced with the nicer, - more straight AM312 PIR design. This does add vertical height to the sensor but I consider this a good tradeoff, and think it looks neat. - - Note that like all cheap PIRs, the AM312 is prone to misfiring if exposed. For this reason, [the case](/case) is recommended for any serious - PIR use-cases. - -3. Completely redesigned the custom PCB around the above sensor changes, which is now more compact in a 50x55mm almost-square configuration. - -4. Significantly cleaned up the ESPHome configuration, to support the above sensors and remove a lot of cruft that was caused by the BME680. - -## Parts List - -| Qty | Component | Cost (2025/05 CAD, ex. shipping) | Links | -|-------|--------------------|----------------------------------|-------| -| 1 | GY-SGP41 | $11.08 | [AliExpress](https://www.aliexpress.com/item/1005006746827606.html) | -| 1 | GY-SHT45 | $5.67 | [AliExpress](https://www.aliexpress.com/item/1005008175340220.html)* | -| 1 | AM312 | $1.97 | [AliExpress](https://www.aliexpress.com/item/1005008067324301.html) | -| 1 | TSL2591 | $4.59 | [AliExpress](https://www.aliexpress.com/item/1005008619462097.html) | -| 1 | HL-LD2510C | $4.79 | [AliExpress](https://www.aliexpress.com/item/1005006000579211.html)* | -| 1 | INMP441 | $2.93 | [AliExpress](https://www.aliexpress.com/item/1005002902615623.html) | -| 1 | ESP32 HW-395 | $6.67 | [AliExpress](https://www.aliexpress.com/item/1005006019875837.html)* | -| 2 | RBG LED | $0.09 ($9.12/100) | [Amazon](https://www.amazon.ca/dp/B09Y8M2PKS) | -| 1 | 470Ω resistor | $0.08 ($7.99/100) | [Amazon](https://www.amazon.ca/dp/B08MKQX2XT) | -| 2 | Female pin header† | $1.59 ($15.99/10) | [Amazon](https://www.amazon.ca/dp/B08CMNRXJ1) | -| 1 | Custom PCB (JLC) | $0.69 ($6.89/10) | [GitHub](https://github.com/joshuaboniface/supersensor) | -| **TOTAL** | | **$41.74** | | - -`*` Ensure you select the correct device on the page as it shows multiple options. - -`†` One of these sets is optional, and is useful if you do not want to solder the individual sensors directly to the board (see below). - -### To Solder or Not To Solder - -I strongly encourage anyone building one of these units to leverage sockets for all components. First, -this provides good spacing between components which can help with general better performance. It can -also allow for quick swapping if any turn out to be defective or if future changes are warranted. - -**If you use the [case](/case), it is sized assuming socketed components!** So for case users you -must socket all components. - -Note that due to the PCB design, you *must socket at least one* set of components - either the ESP32 -or the sensors on the front. Due to the positioning and overlap, it would be impossible to solder -everything directly to the board, as the ESP covers several of the solder points of the front -sensors and vice versa. - -You can use the provided 40-pin female headers exclusively if you wish, and cut them to length for -the individual sensors as needed, or you can use individually-sized female headers in the following -quantities should you wish for a slightly neater finish: - -* 3x 3-pin (AM302, INMP441 x2) -* 2x 4-pin (SGP41, SHT45) -* 1x 5-pin (LD2410C) -* 1x 6-pin (TSL2591) - -I will leave it up to the reader to source these specific sizes if they desire (I found all except -a 5-pin on Amazon, and just used a 6-pin with one pin removed). - -I still directly solder the RGB LEDs and resistor to the board for simplicity as these very small -leads are not easily socketed, and these components are so inexpensive as to be effectively -disposable along with the PCB should that be required. - -### Part Swaps - -To save a little money, it is possible to swap out the two Sensirion sensors for their less-feature- -rich peers, with no code changes: - -* SGP41 -> SGP40 - removes the NOx functionality -* SHT45 -> SHT40/41/43 - less accuracy - -Personally, I do not find the minimal cost savings to be worth sacrificing the extra potential -functionality, so I recommend using the provided models, but this is up to the builder to decide. - -No other parts can be easily swapped without code or PCB design changes. - -## Air Quality Handling - -The SuperSensor 2.0 features an SGP41 air quality sensor by Sensirion. This is a powerful AQ -sensor which powers several commercial devices including the AirGradient One, which gave -us a lot of our configuration via their sharing of algorithms. - -The sensor provides two base readings: a VOC Index, and a NOx Index. These values are both -floating references centered at 100 (VOC) and 1 (NOx), where that value represents "normal" -air over the previous 24 hours. These sensors are very useful for any sort of quick-change -automations, e.g. turn on a fan if levels spike due to cooking. - -In addition, we leverage AirGradient's published forumulas to convert the VOC index into -actual VOC quantities, in both µg/m³ and ppb. While this may drift due to the sensor's regular -internal recalibration, I feel that following what AirGradient does is sufficient enough -for any real-world home usage. Further, we use a very rough conversion of the aforementioned -VOC quantity into an eCO2 reading, using Isobutylene as a reference gas. These sensors are -more useful for display purposes, to show the current levels in a room in a dashboard or -other such place, for human consumption. Note that no such conversions are done for NOx as -there are no (that I can find) published empirical calculations for this conversion, unlike -for VOCs via AirGradient. - -Note however that like all MOx sensors, the SGP41 does not differentiate gasses, and as -such cannot tell the difference between normal, everyday natural VOCs like those in -breath or from e.g. ripening fruit, and dangerous VOCs from e.g. construction materials. -It also reacts strongly to heavy humidity, resulting in higher values in such environments. -These should be used only as a general indication of air quality over short periods, rather -than an absolute reference over long periods (much to my own frustration but inevitable -begruding acceptance). - -## Room Health - -The SuperSensor 2.0 leverages the outputs of the SHT45 and SGP41 sensors to calculate a -"Room Health", expressed as a percentage, which represents how "healthy" i.e. comfortable -a room is for a person to be in. - -The room health is calculated based on the VOC level, temperature, and relative humidity. -First, the raw value is converted to a per-sensor 100-0 scale as follows: - - * For VOC levels, there is a set of linear scales based on common VOC level - mappings, such that less than 200 ppb is 100, 200-400 maps to 100-90, - 400-600 maps to 90-70, 600-1500 maps to 70-40, 1500-3000 maps to 40-0, and - greater than 3000 is 0. - * For temperature and humidity, there is a single linear scale based on a - configurable penalty value, such that a value between the configurable - minimum and maximum is 100, and each degree C or %RH outside of that range - decreases the value by the penalty value. - -Next, each indivdual per-sensor value is applied to the total 100-0 value by a configurable -weight, with the defaults being 40% to VOC level, 30% to temperature, and 30% to humidity. The -values can never total more than 100% or total to 0% but are otherwise normalized (i.e. decrease -others before increasing one, or the values will not be accepted; and at least one weight -must be >0). - -The final result is thus a 100-0% range that, in broad strokes, describes the overall -health of the room. For some examples, assuming all of the default values below: - - * Perfect: Temp 23C, humidity 50%RH, and VOC level 150ppb = 100% health - * A little warm: Temp 25C (+1), humidity 50%RH, and VOC level 250ppb = 97% health - * Dry: Temp 22C, humidity 30%RH (-10), VOC level 150ppb = 91% health - * Dirty air: Temp 23C, humidity 50%RH, VOC level 800ppb = 85% health - * Hot & humid: Temp 28C, humidity 70%RH, VOC level 250ppb = 84% health - * All-around bad: Temp 30C, humidity 30%RH, VOC level 2000ppb = 52% health - -These are then mapped to textual values as well with the following bands: - - * 100%-95%: Great - * 95%-90%: Good - * 90%-80%: Fair - * 80%-60%: Poor - * 60%-0%: Bad - -As mentioned above, most portions of this are configurable; see the section below for -specific details of each configuration value. - -## Configurable Options - -There are several UI-configurable options with the SuperSensor to help you -get the most out of the sensor for your particular use-case. - -**Note:** Configuration of the LD2410C is excluded here, as it is extensively -configurable. See [the documentation](https://esphome.io/components/sensor/ld2410.html) for more details on its options. - -### Enable Voice Support (switch) - -If enabled (the default), the SuperSensor's voice functionality including -wake word will be started, or it can be disabled to use the SuperSensor -purely as a presence/environmental sensor. - -### Enable Presence LED (switch) - -If enabled (the default), when overall presence is detected, the LEDs will -glow "white" at 15% power to signal presence. - -### Temperature Offset (number, -30 to +10 @ 0.1, -5 default) - -Allows calibration of the SHT45 temperature sensor with an offset from -30 to +10 -degrees C. Useful if the sensor is misreporting actual ambient tempreatures. Due -to internal heating of the SHT45 by the ESP32, this defaults to -5; further -calibration may be needed for your sensors and environment based on an external -reference. - -### Humidity Offset (number, -20 to +20 @ 0.1) - -Allows calibration of the SHT45 humidity sensor with an offset from -10 to +10 -percent relative humidity. Useful if the sensor is misreporting actual humidity -based on an external reference. - -### PIR Hold Time (number, 0 to +60 @ 5, 0 default) - -The SuperSensor uses an AM312 PIR sensor, which has a stock hold time of ~2.5 -seconds. This setting allows increasing that value, with retrigger support, to -up to 60 seconds, allowing the PIR detection to report for longer. 0 represents -"as long as the AM312 fires". - -### Light Threshold Control (number, 0 to +200 @ 5, 30 default) - -The SuperSensor features a "light presence" binary sensor based on the light -level reported by the TSL2591 sensor. This control defines the minimum lux -value from the sensor to be considered "presence". For instance, if you have -a room that is usually dark at 0-5 lux, but illuminated to 100 lux when a -(non-automated) light switch is turned on, you could set a threshold here -of say 30 lux: then, while the light is on, "light presence" is detected, -and when the light is off, "light presence" is cleared. Light presence can -be used standalone or as part of the integrated occupancy sensor (below). - -### Integrated Occupancy Sensor (selector) - -The SuperSensor features a fully integrated "occupancy" sensor, which can be -configured to provide exactly the sort of occupancy detection you may want -for your room. - -There are 7 options (plus "None"/disabled), with both "detect" and "clear" -handled separately. Occupancy is always detected when ALL of the selected -sensors report detection, and occupancy is always cleared when ANY of the -selected sensors stop reporting detection (logical AND in, logical OR out). - - * PIR + Radar + Light - * PIR + Radar - * PIR + Light - * Radar + Light - * PIR Only - * Radar Only - * Light Only - * None - -### Room Health Sensor - -#### Minimum Temperature (number, 15 to 30 @ 0.5, 21 default) - -The lower bounds of a fully comfortable temperature; temperature values below -this value will begin decreasing the room health score. - -#### Maximum Temperature (number, 15 to 30 @ 0.5, 24 default) - -The upper bounds of a fully comfortable temperature; temperature values above -this value will begin decreasing the room health score. - -#### Temperature Penalty (number, 1 to 20 @ 1, 10 default) - -The penalty value per degree of temperature deviation from ideal levels, applied -to the pre-weighting value for temperature. - -#### Minimum Humidity (number, 20 to 80 @ 1, 40 default) - -The lower bounds of a fully comfortable relative humidity level; relative -humidity values below this value will begin decreasing the room health score. - -#### Maximum Humidity (number, 20 to 80 @ 1, 60 default) - -The upper bounds of a fully comfortable relative humidity level; relative -humidity values above this value will begin decreasing the room health score. - -#### Humidity Penalty (number, 1 to 10 @ 1, 5 default) - -The penalty value per % of relative humidity deviation from ideal levels, applied -to the pre-weighting value for humidity. - -#### VOC Weight (number, 0.0 to 1.0, 0.4 default) - -The weighting value of the VOC score relative to the other two for calculating -the total room health. - -Note: Cannot exceed 0.4 without first decreasing one of the other weights (total max of 1.0). - -#### Temperature Weight (number, 0.0 to 1.0, 0.3 default) - -The weighting value of the Temperature score relative to the other two for -calculating the total room health. - -Note: Cannot exceed 0.3 without first decreasing one of the other weights (total max of 1.0). - -#### Humidity Weight (number, 0.0 to 1.0, 0.3 default) - -The weighting value of the Humidity score relative to the other two for calculating -the total room health. - -Note: Cannot exceed 0.3 without first decreasing one of the other weights (total max of 1.0). +This repository will remain with a package redirect for a while but will no longer be the source of truth +for Issues, etc. Please direct all activity to that other repository instead! diff --git a/supersensor.yaml b/supersensor.yaml index 746b6f1..c97b470 100644 --- a/supersensor.yaml +++ b/supersensor.yaml @@ -1,1507 +1,5 @@ ---- - -############################################################################### -# SuperSensor v2.0 ESPHome configuration -############################################################################### # -# Copyright (C) 2025 Joshua M. Boniface +# Redirect to https://github.com/supersensor/supersensor.yaml@v2.x for repository unification pending v3.x # -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, version 3. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -############################################################################### - -esphome: - name: supersensor - name_add_mac_suffix: true - friendly_name: "Supersensor" - project: - name: joshuaboniface.supersensor - version: "2.0" - min_version: 2025.5.0 - on_boot: - - priority: 600 - then: - - light.turn_on: - id: output_led - - delay: 0.25s - - light.turn_off: - id: output_led - - delay: 0.25s - - light.turn_on: - id: output_led - - delay: 0.25s - - light.turn_off: - id: output_led - - lambda: |- - id(supersensor_occupancy).publish_state(false); - id(pir_presence).publish_state(false); - id(light_presence).publish_state(false); - id(radar_presence).publish_state(false); - - priority: -100 - then: - - if: - condition: - - switch.is_on: enable_voice_support - then: - - micro_wake_word.start: - -preferences: - flash_write_interval: 15sec - -dashboard_import: - package_import_url: github://joshuaboniface/supersensor2/supersensor.yaml - -esp32: - board: esp32dev - framework: - type: esp-idf - sdkconfig_options: - CONFIG_ESP32_DEFAULT_CPU_FREQ_240: "y" - CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ: "240" - CONFIG_ESP32_DATA_CACHE_64KB: "y" - CONFIG_ESP32_DATA_CACHE_LINE_64B: "y" - CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y" - CONFIG_ESP32S3_DEFAULT_CPU_FREQ_MHZ: "240" - CONFIG_ESP32S3_DATA_CACHE_64KB: "y" - CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" - CONFIG_SPIRAM_CACHE_WORKAROUND: "y" - CONFIG_OPTIMIZATION_LEVEL_RELEASE: "y" - -globals: - # Defaults to -5 due to heating from the ESP - - id: temperature_offset - type: float - restore_value: true - initial_value: "-5.0" - - - id: humidity_offset - type: float - restore_value: true - initial_value: "0.0" - - - id: room_health_temperature_min - type: float - restore_value: true - initial_value: "21.0" - - - id: room_health_temperature_max - type: float - restore_value: true - initial_value: "24.0" - - - id: room_health_temperature_penalty - type: float - restore_value: true - initial_value: "10.0" - - - id: room_health_humidity_min - type: float - restore_value: true - initial_value: "40.0" - - - id: room_health_humidity_max - type: float - restore_value: true - initial_value: "60.0" - - - id: room_health_humidity_penalty - type: float - restore_value: true - initial_value: "5.0" - - - id: room_health_voc_weight - type: float - restore_value: true - initial_value: "0.4" - - - id: room_health_temperature_weight - type: float - restore_value: true - initial_value: "0.3" - - - id: room_health_humidity_weight - type: float - restore_value: true - initial_value: "0.3" - - - id: pir_hold_time - type: int - restore_value: true - initial_value: "15" - - - id: light_presence_threshold - type: int - restore_value: true - initial_value: "30" - - - id: occupancy_detect_mode - type: int - restore_value: true - initial_value: "0" - - - id: occupancy_clear_mode - type: int - restore_value: true - initial_value: "0" - - - id: last_api_connected_time - type: uint32_t - restore_value: no - initial_value: "0" - - - id: current_wake_word - type: std::string - restore_value: yes - initial_value: '"mww_computer"' - - - id: light_is_holding - type: bool - restore_value: no - initial_value: 'false' - -script: - - id: light_off - then: - - if: - condition: - lambda: 'return !id(light_is_holding);' - then: - - if: - condition: - - binary_sensor.is_on: supersensor_occupancy - - switch.is_on: enable_presence_led - then: - - light.turn_on: - id: output_led - brightness: 25% - red: 1 - green: 1 - blue: 1 - transition_length: 1s - else: - - light.turn_off: - id: output_led - transition_length: 1s - - - id: pir_handler - then: - - lambda: |- - id(pir_presence).publish_state(true); - - while: - condition: - binary_sensor.is_on: pir_gpio - then: - - delay: !lambda 'return(id(pir_hold_time) * 1000);' - - lambda: |- - id(pir_presence).publish_state(false); - - - id: light_handler - then: - - lambda: |- - if (id(tsl2591_lux).state >= id(light_presence_threshold)) { - id(light_presence).publish_state(true); - } else { - id(light_presence).publish_state(false); - } - - - id: occupancy_detect_handler - then: - - lambda: |- - ESP_LOGD("occupancy_detect_handler", "Occupancy detect handler triggered"); - - // Get the current values of our presence sensors - bool pir = id(pir_presence).state; - bool radar = id(radar_presence).state; - bool light = id(light_presence).state; - - // Determine if PIR counts (2nd bit of presence_type) - int pir_counts = (id(occupancy_detect_mode) & ( 1 << 2 )) >> 2; - - // Determine if Radar counts (1st bit of presence_type) - int radar_counts = (id(occupancy_detect_mode) & ( 1 << 1 )) >> 1; - - // Determine if Light counts (0th bit of presence_type) - int light_counts = (id(occupancy_detect_mode) & ( 1 << 0 )) >> 0; - - // Determine our results - bool new_state = false; - if (pir_counts & radar_counts & light_counts) { - // Logical AND of pir & radar & light - new_state = pir & radar & light; - } else if (pir_counts & radar_counts) { - // Logical AND of pir & radar - new_state = pir & radar; - } else if (pir_counts & light_counts) { - // Logical AND of pir & light - new_state = pir & light; - } else if (radar_counts & light_counts) { - // Logical AND of radar & light - new_state = radar & light; - } else if (pir_counts) { - // Only pir - new_state = pir; - } else if (radar_counts) { - // Only radar - new_state = radar; - } else if (light_counts) { - // Only light - new_state = light; - } - - ESP_LOGD("occupancy_detect_handler", "New state: %s", new_state ? "true" : "false"); - - // Force update even if state hasn't changed - id(supersensor_occupancy).publish_state(new_state); - - // Add a delayed re-publish to ensure state propagation - if (new_state) { - id(supersensor_occupancy).publish_state(new_state); - } - - - id: occupancy_clear_handler - then: - - lambda: |- - ESP_LOGD("occupancy_clear_handler", "Occupancy clear handler triggered"); - - // Get the current values of our presence sensors - bool pir = id(pir_presence).state; - bool radar = id(radar_presence).state; - bool light = id(light_presence).state; - - // Determine if PIR counts (2nd bit of presence_type) - int pir_counts = (id(occupancy_clear_mode) & ( 1 << 2 )) >> 2; - - // Determine if Radar counts (1st bit of presence_type) - int radar_counts = (id(occupancy_clear_mode) & ( 1 << 1 )) >> 1; - - // Determine if Light counts (0th bit of presence_type) - int light_counts = (id(occupancy_clear_mode) & ( 1 << 0 )) >> 0; - - // Determine our results - bool new_state = false; - if (pir_counts & radar_counts & light_counts) { - // Logical AND of pir & radar & light - new_state = pir & radar & light; - } else if (pir_counts & radar_counts) { - // Logical AND of pir & radar - new_state = pir & radar; - } else if (pir_counts & light_counts) { - // Logical AND of pir & light - new_state = pir & light; - } else if (radar_counts & light_counts) { - // Logical AND of radar & light - new_state = radar & light; - } else if (pir_counts) { - // Only pir - new_state = pir; - } else if (radar_counts) { - // Only radar - new_state = radar; - } else if (light_counts) { - // Only light - new_state = light; - } - - ESP_LOGD("occupancy_clear_handler", "New state: %s", new_state ? "true" : "false"); - - // Force update even if state hasn't changed - id(supersensor_occupancy).publish_state(new_state); - - // Add a delayed re-publish to ensure state propagation - if (!new_state) { - id(supersensor_occupancy).publish_state(new_state); - } - -interval: - # Regular MWW state check every 30s - - interval: 30s - then: - - if: - condition: - - switch.is_on: enable_voice_support - - not: - micro_wake_word.is_running - then: - - micro_wake_word.start: - - # Regular occupancy state reporting to HASS every 30s - - interval: 30s - then: - - lambda: |- - bool current_state = id(supersensor_occupancy).state; - ESP_LOGD("state_reporter", "Republishing occupancy state: %s", current_state ? "true" : "false"); - id(supersensor_occupancy).publish_state(current_state); - - # API watchdog every 5 minutes - - interval: 300s - then: - - lambda: |- - if (api::global_api_server->is_connected()) { - id(last_api_connected_time) = millis(); - } else if (millis() - id(last_api_connected_time) > 300000) { - ESP_LOGE("api_watchdog", "API disconnected for too long, rebooting..."); - App.safe_reboot(); - } - -logger: - level: DEBUG - baud_rate: 115200 - -api: - reboot_timeout: 15min - -ota: - platform: esphome - -web_server: - port: 80 - -captive_portal: - -mdns: - disabled: false - -wifi: - ap: {} - domain: "" - output_power: 8.5dB - reboot_timeout: 15min - power_save_mode: none - -time: - - platform: homeassistant - id: homeassistant_time - on_time_sync: - then: - - logger.log: "Time synchronized with Home Assistant" - -debug: - update_interval: 15s - -uart: - id: ld2410_uart - rx_pin: GPIO19 - tx_pin: GPIO18 - baud_rate: 256000 - data_bits: 8 - stop_bits: 1 - parity: NONE - -i2c: - sda: GPIO27 - scl: GPIO26 - scan: true - -i2s_audio: - - id: i2s_input - i2s_lrclk_pin: - number: GPIO17 # WS - i2s_bclk_pin: - number: GPIO16 # SCK -# # Dummy I2S audio pipeline for "speaker" -# - id: i2s_dummy -# i2s_lrclk_pin: -# number: GPIO15 # WS -# ignore_strapping_warning: true # This isn't connected to anything anyways -# i2s_bclk_pin: -# number: GPIO14 # SCK - -microphone: - - platform: i2s_audio - id: mic - i2s_audio_id: i2s_input - i2s_din_pin: GPIO4 # SD - adc_type: external - pdm: false - channel: left - -#speaker: -# # Dummy speaker to fix home-assistant/core#142363 -# - platform: i2s_audio -# id: dummy_speaker -# i2s_audio_id: i2s_dummy -# i2s_dout_pin: GPIO25 -# dac_type: external -# bits_per_sample: 16bit -# sample_rate: 16000 -# channel: mono #mono will have bad performance, however, we are not using the on device speaker -# buffer_duration: 60ms - -micro_wake_word: - id: mww - microphone: - microphone: mic - gain_factor: 2 - stop_after_detection: false - models: - - model: github://joshuaboniface/Custom_V2_MicroWakeWords/models/computer/computer.json - id: mww_computer - - model: github://esphome/micro-wake-word-models/models/v2/hey_jarvis.json - id: mww_hey_jarvis - - model: github://esphome/micro-wake-word-models/models/v2/hey_mycroft.json - id: mww_hey_mycroft - - model: github://esphome/micro-wake-word-models/models/v2/okay_nabu.json - id: mww_okay_nabu - - model: github://esphome/micro-wake-word-models/models/v2/alexa.json - id: mww_alexa - vad: - on_wake_word_detected: - - logger.log: "A wake word was detected!" - - if: - condition: - voice_assistant.is_running: - then: - voice_assistant.stop: - - voice_assistant.start: - wake_word: !lambda return wake_word; - -voice_assistant: - id: va - microphone: mic -# speaker: dummy_speaker - micro_wake_word: mww - use_wake_word: false - noise_suppression_level: 3 - auto_gain: 31 dbfs - volume_multiplier: 4 - on_wake_word_detected: - - logger.log: "Wake word detected in VA pipeline" - - light.turn_on: - id: output_led - brightness: 100% - red: 0 - green: 0 - blue: 1 - on_listening: - - logger.log: "Listening for commands" - - light.turn_on: - id: output_led - brightness: 100% - red: 0 - green: 0 - blue: 1 - on_stt_vad_end: - - logger.log: "Processing STT result" - - light.turn_on: - id: output_led - brightness: 75% - red: 0 - green: 1 - blue: 1 - on_tts_start: - - if: - condition: - - lambda: |- - ESP_LOGI("tts_response", "%s", x.c_str()); - return x.rfind("Sorry", 0) == 0; - then: - - logger.log: "Command failed!" - - light.turn_on: - id: output_led - effect: hold - brightness: 100% - red: 1 - green: 0 - blue: 0 - - voice_assistant.stop: - else: - - logger.log: "Command successful!" - - light.turn_on: - id: output_led - effect: hold - brightness: 100% - red: 0 - green: 1 - blue: 0 - - voice_assistant.stop: - on_tts_end: - - logger.log: "Finished STT result" - - voice_assistant.stop: - -light: - - platform: rgb - id: output_led - red: rgb_r - green: rgb_g - blue: rgb_b - default_transition_length: 0.15s - flash_transition_length: 0.15s - effects: - - strobe: - name: flash_white - colors: - - state: true - brightness: 25% - red: 100% - green: 90% - blue: 90% - duration: 0.5s - - state: false - duration: 0.5s - - automation: - name: hold - sequence: - - globals.set: - id: light_is_holding - value: "true" - - delay: 5s - - globals.set: - id: light_is_holding - value: "false" - - script.execute: light_off - -output: - - platform: ledc - id: rgb_r - pin: GPIO23 - - platform: ledc - id: rgb_g - pin: GPIO22 - - platform: ledc - id: rgb_b - pin: GPIO21 - -ld2410: - id: ld2410_radar - uart_id: ld2410_uart - -sensor: - - platform: uptime - name: "ESP32 Uptime" - icon: mdi:clock-alert - update_interval: 5s - entity_category: diagnostic - - - platform: wifi_signal - name: "WiFi RSSI" - icon: mdi:wifi-strength-2 - update_interval: 5s - entity_category: diagnostic - - - platform: internal_temperature - name: "ESP32 Temperature" - icon: mdi:thermometer - unit_of_measurement: °C - device_class: TEMPERATURE - update_interval: 5s - entity_category: diagnostic - - - platform: template - name: "ESP32 Free Memory" - icon: mdi:memory - unit_of_measurement: 'kB' - state_class: measurement - update_interval: 5s - lambda: |- - return heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024; - entity_category: diagnostic - - - platform: debug - free: - name: "ESP32 Heap Free" - block: - name: "ESP32 Heap Max Block" - loop_time: - name: "ESP32 Loop Time" - cpu_frequency: - name: "ESP32 CPU Frequency" - - - platform: sgp4x - voc: - name: "SGP41 VOC Index" - id: sgp41_voc_index - accuracy_decimals: 0 - icon: mdi:waves-arrow-up - filters: - - sliding_window_moving_average: # We take a reading every 15 seconds, but calculate the sliding - window_size: 12 # average over 12 readings i.e. 60 seconds/1 minute to normalize - send_every: 3 # brief spikes while still sending a value every 15 seconds. - nox: - name: "SGP41 NOx Index" - id: sgp41_nox_index - accuracy_decimals: 0 - icon: mdi:waves-arrow-up - filters: - - sliding_window_moving_average: - window_size: 12 - send_every: 3 - compensation: - temperature_source: sht45_temperature - humidity_source: sht45_humidity - store_baseline: true - update_interval: 5s - - - platform: template - name: "SGP41 TVOC (µg/m³)" - id: sgp41_tvoc_ugm3 - icon: mdi:molecule - lambda: |- - float i = id(sgp41_voc_index).state; - if (i < 1) return NAN; - float tvoc = (log(501.0 - i) - 6.24) * -878.53; - return tvoc; - unit_of_measurement: "µg/m³" - accuracy_decimals: 0 - update_interval: 15s - - - platform: template - name: "SGP41 TVOC (ppb)" - id: sgp41_tvoc_ppb - icon: mdi:molecule - lambda: |- - float tvoc_ugm3 = id(sgp41_tvoc_ugm3).state; - float tvoc_ppm = tvoc_ugm3 * 0.436; // ppb estimated using isobutylene MW (56.1 g/mol) - return tvoc_ppm; - unit_of_measurement: "ppb" - accuracy_decimals: 0 - update_interval: 15s - - - platform: template - name: "SGP41 eCO2 (appr.)" - id: sgp41_eco2_appr - icon: mdi:molecule-co2 - lambda: |- - float tvoc_ppb = id(sgp41_tvoc_ppb).state; - float eco2_ppm = 400.0 + 1.5 * tvoc_ppb; - if (eco2_ppm > 2000) eco2_ppm = 2000; - return eco2_ppm; - unit_of_measurement: "ppm" - accuracy_decimals: 0 - update_interval: 15s - - - platform: sht4x - temperature: - name: "SHT45 Temperature" - id: sht45_temperature - accuracy_decimals: 1 - filters: - - offset: !lambda return id(temperature_offset); - - sliding_window_moving_average: - window_size: 20 - send_every: 1 - humidity: - name: "SHT45 Relative Humidity" - id: sht45_humidity - accuracy_decimals: 1 - filters: - - lambda: |- - // Grab measured and corrected temperatures - float t_meas = id(sht45_temperature).state - id(temperature_offset); - float t_corr = id(sht45_temperature).state; - float rh_meas = x; - - // Compute saturation vapor pressures (Magnus formula) - auto es = [](float T) { return 6.112 * exp((17.62 * T) / (243.12 + T)); }; - float rh_corr = rh_meas * es(t_meas) / es(t_corr); - - // Clamp to 0–100 % - if (rh_corr < 0) rh_corr = 0; - if (rh_corr > 100) rh_corr = 100; - return rh_corr + id(humidity_offset); - - offset: !lambda return id(humidity_offset); - - sliding_window_moving_average: - window_size: 20 - send_every: 1 - heater_max_duty: 0.0 - update_interval: 15s - - - platform: absolute_humidity - name: "SHT45 Absolute Humidity" - temperature: sht45_temperature - humidity: sht45_humidity - id: sht45_absolute_humidity - - - platform: template - name: "SHT45 Dew Point" - icon: mdi:thermometer-water - id: sht45_dew_point - unit_of_measurement: "°C" - lambda: |- - float temp = id(sht45_temperature).state; - float rh = id(sht45_humidity).state; - if (isnan(temp) || isnan(rh)) return NAN; - float a = 17.27, b = 237.7; - float alpha = ((a * temp) / (b + temp)) + log(rh / 100.0); - return (b * alpha) / (a - alpha); - update_interval: 15s - - - platform: template - name: "Room Health Score" - id: room_health_score - unit_of_measurement: "%" - icon: mdi:home-heart - lambda: |- - float voc = id(sgp41_tvoc_ppb).state; - if (isnan(voc) || voc < 1) voc = 1; - float temp = id(sht45_temperature).state; - float humidity = id(sht45_humidity).state; - - float temp_min = id(room_health_temperature_min); - float temp_max = id(room_health_temperature_max); - float temp_penalty = id(room_health_temperature_penalty); - float humid_min = id(room_health_humidity_min); - float humid_max = id(room_health_humidity_max); - float humid_penalty = id(room_health_humidity_penalty); - float voc_weight = id(room_health_voc_weight); - float temp_weight = id(room_health_temperature_weight); - float humid_weight = id(room_health_humidity_weight); - - // VOC score (0–100) mapped to categories from Chemical Pollution levels below - float voc_score; - if (voc <= 200) { - voc_score = 100.0; - } else if (voc <= 400) { - // 200–400: 100 → 90 - voc_score = 100.0 - (voc - 200) * (10.0 / 200.0); - } else if (voc <= 600) { - // 400–600: 90 → 70 - voc_score = 90.0 - (voc - 400) * (20.0 / 200.0); - } else if (voc <= 1500) { - // 600–1500: 70 → 40 - voc_score = 70.0 - (voc - 600) * (30.0 / 900.0); - } else if (voc <= 3000) { - // 1500–3000: 40 → 0 - voc_score = 40.0 - (voc - 1500) * (40.0 / 1500.0); - } else { - voc_score = 0.0; - } - - // Temperature score - float temp_score = 100; - if (temp < temp_min) temp_score = 100 - (temp_min - temp) * temp_penalty; - else if (temp > temp_max) temp_score = 100 - (temp - temp_max) * temp_penalty; - if (temp_score < 0) temp_score = 0; - - // Humidity score - float humidity_score = 100; - if (humidity < humid_min) humidity_score = 100 - (humid_min - humidity) * humid_penalty; - else if (humidity > humid_max) humidity_score = 100 - (humidity - humid_max) * humid_penalty; - if (humidity_score < 0) humidity_score = 0; - - // Weighted average - float total_weights = voc_weight + temp_weight + humid_weight; - if (total_weights <= 0) total_weights = 1.0; - voc_weight /= total_weights; - temp_weight /= total_weights; - humid_weight /= total_weights; - float overall_score = (voc_score * voc_weight + temp_score * temp_weight + humidity_score * humid_weight); - - return (int) round(overall_score); - update_interval: 15s - - - platform: tsl2591 - address: 0x29 - update_interval: 2s - integration_time: 600ms - power_save_mode: no - gain: auto - device_factor: 53 - glass_attenuation_factor: 7.7 - visible: - name: "TSL2591 Raw Visible" - infrared: - name: "TSL2591 Raw Infrared" - full_spectrum: - name: "TSL2591 Raw Full Spectrum" - calculated_lux: - id: tsl2591_lux - name: "TSL2591 Illumination" - unit_of_measurement: lx - accuracy_decimals: 1 - on_value: - - script.execute: light_handler - actual_gain: - id: "actual_gain" - name: "TSL2591 Gain" - - - platform: ld2410 - ld2410_id: ld2410_radar - moving_distance: - name: "LD2410C Moving Distance" - id: moving_distance - icon: mdi:signal-distance-variant - still_distance: - name: "LD2410C Still Distance" - id: still_distance - icon: mdi:signal-distance-variant - moving_energy: - name: "LD2410C Move Energy" - icon: mdi:flash - still_energy: - name: "LD2410C Still Energy" - icon: mdi:flash - detection_distance: - name: "LD2410C Presence Distance" - icon: mdi:signal-distance-variant - -binary_sensor: - - platform: template - name: "SuperSensor Occupancy" - id: supersensor_occupancy - device_class: occupancy - on_state: - then: - - script.execute: light_off - - - platform: gpio - name: "PIR GPIO" - id: pir_gpio - pin: - number: GPIO32 - mode: - input: true - pulldown: true - internal: false - device_class: motion - on_press: - - script.stop: pir_handler - - script.execute: pir_handler - - - platform: template - name: "PIR Presence" - id: pir_presence - device_class: motion - on_press: - - script.execute: occupancy_detect_handler - on_release: - - script.execute: occupancy_clear_handler - - - platform: template - name: "Light Presence" - id: light_presence - device_class: motion - on_press: - - script.execute: occupancy_detect_handler - on_release: - - script.execute: occupancy_clear_handler - - - platform: ld2410 - ld2410_id: ld2410_radar - has_target: - name: "LD2410C Presence" - id: radar_presence - icon: mdi:motion-sensor - device_class: motion - on_press: - - script.execute: occupancy_detect_handler - on_release: - - script.execute: occupancy_clear_handler - has_moving_target: - name: "LD2410C Moving Target" - has_still_target: - name: "LD2410C Still Target" - -text_sensor: - - platform: version - name: "ESPHome Version" - entity_category: diagnostic - - - platform: wifi_info - ip_address: - name: "WiFi IP Address" - ssid: - name: "WiFi SSID" - bssid: - name: "WiFi BSSID" - mac_address: - name: "WiFi MAC Address" - - - platform: debug - device: - name: "Device Info" - reset_reason: - name: "Reset Reason" - - - platform: ld2410 - version: - name: "LD2410C Firmware Version" - mac_address: - name: "LD2410C MAC Address" - - - platform: template - name: "Chemical Pollution" - id: sgp41_chemical_pollution - icon: mdi:molecule - lambda: |- - float voc = id(sgp41_tvoc_ppb).state; - if (isnan(voc) || voc < 1) return {"Unknown"}; - else if (voc <= 200) return {"Excellent"}; - else if (voc <= 400) return {"Good"}; - else if (voc <= 600) return {"Moderate"}; - else if (voc <= 1500) return {"Unhealthy"}; - else return {"Hazardous"}; - update_interval: 15s - - - platform: template - name: "Room Health" - id: room_health_text - icon: mdi:home-heart - lambda: |- - float score = id(room_health_score).state; - if (score < 0) return {"Unknown"}; - else if (score >= 95.0) return {"Great"}; - else if (score >= 90.0) return {"Good"}; - else if (score >= 80.0) return {"Fair"}; - else if (score >= 60.0) return {"Poor"}; - else return {"Bad"}; - update_interval: 15s - -button: - - platform: ld2410 - restart: - name: "LD2410C Restart" - icon: mdi:power-cycle - entity_category: diagnostic - factory_reset: - name: "LD2410C Factory Reset" - icon: mdi:restart-alert - entity_category: diagnostic - - - platform: restart - name: "ESP32 Restart" - icon: mdi:power-cycle - entity_category: diagnostic - - - platform: factory_reset - name: "ESP32 Factory Reset" - icon: mdi:restart-alert - entity_category: diagnostic - -switch: - # Global enable/disable for voice support - - platform: template - name: "Enable Voice Support" - icon: mdi:account-voice - id: enable_voice_support - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - on_turn_on: - - micro_wake_word.start: - on_turn_off: - - micro_wake_word.stop: - - # Global enable/disable for presence LED - - platform: template - name: "Enable Presence LED" - icon: mdi:lightbulb-alert - id: enable_presence_led - optimistic: true - restore_mode: RESTORE_DEFAULT_ON - on_turn_on: - - script.execute: light_off - on_turn_off: - - script.execute: light_off - - - platform: ld2410 - engineering_mode: - name: "LD2410C Engineering Mode" - entity_category: diagnostic - bluetooth: - name: "LD2410C Bluetooth" - entity_category: diagnostic - -number: - # Temperature offset: - # A calibration from -30 to +10 for the temperature sensor - - platform: template - name: "Temperature Offset" - id: temperature_offset_setter - min_value: -30 - max_value: 10 - step: 0.1 - lambda: |- - return id(temperature_offset); - set_action: - then: - - globals.set: - id: temperature_offset - value: !lambda 'return float(x);' - - # Humidity offset: - # A calibration from -50 to +50 for the humidity sensor - - platform: template - name: "Humidity Offset" - id: humidity_offset_setter - min_value: -50 - max_value: 50 - step: 0.1 - lambda: |- - return id(humidity_offset); - set_action: - then: - - globals.set: - id: humidity_offset - value: !lambda 'return float(x);' - - # PIR Hold Time: - # The number of seconds after motion detection for the PIR sensor to remain held on - - platform: template - name: "PIR Hold Time" - id: pir_hold_time_setter - min_value: 0 - max_value: 60 - step: 5 - lambda: |- - return id(pir_hold_time); - set_action: - then: - - globals.set: - id: pir_hold_time - value: !lambda 'return int(x);' - - # Light Presence Threshold - # The minimum Lux value to consider presence based on the ambient light level - - platform: template - name: "Light Presence Threshold" - id: light_presence_threshold_setter - min_value: 0 - max_value: 200 - step: 5 - lambda: |- - return id(light_presence_threshold); - set_action: - then: - - globals.set: - id: light_presence_threshold - value: !lambda 'return int(x);' - - # Room Health Calibration Values - # These values allow the user to tweak the values of the room health calculation - - platform: template - name: "Room Health Min Temperature" - id: room_health_temperature_min_setter - min_value: 15 - max_value: 30 - step: 0.5 - lambda: |- - return id(room_health_temperature_min); - set_action: - then: - - globals.set: - id: room_health_temperature_min - value: !lambda 'return float(x);' - - platform: template - name: "Room Health Max Temperature" - id: room_health_temperature_max_setter - min_value: 15 - max_value: 30 - step: 0.5 - lambda: |- - return id(room_health_temperature_max); - set_action: - then: - - globals.set: - id: room_health_temperature_max - value: !lambda 'return float(x);' - - platform: template - name: "Room Health Temperature Penalty" - id: room_health_temperature_penalty_setter - min_value: 1 - max_value: 20 - step: 1 - lambda: |- - return int(id(room_health_temperature_penalty)); - set_action: - then: - - globals.set: - id: room_health_temperature_penalty - value: !lambda 'return float(x);' - - platform: template - name: "Room Health Min Humidity" - id: room_health_humidity_min_setter - min_value: 20 - max_value: 80 - step: 1.0 - lambda: |- - return id(room_health_humidity_min); - set_action: - then: - - globals.set: - id: room_health_humidity_min - value: !lambda 'return float(x);' - - platform: template - name: "Room Health Max Humidity" - id: room_health_humidity_max_setter - min_value: 20 - max_value: 80 - step: 1.0 - lambda: |- - return id(room_health_humidity_max); - set_action: - then: - - globals.set: - id: room_health_humidity_max - value: !lambda 'return float(x);' - - platform: template - name: "Room Health Humidity Penalty" - id: room_health_humidity_penalty_setter - min_value: 1 - max_value: 10 - step: 1 - lambda: |- - return int(id(room_health_humidity_penalty)); - set_action: - then: - - globals.set: - id: room_health_humidity_penalty - value: !lambda 'return float(x);' - - platform: template - name: "Room Health VOC Weight" - id: room_health_voc_weight_setter - min_value: 0.00 - max_value: 1.00 - step: 0.01 - lambda: |- - return id(room_health_voc_weight); - set_action: - - if: - condition: - lambda: |- - float total = x + id(room_health_temperature_weight) + id(room_health_humidity_weight); - return (total > 0.0) && (total <= 1.0); - then: - - globals.set: - id: room_health_voc_weight - value: !lambda 'return float(x);' - else: - - logger.log: - format: "Rejected VOC weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)" - args: [ 'x' ] - - platform: template - name: "Room Health Temperature Weight" - id: room_health_temperature_weight_setter - min_value: 0.00 - max_value: 1.00 - step: 0.01 - lambda: |- - return id(room_health_temperature_weight); - set_action: - - if: - condition: - lambda: |- - float total = x + id(room_health_voc_weight) + id(room_health_humidity_weight); - return (total > 0.0) && (total <= 1.0); - then: - - globals.set: - id: room_health_temperature_weight - value: !lambda 'return float(x);' - else: - - logger.log: - format: "Rejected Temperature weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)" - args: [ 'x' ] - - platform: template - name: "Room Health Humidity Weight" - id: room_health_humidity_weight_setter - min_value: 0.00 - max_value: 1.00 - step: 0.01 - lambda: |- - return id(room_health_humidity_weight); - set_action: - - if: - condition: - lambda: |- - float total = x + id(room_health_temperature_weight) + id(room_health_voc_weight); - return (total > 0.0) && (total <= 1.0); - then: - - globals.set: - id: room_health_humidity_weight - value: !lambda 'return float(x);' - else: - - logger.log: - format: "Rejected Humidity weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)" - args: [ 'x' ] - - # LD2410c configuration values - - platform: ld2410 - timeout: - name: "LD2410C Timeout" - light_threshold: - name: "LD2410C Light Threshold" - max_move_distance_gate: - name: "LD2410C Max Move Distance Gate" - max_still_distance_gate: - name: "LD2410C Max Still Distance Gate" - g0: - move_threshold: - name: "LD2410C Gate0 Move Threshold" - still_threshold: - name: "LD2410C Gate0 Still Threshold" - g1: - move_threshold: - name: "LD2410C Gate1 Move Threshold" - still_threshold: - name: "LD2410C Gate1 Still Threshold" - g2: - move_threshold: - name: "LD2410C Gate2 Move Threshold" - still_threshold: - name: "LD2410C Gate2 Still Threshold" - g3: - move_threshold: - name: "LD2410C Gate3 Move Threshold" - still_threshold: - name: "LD2410C Gate3 Still Threshold" - g4: - move_threshold: - name: "LD2410C Gate4 Move Threshold" - still_threshold: - name: "LD2410C Gate4 Still Threshold" - g5: - move_threshold: - name: "LD2410C Gate5 Move Threshold" - still_threshold: - name: "LD2410C Gate5 Still Threshold" - g6: - move_threshold: - name: "LD2410C Gate6 Move Threshold" - still_threshold: - name: "LD2410C Gate6 Still Threshold" - g7: - move_threshold: - name: "LD2410C Gate7 Move Threshold" - still_threshold: - name: "LD2410C Gate7 Still Threshold" - g8: - move_threshold: - name: "LD2410C Gate8 Move Threshold" - still_threshold: - name: "LD2410C Gate8 Still Threshold" - -select: - # Occupancy Detect Mode: - # This selector defines the detection mode for the integrated occupancy sensor. Depending on the - # selected option, only the given sensor(s) will be used to judge when occupancy begins (i.e. - # when the given sensor(s) detect, occupancy detects). - # * PIR + Radar + Light: - # All 3 sensors reporting detection simultaneously will begin occupancy - # * PIR + Radar - # Both PIR and Radar sensors reporting detection simultaneously will begin occupancy - # * PIR + Light - # Both PIR and Light sensors reporting detection simultaneously will begin occupancy - # * Radar + Light - # Both Radar and Light sensors reporting detection simultaneously will begin occupancy - # * PIR Only - # PIR sensor reporting detection will begin occupancy - # * Radar Only - # Radar sensor reporting detection will begin occupancy - # * Light Only - # Light sensor reporting detection will begin occupancy - # * None - # No sensors will begin occupancy and the integrated occupancy functionality is disabled - # Values are reported as integers using bitwise logic: - # Bit 2: PIR - # Bit 1: Radar - # Bit 0: Light - - platform: template - name: "Occupancy Detect Mode" - id: occupancy_detect_mode_setter - options: - - "PIR + Radar + Light" # 111 = 7 - - "PIR + Radar" # 110 = 6 - - "PIR + Light" # 101 = 5 - - "Radar + Light" # 011 = 3 - - "PIR Only" # 100 = 4 - - "Radar Only" # 010 = 2 - - "Light Only" # 001 = 1 - - "None" # 000 = 0 - initial_option: "None" - optimistic: true - restore_value: true - set_action: - - globals.set: - id: occupancy_detect_mode - value: !lambda |- - ESP_LOGD("occupancy_detect_mode_setter", x.c_str()); - if (x == "PIR + Radar + Light") { - return 7; - } else if (x == "PIR + Radar") { - return 6; - } else if (x == "PIR + Light") { - return 5; - } else if (x == "Radar + Light") { - return 3; - } else if (x == "PIR Only") { - return 4; - } else if (x == "Radar Only") { - return 2; - } else if (x == "Light Only") { - return 1; - } else { - return 0; - } - - # Occupancy Clear Mode: - # This selector defines the clear mode for the integrated occupancy sensor. Depending on the - # selected option, only the given sensor(s) will be used to judge when occupancy ends (i.e. - # when the given sensor(s) clear, occupancy clears). - # * PIR + Radar + Light: - # Any of the 3 sensors clearing will end occupancy - # * PIR + Radar: - # Either of the PIR or Radar sensors clearing will end occupancy - # * PIR + Light: - # Either of the PIR or Light sensors clearing will end occupancy - # * Radar + Light: - # Either of the Radar or Light sensors clearing will end occupancy - # * PIR Only - # PIR sensor clearing will end occupancy - # * Radar Only - # Radar sensor clearing will end occupancy - # * Light Only - # Light sensor clearing will end occupancy - # * None - # No sensors will end occupancy; state will persist indefinitely once triggered - # Values are reported as integers using bitwise logic: - # Bit 0: PIR - # Bit 1: Radar - # Bit 2: Light - - platform: template - name: "Occupancy Clear Mode" - id: occupancy_clear_mode_setter - options: - - "PIR + Radar + Light" # 111 = 7 - - "PIR + Radar" # 110 = 6 - - "PIR + Light" # 101 = 5 - - "Radar + Light" # 011 = 3 - - "PIR Only" # 100 = 4 - - "Radar Only" # 010 = 2 - - "Light Only" # 001 = 1 - - "None" # 000 = 0 - initial_option: "None" - optimistic: true - restore_value: true - set_action: - - globals.set: - id: occupancy_clear_mode - value: !lambda |- - ESP_LOGD("occupancy_detect_mode_setter", x.c_str()); - if (x == "PIR + Radar + Light") { - return 7; - } else if (x == "PIR + Radar") { - return 6; - } else if (x == "PIR + Light") { - return 5; - } else if (x == "Radar + Light") { - return 3; - } else if (x == "PIR Only") { - return 4; - } else if (x == "Radar Only") { - return 2; - } else if (x == "Light Only") { - return 1; - } else { - return 0; - } - - - platform: ld2410 - distance_resolution: - name: "LD2410C Distance Resolution" - - - platform: template - name: "Wake Word Selector" - id: wake_word_selector - options: - - "Computer" - - "Hey Jarvis" - - "Hey Mycroft" - - "Okay Nabu" - - "Alexa" - initial_option: "Computer" - optimistic: true - restore_value: true - set_action: - # Disable models that aren't selected - - if: - condition: - lambda: 'return x != "Computer";' - then: - - micro_wake_word.disable_model: mww_computer - - if: - condition: - lambda: 'return x != "Hey Jarvis";' - then: - - micro_wake_word.disable_model: mww_hey_jarvis - - if: - condition: - lambda: 'return x != "Hey Mycroft";' - then: - - micro_wake_word.disable_model: mww_hey_mycroft - - if: - condition: - lambda: 'return x != "Okay Nabu";' - then: - - micro_wake_word.disable_model: mww_okay_nabu - - if: - condition: - lambda: 'return x != "Alexa";' - then: - - micro_wake_word.disable_model: mww_alexa - # Enable model we selected - - if: - condition: - lambda: 'return x == "Computer";' - then: - - micro_wake_word.enable_model: mww_computer - - if: - condition: - lambda: 'return x == "Hey Jarvis";' - then: - - micro_wake_word.enable_model: mww_hey_jarvis - - if: - condition: - lambda: 'return x == "Hey Mycroft";' - then: - - micro_wake_word.enable_model: mww_hey_mycroft - - if: - condition: - lambda: 'return x == "Okay Nabu";' - then: - - micro_wake_word.enable_model: mww_okay_nabu - - if: - condition: - lambda: 'return x == "Alexa";' - then: - - micro_wake_word.enable_model: mww_alexa - - - platform: template - name: "Wake Word Sensitivity" - optimistic: true - initial_option: Default - restore_value: true - options: - - Default - - More sensitive - - Very sensitive - set_action: - # Sets specific wake word probabilities computed for each particular model - # Note probability cutoffs are set as a quantized uint8 value, each comment has the corresponding floating point cutoff - - lambda: |- - if (x == "Default") { - id(mww_computer).set_probability_cutoff(168); // 0.66 (default) - id(mww_hey_jarvis).set_probability_cutoff(247); // 0.97 (default) - id(mww_hey_mycroft).set_probability_cutoff(242); // 0.95 (default) - id(mww_okay_nabu).set_probability_cutoff(217); // 0.85 (default) - id(mww_alexa).set_probability_cutoff(217); // 0.85 (default) - } else if (x == "More sensitive") { - id(mww_computer).set_probability_cutoff(153); // 0.60 - id(mww_hey_jarvis).set_probability_cutoff(235); // 0.92 - id(mww_hey_mycroft).set_probability_cutoff(237); // 0.93 - id(mww_okay_nabu).set_probability_cutoff(176); // 0.69 - id(mww_alexa).set_probability_cutoff(176); // 0.69 - } else if (x == "Very sensitive") { - id(mww_computer).set_probability_cutoff(138); // 0.54 - id(mww_hey_jarvis).set_probability_cutoff(212); // 0.83 - id(mww_hey_mycroft).set_probability_cutoff(230); // 0.90 - id(mww_okay_nabu).set_probability_cutoff(143); // 0.56 - id(mww_alexa).set_probability_cutoff(143); // 0.56 - } +packages: + redirect: github://joshuaboniface/supersensor/supersensor.yaml@v2.x diff --git a/supersensor.yaml.orig b/supersensor.yaml.orig new file mode 100644 index 0000000..746b6f1 --- /dev/null +++ b/supersensor.yaml.orig @@ -0,0 +1,1507 @@ +--- + +############################################################################### +# SuperSensor v2.0 ESPHome configuration +############################################################################### +# +# Copyright (C) 2025 Joshua M. Boniface +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################### + +esphome: + name: supersensor + name_add_mac_suffix: true + friendly_name: "Supersensor" + project: + name: joshuaboniface.supersensor + version: "2.0" + min_version: 2025.5.0 + on_boot: + - priority: 600 + then: + - light.turn_on: + id: output_led + - delay: 0.25s + - light.turn_off: + id: output_led + - delay: 0.25s + - light.turn_on: + id: output_led + - delay: 0.25s + - light.turn_off: + id: output_led + - lambda: |- + id(supersensor_occupancy).publish_state(false); + id(pir_presence).publish_state(false); + id(light_presence).publish_state(false); + id(radar_presence).publish_state(false); + - priority: -100 + then: + - if: + condition: + - switch.is_on: enable_voice_support + then: + - micro_wake_word.start: + +preferences: + flash_write_interval: 15sec + +dashboard_import: + package_import_url: github://joshuaboniface/supersensor2/supersensor.yaml + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_ESP32_DEFAULT_CPU_FREQ_240: "y" + CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ: "240" + CONFIG_ESP32_DATA_CACHE_64KB: "y" + CONFIG_ESP32_DATA_CACHE_LINE_64B: "y" + CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y" + CONFIG_ESP32S3_DEFAULT_CPU_FREQ_MHZ: "240" + CONFIG_ESP32S3_DATA_CACHE_64KB: "y" + CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" + CONFIG_SPIRAM_CACHE_WORKAROUND: "y" + CONFIG_OPTIMIZATION_LEVEL_RELEASE: "y" + +globals: + # Defaults to -5 due to heating from the ESP + - id: temperature_offset + type: float + restore_value: true + initial_value: "-5.0" + + - id: humidity_offset + type: float + restore_value: true + initial_value: "0.0" + + - id: room_health_temperature_min + type: float + restore_value: true + initial_value: "21.0" + + - id: room_health_temperature_max + type: float + restore_value: true + initial_value: "24.0" + + - id: room_health_temperature_penalty + type: float + restore_value: true + initial_value: "10.0" + + - id: room_health_humidity_min + type: float + restore_value: true + initial_value: "40.0" + + - id: room_health_humidity_max + type: float + restore_value: true + initial_value: "60.0" + + - id: room_health_humidity_penalty + type: float + restore_value: true + initial_value: "5.0" + + - id: room_health_voc_weight + type: float + restore_value: true + initial_value: "0.4" + + - id: room_health_temperature_weight + type: float + restore_value: true + initial_value: "0.3" + + - id: room_health_humidity_weight + type: float + restore_value: true + initial_value: "0.3" + + - id: pir_hold_time + type: int + restore_value: true + initial_value: "15" + + - id: light_presence_threshold + type: int + restore_value: true + initial_value: "30" + + - id: occupancy_detect_mode + type: int + restore_value: true + initial_value: "0" + + - id: occupancy_clear_mode + type: int + restore_value: true + initial_value: "0" + + - id: last_api_connected_time + type: uint32_t + restore_value: no + initial_value: "0" + + - id: current_wake_word + type: std::string + restore_value: yes + initial_value: '"mww_computer"' + + - id: light_is_holding + type: bool + restore_value: no + initial_value: 'false' + +script: + - id: light_off + then: + - if: + condition: + lambda: 'return !id(light_is_holding);' + then: + - if: + condition: + - binary_sensor.is_on: supersensor_occupancy + - switch.is_on: enable_presence_led + then: + - light.turn_on: + id: output_led + brightness: 25% + red: 1 + green: 1 + blue: 1 + transition_length: 1s + else: + - light.turn_off: + id: output_led + transition_length: 1s + + - id: pir_handler + then: + - lambda: |- + id(pir_presence).publish_state(true); + - while: + condition: + binary_sensor.is_on: pir_gpio + then: + - delay: !lambda 'return(id(pir_hold_time) * 1000);' + - lambda: |- + id(pir_presence).publish_state(false); + + - id: light_handler + then: + - lambda: |- + if (id(tsl2591_lux).state >= id(light_presence_threshold)) { + id(light_presence).publish_state(true); + } else { + id(light_presence).publish_state(false); + } + + - id: occupancy_detect_handler + then: + - lambda: |- + ESP_LOGD("occupancy_detect_handler", "Occupancy detect handler triggered"); + + // Get the current values of our presence sensors + bool pir = id(pir_presence).state; + bool radar = id(radar_presence).state; + bool light = id(light_presence).state; + + // Determine if PIR counts (2nd bit of presence_type) + int pir_counts = (id(occupancy_detect_mode) & ( 1 << 2 )) >> 2; + + // Determine if Radar counts (1st bit of presence_type) + int radar_counts = (id(occupancy_detect_mode) & ( 1 << 1 )) >> 1; + + // Determine if Light counts (0th bit of presence_type) + int light_counts = (id(occupancy_detect_mode) & ( 1 << 0 )) >> 0; + + // Determine our results + bool new_state = false; + if (pir_counts & radar_counts & light_counts) { + // Logical AND of pir & radar & light + new_state = pir & radar & light; + } else if (pir_counts & radar_counts) { + // Logical AND of pir & radar + new_state = pir & radar; + } else if (pir_counts & light_counts) { + // Logical AND of pir & light + new_state = pir & light; + } else if (radar_counts & light_counts) { + // Logical AND of radar & light + new_state = radar & light; + } else if (pir_counts) { + // Only pir + new_state = pir; + } else if (radar_counts) { + // Only radar + new_state = radar; + } else if (light_counts) { + // Only light + new_state = light; + } + + ESP_LOGD("occupancy_detect_handler", "New state: %s", new_state ? "true" : "false"); + + // Force update even if state hasn't changed + id(supersensor_occupancy).publish_state(new_state); + + // Add a delayed re-publish to ensure state propagation + if (new_state) { + id(supersensor_occupancy).publish_state(new_state); + } + + - id: occupancy_clear_handler + then: + - lambda: |- + ESP_LOGD("occupancy_clear_handler", "Occupancy clear handler triggered"); + + // Get the current values of our presence sensors + bool pir = id(pir_presence).state; + bool radar = id(radar_presence).state; + bool light = id(light_presence).state; + + // Determine if PIR counts (2nd bit of presence_type) + int pir_counts = (id(occupancy_clear_mode) & ( 1 << 2 )) >> 2; + + // Determine if Radar counts (1st bit of presence_type) + int radar_counts = (id(occupancy_clear_mode) & ( 1 << 1 )) >> 1; + + // Determine if Light counts (0th bit of presence_type) + int light_counts = (id(occupancy_clear_mode) & ( 1 << 0 )) >> 0; + + // Determine our results + bool new_state = false; + if (pir_counts & radar_counts & light_counts) { + // Logical AND of pir & radar & light + new_state = pir & radar & light; + } else if (pir_counts & radar_counts) { + // Logical AND of pir & radar + new_state = pir & radar; + } else if (pir_counts & light_counts) { + // Logical AND of pir & light + new_state = pir & light; + } else if (radar_counts & light_counts) { + // Logical AND of radar & light + new_state = radar & light; + } else if (pir_counts) { + // Only pir + new_state = pir; + } else if (radar_counts) { + // Only radar + new_state = radar; + } else if (light_counts) { + // Only light + new_state = light; + } + + ESP_LOGD("occupancy_clear_handler", "New state: %s", new_state ? "true" : "false"); + + // Force update even if state hasn't changed + id(supersensor_occupancy).publish_state(new_state); + + // Add a delayed re-publish to ensure state propagation + if (!new_state) { + id(supersensor_occupancy).publish_state(new_state); + } + +interval: + # Regular MWW state check every 30s + - interval: 30s + then: + - if: + condition: + - switch.is_on: enable_voice_support + - not: + micro_wake_word.is_running + then: + - micro_wake_word.start: + + # Regular occupancy state reporting to HASS every 30s + - interval: 30s + then: + - lambda: |- + bool current_state = id(supersensor_occupancy).state; + ESP_LOGD("state_reporter", "Republishing occupancy state: %s", current_state ? "true" : "false"); + id(supersensor_occupancy).publish_state(current_state); + + # API watchdog every 5 minutes + - interval: 300s + then: + - lambda: |- + if (api::global_api_server->is_connected()) { + id(last_api_connected_time) = millis(); + } else if (millis() - id(last_api_connected_time) > 300000) { + ESP_LOGE("api_watchdog", "API disconnected for too long, rebooting..."); + App.safe_reboot(); + } + +logger: + level: DEBUG + baud_rate: 115200 + +api: + reboot_timeout: 15min + +ota: + platform: esphome + +web_server: + port: 80 + +captive_portal: + +mdns: + disabled: false + +wifi: + ap: {} + domain: "" + output_power: 8.5dB + reboot_timeout: 15min + power_save_mode: none + +time: + - platform: homeassistant + id: homeassistant_time + on_time_sync: + then: + - logger.log: "Time synchronized with Home Assistant" + +debug: + update_interval: 15s + +uart: + id: ld2410_uart + rx_pin: GPIO19 + tx_pin: GPIO18 + baud_rate: 256000 + data_bits: 8 + stop_bits: 1 + parity: NONE + +i2c: + sda: GPIO27 + scl: GPIO26 + scan: true + +i2s_audio: + - id: i2s_input + i2s_lrclk_pin: + number: GPIO17 # WS + i2s_bclk_pin: + number: GPIO16 # SCK +# # Dummy I2S audio pipeline for "speaker" +# - id: i2s_dummy +# i2s_lrclk_pin: +# number: GPIO15 # WS +# ignore_strapping_warning: true # This isn't connected to anything anyways +# i2s_bclk_pin: +# number: GPIO14 # SCK + +microphone: + - platform: i2s_audio + id: mic + i2s_audio_id: i2s_input + i2s_din_pin: GPIO4 # SD + adc_type: external + pdm: false + channel: left + +#speaker: +# # Dummy speaker to fix home-assistant/core#142363 +# - platform: i2s_audio +# id: dummy_speaker +# i2s_audio_id: i2s_dummy +# i2s_dout_pin: GPIO25 +# dac_type: external +# bits_per_sample: 16bit +# sample_rate: 16000 +# channel: mono #mono will have bad performance, however, we are not using the on device speaker +# buffer_duration: 60ms + +micro_wake_word: + id: mww + microphone: + microphone: mic + gain_factor: 2 + stop_after_detection: false + models: + - model: github://joshuaboniface/Custom_V2_MicroWakeWords/models/computer/computer.json + id: mww_computer + - model: github://esphome/micro-wake-word-models/models/v2/hey_jarvis.json + id: mww_hey_jarvis + - model: github://esphome/micro-wake-word-models/models/v2/hey_mycroft.json + id: mww_hey_mycroft + - model: github://esphome/micro-wake-word-models/models/v2/okay_nabu.json + id: mww_okay_nabu + - model: github://esphome/micro-wake-word-models/models/v2/alexa.json + id: mww_alexa + vad: + on_wake_word_detected: + - logger.log: "A wake word was detected!" + - if: + condition: + voice_assistant.is_running: + then: + voice_assistant.stop: + - voice_assistant.start: + wake_word: !lambda return wake_word; + +voice_assistant: + id: va + microphone: mic +# speaker: dummy_speaker + micro_wake_word: mww + use_wake_word: false + noise_suppression_level: 3 + auto_gain: 31 dbfs + volume_multiplier: 4 + on_wake_word_detected: + - logger.log: "Wake word detected in VA pipeline" + - light.turn_on: + id: output_led + brightness: 100% + red: 0 + green: 0 + blue: 1 + on_listening: + - logger.log: "Listening for commands" + - light.turn_on: + id: output_led + brightness: 100% + red: 0 + green: 0 + blue: 1 + on_stt_vad_end: + - logger.log: "Processing STT result" + - light.turn_on: + id: output_led + brightness: 75% + red: 0 + green: 1 + blue: 1 + on_tts_start: + - if: + condition: + - lambda: |- + ESP_LOGI("tts_response", "%s", x.c_str()); + return x.rfind("Sorry", 0) == 0; + then: + - logger.log: "Command failed!" + - light.turn_on: + id: output_led + effect: hold + brightness: 100% + red: 1 + green: 0 + blue: 0 + - voice_assistant.stop: + else: + - logger.log: "Command successful!" + - light.turn_on: + id: output_led + effect: hold + brightness: 100% + red: 0 + green: 1 + blue: 0 + - voice_assistant.stop: + on_tts_end: + - logger.log: "Finished STT result" + - voice_assistant.stop: + +light: + - platform: rgb + id: output_led + red: rgb_r + green: rgb_g + blue: rgb_b + default_transition_length: 0.15s + flash_transition_length: 0.15s + effects: + - strobe: + name: flash_white + colors: + - state: true + brightness: 25% + red: 100% + green: 90% + blue: 90% + duration: 0.5s + - state: false + duration: 0.5s + - automation: + name: hold + sequence: + - globals.set: + id: light_is_holding + value: "true" + - delay: 5s + - globals.set: + id: light_is_holding + value: "false" + - script.execute: light_off + +output: + - platform: ledc + id: rgb_r + pin: GPIO23 + - platform: ledc + id: rgb_g + pin: GPIO22 + - platform: ledc + id: rgb_b + pin: GPIO21 + +ld2410: + id: ld2410_radar + uart_id: ld2410_uart + +sensor: + - platform: uptime + name: "ESP32 Uptime" + icon: mdi:clock-alert + update_interval: 5s + entity_category: diagnostic + + - platform: wifi_signal + name: "WiFi RSSI" + icon: mdi:wifi-strength-2 + update_interval: 5s + entity_category: diagnostic + + - platform: internal_temperature + name: "ESP32 Temperature" + icon: mdi:thermometer + unit_of_measurement: °C + device_class: TEMPERATURE + update_interval: 5s + entity_category: diagnostic + + - platform: template + name: "ESP32 Free Memory" + icon: mdi:memory + unit_of_measurement: 'kB' + state_class: measurement + update_interval: 5s + lambda: |- + return heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024; + entity_category: diagnostic + + - platform: debug + free: + name: "ESP32 Heap Free" + block: + name: "ESP32 Heap Max Block" + loop_time: + name: "ESP32 Loop Time" + cpu_frequency: + name: "ESP32 CPU Frequency" + + - platform: sgp4x + voc: + name: "SGP41 VOC Index" + id: sgp41_voc_index + accuracy_decimals: 0 + icon: mdi:waves-arrow-up + filters: + - sliding_window_moving_average: # We take a reading every 15 seconds, but calculate the sliding + window_size: 12 # average over 12 readings i.e. 60 seconds/1 minute to normalize + send_every: 3 # brief spikes while still sending a value every 15 seconds. + nox: + name: "SGP41 NOx Index" + id: sgp41_nox_index + accuracy_decimals: 0 + icon: mdi:waves-arrow-up + filters: + - sliding_window_moving_average: + window_size: 12 + send_every: 3 + compensation: + temperature_source: sht45_temperature + humidity_source: sht45_humidity + store_baseline: true + update_interval: 5s + + - platform: template + name: "SGP41 TVOC (µg/m³)" + id: sgp41_tvoc_ugm3 + icon: mdi:molecule + lambda: |- + float i = id(sgp41_voc_index).state; + if (i < 1) return NAN; + float tvoc = (log(501.0 - i) - 6.24) * -878.53; + return tvoc; + unit_of_measurement: "µg/m³" + accuracy_decimals: 0 + update_interval: 15s + + - platform: template + name: "SGP41 TVOC (ppb)" + id: sgp41_tvoc_ppb + icon: mdi:molecule + lambda: |- + float tvoc_ugm3 = id(sgp41_tvoc_ugm3).state; + float tvoc_ppm = tvoc_ugm3 * 0.436; // ppb estimated using isobutylene MW (56.1 g/mol) + return tvoc_ppm; + unit_of_measurement: "ppb" + accuracy_decimals: 0 + update_interval: 15s + + - platform: template + name: "SGP41 eCO2 (appr.)" + id: sgp41_eco2_appr + icon: mdi:molecule-co2 + lambda: |- + float tvoc_ppb = id(sgp41_tvoc_ppb).state; + float eco2_ppm = 400.0 + 1.5 * tvoc_ppb; + if (eco2_ppm > 2000) eco2_ppm = 2000; + return eco2_ppm; + unit_of_measurement: "ppm" + accuracy_decimals: 0 + update_interval: 15s + + - platform: sht4x + temperature: + name: "SHT45 Temperature" + id: sht45_temperature + accuracy_decimals: 1 + filters: + - offset: !lambda return id(temperature_offset); + - sliding_window_moving_average: + window_size: 20 + send_every: 1 + humidity: + name: "SHT45 Relative Humidity" + id: sht45_humidity + accuracy_decimals: 1 + filters: + - lambda: |- + // Grab measured and corrected temperatures + float t_meas = id(sht45_temperature).state - id(temperature_offset); + float t_corr = id(sht45_temperature).state; + float rh_meas = x; + + // Compute saturation vapor pressures (Magnus formula) + auto es = [](float T) { return 6.112 * exp((17.62 * T) / (243.12 + T)); }; + float rh_corr = rh_meas * es(t_meas) / es(t_corr); + + // Clamp to 0–100 % + if (rh_corr < 0) rh_corr = 0; + if (rh_corr > 100) rh_corr = 100; + return rh_corr + id(humidity_offset); + - offset: !lambda return id(humidity_offset); + - sliding_window_moving_average: + window_size: 20 + send_every: 1 + heater_max_duty: 0.0 + update_interval: 15s + + - platform: absolute_humidity + name: "SHT45 Absolute Humidity" + temperature: sht45_temperature + humidity: sht45_humidity + id: sht45_absolute_humidity + + - platform: template + name: "SHT45 Dew Point" + icon: mdi:thermometer-water + id: sht45_dew_point + unit_of_measurement: "°C" + lambda: |- + float temp = id(sht45_temperature).state; + float rh = id(sht45_humidity).state; + if (isnan(temp) || isnan(rh)) return NAN; + float a = 17.27, b = 237.7; + float alpha = ((a * temp) / (b + temp)) + log(rh / 100.0); + return (b * alpha) / (a - alpha); + update_interval: 15s + + - platform: template + name: "Room Health Score" + id: room_health_score + unit_of_measurement: "%" + icon: mdi:home-heart + lambda: |- + float voc = id(sgp41_tvoc_ppb).state; + if (isnan(voc) || voc < 1) voc = 1; + float temp = id(sht45_temperature).state; + float humidity = id(sht45_humidity).state; + + float temp_min = id(room_health_temperature_min); + float temp_max = id(room_health_temperature_max); + float temp_penalty = id(room_health_temperature_penalty); + float humid_min = id(room_health_humidity_min); + float humid_max = id(room_health_humidity_max); + float humid_penalty = id(room_health_humidity_penalty); + float voc_weight = id(room_health_voc_weight); + float temp_weight = id(room_health_temperature_weight); + float humid_weight = id(room_health_humidity_weight); + + // VOC score (0–100) mapped to categories from Chemical Pollution levels below + float voc_score; + if (voc <= 200) { + voc_score = 100.0; + } else if (voc <= 400) { + // 200–400: 100 → 90 + voc_score = 100.0 - (voc - 200) * (10.0 / 200.0); + } else if (voc <= 600) { + // 400–600: 90 → 70 + voc_score = 90.0 - (voc - 400) * (20.0 / 200.0); + } else if (voc <= 1500) { + // 600–1500: 70 → 40 + voc_score = 70.0 - (voc - 600) * (30.0 / 900.0); + } else if (voc <= 3000) { + // 1500–3000: 40 → 0 + voc_score = 40.0 - (voc - 1500) * (40.0 / 1500.0); + } else { + voc_score = 0.0; + } + + // Temperature score + float temp_score = 100; + if (temp < temp_min) temp_score = 100 - (temp_min - temp) * temp_penalty; + else if (temp > temp_max) temp_score = 100 - (temp - temp_max) * temp_penalty; + if (temp_score < 0) temp_score = 0; + + // Humidity score + float humidity_score = 100; + if (humidity < humid_min) humidity_score = 100 - (humid_min - humidity) * humid_penalty; + else if (humidity > humid_max) humidity_score = 100 - (humidity - humid_max) * humid_penalty; + if (humidity_score < 0) humidity_score = 0; + + // Weighted average + float total_weights = voc_weight + temp_weight + humid_weight; + if (total_weights <= 0) total_weights = 1.0; + voc_weight /= total_weights; + temp_weight /= total_weights; + humid_weight /= total_weights; + float overall_score = (voc_score * voc_weight + temp_score * temp_weight + humidity_score * humid_weight); + + return (int) round(overall_score); + update_interval: 15s + + - platform: tsl2591 + address: 0x29 + update_interval: 2s + integration_time: 600ms + power_save_mode: no + gain: auto + device_factor: 53 + glass_attenuation_factor: 7.7 + visible: + name: "TSL2591 Raw Visible" + infrared: + name: "TSL2591 Raw Infrared" + full_spectrum: + name: "TSL2591 Raw Full Spectrum" + calculated_lux: + id: tsl2591_lux + name: "TSL2591 Illumination" + unit_of_measurement: lx + accuracy_decimals: 1 + on_value: + - script.execute: light_handler + actual_gain: + id: "actual_gain" + name: "TSL2591 Gain" + + - platform: ld2410 + ld2410_id: ld2410_radar + moving_distance: + name: "LD2410C Moving Distance" + id: moving_distance + icon: mdi:signal-distance-variant + still_distance: + name: "LD2410C Still Distance" + id: still_distance + icon: mdi:signal-distance-variant + moving_energy: + name: "LD2410C Move Energy" + icon: mdi:flash + still_energy: + name: "LD2410C Still Energy" + icon: mdi:flash + detection_distance: + name: "LD2410C Presence Distance" + icon: mdi:signal-distance-variant + +binary_sensor: + - platform: template + name: "SuperSensor Occupancy" + id: supersensor_occupancy + device_class: occupancy + on_state: + then: + - script.execute: light_off + + - platform: gpio + name: "PIR GPIO" + id: pir_gpio + pin: + number: GPIO32 + mode: + input: true + pulldown: true + internal: false + device_class: motion + on_press: + - script.stop: pir_handler + - script.execute: pir_handler + + - platform: template + name: "PIR Presence" + id: pir_presence + device_class: motion + on_press: + - script.execute: occupancy_detect_handler + on_release: + - script.execute: occupancy_clear_handler + + - platform: template + name: "Light Presence" + id: light_presence + device_class: motion + on_press: + - script.execute: occupancy_detect_handler + on_release: + - script.execute: occupancy_clear_handler + + - platform: ld2410 + ld2410_id: ld2410_radar + has_target: + name: "LD2410C Presence" + id: radar_presence + icon: mdi:motion-sensor + device_class: motion + on_press: + - script.execute: occupancy_detect_handler + on_release: + - script.execute: occupancy_clear_handler + has_moving_target: + name: "LD2410C Moving Target" + has_still_target: + name: "LD2410C Still Target" + +text_sensor: + - platform: version + name: "ESPHome Version" + entity_category: diagnostic + + - platform: wifi_info + ip_address: + name: "WiFi IP Address" + ssid: + name: "WiFi SSID" + bssid: + name: "WiFi BSSID" + mac_address: + name: "WiFi MAC Address" + + - platform: debug + device: + name: "Device Info" + reset_reason: + name: "Reset Reason" + + - platform: ld2410 + version: + name: "LD2410C Firmware Version" + mac_address: + name: "LD2410C MAC Address" + + - platform: template + name: "Chemical Pollution" + id: sgp41_chemical_pollution + icon: mdi:molecule + lambda: |- + float voc = id(sgp41_tvoc_ppb).state; + if (isnan(voc) || voc < 1) return {"Unknown"}; + else if (voc <= 200) return {"Excellent"}; + else if (voc <= 400) return {"Good"}; + else if (voc <= 600) return {"Moderate"}; + else if (voc <= 1500) return {"Unhealthy"}; + else return {"Hazardous"}; + update_interval: 15s + + - platform: template + name: "Room Health" + id: room_health_text + icon: mdi:home-heart + lambda: |- + float score = id(room_health_score).state; + if (score < 0) return {"Unknown"}; + else if (score >= 95.0) return {"Great"}; + else if (score >= 90.0) return {"Good"}; + else if (score >= 80.0) return {"Fair"}; + else if (score >= 60.0) return {"Poor"}; + else return {"Bad"}; + update_interval: 15s + +button: + - platform: ld2410 + restart: + name: "LD2410C Restart" + icon: mdi:power-cycle + entity_category: diagnostic + factory_reset: + name: "LD2410C Factory Reset" + icon: mdi:restart-alert + entity_category: diagnostic + + - platform: restart + name: "ESP32 Restart" + icon: mdi:power-cycle + entity_category: diagnostic + + - platform: factory_reset + name: "ESP32 Factory Reset" + icon: mdi:restart-alert + entity_category: diagnostic + +switch: + # Global enable/disable for voice support + - platform: template + name: "Enable Voice Support" + icon: mdi:account-voice + id: enable_voice_support + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + on_turn_on: + - micro_wake_word.start: + on_turn_off: + - micro_wake_word.stop: + + # Global enable/disable for presence LED + - platform: template + name: "Enable Presence LED" + icon: mdi:lightbulb-alert + id: enable_presence_led + optimistic: true + restore_mode: RESTORE_DEFAULT_ON + on_turn_on: + - script.execute: light_off + on_turn_off: + - script.execute: light_off + + - platform: ld2410 + engineering_mode: + name: "LD2410C Engineering Mode" + entity_category: diagnostic + bluetooth: + name: "LD2410C Bluetooth" + entity_category: diagnostic + +number: + # Temperature offset: + # A calibration from -30 to +10 for the temperature sensor + - platform: template + name: "Temperature Offset" + id: temperature_offset_setter + min_value: -30 + max_value: 10 + step: 0.1 + lambda: |- + return id(temperature_offset); + set_action: + then: + - globals.set: + id: temperature_offset + value: !lambda 'return float(x);' + + # Humidity offset: + # A calibration from -50 to +50 for the humidity sensor + - platform: template + name: "Humidity Offset" + id: humidity_offset_setter + min_value: -50 + max_value: 50 + step: 0.1 + lambda: |- + return id(humidity_offset); + set_action: + then: + - globals.set: + id: humidity_offset + value: !lambda 'return float(x);' + + # PIR Hold Time: + # The number of seconds after motion detection for the PIR sensor to remain held on + - platform: template + name: "PIR Hold Time" + id: pir_hold_time_setter + min_value: 0 + max_value: 60 + step: 5 + lambda: |- + return id(pir_hold_time); + set_action: + then: + - globals.set: + id: pir_hold_time + value: !lambda 'return int(x);' + + # Light Presence Threshold + # The minimum Lux value to consider presence based on the ambient light level + - platform: template + name: "Light Presence Threshold" + id: light_presence_threshold_setter + min_value: 0 + max_value: 200 + step: 5 + lambda: |- + return id(light_presence_threshold); + set_action: + then: + - globals.set: + id: light_presence_threshold + value: !lambda 'return int(x);' + + # Room Health Calibration Values + # These values allow the user to tweak the values of the room health calculation + - platform: template + name: "Room Health Min Temperature" + id: room_health_temperature_min_setter + min_value: 15 + max_value: 30 + step: 0.5 + lambda: |- + return id(room_health_temperature_min); + set_action: + then: + - globals.set: + id: room_health_temperature_min + value: !lambda 'return float(x);' + - platform: template + name: "Room Health Max Temperature" + id: room_health_temperature_max_setter + min_value: 15 + max_value: 30 + step: 0.5 + lambda: |- + return id(room_health_temperature_max); + set_action: + then: + - globals.set: + id: room_health_temperature_max + value: !lambda 'return float(x);' + - platform: template + name: "Room Health Temperature Penalty" + id: room_health_temperature_penalty_setter + min_value: 1 + max_value: 20 + step: 1 + lambda: |- + return int(id(room_health_temperature_penalty)); + set_action: + then: + - globals.set: + id: room_health_temperature_penalty + value: !lambda 'return float(x);' + - platform: template + name: "Room Health Min Humidity" + id: room_health_humidity_min_setter + min_value: 20 + max_value: 80 + step: 1.0 + lambda: |- + return id(room_health_humidity_min); + set_action: + then: + - globals.set: + id: room_health_humidity_min + value: !lambda 'return float(x);' + - platform: template + name: "Room Health Max Humidity" + id: room_health_humidity_max_setter + min_value: 20 + max_value: 80 + step: 1.0 + lambda: |- + return id(room_health_humidity_max); + set_action: + then: + - globals.set: + id: room_health_humidity_max + value: !lambda 'return float(x);' + - platform: template + name: "Room Health Humidity Penalty" + id: room_health_humidity_penalty_setter + min_value: 1 + max_value: 10 + step: 1 + lambda: |- + return int(id(room_health_humidity_penalty)); + set_action: + then: + - globals.set: + id: room_health_humidity_penalty + value: !lambda 'return float(x);' + - platform: template + name: "Room Health VOC Weight" + id: room_health_voc_weight_setter + min_value: 0.00 + max_value: 1.00 + step: 0.01 + lambda: |- + return id(room_health_voc_weight); + set_action: + - if: + condition: + lambda: |- + float total = x + id(room_health_temperature_weight) + id(room_health_humidity_weight); + return (total > 0.0) && (total <= 1.0); + then: + - globals.set: + id: room_health_voc_weight + value: !lambda 'return float(x);' + else: + - logger.log: + format: "Rejected VOC weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)" + args: [ 'x' ] + - platform: template + name: "Room Health Temperature Weight" + id: room_health_temperature_weight_setter + min_value: 0.00 + max_value: 1.00 + step: 0.01 + lambda: |- + return id(room_health_temperature_weight); + set_action: + - if: + condition: + lambda: |- + float total = x + id(room_health_voc_weight) + id(room_health_humidity_weight); + return (total > 0.0) && (total <= 1.0); + then: + - globals.set: + id: room_health_temperature_weight + value: !lambda 'return float(x);' + else: + - logger.log: + format: "Rejected Temperature weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)" + args: [ 'x' ] + - platform: template + name: "Room Health Humidity Weight" + id: room_health_humidity_weight_setter + min_value: 0.00 + max_value: 1.00 + step: 0.01 + lambda: |- + return id(room_health_humidity_weight); + set_action: + - if: + condition: + lambda: |- + float total = x + id(room_health_temperature_weight) + id(room_health_voc_weight); + return (total > 0.0) && (total <= 1.0); + then: + - globals.set: + id: room_health_humidity_weight + value: !lambda 'return float(x);' + else: + - logger.log: + format: "Rejected Humidity weight %.2f (total would be out of range: must be > 0.0 and ≤ 1.0)" + args: [ 'x' ] + + # LD2410c configuration values + - platform: ld2410 + timeout: + name: "LD2410C Timeout" + light_threshold: + name: "LD2410C Light Threshold" + max_move_distance_gate: + name: "LD2410C Max Move Distance Gate" + max_still_distance_gate: + name: "LD2410C Max Still Distance Gate" + g0: + move_threshold: + name: "LD2410C Gate0 Move Threshold" + still_threshold: + name: "LD2410C Gate0 Still Threshold" + g1: + move_threshold: + name: "LD2410C Gate1 Move Threshold" + still_threshold: + name: "LD2410C Gate1 Still Threshold" + g2: + move_threshold: + name: "LD2410C Gate2 Move Threshold" + still_threshold: + name: "LD2410C Gate2 Still Threshold" + g3: + move_threshold: + name: "LD2410C Gate3 Move Threshold" + still_threshold: + name: "LD2410C Gate3 Still Threshold" + g4: + move_threshold: + name: "LD2410C Gate4 Move Threshold" + still_threshold: + name: "LD2410C Gate4 Still Threshold" + g5: + move_threshold: + name: "LD2410C Gate5 Move Threshold" + still_threshold: + name: "LD2410C Gate5 Still Threshold" + g6: + move_threshold: + name: "LD2410C Gate6 Move Threshold" + still_threshold: + name: "LD2410C Gate6 Still Threshold" + g7: + move_threshold: + name: "LD2410C Gate7 Move Threshold" + still_threshold: + name: "LD2410C Gate7 Still Threshold" + g8: + move_threshold: + name: "LD2410C Gate8 Move Threshold" + still_threshold: + name: "LD2410C Gate8 Still Threshold" + +select: + # Occupancy Detect Mode: + # This selector defines the detection mode for the integrated occupancy sensor. Depending on the + # selected option, only the given sensor(s) will be used to judge when occupancy begins (i.e. + # when the given sensor(s) detect, occupancy detects). + # * PIR + Radar + Light: + # All 3 sensors reporting detection simultaneously will begin occupancy + # * PIR + Radar + # Both PIR and Radar sensors reporting detection simultaneously will begin occupancy + # * PIR + Light + # Both PIR and Light sensors reporting detection simultaneously will begin occupancy + # * Radar + Light + # Both Radar and Light sensors reporting detection simultaneously will begin occupancy + # * PIR Only + # PIR sensor reporting detection will begin occupancy + # * Radar Only + # Radar sensor reporting detection will begin occupancy + # * Light Only + # Light sensor reporting detection will begin occupancy + # * None + # No sensors will begin occupancy and the integrated occupancy functionality is disabled + # Values are reported as integers using bitwise logic: + # Bit 2: PIR + # Bit 1: Radar + # Bit 0: Light + - platform: template + name: "Occupancy Detect Mode" + id: occupancy_detect_mode_setter + options: + - "PIR + Radar + Light" # 111 = 7 + - "PIR + Radar" # 110 = 6 + - "PIR + Light" # 101 = 5 + - "Radar + Light" # 011 = 3 + - "PIR Only" # 100 = 4 + - "Radar Only" # 010 = 2 + - "Light Only" # 001 = 1 + - "None" # 000 = 0 + initial_option: "None" + optimistic: true + restore_value: true + set_action: + - globals.set: + id: occupancy_detect_mode + value: !lambda |- + ESP_LOGD("occupancy_detect_mode_setter", x.c_str()); + if (x == "PIR + Radar + Light") { + return 7; + } else if (x == "PIR + Radar") { + return 6; + } else if (x == "PIR + Light") { + return 5; + } else if (x == "Radar + Light") { + return 3; + } else if (x == "PIR Only") { + return 4; + } else if (x == "Radar Only") { + return 2; + } else if (x == "Light Only") { + return 1; + } else { + return 0; + } + + # Occupancy Clear Mode: + # This selector defines the clear mode for the integrated occupancy sensor. Depending on the + # selected option, only the given sensor(s) will be used to judge when occupancy ends (i.e. + # when the given sensor(s) clear, occupancy clears). + # * PIR + Radar + Light: + # Any of the 3 sensors clearing will end occupancy + # * PIR + Radar: + # Either of the PIR or Radar sensors clearing will end occupancy + # * PIR + Light: + # Either of the PIR or Light sensors clearing will end occupancy + # * Radar + Light: + # Either of the Radar or Light sensors clearing will end occupancy + # * PIR Only + # PIR sensor clearing will end occupancy + # * Radar Only + # Radar sensor clearing will end occupancy + # * Light Only + # Light sensor clearing will end occupancy + # * None + # No sensors will end occupancy; state will persist indefinitely once triggered + # Values are reported as integers using bitwise logic: + # Bit 0: PIR + # Bit 1: Radar + # Bit 2: Light + - platform: template + name: "Occupancy Clear Mode" + id: occupancy_clear_mode_setter + options: + - "PIR + Radar + Light" # 111 = 7 + - "PIR + Radar" # 110 = 6 + - "PIR + Light" # 101 = 5 + - "Radar + Light" # 011 = 3 + - "PIR Only" # 100 = 4 + - "Radar Only" # 010 = 2 + - "Light Only" # 001 = 1 + - "None" # 000 = 0 + initial_option: "None" + optimistic: true + restore_value: true + set_action: + - globals.set: + id: occupancy_clear_mode + value: !lambda |- + ESP_LOGD("occupancy_detect_mode_setter", x.c_str()); + if (x == "PIR + Radar + Light") { + return 7; + } else if (x == "PIR + Radar") { + return 6; + } else if (x == "PIR + Light") { + return 5; + } else if (x == "Radar + Light") { + return 3; + } else if (x == "PIR Only") { + return 4; + } else if (x == "Radar Only") { + return 2; + } else if (x == "Light Only") { + return 1; + } else { + return 0; + } + + - platform: ld2410 + distance_resolution: + name: "LD2410C Distance Resolution" + + - platform: template + name: "Wake Word Selector" + id: wake_word_selector + options: + - "Computer" + - "Hey Jarvis" + - "Hey Mycroft" + - "Okay Nabu" + - "Alexa" + initial_option: "Computer" + optimistic: true + restore_value: true + set_action: + # Disable models that aren't selected + - if: + condition: + lambda: 'return x != "Computer";' + then: + - micro_wake_word.disable_model: mww_computer + - if: + condition: + lambda: 'return x != "Hey Jarvis";' + then: + - micro_wake_word.disable_model: mww_hey_jarvis + - if: + condition: + lambda: 'return x != "Hey Mycroft";' + then: + - micro_wake_word.disable_model: mww_hey_mycroft + - if: + condition: + lambda: 'return x != "Okay Nabu";' + then: + - micro_wake_word.disable_model: mww_okay_nabu + - if: + condition: + lambda: 'return x != "Alexa";' + then: + - micro_wake_word.disable_model: mww_alexa + # Enable model we selected + - if: + condition: + lambda: 'return x == "Computer";' + then: + - micro_wake_word.enable_model: mww_computer + - if: + condition: + lambda: 'return x == "Hey Jarvis";' + then: + - micro_wake_word.enable_model: mww_hey_jarvis + - if: + condition: + lambda: 'return x == "Hey Mycroft";' + then: + - micro_wake_word.enable_model: mww_hey_mycroft + - if: + condition: + lambda: 'return x == "Okay Nabu";' + then: + - micro_wake_word.enable_model: mww_okay_nabu + - if: + condition: + lambda: 'return x == "Alexa";' + then: + - micro_wake_word.enable_model: mww_alexa + + - platform: template + name: "Wake Word Sensitivity" + optimistic: true + initial_option: Default + restore_value: true + options: + - Default + - More sensitive + - Very sensitive + set_action: + # Sets specific wake word probabilities computed for each particular model + # Note probability cutoffs are set as a quantized uint8 value, each comment has the corresponding floating point cutoff + - lambda: |- + if (x == "Default") { + id(mww_computer).set_probability_cutoff(168); // 0.66 (default) + id(mww_hey_jarvis).set_probability_cutoff(247); // 0.97 (default) + id(mww_hey_mycroft).set_probability_cutoff(242); // 0.95 (default) + id(mww_okay_nabu).set_probability_cutoff(217); // 0.85 (default) + id(mww_alexa).set_probability_cutoff(217); // 0.85 (default) + } else if (x == "More sensitive") { + id(mww_computer).set_probability_cutoff(153); // 0.60 + id(mww_hey_jarvis).set_probability_cutoff(235); // 0.92 + id(mww_hey_mycroft).set_probability_cutoff(237); // 0.93 + id(mww_okay_nabu).set_probability_cutoff(176); // 0.69 + id(mww_alexa).set_probability_cutoff(176); // 0.69 + } else if (x == "Very sensitive") { + id(mww_computer).set_probability_cutoff(138); // 0.54 + id(mww_hey_jarvis).set_probability_cutoff(212); // 0.83 + id(mww_hey_mycroft).set_probability_cutoff(230); // 0.90 + id(mww_okay_nabu).set_probability_cutoff(143); // 0.56 + id(mww_alexa).set_probability_cutoff(143); // 0.56 + }