diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index bd166b3e9f7a..d7efe5aa34e9 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -451,6 +451,7 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): self._discovered_host: str = "" self._site_id: int | None = None self._site_name: str = "" + self._approval_expired: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -603,6 +604,11 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): if user_input is None: return self.async_show_form(step_id="pair") + if self._approval_expired: + # The user saw the expired-window notice and submitted to try again. + self._approval_expired = False + return await self._async_begin_pairing() + try: client = await self._find_authorized_client() except PowerwallLookupError: @@ -618,6 +624,10 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): return await self.async_step_credentials() if client.state == AuthorizedClientState.PENDING_VERIFICATION: return self.async_show_form(step_id="pair", errors={"base": "key_pending"}) + if client.state == AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT: + # Surface the expiry; the user's next submit reopens the window. + self._approval_expired = True + return self.async_show_form(step_id="pair", errors={"base": "key_expired"}) # An unrecognized state reported as pending would trap the user forever. LOGGER.debug("Unrecognized authorized-client state: %s", client.state) return self.async_show_form(step_id="pair", errors={"base": "cannot_connect"}) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 1d69696b2b67..09e5e0a0544b 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -58,6 +58,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_password": "[%key:common::config_flow::error::invalid_auth%]", + "key_expired": "The approval window on your Powerwall closed before the key was approved. Submit to open a new window, flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again.", "key_not_approved": "Your Powerwall system rejected Home Assistant's key because it has not been approved. Flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again.", "key_not_registered": "Home Assistant's key is no longer registered on your Powerwall system. Restart setup to register it again.", "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." diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 50db98dd7ed8..f48de8cfb5d1 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -2195,6 +2195,11 @@ async def test_add_authorized_client_failure_aborts(hass: HomeAssistant) -> None [ pytest.param(InvalidResponse(), "cannot_connect", id="lookup_failure"), pytest.param(_empty_clients(), "key_not_registered", id="key_not_registered"), + pytest.param( + _own_key_clients(AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT), + "key_expired", + id="key_expired", + ), pytest.param(_own_key_clients("gremlin"), "cannot_connect", id="unknown_state"), ], ) @@ -2228,6 +2233,69 @@ async def test_pair_step_second_lookup_errors( assert result["errors"] == {"base": expected_error} +@pytest.mark.usefixtures("mock_rsa_key") +async def test_pair_step_timeout_retry_reopens_window_and_succeeds( + hass: HomeAssistant, +) -> None: + """Submitting the expired form reopens the window and pairing can complete.""" + entry = await _setup_account_no_subentry(hass) + + client = _mock_powerwall_client() + with ( + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients", + new=AsyncMock( + side_effect=[ + _empty_clients(), + _own_key_clients( + AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT + ), + _own_key_clients( + AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT + ), + _own_key_clients(AuthorizedClientState.VERIFIED), + ] + ), + ), + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.add_authorized_client", + new=AsyncMock(), + ) as mock_add, + patch( + "homeassistant.components.teslemetry.config_flow.PowerwallClient", + return_value=client, + ), + patch.object(hass.config_entries, "async_schedule_reload"), + ): + result = await _start_add_flow_select_site(hass, entry) + assert result["step_id"] == "pair" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "pair" + assert result["errors"] == {"base": "key_expired"} + + # Submitting the expired form re-registers to open a fresh window. + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "pair" + + result = await hass.config_entries.subentries.async_configure( + result["flow_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.CREATE_ENTRY + assert mock_add.await_count == 2 + + @pytest.mark.usefixtures("mock_rsa_key") @pytest.mark.parametrize( ("patch_target", "error"),