Moldindicator Sensor (#1575)

* Adds MoldIndicator sensor platform

This sensor may be used to get an indication for possible mold growth in rooms.
It calculates the humidity at a pre-calibrated indoor point (wall, window).

* Automatic conversion to Fahrenheit for mold_indicator

* Minor change to critical temp label

* Fixed docstrings and styles

* Minor changes to MoldIndicator implementation

* Added first (non-working) implementation for mold_indicator test

* Small style changes

* Minor improvements to mold_indicator

* Completed unit test for mold indicator

* Fix to moldindicator initialization

* Adds missing period. Now that really matters..

* Adds test for sensor_changed function
This commit is contained in:
Felix 2016-05-21 18:58:59 +02:00 committed by Paulus Schoutsen
parent 7f0b8c5e70
commit eaebe83429
2 changed files with 399 additions and 0 deletions

View File

@ -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),
}

View File

@ -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'