Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions homeassistant/components/onkyo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
33 changes: 15 additions & 18 deletions homeassistant/components/onkyo/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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})

Expand Down
9 changes: 3 additions & 6 deletions homeassistant/components/onkyo/receiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
43 changes: 43 additions & 0 deletions homeassistant/components/portainer/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Mapping
import logging
from typing import Any

Expand Down Expand Up @@ -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."""
Expand Down
8 changes: 4 additions & 4 deletions homeassistant/components/portainer/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)},
Expand Down Expand Up @@ -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)},
Expand All @@ -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)},
Expand Down
12 changes: 11 additions & 1 deletion homeassistant/components/portainer/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
30 changes: 24 additions & 6 deletions homeassistant/components/togrill/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -206,12 +213,23 @@ 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,
service_info: BluetoothServiceInfoBleak,
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()
1 change: 1 addition & 0 deletions homeassistant/components/vicare/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
UNSUPPORTED_DEVICES = [
"Heatbox1",
"Heatbox2_SRC",
"E3_TCU10_x07",
"E3_TCU41_x04",
"E3_FloorHeatingCircuitChannel",
"E3_FloorHeatingCircuitDistributorBox",
Expand Down
4 changes: 2 additions & 2 deletions tests/components/onkyo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
26 changes: 22 additions & 4 deletions tests/components/portainer/test_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
Loading
Loading