diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 45dd671a3566..1c9da369ee74 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -7,7 +7,13 @@ from pathlib import Path from typing import Any, Final, cast from aiohttp import ClientError -from aiopowerwall import PowerwallClient, PowerwallEnergySite, PowerwallError +from aiopowerwall import ( + PowerwallClient, + PowerwallConnectionError, + PowerwallEnergySite, + PowerwallError, + PowerwallRateLimitError, +) from bleak.exc import BleakError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( @@ -21,6 +27,7 @@ from tesla_fleet_api.exceptions import ( from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle +from tesla_fleet_api.teslemetry.energysite import TeslemetryEnergySite from teslemetry_stream import TeslemetryStream, TeslemetryStreamAuthenticationError from teslemetry_stream.const import SseTopic @@ -29,7 +36,7 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.bluetooth import async_ble_device_from_address -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_ADDRESS, @@ -63,6 +70,7 @@ from .const import ( CLIENT_ID, CONF_VIN, DOMAIN, + ISSUE_GATEWAY_NOT_FOUND, LOGGER, POWERWALL_KEY_FILE, RSA_PARENT_KEY, @@ -77,7 +85,12 @@ from .coordinator import ( TeslemetryMetadataCoordinator, TeslemetryVehicleDataCoordinator, ) -from .helpers import async_get_ble_parent, async_update_device_sw_version, flatten +from .helpers import ( + async_get_ble_parent, + async_update_device_sw_version, + create_powerwall_client, + flatten, +) from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData from .services import async_setup_services @@ -430,7 +443,7 @@ async def _async_resolve_local_control( entry: TeslemetryConfigEntry, battery: bool, site_id: int, - cloud_energy_site: EnergySite, + cloud_energy_site: TeslemetryEnergySite, ) -> tuple[bool, str | None, EnergySite | EnergySiteRouter]: """Resolve opt-in local control for an energy site.""" # Only a battery/Powerwall gateway can pair for local (TEDAPI) control. @@ -438,6 +451,7 @@ async def _async_resolve_local_control( return False, None, cloud_energy_site subentry_id = _find_energy_subentry_id(entry, site_id) if subentry_id is None: + ir.async_delete_issue(hass, DOMAIN, _gateway_issue_id(site_id)) return True, None, cloud_energy_site # A local-gateway failure for one site must not tear down the integration. try: @@ -455,30 +469,118 @@ async def _async_resolve_local_control( return True, subentry_id, api +def _gateway_issue_id(site_id: int) -> str: + """Return the repair issue id for a site whose local gateway was not found.""" + return f"{ISSUE_GATEWAY_NOT_FOUND}_{site_id}" + + async def _async_resolve_energy_site_api( hass: HomeAssistant, entry: TeslemetryConfigEntry, subentry_id: str, - cloud_energy_site: EnergySite, + cloud_energy_site: TeslemetryEnergySite, ) -> EnergySite | EnergySiteRouter: """Return the API an energy site's platforms should call.""" - data = entry.subentries[subentry_id].data - host = data.get(CONF_HOST) - password = data.get(CONF_PASSWORD) + subentry = entry.subentries[subentry_id] + host = subentry.data.get(CONF_HOST) + password = subentry.data.get(CONF_PASSWORD) if not host or not password: return cloud_energy_site key_pem = await _async_get_rsa_key_pem(hass) - powerwall_client = PowerwallClient( - host=host, - gateway_password=password, - rsa_private_key_pem=key_pem, - session=async_get_clientsession(hass), - ) + powerwall_client = create_powerwall_client(hass, host, password, key_pem) + try: + await powerwall_client.connect() + except PowerwallRateLimitError as err: + LOGGER.debug( + "Local gateway for energy site %s is rate limiting: %s", + cloud_energy_site.energy_site_id, + err, + ) + ir.async_delete_issue( + hass, DOMAIN, _gateway_issue_id(cloud_energy_site.energy_site_id) + ) + except PowerwallError as err: + # Another device may have taken the old address, so any refusal is a lead. + powerwall_client = await _async_rediscover_gateway( + hass, entry, subentry, powerwall_client, cloud_energy_site, key_pem, err + ) + else: + ir.async_delete_issue( + hass, DOMAIN, _gateway_issue_id(cloud_energy_site.energy_site_id) + ) local_energy_site = PowerwallEnergySite(powerwall_client) return EnergySiteRouter(local_energy_site, cloud_energy_site) +async def _async_rediscover_gateway( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + subentry: ConfigSubentry, + stale_client: PowerwallClient, + cloud_energy_site: TeslemetryEnergySite, + key_pem: bytes, + error: PowerwallError, +) -> PowerwallClient: + """Return a client at the gateway's new address, or the stale client if not found.""" + site_id = cloud_energy_site.energy_site_id + issue_id = _gateway_issue_id(site_id) + try: + host = await cloud_energy_site.find_gateway_address() + except (ClientError, TeslaFleetError) as err: + LOGGER.debug( + "Gateway address lookup failed for energy site %s: %s", site_id, err + ) + host = None + if host == stale_client.host and not isinstance(error, PowerwallConnectionError): + # The cloud confirms the address, so the refusing device is the gateway itself. + LOGGER.warning( + "Local gateway for energy site %s refused the connection; " + "commands will fall back to cloud control: %s", + site_id, + error, + ) + ir.async_delete_issue(hass, DOMAIN, issue_id) + return stale_client + if host and host != stale_client.host: + client = create_powerwall_client( + hass, host, subentry.data[CONF_PASSWORD], key_pem + ) + try: + await client.connect() + except PowerwallError as err: + LOGGER.debug( + "Local gateway for energy site %s unreachable at %s: %s", + site_id, + host, + err, + ) + else: + LOGGER.info("Local gateway for energy site %s moved to %s", site_id, host) + hass.config_entries.async_update_subentry( + entry, subentry, data={**subentry.data, CONF_HOST: host} + ) + ir.async_delete_issue(hass, DOMAIN, issue_id) + return client + + LOGGER.warning( + "Local gateway for energy site %s could not be found; " + "commands will fall back to cloud control", + site_id, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_GATEWAY_NOT_FOUND, + translation_placeholders={"site": subentry.title}, + data={"entry_id": entry.entry_id, "subentry_id": subentry.subentry_id}, + ) + return stale_client + + async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: """Set up Teslemetry config.""" @@ -652,7 +754,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - (DOMAIN, c["din"]) for c in product["components"]["wall_connectors"] } - energy_site = teslemetry.energySites.create(site_id) + energy_site = cast( + TeslemetryEnergySite, teslemetry.energySites.create(site_id) + ) device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index d7efe5aa34e9..602b85a05d14 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, cast, override from aiohttp import ClientError -from aiopowerwall import PowerwallAuthenticationError, PowerwallClient, PowerwallError +from aiopowerwall import PowerwallAuthenticationError, PowerwallError from bleak.exc import BleakError import probatio from tesla_fleet_api.const import ( @@ -25,7 +25,6 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, WhitelistOperationAttemptingToAddExistingKey, ) -from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth from tesla_fleet_api.teslemetry import Teslemetry from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, TeslemetryEnergySite @@ -70,33 +69,18 @@ from .const import ( SUBENTRY_TYPE_ENERGY_SITE, SUBENTRY_TYPE_VEHICLE, ) -from .helpers import async_get_ble_parent -from .models import TeslemetryEnergyData +from .helpers import ( + PowerwallKeyRejectedError, + async_get_ble_parent, + async_verify_local_gateway, + cloud_energy_site, +) class PowerwallLookupError(Exception): """Signal that the authorized-client lookup failed for a non-retryable reason.""" -class PowerwallKeyRejectedError(Exception): - """Signal that the gateway refused a v1r-signed read with our RSA key.""" - - -def _cloud_energy_site(energy_data: TeslemetryEnergyData) -> TeslemetryEnergySite: - """Return the cloud energy-site API for pairing. - - Pairing always registers the key through the Teslemetry cloud; a paired - site's api is an EnergySiteRouter, so unwrap its cloud secondary rather - than routing to the local Powerwall primary. - """ - return cast( - TeslemetryEnergySite, - energy_data.api.secondary - if isinstance(energy_data.api, EnergySiteRouter) - else energy_data.api, - ) - - class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): @@ -487,7 +471,7 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): self._site_id = energy_data.id self._site_name = energy_data.device.get("name") or "Energy Site" if abort := await self._prepare_energy_site( - _cloud_energy_site(energy_data) + cloud_energy_site(energy_data.api) ): return abort return await self._async_begin_pairing() @@ -525,7 +509,7 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): ) if energy_data is None: return self.async_abort(reason="cannot_connect") - if abort := await self._prepare_energy_site(_cloud_energy_site(energy_data)): + if abort := await self._prepare_energy_site(cloud_energy_site(energy_data.api)): return abort return await self._async_begin_pairing() @@ -651,24 +635,6 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): None, ) - async def _verify_local_gateway(self, host: str, password: str) -> None: - """Prove the LAN connection and the RSA key against the gateway.""" - if TYPE_CHECKING: - assert self._key_pem is not None - assert self._energy_site is not None - async with PowerwallClient( - host=host, - gateway_password=password, - rsa_private_key_pem=self._key_pem, - session=async_get_clientsession(self.hass), - ) as client: - await client.connect() - try: - # connect() passed the password, so a failure here is key rejection. - await client.get_status() - except PowerwallAuthenticationError as err: - raise PowerwallKeyRejectedError from err - def _default_gateway_host(self) -> str: """Return the host to pre-fill on the credentials form, or "" for blank. @@ -691,11 +657,14 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): if user_input is not None: if TYPE_CHECKING: assert self._energy_site is not None + assert self._key_pem is not None host = user_input[CONF_HOST].strip() # The gateway accepts only the last 5 characters of the Wi-Fi password. password = user_input[CONF_PASSWORD].strip()[-5:] try: - await self._verify_local_gateway(host, password) + await async_verify_local_gateway( + self.hass, host, password, self._key_pem + ) except PowerwallKeyRejectedError as err: LOGGER.debug("Powerwall rejected the signed read: %s", err.__cause__) errors["base"] = "key_not_approved" diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index 6c79f967ed90..d14c26f0a459 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -23,6 +23,7 @@ SUBENTRY_TYPE_ENERGY_SITE = "energy_site" CONF_SITE_ID = "site_id" POWERWALL_KEY_FILE = "tesla_powerwall.key" RSA_PARENT_KEY = f"{DOMAIN}_rsa_parent" +ISSUE_GATEWAY_NOT_FOUND = "gateway_not_found" ENERGY_HISTORY_FIELDS = [ "solar_energy_exported", diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py index e3ef8a1b3ebf..b8d0840dd158 100644 --- a/homeassistant/components/teslemetry/helpers.py +++ b/homeassistant/components/teslemetry/helpers.py @@ -2,18 +2,65 @@ import asyncio from collections.abc import Awaitable -from typing import Any +from typing import Any, cast +from aiopowerwall import PowerwallAuthenticationError, PowerwallClient from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth +from tesla_fleet_api.teslemetry import EnergySite +from tesla_fleet_api.teslemetry.energysite import TeslemetryEnergySite from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import BLE_PARENT_KEY, BLE_PARENT_LOCK_KEY, DOMAIN, LOGGER, VEHICLE_KEY_FILE +class PowerwallKeyRejectedError(Exception): + """Signal that the gateway refused a v1r-signed read with our RSA key.""" + + +def cloud_energy_site(api: EnergySite | EnergySiteRouter) -> TeslemetryEnergySite: + """Return the cloud energy-site API behind a site's resolved api. + + Pairing and gateway discovery always go through the Teslemetry cloud; a + paired site's api is an EnergySiteRouter, so unwrap its cloud secondary + rather than routing to the local Powerwall primary. + """ + return cast( + TeslemetryEnergySite, + api.secondary if isinstance(api, EnergySiteRouter) else api, + ) + + +def create_powerwall_client( + hass: HomeAssistant, host: str, password: str, key_pem: bytes +) -> PowerwallClient: + """Return a local Powerwall gateway client on the shared HTTP session.""" + return PowerwallClient( + host=host, + gateway_password=password, + rsa_private_key_pem=key_pem, + session=async_get_clientsession(hass), + ) + + +async def async_verify_local_gateway( + hass: HomeAssistant, host: str, password: str, key_pem: bytes +) -> None: + """Prove the LAN connection and the RSA key against the gateway.""" + async with create_powerwall_client(hass, host, password, key_pem) as client: + await client.connect() + try: + # connect() passed the password, so a failure here is key rejection. + await client.get_status() + except PowerwallAuthenticationError as err: + raise PowerwallKeyRejectedError from err + + async def async_get_ble_parent(hass: HomeAssistant) -> TeslaBluetooth: """Return a shared TeslaBluetooth parent with the private key loaded.""" lock: asyncio.Lock = hass.data.setdefault(BLE_PARENT_LOCK_KEY, asyncio.Lock()) diff --git a/homeassistant/components/teslemetry/repairs.py b/homeassistant/components/teslemetry/repairs.py index 637e9719c495..5f7535bca3fb 100644 --- a/homeassistant/components/teslemetry/repairs.py +++ b/homeassistant/components/teslemetry/repairs.py @@ -1,15 +1,28 @@ """Repairs for the Teslemetry integration.""" +from typing import TYPE_CHECKING, Any + +from aiohttp import ClientError +from aiopowerwall import PowerwallAuthenticationError, PowerwallError +import probatio +from tesla_fleet_api.exceptions import PrivateKeyError, TeslaFleetError + from homeassistant.components.repairs import ( ConfirmRepairFlow, RepairsFlow, RepairsFlowResult, ) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState, ConfigSubentry +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant, callback -from . import TeslemetryConfigEntry -from .const import VEHICLE_ISSUE_LEARN_MORE +from . import TeslemetryConfigEntry, _async_get_rsa_key_pem +from .const import ISSUE_GATEWAY_NOT_FOUND, LOGGER, VEHICLE_ISSUE_LEARN_MORE +from .helpers import ( + PowerwallKeyRejectedError, + async_verify_local_gateway, + cloud_energy_site, +) class VehicleMetadataRepairFlow(RepairsFlow): @@ -56,6 +69,117 @@ class VehicleMetadataRepairFlow(RepairsFlow): ) +class GatewayNotFoundRepairFlow(RepairsFlow): + """Handle a repair that finds a local Powerwall gateway's current address.""" + + def __init__(self, entry_id: str, subentry_id: str) -> None: + """Create flow.""" + self.entry_id = entry_id + self.subentry_id = subentry_id + self._entry: TeslemetryConfigEntry | None = None + self._subentry: ConfigSubentry | None = None + self._key_pem = b"" + self._default_host = "" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> RepairsFlowResult: + """Look up the gateway's address through the cloud before asking the user.""" + entry: TeslemetryConfigEntry | None = self.hass.config_entries.async_get_entry( + self.entry_id + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + energy_data = next( + ( + energysite + for energysite in entry.runtime_data.energysites + if energysite.subentry_id == self.subentry_id + ), + None, + ) + if energy_data is None or self.subentry_id not in entry.subentries: + return self.async_abort(reason="entry_not_loaded") + self._entry = entry + self._subentry = entry.subentries[self.subentry_id] + + try: + self._key_pem = await _async_get_rsa_key_pem(self.hass) + except (OSError, ValueError, PrivateKeyError) as err: + LOGGER.debug("RSA key load failed: %s", err) + return self.async_abort(reason="cannot_connect") + + try: + host = await cloud_energy_site(energy_data.api).find_gateway_address() + except (ClientError, TeslaFleetError) as err: + LOGGER.debug("Gateway address lookup failed: %s", err) + host = None + errors: dict[str, str] = {} + if host: + if (error := await self._async_verify(host)) is None: + return self._async_save_host(host) + errors["base"] = error + + self._default_host = host or self._subentry.data[CONF_HOST] + return self._async_show_host_form(errors) + + async def async_step_host( + self, user_input: dict[str, Any] | None = None + ) -> RepairsFlowResult: + """Ask for the gateway's address and verify it with the saved password.""" + errors: dict[str, str] = {} + if user_input is not None: + host = user_input[CONF_HOST].strip() + if (error := await self._async_verify(host)) is None: + return self._async_save_host(host) + errors["base"] = error + self._default_host = host + return self._async_show_host_form(errors) + + @callback + def _async_show_host_form(self, errors: dict[str, str]) -> RepairsFlowResult: + """Show the gateway address form.""" + if TYPE_CHECKING: + assert self._subentry is not None + return self.async_show_form( + step_id="host", + data_schema=probatio.Schema( + {probatio.Required(CONF_HOST, default=self._default_host): str} + ), + description_placeholders={"site": self._subentry.title}, + errors=errors, + ) + + async def _async_verify(self, host: str) -> str | None: + """Verify the gateway at host, returning an error key on failure.""" + if TYPE_CHECKING: + assert self._subentry is not None + try: + await async_verify_local_gateway( + self.hass, host, self._subentry.data[CONF_PASSWORD], self._key_pem + ) + except PowerwallKeyRejectedError: + return "key_not_approved" + except PowerwallAuthenticationError: + return "invalid_auth" + except PowerwallError as err: + LOGGER.debug("Local Powerwall verify failed at %s: %s", host, err) + return "cannot_connect" + return None + + @callback + def _async_save_host(self, host: str) -> RepairsFlowResult: + """Persist the verified gateway address and reload to restore local control.""" + if TYPE_CHECKING: + assert self._entry is not None + assert self._subentry is not None + self.hass.config_entries.async_update_subentry( + self._entry, self._subentry, data={**self._subentry.data, CONF_HOST: host} + ) + self.hass.config_entries.async_schedule_reload(self._entry.entry_id) + return self.async_create_entry(data={}) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -72,5 +196,12 @@ async def async_create_fix_flow( and entry.state is ConfigEntryState.LOADED ): return VehicleMetadataRepairFlow(entry, vin, issue_type, vehicle) + if ( + issue_id.startswith(f"{ISSUE_GATEWAY_NOT_FOUND}_") + and data is not None + and isinstance(entry_id := data.get("entry_id"), str) + and isinstance(subentry_id := data.get("subentry_id"), str) + ): + return GatewayNotFoundRepairFlow(entry_id, subentry_id) return ConfirmRepairFlow() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index a8eb1d7cc6ad..a6d059707cdc 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1297,6 +1297,32 @@ } }, "issues": { + "gateway_not_found": { + "fix_flow": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "[%key:component::teslemetry::config_subentries::energy_site::abort::entry_not_loaded%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "The Powerwall system at this address did not accept the saved password. Check that the address belongs to the Powerwall system for this energy site.", + "key_not_approved": "[%key:component::teslemetry::config_subentries::energy_site::error::key_not_approved%]" + }, + "step": { + "host": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The address of your Powerwall system on your local network." + }, + "description": "Home Assistant cannot reach the Powerwall system for {site} at its saved address on your local network, so local control is unavailable and commands are sent through the cloud. Enter its current local network address.", + "title": "[%key:component::teslemetry::issues::gateway_not_found::title%]" + } + } + }, + "title": "Local control unavailable for {site}" + }, "key": { "fix_flow": { "error": { diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 5078e6edb70d..fabeccd03022 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -335,3 +335,12 @@ def mock_stream_connected(): return_value=True, ) as mock_stream_connected: yield mock_stream_connected + + +@pytest.fixture(autouse=True) +def mock_powerwall_connect() -> Generator[AsyncMock]: + """Mock the local Powerwall gateway connection.""" + with patch( + "aiopowerwall.PowerwallClient.connect", return_value="GATEWAY-DIN" + ) as mock_connect: + yield mock_connect diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index f48de8cfb5d1..0db8020c68a1 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -1654,7 +1654,7 @@ async def test_energy_subentry_pairing_requires_key_approval( new=AsyncMock(), ) as mock_add, patch( - "homeassistant.components.teslemetry.config_flow.PowerwallClient", + "homeassistant.components.teslemetry.helpers.PowerwallClient", return_value=client, ), patch.object(hass.config_entries, "async_schedule_reload"), @@ -1757,7 +1757,7 @@ async def test_subentry_credentials_errors( ), ), patch( - "homeassistant.components.teslemetry.config_flow.PowerwallClient", + "homeassistant.components.teslemetry.helpers.PowerwallClient", return_value=client, ), ): @@ -1911,7 +1911,7 @@ async def test_add_flow_creates_subentry_bound_to_existing_device( ), ), patch( - "homeassistant.components.teslemetry.config_flow.PowerwallClient", + "homeassistant.components.teslemetry.helpers.PowerwallClient", return_value=client, ), patch.object(hass.config_entries, "async_schedule_reload"), @@ -1962,7 +1962,7 @@ async def test_subentry_credentials_password_truncated(hass: HomeAssistant) -> N ), ), patch( - "homeassistant.components.teslemetry.config_flow.PowerwallClient", + "homeassistant.components.teslemetry.helpers.PowerwallClient", return_value=client, ) as mock_client, patch.object(hass.config_entries, "async_schedule_reload"), @@ -2367,7 +2367,7 @@ async def test_reconfigure_updates_credentials_and_schedules_reload( ), ), patch( - "homeassistant.components.teslemetry.config_flow.PowerwallClient", + "homeassistant.components.teslemetry.helpers.PowerwallClient", return_value=client, ), patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, @@ -2407,7 +2407,7 @@ async def test_reconfigure_unchanged_credentials_still_schedules_reload( ), ), patch( - "homeassistant.components.teslemetry.config_flow.PowerwallClient", + "homeassistant.components.teslemetry.helpers.PowerwallClient", return_value=client, ), patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 8ce8968b2f7f..02dfda387d5b 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -10,8 +10,14 @@ import time from types import MappingProxyType from unittest.mock import AsyncMock, MagicMock, patch -from aiohttp import ClientResponseError -from aiopowerwall import PowerwallError +from aiohttp import ClientError, ClientResponseError +from aiopowerwall import ( + PowerwallAuthenticationError, + PowerwallConnectionError, + PowerwallError, + PowerwallProtocolError, + PowerwallRateLimitError, +) from bleak.exc import BleakError from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -81,7 +87,11 @@ from homeassistant.exceptions import ( OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import UpdateFailed @@ -1279,7 +1289,9 @@ async def test_stream_rejected_token_starts_reauth( SITE_ID = 123456 HOST = "192.168.91.1" +NEW_HOST = "192.168.91.2" PASSWORD = "abcde" +GATEWAY_ISSUE_ID = f"gateway_not_found_{SITE_ID}" # aiopowerwall's PowerwallClient parses the PEM at construction time, so tests # that build one need a real (if undersized, for speed) RSA key rather than @@ -1499,7 +1511,7 @@ async def test_local_control_unexpected_typeerror_is_not_swallowed( return_value=_TEST_RSA_KEY_PEM, ), patch( - "homeassistant.components.teslemetry.PowerwallClient", + "homeassistant.components.teslemetry.helpers.PowerwallClient", side_effect=TypeError("unexpected argument"), ), patch("homeassistant.components.teslemetry.PLATFORMS", []), @@ -1578,6 +1590,274 @@ async def test_energy_site_router_command_routing( assert cloud.await_count == cloud_awaits +async def _setup_entry_with_powerwall( + hass: HomeAssistant, entry: MockConfigEntry, gateway_lookup: AsyncMock +) -> None: + """Set up an entry whose paired site's gateway lookup is mocked.""" + with ( + patch( + "homeassistant.components.teslemetry._async_get_rsa_key_pem", + return_value=_TEST_RSA_KEY_PEM, + ), + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_gateway_address", + new=gateway_lookup, + ), + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_local_gateway_reachable_skips_lookup( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, +) -> None: + """A gateway reachable at its stored host is used without a cloud lookup.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + gateway_lookup = AsyncMock(return_value=NEW_HOST) + + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + + router = entry.runtime_data.energysites[0].api + assert isinstance(router, EnergySiteRouter) + assert router.primary.powerwall.host == HOST + mock_powerwall_connect.assert_awaited_once() + gateway_lookup.assert_not_awaited() + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +@pytest.mark.parametrize( + "connect_error", + [ + pytest.param(PowerwallConnectionError("unreachable"), id="unreachable"), + pytest.param(PowerwallProtocolError("Login failed (404)"), id="other_device"), + pytest.param(PowerwallAuthenticationError("denied"), id="password_rejected"), + ], +) +async def test_local_gateway_rediscovered_through_cloud( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, + connect_error: PowerwallError, +) -> None: + """A gateway that fails at its stored host but connects at a new address is moved.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + mock_powerwall_connect.side_effect = [connect_error, "GATEWAY-DIN"] + + await _setup_entry_with_powerwall(hass, entry, AsyncMock(return_value=NEW_HOST)) + + assert entry.state is ConfigEntryState.LOADED + subentry = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0] + assert subentry.data == { + CONF_SITE_ID: SITE_ID, + CONF_HOST: NEW_HOST, + CONF_PASSWORD: PASSWORD, + } + router = entry.runtime_data.energysites[0].api + assert isinstance(router, EnergySiteRouter) + assert router.primary.powerwall.host == NEW_HOST + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +@pytest.mark.parametrize( + ("connect_error", "lookup_result"), + [ + pytest.param(PowerwallConnectionError("unreachable"), [None], id="no_address"), + pytest.param( + PowerwallConnectionError("unreachable"), [HOST], id="same_address" + ), + pytest.param( + PowerwallConnectionError("unreachable"), + [NEW_HOST], + id="new_address_unreachable", + ), + pytest.param( + PowerwallConnectionError("unreachable"), + InvalidResponse(), + id="invalid_response", + ), + pytest.param( + PowerwallConnectionError("unreachable"), ClientError(), id="client_error" + ), + pytest.param( + PowerwallProtocolError("Login failed (404)"), + [None], + id="other_device_no_address", + ), + ], +) +async def test_local_gateway_not_found_raises_repair( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, + connect_error: PowerwallError, + lookup_result: list[str | None] | Exception, +) -> None: + """A gateway that cannot be rediscovered raises a repair and keeps cloud control.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + mock_powerwall_connect.side_effect = connect_error + + await _setup_entry_with_powerwall(hass, entry, AsyncMock(side_effect=lookup_result)) + + assert entry.state is ConfigEntryState.LOADED + subentry = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0] + assert subentry.data[CONF_HOST] == HOST + router = entry.runtime_data.energysites[0].api + assert isinstance(router, EnergySiteRouter) + assert router.primary.powerwall.host == HOST + issue = issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) + assert issue is not None + assert issue.is_fixable + assert issue.translation_key == "gateway_not_found" + assert issue.translation_placeholders == {"site": "Energy Site"} + assert issue.data == { + "entry_id": entry.entry_id, + "subentry_id": subentry.subentry_id, + } + + +@pytest.mark.parametrize( + "connect_error", + [ + pytest.param(PowerwallProtocolError("Login failed (404)"), id="protocol"), + pytest.param(PowerwallAuthenticationError("denied"), id="password_rejected"), + ], +) +async def test_local_gateway_refusal_at_confirmed_address( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, + connect_error: PowerwallError, +) -> None: + """A refusal at the address the cloud confirms is not a moved gateway.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + mock_powerwall_connect.side_effect = connect_error + gateway_lookup = AsyncMock(return_value=HOST) + + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + + assert entry.state is ConfigEntryState.LOADED + router = entry.runtime_data.energysites[0].api + assert isinstance(router, EnergySiteRouter) + assert router.primary.powerwall.host == HOST + gateway_lookup.assert_awaited_once() + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +async def test_local_gateway_rate_limited_skips_lookup( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, +) -> None: + """A rate-limited gateway answered at its address, so no lookup runs.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + mock_powerwall_connect.side_effect = PowerwallRateLimitError("busy") + gateway_lookup = AsyncMock(return_value=NEW_HOST) + + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + + assert entry.state is ConfigEntryState.LOADED + router = entry.runtime_data.energysites[0].api + assert isinstance(router, EnergySiteRouter) + assert router.primary.powerwall.host == HOST + gateway_lookup.assert_not_awaited() + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +async def test_local_gateway_repair_cleared_after_local_success( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, +) -> None: + """The gateway repair clears once a later setup connects locally.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + mock_powerwall_connect.side_effect = PowerwallConnectionError("unreachable") + gateway_lookup = AsyncMock(return_value=None) + + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is not None + + await hass.config_entries.async_unload(entry.entry_id) + mock_powerwall_connect.side_effect = None + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + + assert entry.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +async def test_local_gateway_repair_cleared_when_rate_limited( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, +) -> None: + """The gateway repair clears once a later setup finds the gateway rate limiting.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + mock_powerwall_connect.side_effect = PowerwallConnectionError("unreachable") + gateway_lookup = AsyncMock(return_value=None) + + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is not None + + await hass.config_entries.async_unload(entry.entry_id) + mock_powerwall_connect.side_effect = PowerwallRateLimitError("busy") + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + + assert entry.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +async def test_local_gateway_repair_cleared_at_confirmed_address( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_powerwall_connect: AsyncMock, +) -> None: + """The gateway repair clears once a later setup confirms the gateway's address.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + mock_powerwall_connect.side_effect = PowerwallConnectionError("unreachable") + gateway_lookup = AsyncMock(return_value=None) + + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is not None + + await hass.config_entries.async_unload(entry.entry_id) + mock_powerwall_connect.side_effect = PowerwallProtocolError("Login failed (404)") + gateway_lookup = AsyncMock(return_value=HOST) + await _setup_entry_with_powerwall(hass, entry, gateway_lookup) + + assert entry.state is ConfigEntryState.LOADED + gateway_lookup.assert_awaited_once() + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +async def test_local_gateway_repair_cleared_without_subentry( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """The gateway repair clears once the site is no longer set up for local control.""" + ir.async_create_issue( + hass, + DOMAIN, + GATEWAY_ISSUE_ID, + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="gateway_not_found", + translation_placeholders={"site": "Energy Site"}, + ) + + await _setup_account_no_subentry(hass) + + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + async def test_stale_cleanup_preserves_foreign_subentry(hass: HomeAssistant) -> None: """Energy stale-subentry cleanup does not remove other subentry types.""" entry = mock_config_entry() diff --git a/tests/components/teslemetry/test_repairs.py b/tests/components/teslemetry/test_repairs.py index d7a220af086d..d67aa06c99b3 100644 --- a/tests/components/teslemetry/test_repairs.py +++ b/tests/components/teslemetry/test_repairs.py @@ -1,30 +1,52 @@ """Test the Teslemetry repairs.""" +from collections.abc import Generator from copy import deepcopy from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch +from aiohttp import ClientError +from aiopowerwall import ( + PowerwallAuthenticationError, + PowerwallConnectionError, + PowerwallError, +) from freezegun.api import FrozenDateTimeFactory import pytest +from tesla_fleet_api.exceptions import InvalidResponse, PrivateKeyError from homeassistant.components.repairs import ConfirmRepairFlow -from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.components.teslemetry.const import ( + CONF_SITE_ID, + DOMAIN, + RSA_PARENT_KEY, + SUBENTRY_TYPE_ENERGY_SITE, +) from homeassistant.components.teslemetry.coordinator import METADATA_INTERVAL from homeassistant.components.teslemetry.repairs import async_create_fix_flow -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from . import setup_platform +from . import mock_config_entry, setup_platform from .const import METADATA -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow from tests.typing import ClientSessionGenerator VEHICLE_VIN = "LRW3F7EK4NC700000" +SITE_ID = 123456 +HOST = "192.168.91.1" +NEW_HOST = "192.168.91.2" +PASSWORD = "abcde" +GATEWAY_ISSUE_ID = f"gateway_not_found_{SITE_ID}" +FIND_GATEWAY_ADDRESS = ( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_gateway_address" +) def _metadata_with_issue(issue: str | None) -> dict[str, Any]: @@ -215,3 +237,231 @@ async def test_repair_invalid_data_returns_confirm_flow( """Test invalid repair flow data falls back to a confirm flow.""" flow = await async_create_fix_flow(hass, "key_VIN", data) assert isinstance(flow, ConfirmRepairFlow) + + +@pytest.fixture +def mock_powerwall_client() -> Generator[MagicMock]: + """Mock the local Powerwall gateway client, starting unreachable.""" + client = MagicMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + client.connect = AsyncMock(side_effect=PowerwallConnectionError("unreachable")) + client.get_status = AsyncMock() + with patch( + "homeassistant.components.teslemetry.helpers.PowerwallClient", + return_value=client, + ): + yield client + + +async def _setup_entry_with_lost_gateway(hass: HomeAssistant) -> MockConfigEntry: + """Set up an entry whose paired gateway could not be found.""" + assert await async_setup_component(hass, "repairs", {}) + hass.data[RSA_PARENT_KEY] = b"test-key-pem" + base = mock_config_entry() + entry = MockConfigEntry( + domain=base.domain, + version=base.version, + minor_version=base.minor_version, + unique_id=base.unique_id, + data=dict(base.data), + subentries_data=[ + ConfigSubentryData( + subentry_type=SUBENTRY_TYPE_ENERGY_SITE, + unique_id=str(SITE_ID), + title="Energy Site", + data={ + CONF_SITE_ID: SITE_ID, + CONF_HOST: HOST, + CONF_PASSWORD: PASSWORD, + }, + ) + ], + ) + entry.add_to_hass(hass) + with ( + patch(FIND_GATEWAY_ADDRESS, new=AsyncMock(return_value=None)), + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def _host_default(result: dict[str, Any]) -> str: + """Return the host field default from a serialized host form.""" + return next( + field["default"] + for field in result["data_schema"] + if field["name"] == CONF_HOST + ) + + +async def test_gateway_repair_fixed_through_cloud( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + mock_powerwall_client: MagicMock, +) -> None: + """The fix flow persists the address the cloud reports once it verifies.""" + entry = await _setup_entry_with_lost_gateway(hass) + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is not None + client = await hass_client() + + mock_powerwall_client.connect.side_effect = None + with ( + patch(FIND_GATEWAY_ADDRESS, new=AsyncMock(return_value=NEW_HOST)), + patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, + ): + result = await start_repair_fix_flow(client, DOMAIN, GATEWAY_ISSUE_ID) + + assert result["type"] == FlowResultType.CREATE_ENTRY + subentry = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0] + assert subentry.data == { + CONF_SITE_ID: SITE_ID, + CONF_HOST: NEW_HOST, + CONF_PASSWORD: PASSWORD, + } + mock_reload.assert_called_once_with(entry.entry_id) + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +@pytest.mark.parametrize( + ("lookup_result", "expected_default", "lookup_errors"), + [ + pytest.param([None], HOST, {}, id="no_address"), + pytest.param(InvalidResponse(), HOST, {}, id="invalid_response"), + pytest.param(ClientError(), HOST, {}, id="client_error"), + pytest.param( + [NEW_HOST], + NEW_HOST, + {"base": "cannot_connect"}, + id="new_address_unreachable", + ), + ], +) +@pytest.mark.parametrize( + ("connect_error", "status_error", "error"), + [ + pytest.param( + PowerwallConnectionError("unreachable"), + None, + "cannot_connect", + id="cannot_connect", + ), + pytest.param( + PowerwallAuthenticationError("denied"), + None, + "invalid_auth", + id="invalid_auth", + ), + pytest.param( + None, + PowerwallAuthenticationError("unapproved"), + "key_not_approved", + id="key_not_approved", + ), + ], +) +async def test_gateway_repair_manual_address( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + mock_powerwall_client: MagicMock, + lookup_result: list[str | None] | Exception, + expected_default: str, + lookup_errors: dict[str, str], + connect_error: PowerwallError | None, + status_error: PowerwallError | None, + error: str, +) -> None: + """The fix flow asks for the address when the cloud lookup cannot fix it.""" + entry = await _setup_entry_with_lost_gateway(hass) + client = await hass_client() + + with patch(FIND_GATEWAY_ADDRESS, new=AsyncMock(side_effect=lookup_result)): + result = await start_repair_fix_flow(client, DOMAIN, GATEWAY_ISSUE_ID) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "host" + assert result["description_placeholders"] == {"site": "Energy Site"} + assert _host_default(result) == expected_default + assert result["errors"] == lookup_errors + + mock_powerwall_client.connect.side_effect = connect_error + mock_powerwall_client.get_status.side_effect = status_error + result = await process_repair_fix_flow( + client, result["flow_id"], json={CONF_HOST: "192.168.91.3"} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + assert _host_default(result) == "192.168.91.3" + + mock_powerwall_client.connect.side_effect = None + mock_powerwall_client.get_status.side_effect = None + with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload: + result = await process_repair_fix_flow( + client, result["flow_id"], json={CONF_HOST: f" {NEW_HOST} "} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + subentry = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0] + assert subentry.data[CONF_HOST] == NEW_HOST + assert subentry.data[CONF_PASSWORD] == PASSWORD + mock_reload.assert_called_once_with(entry.entry_id) + assert issue_registry.async_get_issue(DOMAIN, GATEWAY_ISSUE_ID) is None + + +@pytest.mark.usefixtures("mock_powerwall_client") +async def test_gateway_repair_key_load_failure_aborts( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """The fix flow aborts when the RSA key cannot be loaded.""" + await _setup_entry_with_lost_gateway(hass) + client = await hass_client() + + with patch( + "homeassistant.components.teslemetry.repairs._async_get_rsa_key_pem", + side_effect=PrivateKeyError("malformed", "Not a valid PEM private key"), + ): + result = await start_repair_fix_flow(client, DOMAIN, GATEWAY_ISSUE_ID) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("entry_id", "subentry_id"), + [ + pytest.param("missing", None, id="missing_entry"), + pytest.param(None, "missing", id="missing_subentry"), + ], +) +@pytest.mark.usefixtures("mock_powerwall_client") +async def test_gateway_repair_aborts_without_loaded_site( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entry_id: str | None, + subentry_id: str | None, +) -> None: + """The fix flow aborts when its entry or site is no longer loaded.""" + entry = await _setup_entry_with_lost_gateway(hass) + subentry = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0] + ir.async_create_issue( + hass, + DOMAIN, + "gateway_not_found_stale", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="gateway_not_found", + translation_placeholders={"site": "Energy Site"}, + data={ + "entry_id": entry_id or entry.entry_id, + "subentry_id": subentry_id or subentry.subentry_id, + }, + ) + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, "gateway_not_found_stale") + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded"