Add customizable room health options and tweak

Permits the user customization of the parameters of the Room Health
Score, enabling fine-grained control over this metric instead of
hardcoding values that may not be suitable for each person or room.
This commit is contained in:
2025-10-01 13:24:54 -04:00
parent 86a830897f
commit 9d19687473
2 changed files with 419 additions and 164 deletions

308
README.md
View File

@@ -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).

View File

@@ -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
@@ -666,25 +711,55 @@ sensor:
float temp = id(sht45_temperature).state;
float humidity = id(sht45_humidity).state;
// VOC Score (0100)
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;
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);
// Temperature Score (0100)
float temp_score = 100.0 - abs(temp - 23.0) * 10.0;
// VOC score (0100) mapped to categories from Chemical Pollution levels below
float voc_score;
if (voc_index <= 200) {
voc_score = 100.0;
} else if (voc_index <= 400) {
// 200400: 100 → 90
voc_score = 100.0 - (voc_index - 200) * (10.0 / 200.0);
} else if (voc_index <= 600) {
// 400600: 90 → 70
voc_score = 90.0 - (voc_index - 400) * (20.0 / 200.0);
} else if (voc_index <= 1500) {
// 6001500: 70 → 40
voc_score = 70.0 - (voc_index - 600) * (30.0 / 900.0);
} else if (voc_index <= 3000) {
// 15003000: 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 (0100), ideal range 3555%
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 +900,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 +914,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 +1040,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"