diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py new file mode 100644 index 00000000000..8f45647f5a2 --- /dev/null +++ b/homeassistant/components/sensor/mold_indicator.py @@ -0,0 +1,268 @@ +""" +Calculates mold growth indication from temperature and humidity. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mold_indicator/ +""" +import logging +import math + +import homeassistant.util as util +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_state_change +from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, TEMP_FAHRENHEIT) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Mold Indicator" +CONF_INDOOR_TEMP = "indoor_temp_sensor" +CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" +CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" +CONF_CALIBRATION_FACTOR = "calibration_factor" + +MAGNUS_K2 = 17.62 +MAGNUS_K3 = 243.12 + +ATTR_DEWPOINT = "Dewpoint" +ATTR_CRITICAL_TEMP = "Est. Crit. Temp" + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup MoldIndicator sensor.""" + name = config.get('name', DEFAULT_NAME) + indoor_temp_sensor = config.get(CONF_INDOOR_TEMP) + outdoor_temp_sensor = config.get(CONF_OUTDOOR_TEMP) + indoor_humidity_sensor = config.get(CONF_INDOOR_HUMIDITY) + calib_factor = util.convert(config.get(CONF_CALIBRATION_FACTOR), + float, None) + + if None in (indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor): + _LOGGER.error('Missing required key %s, %s or %s', + CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP, + CONF_INDOOR_HUMIDITY) + return False + + add_devices_callback([MoldIndicator( + hass, name, indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor, + calib_factor)]) + + +# pylint: disable=too-many-instance-attributes +class MoldIndicator(Entity): + """Represents a MoldIndication sensor.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, indoor_temp_sensor, outdoor_temp_sensor, + indoor_humidity_sensor, calib_factor): + """Initialize the sensor.""" + self._state = None + self._name = name + self._indoor_temp_sensor = indoor_temp_sensor + self._indoor_humidity_sensor = indoor_humidity_sensor + self._outdoor_temp_sensor = outdoor_temp_sensor + self._calib_factor = calib_factor + self._is_metric = (hass.config.temperature_unit == TEMP_CELSIUS) + + self._dewpoint = None + self._indoor_temp = None + self._outdoor_temp = None + self._indoor_hum = None + self._crit_temp = None + + track_state_change(hass, indoor_temp_sensor, self._sensor_changed) + track_state_change(hass, outdoor_temp_sensor, self._sensor_changed) + track_state_change(hass, indoor_humidity_sensor, self._sensor_changed) + + # Read initial state + indoor_temp = hass.states.get(indoor_temp_sensor) + outdoor_temp = hass.states.get(outdoor_temp_sensor) + indoor_hum = hass.states.get(indoor_humidity_sensor) + + if indoor_temp: + self._indoor_temp = \ + MoldIndicator._update_temp_sensor(indoor_temp) + + if outdoor_temp: + self._outdoor_temp = \ + MoldIndicator._update_temp_sensor(outdoor_temp) + + if indoor_hum: + self._indoor_hum = \ + MoldIndicator._update_hum_sensor(indoor_hum) + + self.update() + + @staticmethod + def _update_temp_sensor(state): + """Parse temperature sensor value.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = util.convert(state.state, float) + + if temp is None: + _LOGGER.error('Unable to parse sensor temperature: %s', + state.state) + return None + + # convert to celsius if necessary + if unit == TEMP_FAHRENHEIT: + return util.temperature.fahrenheit_to_celcius(temp) + elif unit == TEMP_CELSIUS: + return temp + else: + _LOGGER.error("Temp sensor has unsupported unit: %s" + " (allowed: %s, %s)", + unit, TEMP_CELSIUS, TEMP_FAHRENHEIT) + + return None + + @staticmethod + def _update_hum_sensor(state): + """Parse humidity sensor value.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + hum = util.convert(state.state, float) + + if hum is None: + _LOGGER.error('Unable to parse sensor humidity: %s', + state.state) + return None + + # check unit + if unit != "%": + _LOGGER.error( + "Humidity sensor has unsupported unit: %s %s", + unit, + " (allowed: %)") + + # check range + if hum > 100 or hum < 0: + _LOGGER.error( + "Humidity sensor out of range: %s %s", + hum, + " (allowed: 0-100%)") + + return hum + + def update(self): + """Calculate latest state.""" + # check all sensors + if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): + return + + # re-calculate dewpoint and mold indicator + self._calc_dewpoint() + self._calc_moldindicator() + + def _sensor_changed(self, entity_id, old_state, new_state): + """Called when sensor values change.""" + if new_state is None: + return + + if entity_id == self._indoor_temp_sensor: + # update the indoor temp sensor + self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) + + elif entity_id == self._outdoor_temp_sensor: + # update outdoor temp sensor + self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) + + elif entity_id == self._indoor_humidity_sensor: + # update humidity + self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) + + self.update() + self.update_ha_state() + + def _calc_dewpoint(self): + """Calculate the dewpoint for the indoor air.""" + # use magnus approximation to calculate the dew point + alpha = MAGNUS_K2 * self._indoor_temp / (MAGNUS_K3 + self._indoor_temp) + beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._indoor_temp) + + if self._indoor_hum == 0: + self._dewpoint = -50 # not defined, assume very low value + else: + self._dewpoint = \ + MAGNUS_K3 * (alpha + math.log(self._indoor_hum / 100.0)) / \ + (beta - math.log(self._indoor_hum / 100.0)) + _LOGGER.debug("Dewpoint: %f " + TEMP_CELSIUS, self._dewpoint) + + def _calc_moldindicator(self): + """Calculate the humidity at the (cold) calibration point.""" + if None in (self._dewpoint, self._calib_factor) or \ + self._calib_factor == 0: + + _LOGGER.debug("Invalid inputs - dewpoint: %s," + " calibration-factor: %s", + self._dewpoint, self._calib_factor) + self._state = None + return + + # first calculate the approximate temperature at the calibration point + self._crit_temp = \ + self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \ + self._calib_factor + + _LOGGER.debug( + "Estimated Critical Temperature: %f " + + TEMP_CELSIUS, self._crit_temp) + + # Then calculate the humidity at this point + alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp) + beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._crit_temp) + + crit_humidity = \ + math.exp( + (self._dewpoint * beta - MAGNUS_K3 * alpha) / + (self._dewpoint + MAGNUS_K3)) * 100.0 + + # check bounds and format + if crit_humidity > 100: + self._state = '100' + elif crit_humidity < 0: + self._state = '0' + else: + self._state = '{0:d}'.format(int(crit_humidity)) + + _LOGGER.debug('Mold indicator humidity: %s ', self._state) + + @property + def should_poll(self): + """Polling needed.""" + return False + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + if self._is_metric: + return { + ATTR_DEWPOINT: self._dewpoint, + ATTR_CRITICAL_TEMP: self._crit_temp, + } + else: + return { + ATTR_DEWPOINT: + util.temperature.celcius_to_fahrenheit( + self._dewpoint), + ATTR_CRITICAL_TEMP: + util.temperature.celcius_to_fahrenheit( + self._crit_temp), + } diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py new file mode 100644 index 00000000000..878e6334339 --- /dev/null +++ b/tests/components/sensor/test_moldindicator.py @@ -0,0 +1,131 @@ +"""The tests for the MoldIndicator sensor""" +import unittest + +import homeassistant.components.sensor as sensor +from homeassistant.components.sensor.mold_indicator import (ATTR_DEWPOINT, + ATTR_CRITICAL_TEMP) +from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS) + +from tests.common import get_test_home_assistant + + +class TestSensorMoldIndicator(unittest.TestCase): + """Test the MoldIndicator sensor.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.states.set('test.indoortemp', '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', '50', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.hass.pool.block_till_done() + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup(self): + """Test the mold indicator sensor setup""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert '%' == moldind.attributes.get('unit_of_measurement') + + def test_invalidhum(self): + """Test invalid sensor values""" + self.hass.states.set('test.indoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', '0', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + + # assert state + assert moldind.state == '0' + + def test_calculation(self): + """Test the mold indicator internal calculations""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + + # assert dewpoint + dewpoint = moldind.attributes.get(ATTR_DEWPOINT) + assert dewpoint + assert dewpoint > 9.25 + assert dewpoint < 9.26 + + # assert temperature estimation + esttemp = moldind.attributes.get(ATTR_CRITICAL_TEMP) + assert esttemp + assert esttemp > 14.9 + assert esttemp < 15.1 + + # assert mold indicator value + state = moldind.state + assert state + assert state == '68' + + def test_sensor_changed(self): + """Test the sensor_changed function""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + # Change indoor temp + self.hass.states.set('test.indoortemp', '30', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '90' + + # Change outdoor temp + self.hass.states.set('test.outdoortemp', '25', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '57' + + # Change humidity + self.hass.states.set('test.indoorhumidity', '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '23'