mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 15:31:52 -05:00
Bump aioflo to 2026.9.3 and scope Flo consumption by device (#181246)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioflo"],
|
||||
"requirements": ["aioflo==2021.11.0"]
|
||||
"requirements": ["aioflo==2026.9.3"]
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user