mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
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:
co-authored by
Copilot Autofix powered by AI
parent
17fe09622e
commit
b3a105c8b5
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user