Bump aioflo to 2026.9.3 and scope Flo consumption by device (#181246)

This commit is contained in:
BradKollmyer
2026-09-05 10:33:22 +00:00
committed by Franck Nijhof
parent 020abd6252
commit 8efac1a04b
9 changed files with 207 additions and 35 deletions
+40 -5
View File
@@ -3,7 +3,7 @@
import asyncio
import logging
from aioflo import async_get_api
from aioflo.api import API, async_get_api
from aioflo.errors import RequestError
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
@@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_SSO
from .coordinator import FloConfigEntry, FloDeviceDataUpdateCoordinator, FloRuntimeData
_LOGGER = logging.getLogger(__name__)
@@ -18,16 +19,50 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: FloConfigEntry) -> bool:
"""Set up flo from a config entry."""
async def async_get_flo_api(
hass: HomeAssistant,
username: str,
password: str,
*,
use_sso: bool = False,
) -> tuple[API, bool]:
"""Authenticate against Flo, falling back to Moen SSO if legacy auth fails.
Returns the API client and whether SSO was used.
"""
session = async_get_clientsession(hass)
try:
client = await async_get_api(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session
return (
await async_get_api(username, password, session=session, use_sso=use_sso),
use_sso,
)
except RequestError as err:
if use_sso:
raise
_LOGGER.info("Legacy Flo auth failed (%s); retrying with Moen SSO", err)
return (
await async_get_api(username, password, session=session, use_sso=True),
True,
)
async def async_setup_entry(hass: HomeAssistant, entry: FloConfigEntry) -> bool:
"""Set up flo from a config entry."""
try:
client, used_sso = await async_get_flo_api(
hass,
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
use_sso=entry.data.get(CONF_USE_SSO, False),
)
except RequestError as err:
raise ConfigEntryNotReady from err
if used_sso and not entry.data.get(CONF_USE_SSO):
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_USE_SSO: True}
)
user_info = await client.user.get_info(include_location_info=True)
_LOGGER.debug("Flo user information with locations: %s", user_info)
+13 -8
View File
@@ -2,7 +2,6 @@
from typing import Any, override
from aioflo import async_get_api
from aioflo.errors import RequestError
import voluptuous as vol
@@ -10,27 +9,30 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
from . import async_get_flo_api
from .const import CONF_USE_SSO, DOMAIN, LOGGER
DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
)
async def validate_input(hass: HomeAssistant, data):
async def validate_input(hass: HomeAssistant, data) -> bool:
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
Returns True if Moen SSO was used.
"""
session = async_get_clientsession(hass)
try:
await async_get_api(data[CONF_USERNAME], data[CONF_PASSWORD], session=session)
_api, used_sso = await async_get_flo_api(
hass, data[CONF_USERNAME], data[CONF_PASSWORD]
)
except RequestError as request_error:
LOGGER.error("Error connecting to the Flo API: %s", request_error)
raise CannotConnect from request_error
return used_sso
class FloConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -48,9 +50,12 @@ class FloConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(user_input[CONF_USERNAME])
self._abort_if_unique_id_configured()
try:
await validate_input(self.hass, user_input)
used_sso = await validate_input(self.hass, user_input)
entry_data = dict(user_input)
if used_sso:
entry_data[CONF_USE_SSO] = True
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
title=user_input[CONF_USERNAME], data=entry_data
)
except CannotConnect:
errors["base"] = "cannot_connect"
+1
View File
@@ -5,6 +5,7 @@ import logging
LOGGER = logging.getLogger(__package__)
DOMAIN = "flo"
CONF_USE_SSO = "use_sso"
FLO_HOME = "home"
FLO_AWAY = "away"
FLO_SLEEP = "sleep"
+4 -1
View File
@@ -247,6 +247,9 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
start_date = datetime(today.year, today.month, today.day, 0, 0)
end_date = datetime(today.year, today.month, today.day, 23, 59, 59, 999000)
self._water_usage = await self.api_client.water.get_consumption_info(
self._flo_location_id, start_date, end_date
self._flo_location_id,
start_date,
end_date,
device_mac_address=self.mac_address,
)
LOGGER.debug("Updated Flo consumption data: %s", self._water_usage)
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioflo"],
"requirements": ["aioflo==2021.11.0"]
"requirements": ["aioflo==2026.9.3"]
}
+1 -1
View File
@@ -267,7 +267,7 @@ aioesphomeapi==46.2.0
aiofiles==25.1.0
# homeassistant.components.flo
aioflo==2021.11.0
aioflo==2026.9.3
# homeassistant.components.yi
aioftp==0.21.3
+114 -19
View File
@@ -2,18 +2,20 @@
from http import HTTPStatus
import json
import time
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from aioflo.api import SSO_TOKEN_URL
from aioflo.errors import RequestError
import pytest
from homeassistant import config_entries
from homeassistant.components.flo.const import DOMAIN
from homeassistant.components.flo import async_get_flo_api
from homeassistant.components.flo.const import CONF_USE_SSO, DOMAIN
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .common import TEST_EMAIL_ADDRESS, TEST_PASSWORD, TEST_TOKEN, TEST_USER_ID
from .common import TEST_PASSWORD, TEST_USER_ID
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -45,23 +47,13 @@ async def test_form(hass: HomeAssistant) -> None:
async def test_form_cannot_connect(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we handle cannot connect error."""
now = round(time.time())
# Mocks a failed login response for flo.
"""Test we handle cannot connect error when legacy and SSO both fail."""
aioclient_mock.post(
"https://api.meetflo.com/api/v1/users/auth",
json=json.dumps(
{
"token": TEST_TOKEN,
"tokenPayload": {
"user": {"user_id": TEST_USER_ID, "email": TEST_EMAIL_ADDRESS},
"timestamp": now,
},
"tokenExpiration": 86400,
"timeNow": now,
}
),
headers={"Content-Type": CONTENT_TYPE_JSON},
status=HTTPStatus.BAD_REQUEST,
)
aioclient_mock.post(
SSO_TOKEN_URL,
status=HTTPStatus.BAD_REQUEST,
)
result = await hass.config_entries.flow.async_init(
@@ -74,3 +66,106 @@ async def test_form_cannot_connect(
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_sso_after_legacy_failure(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test config flow falls back to Moen SSO when legacy auth fails."""
aioclient_mock.post(
"https://api.meetflo.com/api/v1/users/auth",
status=HTTPStatus.BAD_REQUEST,
)
aioclient_mock.post(
SSO_TOKEN_URL,
text=json.dumps(
{
"token": {
"access_token": "sso-access-token",
"refresh_token": "sso-refresh-token",
"token_type": "Bearer",
"expires_in": 3600,
}
}
),
headers={"Content-Type": CONTENT_TYPE_JSON},
status=HTTPStatus.OK,
)
aioclient_mock.get(
"https://api-gw.meetflo.com/api/v2/users/me",
text=json.dumps({"id": TEST_USER_ID, "email": "email@address.com"}),
headers={"Content-Type": CONTENT_TYPE_JSON},
status=HTTPStatus.OK,
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.flo.async_setup_entry", return_value=True
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"username": TEST_USER_ID, "password": TEST_PASSWORD}
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {
"username": TEST_USER_ID,
"password": TEST_PASSWORD,
CONF_USE_SSO: True,
}
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
async def test_async_get_flo_api_falls_back_to_sso(hass: HomeAssistant) -> None:
"""Test legacy RequestError triggers a SSO retry."""
client = object()
with patch(
"homeassistant.components.flo.async_get_api",
new_callable=AsyncMock,
side_effect=[RequestError("legacy failed"), client],
) as mock_get_api:
api, used_sso = await async_get_flo_api(hass, TEST_USER_ID, TEST_PASSWORD)
assert api is client
assert used_sso is True
assert mock_get_api.await_count == 2
assert mock_get_api.await_args_list[0].kwargs["use_sso"] is False
assert mock_get_api.await_args_list[1].kwargs["use_sso"] is True
async def test_async_get_flo_api_uses_stored_sso(hass: HomeAssistant) -> None:
"""Test a stored SSO flag skips the legacy attempt."""
client = object()
with patch(
"homeassistant.components.flo.async_get_api",
new_callable=AsyncMock,
return_value=client,
) as mock_get_api:
api, used_sso = await async_get_flo_api(
hass, TEST_USER_ID, TEST_PASSWORD, use_sso=True
)
assert api is client
assert used_sso is True
assert mock_get_api.await_count == 1
assert mock_get_api.await_args.kwargs["use_sso"] is True
async def test_async_get_flo_api_stored_sso_does_not_retry(hass: HomeAssistant) -> None:
"""Test a stored SSO failure is not retried."""
with (
patch(
"homeassistant.components.flo.async_get_api",
new_callable=AsyncMock,
side_effect=RequestError("sso failed"),
) as mock_get_api,
pytest.raises(RequestError),
):
await async_get_flo_api(hass, TEST_USER_ID, TEST_PASSWORD, use_sso=True)
assert mock_get_api.await_count == 1
+7
View File
@@ -26,6 +26,13 @@ async def test_device(
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
consumption_macs = {
call[1].query.get("macAddress")
for call in aioclient_mock.mock_calls
if call[0] == "get" and call[1].path == "/api/v2/water/consumption"
}
assert consumption_macs == {"111111111111", "1a2b3c4d5e6f"}
call_count = aioclient_mock.call_count
freezer.tick(timedelta(seconds=90))
+26
View File
@@ -1,8 +1,12 @@
"""Test init."""
from unittest.mock import AsyncMock, patch
from aioflo.errors import RequestError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.flo.const import CONF_USE_SSO
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -30,3 +34,25 @@ async def test_setup_entry(
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.NOT_LOADED
async def test_setup_entry_persists_sso_flag(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test setup stores use_sso when legacy auth fails and SSO succeeds."""
config_entry.add_to_hass(hass)
assert CONF_USE_SSO not in config_entry.data
client = AsyncMock()
client.user.get_info = AsyncMock(return_value={"locations": []})
with patch(
"homeassistant.components.flo.async_get_api",
new_callable=AsyncMock,
side_effect=[RequestError("legacy failed"), client],
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.data[CONF_USE_SSO] is True