Add 502 gateway-unreachable handling to Teslemetry Powerwall pairing (#183792)

This commit is contained in:
Brett Adams
2026-10-04 08:52:03 +02:00
committed by GitHub
parent 46bc023e99
commit 11c7c403b6
3 changed files with 97 additions and 14 deletions
@@ -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)
@@ -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",
+76 -13
View File
@@ -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.