From fa7ecddb66e57b593c6790743fe630886aac5fb4 Mon Sep 17 00:00:00 2001 From: Marcello <58506324+Marcello17@users.noreply.github.com> Date: Mon, 4 May 2026 14:59:05 +0200 Subject: [PATCH] Improve availability in Fluss (#168154) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/fluss/button.py | 5 ++ homeassistant/components/fluss/const.py | 3 +- homeassistant/components/fluss/coordinator.py | 32 ++++++++-- tests/components/fluss/conftest.py | 15 ++++- tests/components/fluss/test_button.py | 62 ++++++++++++++++++- tests/components/fluss/test_init.py | 17 +++++ 6 files changed, 124 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fluss/button.py b/homeassistant/components/fluss/button.py index 7b2009fe04c..ab238396eb7 100644 --- a/homeassistant/components/fluss/button.py +++ b/homeassistant/components/fluss/button.py @@ -29,6 +29,11 @@ class FlussButton(FlussEntity, ButtonEntity): _attr_name = None + @property + def available(self) -> bool: + """Return True only when the device is online.""" + return super().available and self.device["internetConnected"] + async def async_press(self) -> None: """Handle the button press.""" try: diff --git a/homeassistant/components/fluss/const.py b/homeassistant/components/fluss/const.py index b66ae736106..d4480136341 100644 --- a/homeassistant/components/fluss/const.py +++ b/homeassistant/components/fluss/const.py @@ -5,5 +5,4 @@ import logging DOMAIN = "fluss" LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL = 60 # seconds -UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL) +UPDATE_INTERVAL = timedelta(minutes=30) diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py index b8a33dd8b12..a3e9dcdab78 100644 --- a/homeassistant/components/fluss/coordinator.py +++ b/homeassistant/components/fluss/coordinator.py @@ -1,5 +1,8 @@ """DataUpdateCoordinator for Fluss+ integration.""" +from __future__ import annotations + +import asyncio from typing import Any from fluss_api import ( @@ -15,12 +18,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify -from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA +from .const import LOGGER, UPDATE_INTERVAL type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] -class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Manages fetching Fluss device data on a schedule.""" def __init__( @@ -33,11 +36,19 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): LOGGER, name=f"Fluss+ ({slugify(api_key[:8])})", config_entry=config_entry, - update_interval=UPDATE_INTERVAL_TIMEDELTA, + update_interval=UPDATE_INTERVAL, ) + async def _async_get_connectivity(self, device_id: str) -> bool: + """Return connectivity for a device; False if the status call fails.""" + try: + status = await self.api.async_get_device_status(device_id) + except FlussApiClientError: + return False + return status["status"]["internetConnected"] + async def _async_update_data(self) -> dict[str, dict[str, Any]]: - """Fetch data from the Fluss API and return as a dictionary keyed by deviceId.""" + """Fetch Fluss+ devices and merge per-device connectivity status.""" try: devices = await self.api.async_get_devices() except FlussApiClientAuthenticationError as err: @@ -45,4 +56,15 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): except FlussApiClientError as err: raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err - return {device["deviceId"]: device for device in devices.get("devices", [])} + device_list = [ + device + for device in devices["devices"] + if device["userPermissions"]["canUseWiFi"] + ] + connectivity = await asyncio.gather( + *(self._async_get_connectivity(d["deviceId"]) for d in device_list) + ) + return { + device["deviceId"]: {**device, "internetConnected": connected} + for device, connected in zip(device_list, connectivity, strict=False) + } diff --git a/tests/components/fluss/conftest.py b/tests/components/fluss/conftest.py index b585217113d..a9474bed2ed 100644 --- a/tests/components/fluss/conftest.py +++ b/tests/components/fluss/conftest.py @@ -46,8 +46,19 @@ def mock_api_client() -> Generator[AsyncMock]: client = mock_client.return_value client.async_get_devices.return_value = { "devices": [ - {"deviceId": "2a303030sdj1", "deviceName": "Device 1"}, - {"deviceId": "ape93k9302j2", "deviceName": "Device 2"}, + { + "deviceId": "2a303030sdj1", + "deviceName": "Device 1", + "userPermissions": {"canUseWiFi": True}, + }, + { + "deviceId": "ape93k9302j2", + "deviceName": "Device 2", + "userPermissions": {"canUseWiFi": True}, + }, ] } + client.async_get_device_status.return_value = { + "status": {"internetConnected": True} + } yield client diff --git a/tests/components/fluss/test_button.py b/tests/components/fluss/test_button.py index de37b3e630f..f0d8ef811f0 100644 --- a/tests/components/fluss/test_button.py +++ b/tests/components/fluss/test_button.py @@ -7,7 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -48,6 +48,66 @@ async def test_button_press( mock_api_client.async_trigger_device.assert_called_once_with("2a303030sdj1") +async def test_devices_without_wifi_permission_are_filtered( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices whose userPermissions.canUseWiFi is false must not be surfaced.""" + mock_api_client.async_get_devices.return_value = { + "devices": [ + { + "deviceId": "allowed", + "deviceName": "Allowed", + "userPermissions": {"canUseWiFi": True}, + }, + { + "deviceId": "blocked", + "deviceName": "Blocked", + "userPermissions": {"canUseWiFi": False}, + }, + ] + } + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.allowed") is not None + assert hass.states.get("button.blocked") is None + mock_api_client.async_get_device_status.assert_called_once_with("allowed") + + +async def test_button_unavailable_on_status_error( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Buttons become unavailable when the status call errors.""" + mock_api_client.async_get_device_status.side_effect = FlussApiClientError( + "device offline" + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.device_1").state == STATE_UNAVAILABLE + assert hass.states.get("button.device_2").state == STATE_UNAVAILABLE + + +async def test_button_unavailable_when_internet_disconnected( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Buttons become unavailable when the device reports no internet.""" + mock_api_client.async_get_device_status.return_value = { + "status": {"internetConnected": False} + } + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.device_1").state == STATE_UNAVAILABLE + assert hass.states.get("button.device_2").state == STATE_UNAVAILABLE + + async def test_button_press_error( hass: HomeAssistant, mock_api_client: FlussApiClient, diff --git a/tests/components/fluss/test_init.py b/tests/components/fluss/test_init.py index e7f6b3691de..09a9c577fb7 100644 --- a/tests/components/fluss/test_init.py +++ b/tests/components/fluss/test_init.py @@ -10,6 +10,7 @@ from fluss_api import ( import pytest from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import setup_integration @@ -54,3 +55,19 @@ async def test_async_setup_entry_authentication_error( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is state + + +async def test_status_authentication_error_marks_device_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: AsyncMock, +) -> None: + """Test that an auth error from a per-device status call marks the device offline.""" + mock_api_client.async_get_device_status.side_effect = ( + FlussApiClientAuthenticationError("permission revoked") + ) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert hass.states.get("button.device_1").state == STATE_UNAVAILABLE + assert hass.states.get("button.device_2").state == STATE_UNAVAILABLE