diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py index 88824b041e77..c87e0b9fca84 100644 --- a/homeassistant/components/flo/__init__.py +++ b/homeassistant/components/flo/__init__.py @@ -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) diff --git a/homeassistant/components/flo/config_flow.py b/homeassistant/components/flo/config_flow.py index d1722f77b503..3b6f7b4aa916 100644 --- a/homeassistant/components/flo/config_flow.py +++ b/homeassistant/components/flo/config_flow.py @@ -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" diff --git a/homeassistant/components/flo/const.py b/homeassistant/components/flo/const.py index 5b1d926d9f47..b2abe1150c51 100644 --- a/homeassistant/components/flo/const.py +++ b/homeassistant/components/flo/const.py @@ -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" diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index a328770a24fd..93d894b4d97c 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -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) diff --git a/homeassistant/components/flo/manifest.json b/homeassistant/components/flo/manifest.json index f8d300763142..a36e1f6f0df1 100644 --- a/homeassistant/components/flo/manifest.json +++ b/homeassistant/components/flo/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioflo"], - "requirements": ["aioflo==2021.11.0"] + "requirements": ["aioflo==2026.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a87a2998c76..281a4ee2994b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/components/flo/test_config_flow.py b/tests/components/flo/test_config_flow.py index f9237e979a6c..310aa761226f 100644 --- a/tests/components/flo/test_config_flow.py +++ b/tests/components/flo/test_config_flow.py @@ -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 diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index b89d5a1e68cb..88c3afa2d866 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -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)) diff --git a/tests/components/flo/test_init.py b/tests/components/flo/test_init.py index 8dfa712ecb17..6bae65c7bfb2 100644 --- a/tests/components/flo/test_init.py +++ b/tests/components/flo/test_init.py @@ -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