Add energy site reconfigure flow to Teslemetry (#181335)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Brett Adams
2026-09-14 12:07:09 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 17fe09622e
commit b3a105c8b5
3 changed files with 293 additions and 6 deletions
@@ -25,6 +25,7 @@ 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,6 +71,7 @@ from .const import (
SUBENTRY_TYPE_VEHICLE,
)
from .helpers import async_get_ble_parent
from .models import TeslemetryEnergyData
class PowerwallLookupError(Exception):
@@ -80,6 +82,21 @@ 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
):
@@ -468,9 +485,8 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
energy_data = available[user_input[CONF_SITE_ID]]
self._site_id = energy_data.id
self._site_name = energy_data.device.get("name") or "Energy Site"
# Only unpaired sites are offered, so api is always the cloud EnergySite.
if abort := await self._prepare_energy_site(
cast(TeslemetryEnergySite, energy_data.api)
_cloud_energy_site(energy_data)
):
return abort
return await self._async_begin_pairing()
@@ -489,6 +505,29 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
),
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Re-pair an added site's local Powerwall to update its credentials."""
subentry = self._get_reconfigure_subentry()
entry = cast(TeslemetryConfigEntry, self._get_entry())
# runtime_data (the resolved energy sites) exists only while loaded.
if 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 == subentry.subentry_id
),
None,
)
if energy_data is None:
return self.async_abort(reason="cannot_connect")
if abort := await self._prepare_energy_site(_cloud_energy_site(energy_data)):
return abort
return await self._async_begin_pairing()
async def _prepare_energy_site(
self, energy_site: TeslemetryEnergySite
) -> SubentryFlowResult | None:
@@ -620,6 +659,20 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
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.
Discovery wins; on reconfigure a failed discovery falls back to the
subentry's known host rather than leaving the field blank, so a
password-only change is verified against the right gateway. A new
site whose discovery failed is left blank.
"""
if self._discovered_host:
return self._discovered_host
if self.source == SOURCE_RECONFIGURE:
return cast(str, self._get_reconfigure_subentry().data[CONF_HOST])
return ""
async def async_step_credentials(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
@@ -650,7 +703,7 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
{
probatio.Required(
CONF_HOST,
default=self._discovered_host or probatio.UNDEFINED,
default=self._default_gateway_host() or probatio.UNDEFINED,
): str,
probatio.Required(CONF_PASSWORD): str,
}
@@ -660,7 +713,21 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
@callback
def _async_save_credentials(self, host: str, password: str) -> SubentryFlowResult:
"""Persist the verified gateway credentials to a new subentry."""
"""Persist the verified gateway credentials to the subentry."""
if self.source == SOURCE_RECONFIGURE:
entry = self._get_entry()
subentry = self._get_reconfigure_subentry()
self._async_update(
entry,
subentry,
data_updates={CONF_HOST: host, CONF_PASSWORD: password},
)
# Always reload, even when credentials are unchanged: an earlier
# local-control initialization failure leaves only the cloud API active,
# and successful re-verification must install the local-first router.
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="reconfigure_successful")
return self.async_create_entry(
title=self._site_name,
data={
@@ -51,7 +51,8 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "The Teslemetry account must be loaded before setting up local control. Try again once it has finished loading.",
"no_powerwall": "Local control requires a Powerwall, and no energy site with one is currently accessible on your Teslemetry account."
"no_powerwall": "Local control requires a Powerwall, and no energy site with one is currently accessible on your Teslemetry account.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "Energy site",
"error": {
@@ -62,6 +63,7 @@
"key_pending": "Home Assistant's key has not been approved yet. Flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again."
},
"initiate_flow": {
"reconfigure": "Reconfigure energy site",
"user": "Add local energy site"
},
"step": {
+219 -1
View File
@@ -31,7 +31,7 @@ from tesla_fleet_api.exceptions import (
TeslaFleetError,
WhitelistOperationAttemptingToAddExistingKey,
)
from tesla_fleet_api.tesla import VehicleRouter
from tesla_fleet_api.tesla import EnergySiteRouter, VehicleRouter
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth
from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, AuthorizedClients
@@ -2263,3 +2263,221 @@ async def test_rsa_key_load_failure_aborts(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def _setup_paired_account(hass: HomeAssistant) -> MockConfigEntry:
"""Set up an account whose battery site is already paired for local control."""
entry = _entry_with_powerwall()
entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.teslemetry._async_get_rsa_key_pem",
return_value=_TEST_RSA_KEY_PEM,
),
patch("homeassistant.components.teslemetry.PLATFORMS", []),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
@pytest.mark.usefixtures("mock_rsa_key")
async def test_reconfigure_updates_credentials_and_schedules_reload(
hass: HomeAssistant,
) -> None:
"""Reconfiguring a paired site updates its credentials and reloads the entry."""
entry = await _setup_paired_account(hass)
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id
new_host = "192.168.1.50"
client = _mock_powerwall_client()
with (
patch(
"tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients",
new=AsyncMock(
return_value=_own_key_clients(AuthorizedClientState.VERIFIED)
),
),
patch(
"homeassistant.components.teslemetry.config_flow.PowerwallClient",
return_value=client,
),
patch.object(hass.config_entries, "async_schedule_reload") as mock_reload,
):
result = await entry.start_subentry_reconfigure_flow(hass, subentry_id)
assert result["step_id"] == "credentials"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {CONF_HOST: new_host, CONF_PASSWORD: "wxyz9"}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
subentry = entry.subentries[subentry_id]
assert subentry.data[CONF_HOST] == new_host
assert subentry.data[CONF_PASSWORD] == "wxyz9"
# The subentry-change reload listener fires only on membership changes, so the
# step must schedule the reload itself for the new credentials to take effect.
mock_reload.assert_called_once_with(entry.entry_id)
@pytest.mark.usefixtures("mock_rsa_key")
async def test_reconfigure_unchanged_credentials_still_schedules_reload(
hass: HomeAssistant,
) -> None:
"""Reconfiguring with unchanged credentials still reloads the entry."""
entry = await _setup_paired_account(hass)
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id
client = _mock_powerwall_client()
with (
patch(
"tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients",
new=AsyncMock(
return_value=_own_key_clients(AuthorizedClientState.VERIFIED)
),
),
patch(
"homeassistant.components.teslemetry.config_flow.PowerwallClient",
return_value=client,
),
patch.object(hass.config_entries, "async_schedule_reload") as mock_reload,
):
result = await entry.start_subentry_reconfigure_flow(hass, subentry_id)
assert result["step_id"] == "credentials"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
subentry = entry.subentries[subentry_id]
assert subentry.data[CONF_HOST] == HOST
assert subentry.data[CONF_PASSWORD] == PASSWORD
# The stored data is unchanged, but a re-verify must still re-enable local
# control after an earlier cloud fallback, so the reload is scheduled anyway.
mock_reload.assert_called_once_with(entry.entry_id)
async def test_reconfigure_aborts_when_entry_not_loaded(hass: HomeAssistant) -> None:
"""Reconfigure aborts when the account entry is not loaded."""
entry = _entry_with_powerwall()
entry.add_to_hass(hass)
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id
result = await entry.start_subentry_reconfigure_flow(hass, subentry_id)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "entry_not_loaded"
@pytest.mark.usefixtures("mock_rsa_key")
async def test_reconfigure_aborts_when_site_not_resolved(hass: HomeAssistant) -> None:
"""Reconfigure aborts when no resolved energy site matches the subentry."""
entry = await _setup_paired_account(hass)
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id
# Drop the resolved sites so the subentry matches no runtime energy site.
entry.runtime_data.energysites = []
result = await entry.start_subentry_reconfigure_flow(hass, subentry_id)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.usefixtures("mock_rsa_key")
async def test_reconfigure_prefills_existing_host_when_discovery_fails(
hass: HomeAssistant,
) -> None:
"""A failed discovery on reconfigure keeps the subentry's known host default.
Otherwise a password-only change would default to the setup-AP address and
be verified against the wrong gateway.
"""
entry = await _setup_paired_account(hass)
subentry = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0]
# The fixture host equals DEFAULT_GATEWAY_HOST, so set a distinct known host
# to prove the default is the subentry's value and not the setup-AP fallback.
known_host = "192.168.1.50"
hass.config_entries.async_update_subentry(
entry, subentry, data={**subentry.data, CONF_HOST: known_host}
)
with (
patch(
"tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_gateway_address",
new=AsyncMock(side_effect=ClientError),
),
patch(
"tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients",
new=AsyncMock(
return_value=_own_key_clients(AuthorizedClientState.VERIFIED)
),
),
):
result = await entry.start_subentry_reconfigure_flow(hass, subentry.subentry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "credentials"
assert _credentials_host_default(result) == known_host
@pytest.mark.usefixtures("mock_rsa_key")
async def test_reconfigure_aborts_when_rsa_key_load_fails(hass: HomeAssistant) -> None:
"""Reconfigure aborts when the integration's RSA key cannot be loaded."""
entry = await _setup_paired_account(hass)
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id
with patch(
"homeassistant.components.teslemetry.config_flow.Teslemetry.get_rsa_private_key",
side_effect=OSError,
):
result = await entry.start_subentry_reconfigure_flow(hass, subentry_id)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.usefixtures("mock_rsa_key")
async def test_reconfigure_pairs_via_cloud_secondary_not_local_primary(
hass: HomeAssistant,
) -> None:
"""Reconfiguring a paired site pairs through the cloud site, not the local gateway.
A paired site's api is a local-first router, so the pairing lookup must be
unwrapped to the cloud secondary; routing it would hit the local Powerwall.
"""
entry = await _setup_paired_account(hass)
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id
energy_data = entry.runtime_data.energysites[0]
assert isinstance(energy_data.api, EnergySiteRouter)
cloud_lookup = AsyncMock(
return_value=_own_key_clients(AuthorizedClientState.VERIFIED)
)
# find_authorized_clients is a cloud-only method; adding it to the local
# backend makes the router route to it local-first, so an accidentally
# routed lookup would land on the primary and this test would catch it.
local_lookup = AsyncMock(
return_value=_own_key_clients(AuthorizedClientState.VERIFIED)
)
with (
patch.object(
energy_data.api.secondary, "find_authorized_clients", cloud_lookup
),
patch.object(
energy_data.api.primary,
"find_authorized_clients",
local_lookup,
create=True,
),
):
result = await entry.start_subentry_reconfigure_flow(hass, subentry_id)
# Reaching credentials means the cloud lookup reported the key verified.
assert result["step_id"] == "credentials"
cloud_lookup.assert_awaited()
local_lookup.assert_not_awaited()