J. Nick Koston 3a4c64b0a7
Fix missing timeout exception check in powerwall config flow (#107899)
* Fix missing timeout exception check in powerwall config flow

powerwall recently switched to asyncio, and every place we
check for unreachable we need to check for timeout error.
There was one missed

```
09:08 homeassistant homeassistant[546]: 2024-01-12 10:09:08.899 ERROR (MainThread) [homeassistant.components.powerwall.config_flow] Unexpected exception
Jan 12 20:09:08 homeassistant homeassistant[546]: Traceback (most recent call last):
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/src/homeassistant/homeassistant/components/powerwall/config_flow.py", line 168, in _async_try_connect
Jan 12 20:09:08 homeassistant homeassistant[546]:     info = await validate_input(self.hass, user_input)
Jan 12 20:09:08 homeassistant homeassistant[546]:            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/src/homeassistant/homeassistant/components/powerwall/config_flow.py", line 76, in validate_input
Jan 12 20:09:08 homeassistant homeassistant[546]:     site_info, gateway_din = await _login_and_fetch_site_info(
Jan 12 20:09:08 homeassistant homeassistant[546]:                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/src/homeassistant/homeassistant/components/powerwall/config_flow.py", line 43, in _login_and_fetch_site_info
Jan 12 20:09:08 homeassistant homeassistant[546]:     await power_wall.login(password)
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/powerwall.py", line 58, in login
Jan 12 20:09:08 homeassistant homeassistant[546]:     return await self.login_as(User.CUSTOMER, password, email, force_sm_off)
Jan 12 20:09:08 homeassistant homeassistant[546]:            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/powerwall.py", line 49, in login_as
Jan 12 20:09:08 homeassistant homeassistant[546]:     response = await self._api.login(user, email, password, force_sm_off)
Jan 12 20:09:08 homeassistant homeassistant[546]:                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/api.py", line 172, in login
Jan 12 20:09:08 homeassistant homeassistant[546]:     return await self.post(
Jan 12 20:09:08 homeassistant homeassistant[546]:            ^^^^^^^^^^^^^^^^
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/api.py", line 146, in post
Jan 12 20:09:08 homeassistant homeassistant[546]:     response = await self._http_session.post(
Jan 12 20:09:08 homeassistant homeassistant[546]:                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/local/lib/python3.12/site-packages/aiohttp/client.py", line 601, in _request
Jan 12 20:09:08 homeassistant homeassistant[546]:     await resp.start(conn)
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/local/lib/python3.12/site-packages/aiohttp/client_reqrep.py", line 960, in start
Jan 12 20:09:08 homeassistant homeassistant[546]:     with self._timer:
Jan 12 20:09:08 homeassistant homeassistant[546]:   File "/usr/local/lib/python3.12/site-packages/aiohttp/helpers.py", line 735, in __exit__
Jan 12 20:09:08 homeassistant homeassistant[546]:     raise asyncio.TimeoutError from None
Jan 12 20:09:08 homeassistant homeassistant[546]: TimeoutError

```

* cov
2024-01-12 21:39:27 +01:00

284 lines
11 KiB
Python

"""Config flow for Tesla Powerwall integration."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import CookieJar
from tesla_powerwall import (
AccessDeniedError,
MissingAttributeError,
Powerwall,
PowerwallUnreachableError,
SiteInfoResponse,
)
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.components import dhcp
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.util.network import is_ip_address
from . import async_last_update_was_successful
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
ENTRY_FAILURE_STATES = {
config_entries.ConfigEntryState.SETUP_ERROR,
config_entries.ConfigEntryState.SETUP_RETRY,
}
async def _login_and_fetch_site_info(
power_wall: Powerwall, password: str
) -> tuple[SiteInfoResponse, str]:
"""Login to the powerwall and fetch the base info."""
if password is not None:
await power_wall.login(password)
return await asyncio.gather(
power_wall.get_site_info(), power_wall.get_gateway_din()
)
async def _powerwall_is_reachable(ip_address: str, password: str) -> bool:
"""Check if the powerwall is reachable."""
try:
async with Powerwall(ip_address) as power_wall:
await power_wall.login(password)
except AccessDeniedError:
return True
except PowerwallUnreachableError:
return False
return True
async def validate_input(
hass: core.HomeAssistant, data: dict[str, str]
) -> dict[str, str]:
"""Validate the user input allows us to connect.
Data has the keys from schema with values provided by the user.
"""
session = async_create_clientsession(
hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True)
)
async with Powerwall(data[CONF_IP_ADDRESS], http_session=session) as power_wall:
password = data[CONF_PASSWORD]
try:
site_info, gateway_din = await _login_and_fetch_site_info(
power_wall, password
)
except MissingAttributeError as err:
# Only log the exception without the traceback
_LOGGER.error(str(err))
raise WrongVersion from err
# Return info that you want to store in the config entry.
return {"title": site_info.site_name, "unique_id": gateway_din.upper()}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Tesla Powerwall."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the powerwall flow."""
self.ip_address: str | None = None
self.title: str | None = None
self.reauth_entry: config_entries.ConfigEntry | None = None
async def _async_powerwall_is_offline(
self, entry: config_entries.ConfigEntry
) -> bool:
"""Check if the power wall is offline.
We define offline by the config entry
is in a failure/retry state or the updates
are failing and the powerwall is unreachable
since device may be updating.
"""
ip_address = entry.data[CONF_IP_ADDRESS]
password = entry.data[CONF_PASSWORD]
return bool(
entry.state in ENTRY_FAILURE_STATES
or not async_last_update_was_successful(self.hass, entry)
) and not await _powerwall_is_reachable(ip_address, password)
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
"""Handle dhcp discovery."""
self.ip_address = discovery_info.ip
gateway_din = discovery_info.hostname.upper()
# The hostname is the gateway_din (unique_id)
await self.async_set_unique_id(gateway_din)
for entry in self._async_current_entries(include_ignore=False):
if entry.data[CONF_IP_ADDRESS] == discovery_info.ip:
if entry.unique_id is not None and is_ip_address(entry.unique_id):
if self.hass.config_entries.async_update_entry(
entry, unique_id=gateway_din
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
return self.async_abort(reason="already_configured")
if entry.unique_id == gateway_din:
if await self._async_powerwall_is_offline(entry):
if self.hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_IP_ADDRESS: self.ip_address}
):
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
return self.async_abort(reason="already_configured")
# Still need to abort for ignored entries
self._abort_if_unique_id_configured()
self.context["title_placeholders"] = {
"name": gateway_din,
"ip_address": self.ip_address,
}
errors, info, _ = await self._async_try_connect(
{CONF_IP_ADDRESS: self.ip_address, CONF_PASSWORD: gateway_din[-5:]}
)
if errors:
if CONF_PASSWORD in errors:
# The default password is the gateway din last 5
# if it does not work, we have to ask
return await self.async_step_user()
return self.async_abort(reason="cannot_connect")
assert info is not None
self.title = info["title"]
return await self.async_step_confirm_discovery()
async def _async_try_connect(
self, user_input: dict[str, Any]
) -> tuple[dict[str, Any] | None, dict[str, str] | None, dict[str, str]]:
"""Try to connect to the powerwall."""
info = None
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
try:
info = await validate_input(self.hass, user_input)
except (PowerwallUnreachableError, asyncio.TimeoutError) as ex:
errors[CONF_IP_ADDRESS] = "cannot_connect"
description_placeholders = {"error": str(ex)}
except WrongVersion as ex:
errors["base"] = "wrong_version"
description_placeholders = {"error": str(ex)}
except AccessDeniedError as ex:
errors[CONF_PASSWORD] = "invalid_auth"
description_placeholders = {"error": str(ex)}
except Exception as ex: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
description_placeholders = {"error": str(ex)}
return errors, info, description_placeholders
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm a discovered powerwall."""
assert self.ip_address is not None
assert self.unique_id is not None
if user_input is not None:
assert self.title is not None
return self.async_create_entry(
title=self.title,
data={
CONF_IP_ADDRESS: self.ip_address,
CONF_PASSWORD: self.unique_id[-5:],
},
)
self._set_confirm_only()
self.context["title_placeholders"] = {
"name": self.title,
"ip_address": self.ip_address,
}
return self.async_show_form(
step_id="confirm_discovery",
description_placeholders={
"name": self.title,
"ip_address": self.ip_address,
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] | None = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
errors, info, description_placeholders = await self._async_try_connect(
user_input
)
if not errors:
assert info is not None
if info["unique_id"]:
await self.async_set_unique_id(
info["unique_id"], raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS]}
)
self._async_abort_entries_match({CONF_IP_ADDRESS: self.ip_address})
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_IP_ADDRESS, default=self.ip_address): str,
vol.Optional(CONF_PASSWORD): str,
}
),
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle reauth confirmation."""
assert self.reauth_entry is not None
errors: dict[str, str] | None = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
entry_data = self.reauth_entry.data
errors, _, description_placeholders = await self._async_try_connect(
{CONF_IP_ADDRESS: entry_data[CONF_IP_ADDRESS], **user_input}
)
if not errors:
self.hass.config_entries.async_update_entry(
self.reauth_entry, data={**entry_data, **user_input}
)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
errors=errors,
description_placeholders=description_placeholders,
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle configuration by re-auth."""
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
class WrongVersion(exceptions.HomeAssistantError):
"""Error indicating we cannot interact with the powerwall software version."""