diff --git a/README.md b/README.md index 5bb6d03..4f6d17b 100644 --- a/README.md +++ b/README.md @@ -118,144 +118,7 @@ functionality, so I recommend using the provided models, but this is up to the b No other parts can be easily swapped without code or PCB design changes. -## 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 (selector, -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 (selector, -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 (selector, 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 (selector, 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: - -#### PIR + Radar + Light - -Occupancy is detected when all 3 sensors report detected, and occupancy is -cleared when any of the sensors report cleared. - -For detect, this provides the most "safety" against misfires, but requires -a normally-dark room with a non-automated light source and clear PIR -detection positioning. - -For clear, this option is probably not very useful as it is likely to clear -quite frequently from the PIR, but is provided for completeness. - -#### PIR + Radar - -Occupancy is detected when both sensors report detected, and occupancy is -cleared when either of the sensors report cleared. - -For detect, this provides good "safety" against PIR misfires without -needing a normally-dark room, though detection may be slightly delayed -from either sensor. - -For clear, this option is probably not very useful as it is likely to clear -quite frequently from the PIR, but is provided for completeness. - -#### PIR + Light - -Occupancy is detected when both sensors report detected, and occupancy is -cleared when either of the sensors report cleared. - -For detect, this provides some "safety" against PIR misfires, but requires -a normally-dark room with a non-automated light source and clear PIR -detection positioning. - -For clear, this option is probably not very useful as it is likely to clear -quite frequently from the PIR, but is provided for completeness. - -#### Radar + Light - -Occupancy is detected when both sensors report detected, and occupancy is -cleared when either of the sensors report cleared. - -For detect, this allows for radar detection while suppressing occupancy -without light, for instance in a hallway where one might not want a late -night bathroom visit to turn on the lights, or something to that effect. - -For clear, this option can provide a useful option to clear presence -quickly if the lights go out, while still providing Radar presence. - -#### PIR Only - -Occupancy is based entirely on the PIR sensor for both detect and clear. - -Prone to misfires, but otherwise a good option for quick detection and -clearance in a primarily-moving zone (e.g. hallway). - -#### Radar Only - -Occupancy is based entirely on the Radar sensor for both detect and clear. - -Useful for an area with no consistent motion or light level. - -#### Light Only - -Occupancy is based entirely on the Light sensor for both detect and clear. - -Useful for full dependence on an external light source. - -#### None - -Disable the functionality in either direction. - -For detect, no occupancy will ever fire. - -For clear, no states will clear occupancy; with any detect option, this -means that occupancy will be detected only once and never clear, which -is likely not useful. - -## AQ Details +## 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 @@ -283,3 +146,172 @@ It also reacts strongly to heavy humidity, resulting in higher values in such en 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). diff --git a/supersensor.yaml b/supersensor.yaml index 48bb102..37c1df6 100644 --- a/supersensor.yaml +++ b/supersensor.yaml @@ -89,6 +89,51 @@ globals: 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 @@ -663,29 +708,62 @@ sensor: icon: mdi:home-heart lambda: |- float voc_index = id(sgp41_voc_index).state; + if (isnan(voc_index) || voc_index <= 0) { + voc_index = 0; + } float temp = id(sht45_temperature).state; float humidity = id(sht45_humidity).state; - - // VOC Score (0–100) - float voc_score = 0; - if (voc_index <= 100) voc_score = 100; - else if (voc_index <= 200) voc_score = 80; - else if (voc_index <= 300) voc_score = 60; - else if (voc_index <= 400) voc_score = 40; - else if (voc_index <= 500) voc_score = 50; - else voc_score = 0; - - // Temperature Score (0–100) - float temp_score = 100.0 - abs(temp - 23.0) * 10.0; + + 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_index <= 200) { + voc_score = 100.0; + } else if (voc_index <= 400) { + // 200–400: 100 → 90 + voc_score = 100.0 - (voc_index - 200) * (10.0 / 200.0); + } else if (voc_index <= 600) { + // 400–600: 90 → 70 + voc_score = 90.0 - (voc_index - 400) * (20.0 / 200.0); + } else if (voc_index <= 1500) { + // 600–1500: 70 → 40 + voc_score = 70.0 - (voc_index - 600) * (30.0 / 900.0); + } else if (voc_index <= 3000) { + // 1500–3000: 40 → 0 + voc_score = 40.0 - (voc_index - 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 (0–100), ideal range 35–55% - float humidity_score = 100.0 - abs(humidity - 50.0) * 3.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 overall_score = (voc_score * 0.5 + temp_score * 0.25 + humidity_score * 0.25); - + 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 @@ -825,10 +903,10 @@ text_sensor: lambda: |- float voc_index = id(sgp41_voc_index).state; if (voc_index < 1 || voc_index > 500) return {"Unknown"}; - if (voc_index <= 100) return {"Excellent"}; - else if (voc_index <= 200) return {"Good"}; - else if (voc_index <= 300) return {"Moderate"}; - else if (voc_index <= 400) return {"Unhealthy"}; + if (voc_index <= 200) return {"Excellent"}; + else if (voc_index <= 400) return {"Good"}; + else if (voc_index <= 600) return {"Moderate"}; + else if (voc_index <= 1500) return {"Unhealthy"}; else return {"Hazardous"}; update_interval: 15s @@ -839,10 +917,10 @@ text_sensor: lambda: |- float score = id(room_health_score).state; if (score < 0) return {"Unknown"}; - else if (score >= 90.0) return {"Great"}; - else if (score >= 80.0) return {"Good"}; - else if (score >= 60.0) return {"Fair"}; - else if (score >= 40.0) return {"Poor"}; + 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 @@ -965,6 +1043,154 @@ number: 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 Humitity 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"