diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index 352975739752..bd166b3e9f7a 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -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={ diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 43e870454439..1d69696b2b67 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -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": { diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 493a3b7f884a..50db98dd7ed8 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -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()