diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 5e24fe82340..5431dd4a61a 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -13,6 +13,7 @@ import uuid from homeassistant.components.discovery import SERVICE_HOMEKIT from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import call_later REQUIREMENTS = ['homekit==0.10'] @@ -37,6 +38,13 @@ KNOWN_DEVICES = "{}-devices".format(DOMAIN) _LOGGER = logging.getLogger(__name__) +REQUEST_TIMEOUT = 5 # seconds +RETRY_INTERVAL = 60 # seconds + + +class HomeKitConnectionError(ConnectionError): + """Raised when unable to connect to target device.""" + def homekit_http_send(self, message_body=None, encode_chunked=False): r"""Send the currently buffered request and clear the buffer. @@ -89,6 +97,9 @@ class HKDevice(): self.config_num = config_num self.config = config self.configurator = hass.components.configurator + self.conn = None + self.securecon = None + self._connection_warning_logged = False data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) if not os.path.isdir(data_dir): @@ -101,23 +112,35 @@ class HKDevice(): # pylint: disable=protected-access http.client.HTTPConnection._send_output = homekit_http_send - self.conn = http.client.HTTPConnection(self.host, port=self.port) if self.pairing_data is not None: self.accessory_setup() else: self.configure() + def connect(self): + """Open the connection to the HomeKit device.""" + # pylint: disable=import-error + import homekit + + self.conn = http.client.HTTPConnection( + self.host, port=self.port, timeout=REQUEST_TIMEOUT) + if self.pairing_data is not None: + controllerkey, accessorykey = \ + homekit.get_session_keys(self.conn, self.pairing_data) + self.securecon = homekit.SecureHttp( + self.conn.sock, accessorykey, controllerkey) + def accessory_setup(self): """Handle setup of a HomeKit accessory.""" # pylint: disable=import-error import homekit - self.controllerkey, self.accessorykey = \ - homekit.get_session_keys(self.conn, self.pairing_data) - self.securecon = homekit.SecureHttp(self.conn.sock, - self.accessorykey, - self.controllerkey) - response = self.securecon.get('/accessories') - data = json.loads(response.read().decode()) + + try: + data = self.get_json('/accessories') + except HomeKitConnectionError: + call_later( + self.hass, RETRY_INTERVAL, lambda _: self.accessory_setup()) + return for accessory in data['accessories']: serial = get_serial(accessory) if serial in self.hass.data[KNOWN_ACCESSORIES]: @@ -135,6 +158,31 @@ class HKDevice(): discovery.load_platform(self.hass, component, DOMAIN, service_info, self.config) + def get_json(self, target): + """Get JSON data from the device.""" + try: + if self.conn is None: + self.connect() + response = self.securecon.get(target) + data = json.loads(response.read().decode()) + + # After a successful connection, clear the warning logged status + self._connection_warning_logged = False + + return data + except (ConnectionError, OSError, json.JSONDecodeError) as ex: + # Mark connection as failed + if not self._connection_warning_logged: + _LOGGER.warning("Failed to connect to homekit device", + exc_info=ex) + self._connection_warning_logged = True + else: + _LOGGER.debug("Failed to connect to homekit device", + exc_info=ex) + self.conn = None + self.securecon = None + raise HomeKitConnectionError() from ex + def device_config_callback(self, callback_data): """Handle initial pairing.""" # pylint: disable=import-error @@ -142,6 +190,7 @@ class HKDevice(): pairing_id = str(uuid.uuid4()) code = callback_data.get('code').strip() try: + self.connect() self.pairing_data = homekit.perform_pair_setup(self.conn, code, pairing_id) except homekit.exception.UnavailableError: @@ -192,7 +241,7 @@ class HomeKitEntity(Entity): def __init__(self, accessory, devinfo): """Initialise a generic HomeKit device.""" self._name = accessory.model - self._securecon = accessory.securecon + self._accessory = accessory self._aid = devinfo['aid'] self._iid = devinfo['iid'] self._address = "homekit-{}-{}".format(devinfo['serial'], self._iid) @@ -201,8 +250,10 @@ class HomeKitEntity(Entity): def update(self): """Obtain a HomeKit device's state.""" - response = self._securecon.get('/accessories') - data = json.loads(response.read().decode()) + try: + data = self._accessory.get_json('/accessories') + except HomeKitConnectionError: + return for accessory in data['accessories']: if accessory['aid'] != self._aid: continue @@ -222,6 +273,11 @@ class HomeKitEntity(Entity): """Return the name of the device if any.""" return self._name + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._accessory.conn is not None + def update_characteristics(self, characteristics): """Synchronise a HomeKit device state with Home Assistant.""" raise NotImplementedError @@ -229,7 +285,7 @@ class HomeKitEntity(Entity): def put_characteristics(self, characteristics): """Control a HomeKit device state from Home Assistant.""" body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self._accessory.securecon.put('/characteristics', body) def setup(hass, config): diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c8488fa3334..05555e8b5c6 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -227,6 +227,10 @@ def async_call_later(hass, delay, action): hass, action, dt_util.utcnow() + timedelta(seconds=delay)) +call_later = threaded_listener_factory( + async_call_later) + + @callback @bind_hass def async_track_time_interval(hass, action, interval): diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index deefcec773a..5b57ca75d51 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -13,6 +13,7 @@ import homeassistant.core as ha from homeassistant.const import MATCH_ALL from homeassistant.helpers.event import ( async_call_later, + call_later, track_point_in_utc_time, track_point_in_time, track_utc_time_change, @@ -645,6 +646,22 @@ class TestEventHelpers(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(specific_runs)) + def test_call_later(self): + """Test calling an action later.""" + def action(): pass + now = datetime(2017, 12, 19, 15, 40, 0, tzinfo=dt_util.UTC) + + with patch('homeassistant.helpers.event' + '.async_track_point_in_utc_time') as mock, \ + patch('homeassistant.util.dt.utcnow', return_value=now): + call_later(self.hass, 3, action) + + assert len(mock.mock_calls) == 1 + p_hass, p_action, p_point = mock.mock_calls[0][1] + assert p_hass is self.hass + assert p_action is action + assert p_point == now + timedelta(seconds=3) + @asyncio.coroutine def test_async_call_later(hass): @@ -659,7 +676,7 @@ def test_async_call_later(hass): assert len(mock.mock_calls) == 1 p_hass, p_action, p_point = mock.mock_calls[0][1] - assert hass is hass + assert p_hass is hass assert p_action is action assert p_point == now + timedelta(seconds=3) assert remove is mock()