mirror of
https://github.com/home-assistant/core.git
synced 2026-09-30 07:33:52 +01:00
Rediscover moved Teslemetry Powerwall gateways through the cloud (#182188)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user