diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index bbc7226cf9f..a6bc7eff5ca 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -63,7 +63,6 @@ from .const import ( ZONE_TO_PIN, ZONES, ) -from .errors import CannotConnect from .handlers import HANDLERS from .panel import AlarmPanel @@ -258,11 +257,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # creates a panel data store in hass.data[DOMAIN][CONF_DEVICES] await client.async_save_data() - try: - await client.async_connect() - except CannotConnect: - # this will trigger a retry in the future - raise config_entries.ConfigEntryNotReady + # if the cfg entry was created we know we could connect to the panel at some point + # async_connect will handle retries until it establishes a connection + await client.async_connect() for component in PLATFORMS: hass.async_create_task( @@ -355,6 +352,11 @@ class KonnectedView(HomeAssistantView): "unregistered device", status_code=HTTP_BAD_REQUEST ) + panel = device.get("panel") + if panel is not None: + # connect if we haven't already + hass.async_create_task(panel.async_connect()) + try: zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]]) payload[CONF_ZONE] = zone_num @@ -390,6 +392,11 @@ class KonnectedView(HomeAssistantView): f"Device {device_id} not configured", status_code=HTTP_NOT_FOUND ) + panel = device.get("panel") + if panel is not None: + # connect if we haven't already + hass.async_create_task(panel.async_connect()) + # Our data model is based on zone ids but we convert from/to pin ids # based on whether they are specified in the request try: diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index 3b19a700837..3fcb51929ed 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -73,6 +73,9 @@ class AlarmPanel: self.client = None self.status = None self.api_version = KONN_API_VERSIONS[KONN_MODEL] + self.connected = False + self.connect_attempts = 0 + self.cancel_connect_retry = None @property def device_id(self): @@ -84,6 +87,11 @@ class AlarmPanel: """Return the configuration stored in `hass.data` for this device.""" return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) + @property + def available(self): + """Return whether the device is available.""" + return self.connected + def format_zone(self, zone, other_items=None): """Get zone or pin based dict based on the client type.""" payload = { @@ -94,8 +102,15 @@ class AlarmPanel: payload.update(other_items or {}) return payload - async def async_connect(self): + async def async_connect(self, now=None): """Connect to and setup a Konnected device.""" + if self.connected: + return + + if self.cancel_connect_retry: + # cancel any pending connect attempt and try now + self.cancel_connect_retry() + try: self.client = konnected.Client( host=self.host, @@ -118,8 +133,16 @@ class AlarmPanel: except self.client.ClientError as err: _LOGGER.warning("Exception trying to connect to panel: %s", err) - raise CannotConnect + # retry in a bit, never more than ~3 min + self.connect_attempts += 1 + self.cancel_connect_retry = self.hass.helpers.event.async_call_later( + 2 ** min(self.connect_attempts, 5) * 5, self.async_connect + ) + return + + self.connect_attempts = 0 + self.connected = True _LOGGER.info( "Set up Konnected device %s. Open http://%s:%s in a " "web browser to view device status", @@ -129,7 +152,6 @@ class AlarmPanel: ) device_registry = await dr.async_get_registry(self.hass) - device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))}, diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index afc83458aaf..1d26f7875c7 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -81,6 +81,11 @@ class KonnectedSwitch(ToggleEntity): "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, } + @property + def available(self): + """Return whether the panel is available.""" + return self.panel.available + async def async_turn_on(self, **kwargs): """Send a command to turn on the switch.""" resp = await self.panel.update_switch( diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index 74e3b931f61..d36b937d3c3 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -411,12 +411,14 @@ async def test_api(hass, aiohttp_client, mock_panel): "id": "112233445566", "model": "Konnected Pro", "access_token": "abcdefgh", + "api_host": "http://192.168.86.32:8123", "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), } ) device_options = config_flow.OPTIONS_SCHEMA( { + "api_host": "http://192.168.86.32:8123", "io": { "1": "Binary Sensor", "2": "Binary Sensor", diff --git a/tests/components/konnected/test_panel.py b/tests/components/konnected/test_panel.py index f167c558d01..d21fd3c8ebf 100644 --- a/tests/components/konnected/test_panel.py +++ b/tests/components/konnected/test_panel.py @@ -1,11 +1,14 @@ """Test Konnected setup process.""" +from datetime import timedelta + import pytest from homeassistant.components.konnected import config_flow, panel from homeassistant.setup import async_setup_component +from homeassistant.util import utcnow from tests.async_mock import patch -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(name="mock_panel") @@ -551,3 +554,118 @@ async def test_default_options(hass, mock_panel): }, ], } + + +async def test_connect_retry(hass, mock_panel): + """Test that we create a Konnected Panel and save the data.""" + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA( + { + "io": { + "1": "Binary Sensor", + "2": "Binary Sensor", + "3": "Binary Sensor", + "4": "Digital Sensor", + "5": "Digital Sensor", + "6": "Switchable Output", + "out": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "1", "type": "door"}, + { + "zone": "2", + "type": "window", + "name": "winder", + "inverse": True, + }, + {"zone": "3", "type": "door"}, + ], + "sensors": [ + {"zone": "4", "type": "dht"}, + {"zone": "5", "type": "ds18b20", "name": "temper"}, + ], + "switches": [ + { + "zone": "out", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "6"}, + ], + } + ), + } + ) + + entry = MockConfigEntry( + domain="konnected", + title="Konnected Alarm Panel", + data=device_config, + options={}, + ) + entry.add_to_hass(hass) + + # fail first 2 attempts, and succeed the third + mock_panel.get_status.side_effect = [ + mock_panel.ClientError, + mock_panel.ClientError, + { + "hwVersion": "2.3.0", + "swVersion": "2.3.1", + "heap": 10000, + "uptime": 12222, + "ip": "192.168.1.90", + "port": 9123, + "sensors": [], + "actuators": [], + "dht_sensors": [], + "ds18b20_sensors": [], + "mac": "11:22:33:44:55:66", + "model": "Konnected Pro", + "settings": {}, + }, + ] + + # setup the integration and inspect panel behavior + assert ( + await async_setup_component( + hass, + panel.DOMAIN, + { + panel.DOMAIN: { + panel.CONF_ACCESS_TOKEN: "arandomstringvalue", + panel.CONF_API_HOST: "http://192.168.1.1:8123", + } + }, + ) + is True + ) + + # confirm switch is unavailable after initial attempt + await hass.async_block_till_done() + assert hass.states.get("switch.konnected_445566_actuator_6").state == "unavailable" + + # confirm switch is unavailable after second attempt + async_fire_time_changed(hass, utcnow() + timedelta(seconds=11)) + await hass.async_block_till_done() + await hass.helpers.entity_component.async_update_entity( + "switch.konnected_445566_actuator_6" + ) + assert hass.states.get("switch.konnected_445566_actuator_6").state == "unavailable" + + # confirm switch is available after third attempt + async_fire_time_changed(hass, utcnow() + timedelta(seconds=21)) + await hass.async_block_till_done() + await hass.helpers.entity_component.async_update_entity( + "switch.konnected_445566_actuator_6" + ) + assert hass.states.get("switch.konnected_445566_actuator_6").state == "off"