From 11c7c403b65644aa081bde58b6efca6d4d4608e1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 4 Oct 2026 16:52:03 +1000 Subject: [PATCH] Add 502 gateway-unreachable handling to Teslemetry Powerwall pairing (#183792) --- .../components/teslemetry/config_flow.py | 18 ++++ .../components/teslemetry/strings.json | 4 +- .../components/teslemetry/test_config_flow.py | 89 ++++++++++++++++--- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index 3ac5b2ec755c..a5024427f58b 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -18,6 +18,7 @@ from tesla_fleet_api.const import ( from tesla_fleet_api.exceptions import ( BluetoothTimeout, BluetoothTransportError, + EnergyGatewayUnreachable, InvalidToken, NotOnWhitelistFault, PrivateKeyError, @@ -480,6 +481,8 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): try: await self._prepare_energy_site(energy_data) return await self._async_begin_pairing() + except EnergyGatewayUnreachable: + errors["base"] = "powerwall_unreachable" except PowerwallSetupError: errors["base"] = "cannot_connect" @@ -520,6 +523,8 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): try: await self._prepare_energy_site(energy_data) return await self._async_begin_pairing() + except EnergyGatewayUnreachable: + return self.async_abort(reason="powerwall_unreachable") except PowerwallSetupError: return self.async_abort(reason="cannot_connect") @@ -586,6 +591,8 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): key_type=AuthorizedClientKeyType.RSA, authorized_client_type=AuthorizedClientType.CUSTOMER_MOBILE_APP, ) + except EnergyGatewayUnreachable: + raise except (ClientError, TeslaFleetError) as err: LOGGER.error("Add authorized client failed: %s", err) raise PowerwallSetupError from err @@ -605,6 +612,10 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): # The user saw the expired-window notice and submitted to try again. try: result = await self._async_begin_pairing() + except EnergyGatewayUnreachable: + return self.async_show_form( + step_id="pair", errors={"base": "powerwall_unreachable"} + ) except PowerwallSetupError: return self.async_show_form( step_id="pair", errors={"base": "cannot_connect"} @@ -614,6 +625,10 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): try: client = await self._find_authorized_client() + except EnergyGatewayUnreachable: + return self.async_show_form( + step_id="pair", errors={"base": "powerwall_unreachable"} + ) except PowerwallLookupError: return self.async_show_form( step_id="pair", errors={"base": "cannot_connect"} @@ -641,6 +656,9 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow): assert self._energy_site is not None try: result = await self._energy_site.find_authorized_clients() + except EnergyGatewayUnreachable: + # Unwrapped so callers can report an unreachable gateway as retryable. + raise except (ClientError, TeslaFleetError) as err: # Raise so a failed lookup is not mistaken for an unregistered key. LOGGER.debug("find_authorized_clients failed: %s", err) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index af9b96a06a70..528a515747f4 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -52,6 +52,7 @@ "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.", + "powerwall_unreachable": "There is a problem communicating with your Powerwall. Ensure it has a reliable network connection and try again later.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Local energy site", @@ -61,7 +62,8 @@ "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." + "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.", + "powerwall_unreachable": "[%key:component::teslemetry::config_subentries::energy_site::abort::powerwall_unreachable%]" }, "initiate_flow": { "reconfigure": "Reconfigure energy site", diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 05011243c09d..8867cef0d8af 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -21,8 +21,10 @@ import probatio import pytest from tesla_fleet_api.const import AuthorizedClientState from tesla_fleet_api.exceptions import ( + BadGateway, BluetoothTimeout, BluetoothTransportError, + EnergyGatewayUnreachable, InvalidResponse, InvalidToken, NotOnWhitelistFault, @@ -1714,8 +1716,22 @@ async def test_energy_subentry_pairing_requires_key_approval( @pytest.mark.usefixtures("mock_rsa_key") -async def test_subentry_lookup_failure_recovers(hass: HomeAssistant) -> None: - """A malformed authorized-clients read re-shows the form; a retry recovers.""" +@pytest.mark.parametrize( + ("error", "expected_error"), + [ + pytest.param(InvalidResponse, "cannot_connect", id="null_body"), + pytest.param(BadGateway, "cannot_connect", id="bad_gateway"), + pytest.param( + EnergyGatewayUnreachable, + "powerwall_unreachable", + id="gateway_unreachable", + ), + ], +) +async def test_subentry_lookup_failure_recovers( + hass: HomeAssistant, error: type[BaseException], expected_error: str +) -> None: + """A failed authorized-clients read re-shows the form; a retry recovers.""" entry = await _setup_account_no_subentry(hass) client = _mock_powerwall_client() @@ -1724,7 +1740,7 @@ async def test_subentry_lookup_failure_recovers(hass: HomeAssistant) -> None: "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients", new=AsyncMock( side_effect=[ - InvalidResponse, + error, _own_key_clients(AuthorizedClientState.VERIFIED), ] ), @@ -1742,7 +1758,7 @@ async def test_subentry_lookup_failure_recovers(hass: HomeAssistant) -> None: result = await _start_add_flow_select_site(hass, entry) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": expected_error} # A failed lookup must not register the key. mock_add.assert_not_awaited() @@ -2247,7 +2263,22 @@ async def test_unrecognized_state_recovers(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_rsa_key") -async def test_add_authorized_client_failure_recovers(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("error", "expected_error"), + [ + pytest.param(ClientError, "cannot_connect", id="client_error"), + pytest.param(TeslaFleetError, "cannot_connect", id="tesla_fleet_error"), + pytest.param(BadGateway, "cannot_connect", id="bad_gateway"), + pytest.param( + EnergyGatewayUnreachable, + "powerwall_unreachable", + id="gateway_unreachable", + ), + ], +) +async def test_add_authorized_client_failure_recovers( + hass: HomeAssistant, error: type[BaseException], expected_error: str +) -> None: """A failure registering the key re-shows the form; a retry pairs and recovers.""" entry = await _setup_account_no_subentry(hass) @@ -2265,7 +2296,7 @@ async def test_add_authorized_client_failure_recovers(hass: HomeAssistant) -> No ), patch( "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.add_authorized_client", - new=AsyncMock(side_effect=[ClientError, None]), + new=AsyncMock(side_effect=[error, None]), ), patch( "homeassistant.components.teslemetry.helpers.PowerwallClient", @@ -2276,7 +2307,7 @@ async def test_add_authorized_client_failure_recovers(hass: HomeAssistant) -> No result = await _start_add_flow_select_site(hass, entry) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": expected_error} result = await hass.config_entries.subentries.async_configure( result["flow_id"], {CONF_SITE_ID: str(SITE_ID)} @@ -2303,6 +2334,12 @@ async def test_add_authorized_client_failure_recovers(hass: HomeAssistant) -> No ("second_lookup", "expected_error"), [ pytest.param(InvalidResponse(), "cannot_connect", id="lookup_failure"), + pytest.param(BadGateway(), "cannot_connect", id="bad_gateway"), + pytest.param( + EnergyGatewayUnreachable(), + "powerwall_unreachable", + id="gateway_unreachable", + ), pytest.param(_empty_clients(), "key_not_registered", id="key_not_registered"), pytest.param( _own_key_clients(AuthorizedClientState.PENDING_VERIFICATION_TIMEOUT), @@ -2314,7 +2351,7 @@ async def test_add_authorized_client_failure_recovers(hass: HomeAssistant) -> No ) async def test_pair_step_second_lookup_errors( hass: HomeAssistant, - second_lookup: Exception | AuthorizedClients, + second_lookup: BaseException | AuthorizedClients, expected_error: str, ) -> None: """Re-checking the pending key reports each non-approval outcome on the form.""" @@ -2406,8 +2443,20 @@ async def test_pair_step_timeout_retry_reopens_window_and_succeeds( @pytest.mark.usefixtures("mock_rsa_key") +@pytest.mark.parametrize( + ("error", "expected_error"), + [ + pytest.param(ClientError, "cannot_connect", id="client_error"), + pytest.param(BadGateway, "cannot_connect", id="bad_gateway"), + pytest.param( + EnergyGatewayUnreachable, + "powerwall_unreachable", + id="gateway_unreachable", + ), + ], +) async def test_pair_step_timeout_retry_failure_recovers( - hass: HomeAssistant, + hass: HomeAssistant, error: type[BaseException], expected_error: str ) -> None: """A failed re-registration after expiry re-shows the form; a retry recovers.""" entry = await _setup_account_no_subentry(hass) @@ -2434,7 +2483,7 @@ async def test_pair_step_timeout_retry_failure_recovers( ), patch( "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.add_authorized_client", - new=AsyncMock(side_effect=[None, ClientError, None]), + new=AsyncMock(side_effect=[None, error, None]), ) as mock_add, patch( "homeassistant.components.teslemetry.helpers.PowerwallClient", @@ -2455,7 +2504,7 @@ async def test_pair_step_timeout_retry_failure_recovers( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pair" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": expected_error} # The next submit retries re-registration rather than re-checking state. result = await hass.config_entries.subentries.async_configure( @@ -2741,15 +2790,29 @@ async def test_reconfigure_aborts_when_rsa_key_load_fails(hass: HomeAssistant) - @pytest.mark.usefixtures("mock_rsa_key") +@pytest.mark.parametrize( + ("error", "expected_reason"), + [ + pytest.param(TeslaFleetError, "cannot_connect", id="tesla_fleet_error"), + pytest.param(BadGateway, "cannot_connect", id="bad_gateway"), + pytest.param( + EnergyGatewayUnreachable, + "powerwall_unreachable", + id="gateway_unreachable", + ), + ], +) async def test_reconfigure_aborts_when_local_and_cloud_lookups_fail( hass: HomeAssistant, mock_local_authorized_clients: AsyncMock, + error: type[BaseException], + expected_reason: str, ) -> None: """Reconfigure aborts when both the local and the cloud lookup fail.""" entry = await _setup_paired_account(hass) subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id - cloud_lookup = AsyncMock(side_effect=TeslaFleetError) + cloud_lookup = AsyncMock(side_effect=error) add_client = AsyncMock(return_value={}) with ( patch( @@ -2764,7 +2827,7 @@ async def test_reconfigure_aborts_when_local_and_cloud_lookups_fail( result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == expected_reason mock_local_authorized_clients.assert_awaited_once() cloud_lookup.assert_awaited_once() # A failed lookup must not be mistaken for an unregistered key.