Introduce a send_delay for pilight component (#4051)
* Add a method to throttle calls to services This adds CallRateDelayThrottle. This is a class that provides an decorator to throttle calls to services. Instead of the Throttle in homeassistant.util it does this by delaying all subsequent calls instead of just dropping them. Dropping of calls would be bad if we call services to actual change the state of a connected hardware (like rf controlled power plugs). Ihe delay is done by rescheduling the call using track_point_in_utc_time from homeassistant.helpers.event so it should not block the mainloop at all. * Add unittests for CallRateDelayThrottle Signed-off-by: Jan Losinski <losinski@wh2.tu-dresden.de> * Introduce a send_delay for pilight component If pilight is used with a "pilight USB Nano" between the daemon and the hardware, we must use a delay between sending multiple signals. Otherwise the hardware will just skip random codes. We hit this condition for example, if we switch a group of pilight switches on or off. Without the delay, random switch signals will not be transmitted by the RF transmitter. As this seems not necessary, if the transmitter is directly connected via GPIO, we introduce a optional configuration to set the delay. * Add unittests for pilight send_delay handling This adds an unittest to test the delayed calls to the send_code service.
This commit is contained in:
parent
90d894a499
commit
52eb816c62
2 changed files with 164 additions and 12 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue