diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index a4d1ec8f17514d..adbcb605b6c596 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -52,10 +52,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo try: info = await async_interview(host) + except TimeoutError as exc: + raise ConfigEntryNotReady(f"Timed out interviewing: {host}") from exc except OSError as exc: - raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc - if info is None: - raise ConfigEntryNotReady(f"Unable to connect to: {host}") + raise ConfigEntryNotReady(f"Unexpected exception interviewing: {host}") from exc manager = ReceiverManager(hass, entry, info) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index fab2f9b513e445..f317eafec098bc 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -109,24 +109,22 @@ async def async_step_manual( _LOGGER.debug("Config flow manual: %s", host) try: info = await async_interview(host) + except TimeoutError: + _LOGGER.warning("Timed out interviewing: %s", host) + errors["base"] = "cannot_connect" except OSError: - _LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected exception interviewing: %s", host) errors["base"] = "unknown" else: - if info is None: - errors["base"] = "cannot_connect" - else: - self._receiver_info = info + self._receiver_info = info - await self.async_set_unique_id( - info.identifier, raise_on_progress=False - ) - if self.source == SOURCE_RECONFIGURE: - self._abort_if_unique_id_mismatch() - else: - self._abort_if_unique_id_configured() + await self.async_set_unique_id(info.identifier, raise_on_progress=False) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + else: + self._abort_if_unique_id_configured() - return await self.async_step_configure_receiver() + return await self.async_step_configure_receiver() suggested_values = user_input if suggested_values is None and self.source == SOURCE_RECONFIGURE: @@ -214,14 +212,13 @@ async def async_step_ssdp( try: info = await async_interview(host) + except TimeoutError: + _LOGGER.warning("Timed out interviewing: %s", host) + return self.async_abort(reason="cannot_connect") except OSError: - _LOGGER.exception("Unexpected exception interviewing host %s", host) + _LOGGER.exception("Unexpected exception interviewing: %s", host) return self.async_abort(reason="unknown") - if info is None: - _LOGGER.debug("SSDP eiscp is None: %s", host) - return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id(info.identifier) self._abort_if_unique_id_configured(updates={CONF_HOST: info.host}) diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py index e4fe8bc663059f..8fc5c5e7e0df91 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -124,13 +124,10 @@ def start_unloading(self) -> None: self.callbacks.clear() -async def async_interview(host: str) -> ReceiverInfo | None: +async def async_interview(host: str) -> ReceiverInfo: """Interview the receiver.""" - info: ReceiverInfo | None = None - with contextlib.suppress(asyncio.TimeoutError): - async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT): - info = await aioonkyo.interview(host) - return info + async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT): + return await aioonkyo.interview(host) async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]: diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index b7cb0ba8b990ce..175e51488471a4 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -87,6 +88,48 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth when Portainer API authentication fails.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth: ask for new API token and validate.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + try: + await _validate_input( + self.hass, + data={ + **reauth_entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + }, + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except PortainerTimeout: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 378f5f342811ac..e10d6b3558473b 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -66,7 +66,7 @@ async def _async_setup(self) -> None: try: await self.portainer.get_endpoints() except PortainerAuthenticationError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={"error": repr(err)}, @@ -94,7 +94,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: endpoints = await self.portainer.get_endpoints() except PortainerAuthenticationError as err: _LOGGER.error("Authentication error: %s", repr(err)) - raise UpdateFailed( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={"error": repr(err)}, @@ -121,7 +121,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: ) from err except PortainerAuthenticationError as err: _LOGGER.exception("Authentication error") - raise UpdateFailed( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={"error": repr(err)}, diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 083a6763b40fe1..dbbfe17764f318 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -13,6 +13,15 @@ "verify_ssl": "Whether to verify SSL certificates. Disable only if you have a self-signed certificate" }, "description": "You can create an access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**" + }, + "reauth_confirm": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The new API access token for authenticating with Portainer" + }, + "description": "The access token for your Portainer instance needs to be re-authenticated. You can create a new access token in the Portainer UI. Go to **My account > Access tokens** and select **Add access token**" } }, "error": { @@ -22,7 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "device": { diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py index 391561d477a36f..dda20500235816 100644 --- a/homeassistant/components/togrill/coordinator.py +++ b/homeassistant/components/togrill/coordinator.py @@ -139,7 +139,11 @@ async def _connect_and_update_registry(self) -> Client: raise DeviceNotFound("Unable to find device") try: - client = await Client.connect(device, self._notify_callback) + client = await Client.connect( + device, + self._notify_callback, + disconnected_callback=self._disconnected_callback, + ) except BleakError as exc: self.logger.debug("Connection failed", exc_info=True) raise DeviceNotFound("Unable to connect to device") from exc @@ -169,9 +173,6 @@ async def async_shutdown(self) -> None: self.client = None async def _get_connected_client(self) -> Client: - if self.client and not self.client.is_connected: - await self.client.disconnect() - self.client = None if self.client: return self.client @@ -196,6 +197,12 @@ def _notify_callback(self, packet: Packet): async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: """Poll the device.""" + if self.client and not self.client.is_connected: + await self.client.disconnect() + self.client = None + self._async_request_refresh_soon() + raise DeviceFailed("Device was disconnected") + client = await self._get_connected_client() try: await client.request(PacketA0Notify) @@ -206,6 +213,17 @@ async def _async_update_data(self) -> dict[tuple[int, int | None], Packet]: raise DeviceFailed(f"Device failed {exc}") from exc return self.data + @callback + def _async_request_refresh_soon(self) -> None: + self.config_entry.async_create_task( + self.hass, self.async_request_refresh(), eager_start=False + ) + + @callback + def _disconnected_callback(self) -> None: + """Handle Bluetooth device being disconnected.""" + self._async_request_refresh_soon() + @callback def _async_handle_bluetooth_event( self, @@ -213,5 +231,5 @@ def _async_handle_bluetooth_event( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" - if not self.client and isinstance(self.last_exception, DeviceNotFound): - self.hass.async_create_task(self.async_refresh()) + if isinstance(self.last_exception, DeviceNotFound): + self._async_request_refresh_soon() diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index bcf41223d3fef3..f8b74730e576d2 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -19,6 +19,7 @@ UNSUPPORTED_DEVICES = [ "Heatbox1", "Heatbox2_SRC", + "E3_TCU10_x07", "E3_TCU41_x04", "E3_FloorHeatingCircuitChannel", "E3_FloorHeatingCircuitDistributorBox", diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index f8580c2b257c36..0e84d504ec8bd3 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -29,12 +29,12 @@ def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]: """Mock discovery functions.""" - async def get_info(host: str) -> ReceiverInfo | None: + async def get_info(host: str) -> ReceiverInfo: """Get receiver info by host.""" for info in receiver_infos: if info.host == host: return info - return None + raise TimeoutError def get_infos(host: str) -> MagicMock: """Get receiver infos from broadcast.""" diff --git a/tests/components/portainer/test_binary_sensor.py b/tests/components/portainer/test_binary_sensor.py index 6323cbde08dff7..e31937b64f7cd3 100644 --- a/tests/components/portainer/test_binary_sensor.py +++ b/tests/components/portainer/test_binary_sensor.py @@ -49,14 +49,14 @@ async def test_all_entities( PortainerTimeoutError("timeout"), ], ) -async def test_refresh_exceptions( +async def test_refresh_endpoints_exceptions( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, exception: Exception, ) -> None: - """Test entities go unavailable after coordinator refresh failures.""" + """Test entities go unavailable after coordinator refresh failures, for the endpoint fetch.""" await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -69,8 +69,26 @@ async def test_refresh_exceptions( state = hass.states.get("binary_sensor.practical_morse_status") assert state.state == STATE_UNAVAILABLE - # Reset endpoints; fail on containers fetch - mock_portainer_client.get_endpoints.side_effect = None + +@pytest.mark.parametrize( + ("exception"), + [ + PortainerAuthenticationError("bad creds"), + PortainerConnectionError("cannot connect"), + PortainerTimeoutError("timeout"), + ], +) +async def test_refresh_containers_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test entities go unavailable after coordinator refresh failures, for the container fetch.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_portainer_client.get_containers.side_effect = exception freezer.tick(DEFAULT_SCAN_INTERVAL) diff --git a/tests/components/portainer/test_config_flow.py b/tests/components/portainer/test_config_flow.py index a2806b53041818..9bc645f4f34599 100644 --- a/tests/components/portainer/test_config_flow.py +++ b/tests/components/portainer/test_config_flow.py @@ -126,3 +126,99 @@ async def test_duplicate_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full flow of the config flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # There is no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "new_api_key" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + PortainerAuthenticationError, + "invalid_auth", + ), + ( + PortainerConnectionError, + "cannot_connect", + ), + ( + PortainerTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions in the reauth flow.""" + mock_config_entry.add_to_hass(hass) + + mock_portainer_client.get_endpoints.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "new_api_key"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + # Now test that we can recover from the error + mock_portainer_client.get_endpoints.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "new_api_key" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 2e46016b304d38..18c1fe2d7aefcd 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -136,7 +136,8 @@ async def test_options_form(hass: HomeAssistant) -> None: async def test_user_form_timeout(hass: HomeAssistant) -> None: - """Test we handle server search timeout.""" + """Test we handle server search timeout and allow manual entry.""" + # First flow: simulate timeout with ( patch( "homeassistant.components.squeezebox.config_flow.async_discover", @@ -150,16 +151,46 @@ async def test_user_form_timeout(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "no_server_found"} - # simulate manual input of host - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: HOST2} + # Second flow: simulate successful discovery + with ( + patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_discover, + ), + patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), + patch( + "homeassistant.components.squeezebox.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "edit" - assert CONF_HOST in result2["data_schema"].schema - for key in result2["data_schema"].schema: - if key == CONF_HOST: - assert key.description == {"suggested_value": HOST2} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } async def test_user_form_duplicate(hass: HomeAssistant) -> None: diff --git a/tests/components/togrill/conftest.py b/tests/components/togrill/conftest.py index 6b028ca52700f4..c58bc0698a9f78 100644 --- a/tests/components/togrill/conftest.py +++ b/tests/components/togrill/conftest.py @@ -57,9 +57,18 @@ def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mo client_object.mocked_notify = None async def _connect( - address: str, callback: Callable[[Packet], None] | None = None + address: str, + callback: Callable[[Packet], None] | None = None, + disconnected_callback: Callable[[], None] | None = None, ) -> Mock: client_object.mocked_notify = callback + if disconnected_callback: + + def _disconnected_callback(): + client_object.is_connected = False + disconnected_callback() + + client_object.mocked_disconnected_callback = _disconnected_callback return client_object async def _disconnect() -> None: diff --git a/tests/components/togrill/test_sensor.py b/tests/components/togrill/test_sensor.py index d7662d483af5a6..913a295d37955b 100644 --- a/tests/components/togrill/test_sensor.py +++ b/tests/components/togrill/test_sensor.py @@ -1,7 +1,8 @@ """Test sensors for ToGrill integration.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch +from habluetooth import BluetoothServiceInfoBleak import pytest from syrupy.assertion import SnapshotAssertion from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify @@ -16,6 +17,16 @@ from tests.components.bluetooth import inject_bluetooth_service_info +def patch_async_ble_device_from_address( + return_value: BluetoothServiceInfoBleak | None = None, +): + """Patch async_ble_device_from_address to return a mocked BluetoothServiceInfoBleak.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + @pytest.mark.parametrize( "packets", [ @@ -57,3 +68,51 @@ async def test_setup( mock_client.mocked_notify(packet) await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +async def test_device_disconnected( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the switch set.""" + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + entity_id = "sensor.pro_05_battery" + + state = hass.states.get(entity_id) + assert state + assert state.state == "0" + + with patch_async_ble_device_from_address(): + mock_client.mocked_disconnected_callback() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + +async def test_device_discovered( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the switch set.""" + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + entity_id = "sensor.pro_05_battery" + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "0"