diff --git a/custom_components/unfoldedcircle/__init__.py b/custom_components/unfoldedcircle/__init__.py index dae7712..3c2eeff 100755 --- a/custom_components/unfoldedcircle/__init__.py +++ b/custom_components/unfoldedcircle/__init__.py @@ -19,12 +19,7 @@ UnfoldedCircleDockCoordinator, ) -from .helpers import ( - get_ha_websocket_url, - get_registered_websocket_url, - validate_and_register_system_and_driver, -) - +from .helpers import get_registered_websocket_url PLATFORMS: list[Platform] = [ Platform.SWITCH, @@ -202,13 +197,21 @@ def async_migrate_entity_entry( await coordinator.async_config_entry_first_refresh() if coordinator.api.external_entity_configuration_available: - websocket_url = await get_registered_websocket_url(coordinator.api) - if not websocket_url: - websocket_url = get_ha_websocket_url(hass) - - await validate_and_register_system_and_driver( - coordinator.api, hass, websocket_url - ) + if not await get_registered_websocket_url(coordinator.api): + # We haven't registered a new external system yet, raise issue + issue_registry.async_create_issue( + hass, + DOMAIN, + "websocket_connection", + breaks_in_ha_version=None, + data={"config_entry": entry, "name": coordinator.api.name}, + is_fixable=True, + is_persistent=False, + learn_more_url="https://github.com/jackjpowell/hass-unfoldedcircle", + severity=issue_registry.IssueSeverity.WARNING, + translation_key="websocket_connection", + translation_placeholders={"name": coordinator.api.name}, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/custom_components/unfoldedcircle/config_flow.py b/custom_components/unfoldedcircle/config_flow.py index 258b8bc..03571e5 100755 --- a/custom_components/unfoldedcircle/config_flow.py +++ b/custom_components/unfoldedcircle/config_flow.py @@ -3,7 +3,7 @@ import asyncio import logging from typing import Any, Awaitable, Callable, Type - +from aiohttp import ClientConnectionError from pyUnfoldedCircleRemote.const import AUTH_APIKEY_NAME, SIMULATOR_MAC_ADDRESS from pyUnfoldedCircleRemote.remote import ( ApiKeyCreateError, @@ -585,9 +585,7 @@ async def async_step_media_player(self, user_input=None) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: self.options.update(user_input) - if self._remote.external_entity_configuration_available: - return await self.async_step_websocket() - return await self._update_options() + return await self.async_step_remote_host() return self.async_show_form( step_id="media_player", @@ -616,6 +614,49 @@ async def async_step_media_player(self, user_input=None) -> FlowResult: last_step=False, ) + async def async_step_remote_host(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + existing_entry = self._config_entry + + remote_api = Remote( + api_url=user_input.get("host"), + apikey=existing_entry.data["apiKey"], + ) + try: + if await remote_api.validate_connection(): + data = existing_entry.data.copy() + _LOGGER.debug("Updating host for remote") + data["host"] = remote_api.endpoint + except ClientConnectionError: + errors["base"] = "invalid_host" + else: + self.hass.config_entries.async_update_entry(existing_entry, data=data) + + if self._remote.external_entity_configuration_available: + return await self.async_step_websocket() + return await self._update_options() + + last_step = True + if self._remote.external_entity_configuration_available: + last_step = False + + return self.async_show_form( + step_id="remote_host", + data_schema=vol.Schema( + { + vol.Required( + "host", + default=self._config_entry.data["host"], + ): str, + } + ), + description_placeholders={"name": self._remote.name}, + last_step=last_step, + errors=errors, + ) + async def async_step_websocket(self, user_input=None): """Handle a flow initialized by the user.""" errors: dict[str, str] = {} @@ -648,7 +689,9 @@ async def async_step_websocket(self, user_input=None): _LOGGER.error("Invalid Websocket Address: %s", ex) errors["base"] = "invalid_websocket_address" - url = get_ha_websocket_url(self.hass) + url = await get_registered_websocket_url(self._remote) + if url is None: + url = get_ha_websocket_url(self.hass) if user_input is not None: url = user_input.get("websocket_url") diff --git a/custom_components/unfoldedcircle/repairs.py b/custom_components/unfoldedcircle/repairs.py index a0744d8..22f1890 100755 --- a/custom_components/unfoldedcircle/repairs.py +++ b/custom_components/unfoldedcircle/repairs.py @@ -8,10 +8,18 @@ from homeassistant.components.repairs import RepairsFlow from homeassistant.helpers import issue_registry from homeassistant.core import HomeAssistant -from .helpers import validate_dock_password, synchronize_dock_password +from .helpers import ( + validate_dock_password, + synchronize_dock_password, + register_system_and_driver, + get_ha_websocket_url, + validate_websocket_address, +) from .config_flow import CannotConnect, InvalidDockPassword from .const import DOMAIN from . import UnfoldedCircleConfigEntry +from .websocket import UCWebsocketClient + _LOGGER = logging.getLogger(__name__) @@ -93,6 +101,78 @@ async def async_step_confirm( ) +class WebSocketRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, hass, issue_id, data) -> None: + super().__init__() + self.data = data + self.issue_id = issue_id + self.hass = hass + self.config_entry: UnfoldedCircleConfigEntry = self.data.get("config_entry") + self.coordinator = self.config_entry.runtime_data.coordinator + + async def async_step_init( + self, + user_input: dict[str, str] | None = None, + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + if validate_websocket_address(user_input.get("websocket_url")): + await register_system_and_driver( + self.coordinator.api, self.hass, user_input.get("websocket_url") + ) + websocket_client = UCWebsocketClient(self.hass) + configure_entities_subscription = ( + websocket_client.get_driver_subscription( + self.coordinator.api.hostname + ) + ) + if not configure_entities_subscription: + raise WebsocketFailure + try: + await self.hass.config_entries.async_reload( + self.coordinator.config_entry.entry_id + ) + + issue_registry.async_delete_issue( + self.hass, DOMAIN, self.issue_id + ) + + return self.async_abort(reason="ws_connection_successful") + except Exception: + errors["base"] = "cannot_connect" + except CannotConnect: + errors["base"] = "cannot_connect" + except WebsocketFailure: + errors["base"] = "websocket_failure" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="confirm", + errors=errors, + data_schema=vol.Schema( + { + vol.Required( + "websocket_url", default=get_ha_websocket_url(self.hass) + ): str + } + ), + description_placeholders={"name": self.data["name"]}, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -101,3 +181,9 @@ async def async_create_fix_flow( """Create flow.""" if issue_id.startswith("dock_password"): return DockPasswordRepairFlow(hass, issue_id, data) + if issue_id == "websocket_connection": + return WebSocketRepairFlow(hass, issue_id, data) + + +class WebsocketFailure(Exception): + """Error to indicate there the creation of HA token failed.""" diff --git a/custom_components/unfoldedcircle/translations/en.json b/custom_components/unfoldedcircle/translations/en.json index a78cd12..0c56ca7 100755 --- a/custom_components/unfoldedcircle/translations/en.json +++ b/custom_components/unfoldedcircle/translations/en.json @@ -63,7 +63,8 @@ "error": { "ha_driver_failure": "Unexpected error when configuring remote entities", "cannot_create_ha_token": "Unable to create Home Assistant Token", - "invalid_websocket_address": "An invalid home assistant websocket address was supplied" + "invalid_websocket_address": "An invalid home assistant websocket address was supplied", + "invalid_host": "An invalid host was supplied for the remote" }, "step": { "init": { @@ -90,11 +91,18 @@ "suppress_activity_groups": "Suppress creation of activity group entities" } }, + "remote_host": { + "title": "Unfolded Circle Options", + "description": "Configure Host / IP Address of {name}", + "data": { + "host": "Host / IP Address" + } + }, "websocket": { "title": "Unfolded Circle Options", - "description": "Configure Websocket Address", + "description": "Configure Home Assistant Websocket Address", "data": { - "websocket_url": "Websocket Address for Home Assistant" + "websocket_url": "Home Assistant Websocket Address" } }, "select_entities": { @@ -114,6 +122,28 @@ } }, "issues": { + "websocket_connection": { + "title": "Enable improved communications between {name} and Home Assistant", + "fix_flow": { + "step": { + "confirm": { + "title": "Home Assistant Websocket URL", + "description": "To improve communications {name} requires the websocket address of this home assistant server.", + "data": { + "websocket_url": "Home Assistant Websocket URL" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "websocket_failure": "Invalid Websocket URL", + "unknown": "Unexpected error" + }, + "abort": { + "ws_connection_successful": "Improved communications enabled" + } + } + }, "dock_password": { "title": "Supply dock password for {name}", "fix_flow": {