Rediscover moved Teslemetry Powerwall gateways through the cloud (#182188)

This commit is contained in:
Brett Adams
2026-09-26 20:36:11 +02:00
committed by GitHub
parent 20e61c4365
commit f42cc22b86
10 changed files with 896 additions and 79 deletions
+119 -15
View File
@@ -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",
+48 -1
View File
@@ -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())
+135 -4
View File
@@ -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": {
+9
View File
@@ -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,
+284 -4
View File
@@ -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()
+255 -5
View File
@@ -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"