Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Calculates mold growth indication from temperature and humidity."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Mapping
6 import logging
7 import math
8 from typing import TYPE_CHECKING, Any
9 
10 import voluptuous as vol
11 
12 from homeassistant import util
14  PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
15  SensorDeviceClass,
16  SensorEntity,
17  SensorStateClass,
18 )
19 from homeassistant.config_entries import ConfigEntry
20 from homeassistant.const import (
21  ATTR_UNIT_OF_MEASUREMENT,
22  CONF_NAME,
23  CONF_UNIQUE_ID,
24  PERCENTAGE,
25  STATE_UNAVAILABLE,
26  STATE_UNKNOWN,
27  UnitOfTemperature,
28 )
29 from homeassistant.core import (
30  CALLBACK_TYPE,
31  Event,
32  EventStateChangedData,
33  HomeAssistant,
34  State,
35  callback,
36 )
38 from homeassistant.helpers.device import async_device_info_to_link_from_entity
39 from homeassistant.helpers.entity_platform import AddEntitiesCallback
40 from homeassistant.helpers.event import async_track_state_change_event
41 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
42 from homeassistant.util.unit_conversion import TemperatureConverter
43 from homeassistant.util.unit_system import METRIC_SYSTEM
44 
45 from .const import (
46  CONF_CALIBRATION_FACTOR,
47  CONF_INDOOR_HUMIDITY,
48  CONF_INDOOR_TEMP,
49  CONF_OUTDOOR_TEMP,
50  DEFAULT_NAME,
51 )
52 
53 _LOGGER = logging.getLogger(__name__)
54 
55 ATTR_CRITICAL_TEMP = "estimated_critical_temp"
56 ATTR_DEWPOINT = "dewpoint"
57 
58 
59 MAGNUS_K2 = 17.62
60 MAGNUS_K3 = 243.12
61 
62 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
63  {
64  vol.Required(CONF_INDOOR_TEMP): cv.entity_id,
65  vol.Required(CONF_OUTDOOR_TEMP): cv.entity_id,
66  vol.Required(CONF_INDOOR_HUMIDITY): cv.entity_id,
67  vol.Optional(CONF_CALIBRATION_FACTOR): vol.Coerce(float),
68  vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
69  vol.Optional(CONF_UNIQUE_ID): cv.string,
70  }
71 )
72 
73 
75  hass: HomeAssistant,
76  config: ConfigType,
77  async_add_entities: AddEntitiesCallback,
78  discovery_info: DiscoveryInfoType | None = None,
79 ) -> None:
80  """Set up MoldIndicator sensor."""
81  name: str = config.get(CONF_NAME, DEFAULT_NAME)
82  indoor_temp_sensor: str = config[CONF_INDOOR_TEMP]
83  outdoor_temp_sensor: str = config[CONF_OUTDOOR_TEMP]
84  indoor_humidity_sensor: str = config[CONF_INDOOR_HUMIDITY]
85  calib_factor: float = config[CONF_CALIBRATION_FACTOR]
86  unique_id: str | None = config.get(CONF_UNIQUE_ID)
87 
89  [
91  hass,
92  name,
93  hass.config.units is METRIC_SYSTEM,
94  indoor_temp_sensor,
95  outdoor_temp_sensor,
96  indoor_humidity_sensor,
97  calib_factor,
98  unique_id,
99  )
100  ],
101  False,
102  )
103 
104 
106  hass: HomeAssistant,
107  entry: ConfigEntry,
108  async_add_entities: AddEntitiesCallback,
109 ) -> None:
110  """Set up the Mold indicator sensor entry."""
111  name: str = entry.options[CONF_NAME]
112  indoor_temp_sensor: str = entry.options[CONF_INDOOR_TEMP]
113  outdoor_temp_sensor: str = entry.options[CONF_OUTDOOR_TEMP]
114  indoor_humidity_sensor: str = entry.options[CONF_INDOOR_HUMIDITY]
115  calib_factor: float = entry.options[CONF_CALIBRATION_FACTOR]
116 
118  [
120  hass,
121  name,
122  hass.config.units is METRIC_SYSTEM,
123  indoor_temp_sensor,
124  outdoor_temp_sensor,
125  indoor_humidity_sensor,
126  calib_factor,
127  entry.entry_id,
128  )
129  ],
130  False,
131  )
132 
133 
135  """Represents a MoldIndication sensor."""
136 
137  _attr_should_poll = False
138  _attr_native_unit_of_measurement = PERCENTAGE
139  _attr_device_class = SensorDeviceClass.HUMIDITY
140  _attr_state_class = SensorStateClass.MEASUREMENT
141 
142  def __init__(
143  self,
144  hass: HomeAssistant,
145  name: str,
146  is_metric: bool,
147  indoor_temp_sensor: str,
148  outdoor_temp_sensor: str,
149  indoor_humidity_sensor: str,
150  calib_factor: float,
151  unique_id: str | None,
152  ) -> None:
153  """Initialize the sensor."""
154  self._attr_name_attr_name = name
155  self._attr_unique_id_attr_unique_id = unique_id
156  self._indoor_temp_sensor_indoor_temp_sensor = indoor_temp_sensor
157  self._indoor_humidity_sensor_indoor_humidity_sensor = indoor_humidity_sensor
158  self._outdoor_temp_sensor_outdoor_temp_sensor = outdoor_temp_sensor
159  self._calib_factor_calib_factor = calib_factor
160  self._is_metric_is_metric = is_metric
161  self._attr_available_attr_available = False
162  self._entities_entities = {
163  indoor_temp_sensor,
164  indoor_humidity_sensor,
165  outdoor_temp_sensor,
166  }
167  self._dewpoint_dewpoint: float | None = None
168  self._indoor_temp_indoor_temp: float | None = None
169  self._outdoor_temp_outdoor_temp: float | None = None
170  self._indoor_hum_indoor_hum: float | None = None
171  self._crit_temp_crit_temp: float | None = None
172  if indoor_humidity_sensor:
174  hass,
175  indoor_humidity_sensor,
176  )
177  self._preview_callback_preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
178 
179  @callback
181  self,
182  preview_callback: Callable[[str, Mapping[str, Any]], None],
183  ) -> CALLBACK_TYPE:
184  """Render a preview."""
185  # Abort early if there is no source entity_id's or calibration factor
186  if (
187  not self._outdoor_temp_sensor_outdoor_temp_sensor
188  or not self._indoor_temp_sensor_indoor_temp_sensor
189  or not self._indoor_humidity_sensor_indoor_humidity_sensor
190  or not self._calib_factor_calib_factor
191  ):
192  self._attr_available_attr_available = False
193  calculated_state = self._async_calculate_state_async_calculate_state()
194  preview_callback(calculated_state.state, calculated_state.attributes)
195  return self._call_on_remove_callbacks_call_on_remove_callbacks
196 
197  self._preview_callback_preview_callback = preview_callback
198 
199  self._async_setup_sensor_async_setup_sensor()
200  return self._call_on_remove_callbacks_call_on_remove_callbacks
201 
202  async def async_added_to_hass(self) -> None:
203  """Run when entity about to be added to hass."""
204  self._async_setup_sensor_async_setup_sensor()
205 
206  @callback
207  def _async_setup_sensor(self) -> None:
208  """Set up the sensor and start tracking state changes."""
209 
210  @callback
211  def mold_indicator_sensors_state_listener(
212  event: Event[EventStateChangedData],
213  ) -> None:
214  """Handle for state changes for dependent sensors."""
215  new_state = event.data["new_state"]
216  old_state = event.data["old_state"]
217  entity = event.data["entity_id"]
218  _LOGGER.debug(
219  "Sensor state change for %s that had old state %s and new state %s",
220  entity,
221  old_state,
222  new_state,
223  )
224 
225  if self._update_sensor_update_sensor(entity, old_state, new_state):
226  if self._preview_callback_preview_callback:
227  calculated_state = self._async_calculate_state_async_calculate_state()
228  self._preview_callback_preview_callback(
229  calculated_state.state, calculated_state.attributes
230  )
231  # only write state to the state machine if we are not in preview mode
232  else:
233  self.async_schedule_update_ha_stateasync_schedule_update_ha_state(True)
234 
235  @callback
236  def mold_indicator_startup() -> None:
237  """Add listeners and get 1st state."""
238  _LOGGER.debug("Startup for %s", self.entity_identity_id)
239 
241  self.hasshass, list(self._entities_entities), mold_indicator_sensors_state_listener
242  )
243 
244  # Read initial state
245  indoor_temp = self.hasshass.states.get(self._indoor_temp_sensor_indoor_temp_sensor)
246  outdoor_temp = self.hasshass.states.get(self._outdoor_temp_sensor_outdoor_temp_sensor)
247  indoor_hum = self.hasshass.states.get(self._indoor_humidity_sensor_indoor_humidity_sensor)
248 
249  schedule_update = self._update_sensor_update_sensor(
250  self._indoor_temp_sensor_indoor_temp_sensor, None, indoor_temp
251  )
252 
253  schedule_update = (
254  False
255  if not self._update_sensor_update_sensor(
256  self._outdoor_temp_sensor_outdoor_temp_sensor, None, outdoor_temp
257  )
258  else schedule_update
259  )
260 
261  schedule_update = (
262  False
263  if not self._update_sensor_update_sensor(
264  self._indoor_humidity_sensor_indoor_humidity_sensor, None, indoor_hum
265  )
266  else schedule_update
267  )
268 
269  if schedule_update and not self._preview_callback_preview_callback:
270  self.async_schedule_update_ha_stateasync_schedule_update_ha_state(True)
271  if self._preview_callback_preview_callback:
272  # re-calculate dewpoint and mold indicator
273  self._calc_dewpoint_calc_dewpoint()
274  self._calc_moldindicator_calc_moldindicator()
275  if self._attr_native_value_attr_native_value is None:
276  self._attr_available_attr_available = False
277  else:
278  self._attr_available_attr_available = True
279  calculated_state = self._async_calculate_state_async_calculate_state()
280  self._preview_callback_preview_callback(
281  calculated_state.state, calculated_state.attributes
282  )
283 
284  mold_indicator_startup()
285 
287  self, entity: str, old_state: State | None, new_state: State | None
288  ) -> bool:
289  """Update information based on new sensor states."""
290  _LOGGER.debug("Sensor update for %s", entity)
291  if new_state is None:
292  return False
293 
294  # If old_state is not set and new state is unknown then it means
295  # that the sensor just started up
296  if old_state is None and new_state.state == STATE_UNKNOWN:
297  return False
298 
299  if entity == self._indoor_temp_sensor_indoor_temp_sensor:
300  self._indoor_temp_indoor_temp = self._update_temp_sensor_update_temp_sensor(new_state)
301  elif entity == self._outdoor_temp_sensor_outdoor_temp_sensor:
302  self._outdoor_temp_outdoor_temp = self._update_temp_sensor_update_temp_sensor(new_state)
303  elif entity == self._indoor_humidity_sensor_indoor_humidity_sensor:
304  self._indoor_hum_indoor_hum = self._update_hum_sensor_update_hum_sensor(new_state)
305 
306  return True
307 
308  @staticmethod
309  def _update_temp_sensor(state: State) -> float | None:
310  """Parse temperature sensor value."""
311  _LOGGER.debug("Updating temp sensor with value %s", state.state)
312 
313  # Return an error if the sensor change its state to Unknown.
314  if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
315  _LOGGER.error(
316  "Unable to parse temperature sensor %s with state: %s",
317  state.entity_id,
318  state.state,
319  )
320  return None
321 
322  if (temp := util.convert(state.state, float)) is None:
323  _LOGGER.error(
324  "Unable to parse temperature sensor %s with state: %s",
325  state.entity_id,
326  state.state,
327  )
328  return None
329 
330  # convert to celsius if necessary
331  if (
332  unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
333  ) in UnitOfTemperature:
334  return TemperatureConverter.convert(temp, unit, UnitOfTemperature.CELSIUS)
335  _LOGGER.error(
336  "Temp sensor %s has unsupported unit: %s (allowed: %s, %s)",
337  state.entity_id,
338  unit,
339  UnitOfTemperature.CELSIUS,
340  UnitOfTemperature.FAHRENHEIT,
341  )
342 
343  return None
344 
345  @staticmethod
346  def _update_hum_sensor(state: State) -> float | None:
347  """Parse humidity sensor value."""
348  _LOGGER.debug("Updating humidity sensor with value %s", state.state)
349 
350  # Return an error if the sensor change its state to Unknown.
351  if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
352  _LOGGER.error(
353  "Unable to parse humidity sensor %s, state: %s",
354  state.entity_id,
355  state.state,
356  )
357  return None
358 
359  if (hum := util.convert(state.state, float)) is None:
360  _LOGGER.error(
361  "Unable to parse humidity sensor %s, state: %s",
362  state.entity_id,
363  state.state,
364  )
365  return None
366 
367  if (unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) != PERCENTAGE:
368  _LOGGER.error(
369  "Humidity sensor %s has unsupported unit: %s (allowed: %s)",
370  state.entity_id,
371  unit,
372  PERCENTAGE,
373  )
374  return None
375 
376  if hum > 100 or hum < 0:
377  _LOGGER.error(
378  "Humidity sensor %s is out of range: %s (allowed: 0-100)",
379  state.entity_id,
380  hum,
381  )
382  return None
383 
384  return hum
385 
386  async def async_update(self) -> None:
387  """Calculate latest state."""
388  _LOGGER.debug("Update state for %s", self.entity_identity_id)
389  # check all sensors
390  if None in (self._indoor_temp_indoor_temp, self._indoor_hum_indoor_hum, self._outdoor_temp_outdoor_temp):
391  self._attr_available_attr_available = False
392  self._dewpoint_dewpoint = None
393  self._crit_temp_crit_temp = None
394  return
395 
396  # re-calculate dewpoint and mold indicator
397  self._calc_dewpoint_calc_dewpoint()
398  self._calc_moldindicator_calc_moldindicator()
399  if self._attr_native_value_attr_native_value is None:
400  self._attr_available_attr_available = False
401  self._dewpoint_dewpoint = None
402  self._crit_temp_crit_temp = None
403  else:
404  self._attr_available_attr_available = True
405 
406  def _calc_dewpoint(self) -> None:
407  """Calculate the dewpoint for the indoor air."""
408  # Use magnus approximation to calculate the dew point
409  if TYPE_CHECKING:
410  assert self._indoor_temp_indoor_temp and self._indoor_hum_indoor_hum
411  alpha = MAGNUS_K2 * self._indoor_temp_indoor_temp / (MAGNUS_K3 + self._indoor_temp_indoor_temp)
412  beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._indoor_temp_indoor_temp)
413 
414  if self._indoor_hum_indoor_hum == 0:
415  self._dewpoint_dewpoint = -50 # not defined, assume very low value
416  else:
417  self._dewpoint_dewpoint = (
418  MAGNUS_K3
419  * (alpha + math.log(self._indoor_hum_indoor_hum / 100.0))
420  / (beta - math.log(self._indoor_hum_indoor_hum / 100.0))
421  )
422  _LOGGER.debug("Dewpoint: %f %s", self._dewpoint_dewpoint, UnitOfTemperature.CELSIUS)
423 
424  def _calc_moldindicator(self) -> None:
425  """Calculate the humidity at the (cold) calibration point."""
426  if TYPE_CHECKING:
427  assert self._outdoor_temp_outdoor_temp and self._indoor_temp_indoor_temp and self._dewpoint_dewpoint
428 
429  if None in (self._dewpoint_dewpoint, self._calib_factor_calib_factor) or self._calib_factor_calib_factor == 0:
430  _LOGGER.debug(
431  "Invalid inputs - dewpoint: %s, calibration-factor: %s",
432  self._dewpoint_dewpoint,
433  self._calib_factor_calib_factor,
434  )
435  self._attr_native_value_attr_native_value = None
436  self._attr_available_attr_available = False
437  self._crit_temp_crit_temp = None
438  return
439 
440  # first calculate the approximate temperature at the calibration point
441  self._crit_temp_crit_temp = (
442  self._outdoor_temp_outdoor_temp
443  + (self._indoor_temp_indoor_temp - self._outdoor_temp_outdoor_temp) / self._calib_factor_calib_factor
444  )
445 
446  _LOGGER.debug(
447  "Estimated Critical Temperature: %f %s",
448  self._crit_temp_crit_temp,
449  UnitOfTemperature.CELSIUS,
450  )
451 
452  # Then calculate the humidity at this point
453  alpha = MAGNUS_K2 * self._crit_temp_crit_temp / (MAGNUS_K3 + self._crit_temp_crit_temp)
454  beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._crit_temp_crit_temp)
455 
456  crit_humidity = (
457  math.exp(
458  (self._dewpoint_dewpoint * beta - MAGNUS_K3 * alpha)
459  / (self._dewpoint_dewpoint + MAGNUS_K3)
460  )
461  * 100.0
462  )
463 
464  # check bounds and format
465  if crit_humidity > 100:
466  self._attr_native_value_attr_native_value = "100"
467  elif crit_humidity < 0:
468  self._attr_native_value_attr_native_value = "0"
469  else:
470  self._attr_native_value_attr_native_value = f"{int(crit_humidity):d}"
471 
472  _LOGGER.debug("Mold indicator humidity: %s", self.native_valuenative_value)
473 
474  @property
475  def extra_state_attributes(self) -> dict[str, Any]:
476  """Return the state attributes."""
477  if self._is_metric_is_metric:
478  convert_to = UnitOfTemperature.CELSIUS
479  else:
480  convert_to = UnitOfTemperature.FAHRENHEIT
481 
482  dewpoint = (
483  TemperatureConverter.convert(
484  self._dewpoint_dewpoint, UnitOfTemperature.CELSIUS, convert_to
485  )
486  if self._dewpoint_dewpoint is not None
487  else None
488  )
489 
490  crit_temp = (
491  TemperatureConverter.convert(
492  self._crit_temp_crit_temp, UnitOfTemperature.CELSIUS, convert_to
493  )
494  if self._crit_temp_crit_temp is not None
495  else None
496  )
497 
498  return {
499  ATTR_DEWPOINT: round(dewpoint, 2) if dewpoint else None,
500  ATTR_CRITICAL_TEMP: round(crit_temp, 2) if crit_temp else None,
501  }
bool _update_sensor(self, str entity, State|None old_state, State|None new_state)
Definition: sensor.py:288
CALLBACK_TYPE async_start_preview(self, Callable[[str, Mapping[str, Any]], None] preview_callback)
Definition: sensor.py:183
None __init__(self, HomeAssistant hass, str name, bool is_metric, str indoor_temp_sensor, str outdoor_temp_sensor, str indoor_humidity_sensor, float calib_factor, str|None unique_id)
Definition: sensor.py:152
StateType|date|datetime|Decimal native_value(self)
Definition: __init__.py:460
None async_schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1265
CalculatedState _async_calculate_state(self)
Definition: entity.py:1059
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: sensor.py:79
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:109
dr.DeviceInfo|None async_device_info_to_link_from_entity(HomeAssistant hass, str entity_id_or_uuid)
Definition: device.py:28
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314