From 200c92708712061c9cac0689fc115652f1cf12a0 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Tue, 19 Dec 2017 00:52:19 +0000 Subject: [PATCH] Extend Threshold binary sensor to support ranges (#11110) * Extend Threshold binary sensor to support ranges - Adds support for ranges - Threshold type (lower, upper, range) is defined by supplied thresholds (lower, upper) - Adds verbose status/position relative to threshold as attribute (position) * Minor changes (ordering, names, etc.) * Update name * Update name --- .../components/binary_sensor/threshold.py | 129 +++++++--- .../binary_sensor/test_threshold.py | 239 ++++++++++++++++-- 2 files changed, 302 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 5ca037767f2..36e8868661d 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -9,40 +9,48 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) from homeassistant.const import ( - CONF_NAME, CONF_ENTITY_ID, CONF_TYPE, STATE_UNKNOWN, - ATTR_ENTITY_ID, CONF_DEVICE_CLASS) + ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + STATE_UNKNOWN) from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change _LOGGER = logging.getLogger(__name__) ATTR_HYSTERESIS = 'hysteresis' +ATTR_LOWER = 'lower' +ATTR_POSITION = 'position' ATTR_SENSOR_VALUE = 'sensor_value' -ATTR_THRESHOLD = 'threshold' ATTR_TYPE = 'type' +ATTR_UPPER = 'upper' CONF_HYSTERESIS = 'hysteresis' CONF_LOWER = 'lower' -CONF_THRESHOLD = 'threshold' CONF_UPPER = 'upper' DEFAULT_NAME = 'Threshold' DEFAULT_HYSTERESIS = 0.0 -SENSOR_TYPES = [CONF_LOWER, CONF_UPPER] +POSITION_ABOVE = 'above' +POSITION_BELOW = 'below' +POSITION_IN_RANGE = 'in_range' +POSITION_UNKNOWN = 'unknown' + +TYPE_LOWER = 'lower' +TYPE_RANGE = 'range' +TYPE_UPPER = 'upper' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_THRESHOLD): vol.Coerce(float), - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional( - CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): + vol.Coerce(float), + vol.Optional(CONF_LOWER): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UPPER): vol.Coerce(float), }) @@ -51,47 +59,44 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Threshold sensor.""" entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) - threshold = config.get(CONF_THRESHOLD) + lower = config.get(CONF_LOWER) + upper = config.get(CONF_UPPER) hysteresis = config.get(CONF_HYSTERESIS) - limit_type = config.get(CONF_TYPE) device_class = config.get(CONF_DEVICE_CLASS) async_add_devices([ThresholdSensor( - hass, entity_id, name, threshold, - hysteresis, limit_type, device_class) - ], True) - - return True + hass, entity_id, name, lower, upper, hysteresis, device_class)], True) class ThresholdSensor(BinarySensorDevice): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, threshold, - hysteresis, limit_type, device_class): + def __init__(self, hass, entity_id, name, lower, upper, hysteresis, + device_class): """Initialize the Threshold sensor.""" self._hass = hass self._entity_id = entity_id - self.is_upper = limit_type == 'upper' self._name = name - self._threshold = threshold + self._threshold_lower = lower + self._threshold_upper = upper self._hysteresis = hysteresis self._device_class = device_class - self._state = False - self.sensor_value = 0 - @callback + self._state_position = None + self._state = False + self.sensor_value = None + # pylint: disable=invalid-name + @callback def async_threshold_sensor_state_listener( entity, old_state, new_state): """Handle sensor state changes.""" - if new_state.state == STATE_UNKNOWN: - return - try: - self.sensor_value = float(new_state.state) - except ValueError: - _LOGGER.error("State is not numerical") + self.sensor_value = None if new_state.state == STATE_UNKNOWN \ + else float(new_state.state) + except (ValueError, TypeError): + self.sensor_value = None + _LOGGER.warning("State is not numerical") hass.async_add_job(self.async_update_ha_state, True) @@ -118,23 +123,67 @@ class ThresholdSensor(BinarySensorDevice): """Return the sensor class of the sensor.""" return self._device_class + @property + def threshold_type(self): + """Return the type of threshold this sensor represents.""" + if self._threshold_lower and self._threshold_upper: + return TYPE_RANGE + elif self._threshold_lower: + return TYPE_LOWER + elif self._threshold_upper: + return TYPE_UPPER + @property def device_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, - ATTR_SENSOR_VALUE: self.sensor_value, - ATTR_THRESHOLD: self._threshold, ATTR_HYSTERESIS: self._hysteresis, - ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER, + ATTR_LOWER: self._threshold_lower, + ATTR_POSITION: self._state_position, + ATTR_SENSOR_VALUE: self.sensor_value, + ATTR_TYPE: self.threshold_type, + ATTR_UPPER: self._threshold_upper, } @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" - if self._hysteresis == 0 and self.sensor_value == self._threshold: + def below(threshold): + """Determine if the sensor value is below a threshold.""" + return self.sensor_value < (threshold - self._hysteresis) + + def above(threshold): + """Determine if the sensor value is above a threshold.""" + return self.sensor_value > (threshold + self._hysteresis) + + if self.sensor_value is None: + self._state_position = POSITION_UNKNOWN self._state = False - elif self.sensor_value > (self._threshold + self._hysteresis): - self._state = self.is_upper - elif self.sensor_value < (self._threshold - self._hysteresis): - self._state = not self.is_upper + + elif self.threshold_type == TYPE_LOWER: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = True + elif above(self._threshold_lower): + self._state_position = POSITION_ABOVE + self._state = False + + elif self.threshold_type == TYPE_UPPER: + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = True + elif below(self._threshold_upper): + self._state_position = POSITION_BELOW + self._state = False + + elif self.threshold_type == TYPE_RANGE: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = False + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = False + elif above(self._threshold_lower) and below(self._threshold_upper): + self._state_position = POSITION_IN_RANGE + self._state = True diff --git a/tests/components/binary_sensor/test_threshold.py b/tests/components/binary_sensor/test_threshold.py index d8c49de1cc0..38573b295d3 100644 --- a/tests/components/binary_sensor/test_threshold.py +++ b/tests/components/binary_sensor/test_threshold.py @@ -2,7 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS) from tests.common import get_test_home_assistant @@ -23,8 +24,7 @@ class TestThresholdSensor(unittest.TestCase): config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', - 'type': 'upper', + 'upper': '15', 'entity_id': 'sensor.test_monitored', } } @@ -37,12 +37,14 @@ class TestThresholdSensor(unittest.TestCase): state = self.hass.states.get('binary_sensor.threshold') - self.assertEqual('upper', state.attributes.get('type')) self.assertEqual('sensor.test_monitored', state.attributes.get('entity_id')) self.assertEqual(16, state.attributes.get('sensor_value')) - self.assertEqual(float(config['binary_sensor']['threshold']), - state.attributes.get('threshold')) + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('upper', state.attributes.get('type')) assert state.state == 'on' @@ -65,9 +67,7 @@ class TestThresholdSensor(unittest.TestCase): config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', - 'name': 'Test_threshold', - 'type': 'lower', + 'lower': '15', 'entity_id': 'sensor.test_monitored', } } @@ -77,8 +77,12 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 16) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) self.assertEqual('lower', state.attributes.get('type')) assert state.state == 'off' @@ -86,26 +90,17 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 14) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' - self.hass.states.set('sensor.test_monitored', 15) - self.hass.block_till_done() - - state = self.hass.states.get('binary_sensor.test_threshold') - - assert state.state == 'off' - def test_sensor_hysteresis(self): """Test if source is above threshold using hysteresis.""" config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', + 'upper': '15', 'hysteresis': '2.5', - 'name': 'Test_threshold', - 'type': 'upper', 'entity_id': 'sensor.test_monitored', } } @@ -115,34 +110,226 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 20) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(2.5, state.attributes.get('hysteresis')) + self.assertEqual('upper', state.attributes.get('type')) assert state.state == 'on' self.hass.states.set('sensor.test_monitored', 13) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' self.hass.states.set('sensor.test_monitored', 12) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'off' self.hass.states.set('sensor.test_monitored', 17) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'off' self.hass.states.set('sensor.test_monitored', 18) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' + + def test_sensor_in_range_no_hysteresis(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 9) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 21) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + def test_sensor_in_range_with_hysteresis(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'hysteresis': '2', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(float(config['binary_sensor']['hysteresis']), + state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 8) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 7) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 12) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 13) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 22) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 23) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 18) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 17) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + def test_sensor_in_range_unknown_state(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', STATE_UNKNOWN) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('unknown', state.attributes.get('position')) + assert state.state == 'off'