From a9fbc0d986c247c4d13b6310ec39b8a2ee115ca8 Mon Sep 17 00:00:00 2001 From: Greg Haines Date: Sat, 29 Aug 2026 14:16:33 -0500 Subject: [PATCH] Add reconfigure and reauth flows for CentriConnect (#180215) --- .../components/centriconnect/config_flow.py | 132 ++++++++++++-- .../centriconnect/quality_scale.yaml | 4 +- .../components/centriconnect/strings.json | 27 ++- .../centriconnect/snapshots/test_init.ambr | 31 ++++ .../centriconnect/test_config_flow.py | 163 +++++++++++++++++- tests/components/centriconnect/test_init.py | 19 ++ 6 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 tests/components/centriconnect/snapshots/test_init.ambr diff --git a/homeassistant/components/centriconnect/config_flow.py b/homeassistant/components/centriconnect/config_flow.py index 2821f5d594ea..40ed34f56ef8 100644 --- a/homeassistant/components/centriconnect/config_flow.py +++ b/homeassistant/components/centriconnect/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the CentriConnect/MyPropane API integration.""" +from collections.abc import Callable, Mapping import logging from typing import Any, override @@ -11,7 +12,7 @@ from aiocentriconnect.exceptions import ( CentriConnectNotFoundError, CentriConnectTooManyRequestsError, ) -import voluptuous as vol +import probatio from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME @@ -22,11 +23,25 @@ from .const import CENTRICONNECT_DEVICE_ID, DOMAIN _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +STEP_RECONFIGURE_DATA_SCHEMA = probatio.Schema( { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_DEVICE_ID): str, - vol.Required(CONF_PASSWORD): str, + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_PASSWORD): str, + } +) + +STEP_REAUTHENTICATE_DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_PASSWORD): str, + } +) + +STEP_USER_DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_DEVICE_ID): str, + probatio.Required(CONF_PASSWORD): str, } ) @@ -57,34 +72,117 @@ class CentriConnectConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for CentriConnect/MyPropane API.""" VERSION = 1 + _device_id: str | None = None - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None + async def _handle_flow( + self, + step_id: str, + data_schema: probatio.Schema, + user_input: dict[str, Any] | None, + update_user_input: Callable[[dict[str, Any]], dict[str, Any]], + on_success: Callable[[dict[str, Any], dict[str, Any]], ConfigFlowResult], ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the flow for both user and reconfigure steps.""" errors: dict[str, str] = {} if user_input is not None: try: - info = await validate_input(self.hass, user_input) - except CentriConnectConnectionError, CentriConnectTooManyRequestsError: + info = await validate_input(self.hass, update_user_input(user_input)) + except ( + CentriConnectConnectionError, + CentriConnectTooManyRequestsError, + ): errors["base"] = "cannot_connect" except CentriConnectNotFoundError: errors["base"] = "invalid_auth" except CentriConnectEmptyResponseError, CentriConnectDecodeError: errors["base"] = "unknown" - except Exception: + except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id( unique_id=info[CENTRICONNECT_DEVICE_ID], raise_on_progress=True ) - self._abort_if_unique_id_configured( - updates=user_input, reload_on_update=True - ) - return self.async_create_entry(title=info["title"], data=user_input) + return on_success(info, user_input) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id=step_id, data_schema=data_schema, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + old_entry = self._get_reconfigure_entry() + + def _on_success( + info: dict[str, Any], user_input: dict[str, Any] + ) -> ConfigFlowResult: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + old_entry, data_updates=user_input + ) + + return await self._handle_flow( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE_DATA_SCHEMA, + user_input=user_input, + update_user_input=lambda user_input: { + **user_input, + CONF_DEVICE_ID: old_entry.data[CONF_DEVICE_ID], + }, + on_success=_on_success, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self._device_id = entry_data[CONF_DEVICE_ID] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + + def _on_success( + info: dict[str, Any], user_input: dict[str, Any] + ) -> ConfigFlowResult: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return await self._handle_flow( + step_id="reauth_confirm", + data_schema=STEP_REAUTHENTICATE_DATA_SCHEMA, + user_input=user_input, + update_user_input=lambda user_input: { + **user_input, + CONF_DEVICE_ID: self._device_id, + }, + on_success=_on_success, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + def _on_success( + info: dict[str, Any], user_input: dict[str, Any] + ) -> ConfigFlowResult: + self._abort_if_unique_id_configured( + updates=user_input, reload_on_update=True + ) + return self.async_create_entry(title=info["title"], data=user_input) + + return await self._handle_flow( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + user_input=user_input, + update_user_input=lambda user_input: user_input, + on_success=_on_success, ) diff --git a/homeassistant/components/centriconnect/quality_scale.yaml b/homeassistant/components/centriconnect/quality_scale.yaml index d0bc918ebcd6..26f5ba53ffa2 100644 --- a/homeassistant/components/centriconnect/quality_scale.yaml +++ b/homeassistant/components/centriconnect/quality_scale.yaml @@ -42,7 +42,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -70,7 +70,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No user-actionable repair scenarios identified for this integration. diff --git a/homeassistant/components/centriconnect/strings.json b/homeassistant/components/centriconnect/strings.json index 4f9c6cc8943e..754582ee035f 100644 --- a/homeassistant/components/centriconnect/strings.json +++ b/homeassistant/components/centriconnect/strings.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "This CentriConnect/MyPropane device does not match the existing device ID. Please make sure you entered the credentials correctly." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +12,28 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:component::centriconnect::config::step::user::data::password%]", + "username": "[%key:component::centriconnect::config::step::user::data::username%]" + }, + "data_description": { + "password": "[%key:component::centriconnect::config::step::user::data_description::password%]", + "username": "[%key:component::centriconnect::config::step::user::data_description::username%]" + }, + "description": "[%key:component::centriconnect::config::step::user::description%]" + }, + "reconfigure": { + "data": { + "password": "[%key:component::centriconnect::config::step::user::data::password%]", + "username": "[%key:component::centriconnect::config::step::user::data::username%]" + }, + "data_description": { + "password": "[%key:component::centriconnect::config::step::user::data_description::password%]", + "username": "[%key:component::centriconnect::config::step::user::data_description::username%]" + }, + "description": "[%key:component::centriconnect::config::step::user::description%]" + }, "user": { "data": { "device_id": "Device ID", diff --git a/tests/components/centriconnect/snapshots/test_init.ambr b/tests/components/centriconnect/snapshots/test_init.ambr new file mode 100644 index 000000000000..4aef683b709b --- /dev/null +++ b/tests/components/centriconnect/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.1', + 'id': , + 'identifiers': set({ + tuple( + 'centriconnect', + '123a4b5c-678d-9e0f-a123-4b567c8d901e', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'CentriConnect', + 'model': None, + 'model_id': None, + 'name': 'My Tank', + 'name_by_user': None, + 'serial_number': '123a4b5c-678d-9e0f-a123-4b567c8d901e', + 'sw_version': '1.1.2', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/centriconnect/test_config_flow.py b/tests/components/centriconnect/test_config_flow.py index 73c2eed7ef22..6257955d9929 100644 --- a/tests/components/centriconnect/test_config_flow.py +++ b/tests/components/centriconnect/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from aiocentriconnect import Tank from aiocentriconnect.exceptions import ( CentriConnectConnectionError, CentriConnectConnectionTimeoutError, @@ -13,7 +14,7 @@ from aiocentriconnect.exceptions import ( import pytest from homeassistant.components.centriconnect.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -151,3 +152,163 @@ async def test_user_flow_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +RECONFIGURED_USERNAME = "87654321-2109-6543-98a7-f6edc543210b" +RECONFIGURED_PASSWORD = "654321" + + +async def _start_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + { + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + }, + ) + + +async def _start_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reauthenticate flow.""" + mock_config_entry.add_to_hass(hass) + + reauthenticate_result = await mock_config_entry.start_reauth_flow(hass) + + assert reauthenticate_result["type"] is FlowResultType.FORM + assert reauthenticate_result["step_id"] == "reauth_confirm" + + return await hass.config_entries.flow.async_configure( + reauthenticate_result["flow_id"], + { + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + }, + ) + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure reconfigure flow aborts if the device ID changes.""" + mock_centriconnect_client.async_get_tank_data.return_value = Tank( + { + "AlertStatus": "No Alert", + "Altitude": 123.456, + "BatteryVolts": 4.19, + "DeviceID": "different_device_id", + "DeviceName": TEST_TANK_NAME, + "DeviceTempCelsius": 17.0, + "DeviceTempFahrenheit": 63.0, + "LastPostTimeIso": "2026-02-27 22:00:31.000", + "Latitude": 40.7128, + "Longitude": -74.0060, + "NextPostTimeIso": "2026-02-28 10:00:00.000", + "SignalQualLTE": -107.0, + "SolarVolts": 2.46, + "TankLevel": 75.0, + "TankSize": 1000, + "TankSizeUnit": "Gallons", + "VersionHW": "4.1", + "VersionLTE": "1.1.2", + } + ) + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauthenticate_flow( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthenticate flow.""" + + result = await _start_reauth_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauthenticate_unique_id_mismatch( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure reauthenticate flow aborts if the device ID changes.""" + mock_centriconnect_client.async_get_tank_data.return_value = Tank( + { + "AlertStatus": "No Alert", + "Altitude": 123.456, + "BatteryVolts": 4.19, + "DeviceID": "different_device_id", + "DeviceName": TEST_TANK_NAME, + "DeviceTempCelsius": 17.0, + "DeviceTempFahrenheit": 63.0, + "LastPostTimeIso": "2026-02-27 22:00:31.000", + "Latitude": 40.7128, + "Longitude": -74.0060, + "NextPostTimeIso": "2026-02-28 10:00:00.000", + "SignalQualLTE": -107.0, + "SolarVolts": 2.46, + "TankLevel": 75.0, + "TankSize": 1000, + "TankSizeUnit": "Gallons", + "VersionHW": "4.1", + "VersionLTE": "1.1.2", + } + ) + + result = await _start_reauth_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/centriconnect/test_init.py b/tests/components/centriconnect/test_init.py index 02b413bd967b..4053bdd906c9 100644 --- a/tests/components/centriconnect/test_init.py +++ b/tests/components/centriconnect/test_init.py @@ -3,15 +3,34 @@ from unittest.mock import AsyncMock from aiocentriconnect.exceptions import CentriConnectConnectionError +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.centriconnect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.unique_id), mock_config_entry.entry_id + ) + assert device_entry is not None + assert device_entry == snapshot + + async def test_config_entry_not_ready( hass: HomeAssistant, mock_centriconnect_client: AsyncMock,