diff --git a/homeassistant/components/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py index 867bc5886dbf..b7ac70527dce 100644 --- a/homeassistant/components/rainforest_eagle/config_flow.py +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -10,7 +10,14 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE -from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN +from .const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) from .data import CannotConnect, InvalidAuth, async_get_type _LOGGER = logging.getLogger(__name__) @@ -63,11 +70,32 @@ class RainforestEagleConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - user_input[CONF_TYPE] = eagle_type - user_input[CONF_HARDWARE_ADDRESS] = hardware_address - return self.async_create_entry( - title=user_input[CONF_CLOUD_ID], data=user_input - ) + # Verify it is a known device, first + if not eagle_type: + errors["base"] = "unknown_device_type" + elif eagle_type == TYPE_EAGLE_100: + user_input[CONF_TYPE] = eagle_type + + # For EAGLE-100, there is no hardware address to select, so set it to None and move on + user_input[CONF_HARDWARE_ADDRESS] = None + elif eagle_type == TYPE_EAGLE_200: + user_input[CONF_TYPE] = eagle_type + + # For EAGLE-200, a connected meter's hardware address is required to create the entry + if not hardware_address: + # hardware_address will be None if there are no meters at all or if none are currently Connected + errors["base"] = "no_meters_connected" + else: + user_input[CONF_HARDWARE_ADDRESS] = hardware_address + else: + # This is a device that isn't supported, yet, but was detected by async_get_type + errors["base"] = "unsupported_device_type" + + # All information gathering is done, so if there are no errors at this point, create the entry + if not errors: + return self.async_create_entry( + title=user_input[CONF_CLOUD_ID], data=user_input + ) return self.async_show_form( step_id="user", data_schema=create_schema(user_input), errors=errors diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 01f373f3178c..adf135d53f59 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -34,7 +34,7 @@ class InvalidAuth(RainforestError): async def async_get_type(hass, cloud_id, install_code, host): """Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200.""" - # For EAGLE-200, fetch the hardware address of the meter too. + # For EAGLE-200, fetch the hardware address of the first connected meter, too. hub = aioeagle.EagleHub( aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host ) @@ -50,8 +50,17 @@ async def async_get_type(hass, cloud_id, install_code, host): if meters is not None: if meters: - hardware_address = meters[0].hardware_address + # If there is at least one meter, use the first one with a connection status of "Connected" + hardware_address = next( + ( + m.hardware_address + for m in meters + if getattr(m, "connection_status", None) == "Connected" + ), + None, + ) else: + # If there are no meters (empty list, since None was already checked for), set the hardware address to None hardware_address = None return TYPE_EAGLE_200, hardware_address diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index a874770baa9a..b3eed05110c6 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,7 +6,10 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "no_meters_connected": "No meters are currently connected. Ensure your meter is connected and try again.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_device_type": "Unable to determine the type of Rainforest Eagle device. Please ensure your device is supported.", + "unsupported_device_type": "This type of Rainforest Eagle device is not supported." }, "step": { "user": { diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py index 0d3b477b3d5c..adf705e39259 100644 --- a/tests/components/rainforest_eagle/test_config_flow.py +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant.components.rainforest_eagle.const import ( CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN, + TYPE_EAGLE_100, TYPE_EAGLE_200, ) from homeassistant.components.rainforest_eagle.data import CannotConnect, InvalidAuth @@ -16,8 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_multiple_meters_first_connected(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with a list of meters, one of which is connected (should auto-select it).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -25,17 +26,29 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] is None + # Simulate multiple meters with one connected + class MockElectricMeter: + def __init__(self, hardware_address, connection_status) -> None: + self.hardware_address = hardware_address + self.connection_status = connection_status + + meters = [ + MockElectricMeter("meter-1", "Not Joined"), + MockElectricMeter("meter-2", "Connected"), + MockElectricMeter("meter-3", "Not Joined"), + ] + with ( patch( - "homeassistant.components.rainforest_eagle.config_flow.async_get_type", - return_value=(TYPE_EAGLE_200, "mock-hw"), + "aioeagle.EagleHub.get_device_list", + return_value=meters, ), patch( "homeassistant.components.rainforest_eagle.async_setup_entry", return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -45,18 +58,232 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "abcdef" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "abcdef" + assert result["data"] == { CONF_TYPE: TYPE_EAGLE_200, CONF_HOST: "192.168.1.55", CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456", - CONF_HARDWARE_ADDRESS: "mock-hw", + CONF_HARDWARE_ADDRESS: "meter-2", + } + assert result["result"].unique_id == "abcdef" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_eagle_200_meters_none_connected(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with a list of meters, but all are disconnected (Error should be shown).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Simulate all meters being disconnected + class MockElectricMeter: + def __init__(self, hardware_address, connection_status) -> None: + self.hardware_address = hardware_address + self.connection_status = connection_status + + meters = [ + MockElectricMeter("meter-1", "Not Joined"), + MockElectricMeter("meter-2", "Not Joined"), + MockElectricMeter("meter-3", "Not Joined"), + ] + + with patch( + "aioeagle.EagleHub.get_device_list", + return_value=meters, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_meters_connected"} + + +async def test_form_eagle_200_no_meters(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with an empty list of meters (Error should be shown).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Simulate no meters (empty list) + with ( + patch( + "aioeagle.EagleHub.get_device_list", + return_value=[], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_meters_connected"} + + +async def test_form_eagle_100(hass: HomeAssistant) -> None: + """Test proper flow for EAGLE-100 (KeyError from get_device_list, then legacy response).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Patch get_device_list to raise KeyError (expected from EAGLE-100), and async_add_executor_job to return proper EAGLE-100 response + eagle_100_response = {"NetworkInfo": {"ModelId": "Z109-EAGLE"}} + + with ( + patch( + "aioeagle.EagleHub.get_device_list", + side_effect=KeyError, + ), + patch( + "eagle100.Eagle.get_network_info", + return_value=eagle_100_response, + ), + patch( + "homeassistant.core.HomeAssistant.async_add_executor_job", + return_value=eagle_100_response, + ), + patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "abcdef" + assert result["data"] == { + CONF_TYPE: TYPE_EAGLE_100, + CONF_HOST: "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: None, } assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_unknown_device_type(hass: HomeAssistant) -> None: + """Test flow when device type cannot be determined (get_device_list raises an error but other responses aren't the expected values).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Patch get_device_list to raise KeyError (expected from EAGLE-100), and async_add_executor_job to return an unknown device response + unknown_device_response = {"NetworkInfo": {"ModelId": "UNKNOWN-DEVICE"}} + + with ( + patch( + "aioeagle.EagleHub.get_device_list", + side_effect=KeyError, + ), + patch( + "eagle100.Eagle.get_network_info", + return_value=unknown_device_response, + ), + patch( + "homeassistant.core.HomeAssistant.async_add_executor_job", + return_value=unknown_device_response, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown_device_type"} + + +async def test_form_unsupported_device_type(hass: HomeAssistant) -> None: + """Test flow when device type is unsupported (async_get_type returns an unexpected device type).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rainforest_eagle.config_flow.async_get_type", + return_value=("UNSUPPORTED_DEVICE_TYPE", None), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_device_type"} + + +async def test_form_unexpected_exception(hass: HomeAssistant) -> None: + """Test flow when an unexpected exception occurs.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rainforest_eagle.config_flow.async_get_type", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -67,7 +294,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "aioeagle.EagleHub.get_device_list", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -76,8 +303,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -90,7 +317,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "aioeagle.EagleHub.get_device_list", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -99,5 +326,5 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"}