Improve handling of disconnected meters with Rainforest Automation Eagle-200 (#161185)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Matt Philips
2026-04-10 20:13:53 +00:00
committed by Franck Nijhof
co-authored by Joostlek
parent f3038a20af
commit 7eda592c72
4 changed files with 291 additions and 24 deletions
@@ -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
@@ -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
@@ -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": {
@@ -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"}