diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index de4e56c925f..2c92fec3513 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -5,10 +5,16 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/pilight/ """ import logging +import functools import socket +import threading + +from datetime import timedelta import voluptuous as vol +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.util import dt as dt_util import homeassistant.helpers.config_validation as cv from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT, @@ -18,8 +24,12 @@ REQUIREMENTS = ['pilight==0.1.1'] _LOGGER = logging.getLogger(__name__) + +CONF_SEND_DELAY = "send_delay" + DEFAULT_HOST = '127.0.0.1' DEFAULT_PORT = 5000 +DEFAULT_SEND_DELAY = 0.0 DOMAIN = 'pilight' EVENT = 'pilight_received' @@ -37,7 +47,9 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_WHITELIST, default={}): {cv.string: [cv.string]} + vol.Optional(CONF_WHITELIST, default={}): {cv.string: [cv.string]}, + vol.Optional(CONF_SEND_DELAY, default=DEFAULT_SEND_DELAY): + vol.Coerce(float), }), }, extra=vol.ALLOW_EXTRA) @@ -48,6 +60,8 @@ def setup(hass, config): host = config[DOMAIN][CONF_HOST] port = config[DOMAIN][CONF_PORT] + send_throttler = CallRateDelayThrottle(hass, + config[DOMAIN][CONF_SEND_DELAY]) try: pilight_client = pilight.Client(host=host, port=port) @@ -68,6 +82,7 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_pilight_client) + @send_throttler.limited def send_code(call): """Send RF code to the pilight-daemon.""" # Change type to dict from mappingproxy since data has to be JSON @@ -103,3 +118,58 @@ def setup(hass, config): pilight_client.set_callback(handle_received_code) return True + + +# pylint: disable=too-few-public-methods +class CallRateDelayThrottle(object): + """Helper class to provide service call rate throttling. + + This class provides a decorator to decorate service methods that need + to be throttled to not exceed a certain call rate per second. + One instance can be used on multiple service methods to archive + an overall throttling. + + As this uses track_point_in_utc_time to schedule delayed executions + it should not block the mainloop. + """ + + def __init__(self, hass, delay_seconds: float): + """Initialize the delay handler.""" + self._delay = timedelta(seconds=max(0.0, delay_seconds)) + self._queue = [] + self._active = False + self._lock = threading.Lock() + self._next_ts = dt_util.utcnow() + self._schedule = functools.partial(track_point_in_utc_time, hass) + + def limited(self, method): + """Decorator to delay calls on a certain method.""" + @functools.wraps(method) + def decorated(*args, **kwargs): + """The decorated function.""" + if self._delay.total_seconds() == 0.0: + method(*args, **kwargs) + return + + def action(event): + """The action wrapper that gets scheduled.""" + method(*args, **kwargs) + + with self._lock: + self._next_ts = dt_util.utcnow() + self._delay + + if len(self._queue) == 0: + self._active = False + else: + next_action = self._queue.pop(0) + self._schedule(next_action, self._next_ts) + + with self._lock: + if self._active: + self._queue.append(action) + else: + self._active = True + schedule_ts = max(dt_util.utcnow(), self._next_ts) + self._schedule(action, schedule_ts) + + return decorated diff --git a/tests/components/test_pilight.py b/tests/components/test_pilight.py index ca491ee838d..0fe68b4fbe5 100644 --- a/tests/components/test_pilight.py +++ b/tests/components/test_pilight.py @@ -3,9 +3,12 @@ import logging import unittest from unittest.mock import patch import socket +from datetime import timedelta +from homeassistant import core as ha from homeassistant.bootstrap import setup_component from homeassistant.components import pilight +from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant, assert_setup_component @@ -70,7 +73,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.components.pilight._LOGGER.error') def test_connection_failed_error(self, mock_error): """Try to connect at 127.0.0.1:5000 with socket error.""" - with assert_setup_component(3): + with assert_setup_component(4): with patch('pilight.pilight.Client', side_effect=socket.error) as mock_client: self.assertFalse(setup_component( @@ -82,7 +85,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.components.pilight._LOGGER.error') def test_connection_timeout_error(self, mock_error): """Try to connect at 127.0.0.1:5000 with socket timeout.""" - with assert_setup_component(3): + with assert_setup_component(4): with patch('pilight.pilight.Client', side_effect=socket.timeout) as mock_client: self.assertFalse(setup_component( @@ -96,7 +99,7 @@ class TestPilight(unittest.TestCase): @patch('tests.components.test_pilight._LOGGER.error') def test_send_code_no_protocol(self, mock_pilight_error, mock_error): """Try to send data without protocol information, should give error.""" - with assert_setup_component(3): + with assert_setup_component(4): self.assertTrue(setup_component( self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})) @@ -115,7 +118,7 @@ class TestPilight(unittest.TestCase): @patch('tests.components.test_pilight._LOGGER.error') def test_send_code(self, mock_pilight_error): """Try to send proper data.""" - with assert_setup_component(3): + with assert_setup_component(4): self.assertTrue(setup_component( self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})) @@ -134,7 +137,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.components.pilight._LOGGER.error') def test_send_code_fail(self, mock_pilight_error): """Check IOError exception error message.""" - with assert_setup_component(3): + with assert_setup_component(4): with patch('pilight.pilight.Client.send_code', side_effect=IOError): self.assertTrue(setup_component( @@ -150,11 +153,47 @@ class TestPilight(unittest.TestCase): error_log_call = mock_pilight_error.call_args_list[-1] self.assertTrue('Pilight send failed' in str(error_log_call)) + @patch('pilight.pilight.Client', PilightDaemonSim) + @patch('tests.components.test_pilight._LOGGER.error') + def test_send_code_delay(self, mock_pilight_error): + """Try to send proper data with delay afterwards.""" + with assert_setup_component(4): + self.assertTrue(setup_component( + self.hass, pilight.DOMAIN, + {pilight.DOMAIN: {pilight.CONF_SEND_DELAY: 5.0}})) + + # Call with protocol info, should not give error + service_data1 = {'protocol': 'test11', + 'value': 42} + service_data2 = {'protocol': 'test22', + 'value': 42} + self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, + service_data=service_data1, + blocking=True) + self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME, + service_data=service_data2, + blocking=True) + service_data1['protocol'] = [service_data1['protocol']] + service_data2['protocol'] = [service_data2['protocol']] + + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: dt_util.utcnow()}) + self.hass.block_till_done() + error_log_call = mock_pilight_error.call_args_list[-1] + self.assertTrue(str(service_data1) in str(error_log_call)) + + new_time = dt_util.utcnow() + timedelta(seconds=5) + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: new_time}) + self.hass.block_till_done() + error_log_call = mock_pilight_error.call_args_list[-1] + self.assertTrue(str(service_data2) in str(error_log_call)) + @patch('pilight.pilight.Client', PilightDaemonSim) @patch('tests.components.test_pilight._LOGGER.error') def test_start_stop(self, mock_pilight_error): """Check correct startup and stop of pilight daemon.""" - with assert_setup_component(3): + with assert_setup_component(4): self.assertTrue(setup_component( self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})) @@ -178,7 +217,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.core._LOGGER.info') def test_receive_code(self, mock_info): """Check if code receiving via pilight daemon works.""" - with assert_setup_component(3): + with assert_setup_component(4): self.assertTrue(setup_component( self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})) @@ -201,7 +240,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.core._LOGGER.info') def test_whitelist_exact_match(self, mock_info): """Check whitelist filter with matched data.""" - with assert_setup_component(3): + with assert_setup_component(4): whitelist = { 'protocol': [PilightDaemonSim.test_message['protocol']], 'uuid': [PilightDaemonSim.test_message['uuid']], @@ -229,7 +268,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.core._LOGGER.info') def test_whitelist_partial_match(self, mock_info): """Check whitelist filter with partially matched data, should work.""" - with assert_setup_component(3): + with assert_setup_component(4): whitelist = { 'protocol': [PilightDaemonSim.test_message['protocol']], 'id': [PilightDaemonSim.test_message['message']['id']]} @@ -255,7 +294,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.core._LOGGER.info') def test_whitelist_or_match(self, mock_info): """Check whitelist filter with several subsection, should work.""" - with assert_setup_component(3): + with assert_setup_component(4): whitelist = { 'protocol': [PilightDaemonSim.test_message['protocol'], 'other_protocoll'], @@ -282,7 +321,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.core._LOGGER.info') def test_whitelist_no_match(self, mock_info): """Check whitelist filter with unmatched data, should not work.""" - with assert_setup_component(3): + with assert_setup_component(4): whitelist = { 'protocol': ['wrong_protocoll'], 'id': [PilightDaemonSim.test_message['message']['id']]} @@ -296,3 +335,46 @@ class TestPilight(unittest.TestCase): info_log_call = mock_info.call_args_list[-1] self.assertFalse('Event pilight_received' in info_log_call) + + +class TestPilightCallrateThrottler(unittest.TestCase): + """Test the Throttler used to throttle calls to send_code.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def test_call_rate_delay_throttle_disabled(self): + """Test that the limiter is a noop if no delay set.""" + runs = [] + + limit = pilight.CallRateDelayThrottle(self.hass, 0.0) + action = limit.limited(lambda x: runs.append(x)) + + for i in range(3): + action(i) + + self.assertEqual(runs, [0, 1, 2]) + + def test_call_rate_delay_throttle_enabled(self): + """Test that throttling actually work.""" + runs = [] + delay = 5.0 + + limit = pilight.CallRateDelayThrottle(self.hass, delay) + action = limit.limited(lambda x: runs.append(x)) + + for i in range(3): + action(i) + + self.assertEqual(runs, []) + + exp = [] + now = dt_util.utcnow() + for i in range(3): + exp.append(i) + shifted_time = now + (timedelta(seconds=delay + 0.1) * i) + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: shifted_time}) + self.hass.block_till_done() + self.assertEqual(runs, exp)