From 5933c09a1dc56d2053eb8757a96e29a186c881dc Mon Sep 17 00:00:00 2001 From: Marcello <58506324+Marcello17@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:48:22 +0200 Subject: [PATCH] Add Fluss+ Button integration (#139925) Co-authored-by: NjDaGreat <1754227@students.wits.ac.za> Co-authored-by: NjeruFluss <161302608+NjeruFluss@users.noreply.github.com> Co-authored-by: Josef Zweck Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/fluss/__init__.py | 31 +++++ homeassistant/components/fluss/button.py | 40 +++++++ homeassistant/components/fluss/config_flow.py | 55 +++++++++ homeassistant/components/fluss/const.py | 9 ++ homeassistant/components/fluss/coordinator.py | 50 ++++++++ homeassistant/components/fluss/entity.py | 39 +++++++ homeassistant/components/fluss/manifest.json | 11 ++ .../components/fluss/quality_scale.yaml | 69 +++++++++++ homeassistant/components/fluss/strings.json | 23 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/fluss/__init__.py | 102 +++++++++++++++++ tests/components/fluss/conftest.py | 55 +++++++++ .../fluss/snapshots/test_button.ambr | 97 ++++++++++++++++ tests/components/fluss/test_button.py | 69 +++++++++++ tests/components/fluss/test_config_flow.py | 108 ++++++++++++++++++ tests/components/fluss/test_init.py | 56 +++++++++ 20 files changed, 829 insertions(+) create mode 100644 homeassistant/components/fluss/__init__.py create mode 100644 homeassistant/components/fluss/button.py create mode 100644 homeassistant/components/fluss/config_flow.py create mode 100644 homeassistant/components/fluss/const.py create mode 100644 homeassistant/components/fluss/coordinator.py create mode 100644 homeassistant/components/fluss/entity.py create mode 100644 homeassistant/components/fluss/manifest.json create mode 100644 homeassistant/components/fluss/quality_scale.yaml create mode 100644 homeassistant/components/fluss/strings.json create mode 100644 tests/components/fluss/__init__.py create mode 100644 tests/components/fluss/conftest.py create mode 100644 tests/components/fluss/snapshots/test_button.ambr create mode 100644 tests/components/fluss/test_button.py create mode 100644 tests/components/fluss/test_config_flow.py create mode 100644 tests/components/fluss/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index cd7d26f38dc..be3a7755e33 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -530,6 +530,8 @@ build.json @home-assistant/supervisor /tests/components/flo/ @dmulcahey /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor +/homeassistant/components/fluss/ @fluss +/tests/components/fluss/ @fluss /homeassistant/components/flux_led/ @icemanch /tests/components/flux_led/ @icemanch /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck diff --git a/homeassistant/components/fluss/__init__.py b/homeassistant/components/fluss/__init__.py new file mode 100644 index 00000000000..c3d4b347ff5 --- /dev/null +++ b/homeassistant/components/fluss/__init__.py @@ -0,0 +1,31 @@ +"""The Fluss+ integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import FlussDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.BUTTON] + + +type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FlussConfigEntry, +) -> bool: + """Set up Fluss+ from a config entry.""" + coordinator = FlussDataUpdateCoordinator(hass, entry, entry.data[CONF_API_KEY]) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: FlussConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fluss/button.py b/homeassistant/components/fluss/button.py new file mode 100644 index 00000000000..bc8a90e66c0 --- /dev/null +++ b/homeassistant/components/fluss/button.py @@ -0,0 +1,40 @@ +"""Support for Fluss Devices.""" + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FlussApiClientError, FlussDataUpdateCoordinator +from .entity import FlussEntity + +type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FlussConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Fluss Devices, filtering out any invalid payloads.""" + coordinator = entry.runtime_data + devices = coordinator.data + + async_add_entities( + FlussButton(coordinator, device_id, device) + for device_id, device in devices.items() + ) + + +class FlussButton(FlussEntity, ButtonEntity): + """Representation of a Fluss button device.""" + + _attr_name = None + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.coordinator.api.async_trigger_device(self.device_id) + except FlussApiClientError as err: + raise HomeAssistantError(f"Failed to trigger device: {err}") from err diff --git a/homeassistant/components/fluss/config_flow.py b/homeassistant/components/fluss/config_flow.py new file mode 100644 index 00000000000..09c7da62973 --- /dev/null +++ b/homeassistant/components/fluss/config_flow.py @@ -0,0 +1,55 @@ +"""Config flow for Fluss+ integration.""" + +from __future__ import annotations + +from typing import Any + +from fluss_api import ( + FlussApiClient, + FlussApiClientAuthenticationError, + FlussApiClientCommunicationError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string}) + + +class FlussConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fluss+.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + errors: dict[str, str] = {} + if user_input is not None: + api_key = user_input[CONF_API_KEY] + self._async_abort_entries_match({CONF_API_KEY: api_key}) + client = FlussApiClient( + user_input[CONF_API_KEY], session=async_get_clientsession(self.hass) + ) + try: + await client.async_get_devices() + except FlussApiClientCommunicationError: + errors["base"] = "cannot_connect" + except FlussApiClientAuthenticationError: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception occurred") + errors["base"] = "unknown" + if not errors: + return self.async_create_entry( + title="My Fluss+ Devices", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/fluss/const.py b/homeassistant/components/fluss/const.py new file mode 100644 index 00000000000..b66ae736106 --- /dev/null +++ b/homeassistant/components/fluss/const.py @@ -0,0 +1,9 @@ +"""Constants for the Fluss+ integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "fluss" +LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = 60 # seconds +UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL) diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py new file mode 100644 index 00000000000..6f0bc20e30f --- /dev/null +++ b/homeassistant/components/fluss/coordinator.py @@ -0,0 +1,50 @@ +"""DataUpdateCoordinator for Fluss+ integration.""" + +from __future__ import annotations + +from typing import Any + +from fluss_api import ( + FlussApiClient, + FlussApiClientAuthenticationError, + FlussApiClientError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +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 + +type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] + + +class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Manages fetching Fluss device data on a schedule.""" + + def __init__( + self, hass: HomeAssistant, config_entry: FlussConfigEntry, api_key: str + ) -> None: + """Initialize the coordinator.""" + self.api = FlussApiClient(api_key, session=async_get_clientsession(hass)) + super().__init__( + hass, + LOGGER, + name=f"Fluss+ ({slugify(api_key[:8])})", + config_entry=config_entry, + update_interval=UPDATE_INTERVAL_TIMEDELTA, + ) + + 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.""" + try: + devices = await self.api.async_get_devices() + except FlussApiClientAuthenticationError as err: + raise ConfigEntryError(f"Authentication failed: {err}") from err + except FlussApiClientError as err: + raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err + + return {device["deviceId"]: device for device in devices.get("devices", [])} diff --git a/homeassistant/components/fluss/entity.py b/homeassistant/components/fluss/entity.py new file mode 100644 index 00000000000..12de23a587b --- /dev/null +++ b/homeassistant/components/fluss/entity.py @@ -0,0 +1,39 @@ +"""Base entities for the Fluss+ integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import FlussDataUpdateCoordinator + + +class FlussEntity(CoordinatorEntity[FlussDataUpdateCoordinator]): + """Base class for Fluss entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FlussDataUpdateCoordinator, + device_id: str, + device: dict, + ) -> None: + """Initialize the entity with a device ID and device data.""" + super().__init__(coordinator) + self.device_id = device_id + self._attr_unique_id = device_id + self._attr_device_info = DeviceInfo( + identifiers={("fluss", device_id)}, + name=device.get("deviceName"), + manufacturer="Fluss", + model="Fluss+ Device", + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.device_id in self.coordinator.data + + @property + def device(self) -> dict: + """Return the stored device data.""" + return self.coordinator.data[self.device_id] diff --git a/homeassistant/components/fluss/manifest.json b/homeassistant/components/fluss/manifest.json new file mode 100644 index 00000000000..fcd7867ed1a --- /dev/null +++ b/homeassistant/components/fluss/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "fluss", + "name": "Fluss+", + "codeowners": ["@fluss"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fluss", + "iot_class": "cloud_polling", + "loggers": ["fluss-api"], + "quality_scale": "bronze", + "requirements": ["fluss-api==0.1.9.20"] +} diff --git a/homeassistant/components/fluss/quality_scale.yaml b/homeassistant/components/fluss/quality_scale.yaml new file mode 100644 index 00000000000..c2b4a85a688 --- /dev/null +++ b/homeassistant/components/fluss/quality_scale.yaml @@ -0,0 +1,69 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No actions present + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: + status: exempt + comment: | + Not needed + discovery: todo + stale-devices: todo + diagnostics: todo + exception-translations: todo + icon-translations: + status: exempt + comment: | + No icons used + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: todo + repair-issues: + status: exempt + comment: | + No issues to repair + docs-use-cases: done + docs-supported-devices: todo + docs-supported-functions: done + docs-data-update: todo + docs-known-limitations: done + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/fluss/strings.json b/homeassistant/components/fluss/strings.json new file mode 100644 index 00000000000..cf63c7ff91a --- /dev/null +++ b/homeassistant/components/fluss/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "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%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key found in the profile page of the Fluss+ app." + }, + "description": "Your Fluss API key, available in the profile page of the Fluss+ app" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0aa3b8869e3..8252e66b627 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -220,6 +220,7 @@ FLOWS = { "flipr", "flo", "flume", + "fluss", "flux_led", "folder_watcher", "forecast_solar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 24b0d133e56..8660db5f8c4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2092,6 +2092,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "fluss": { + "name": "Fluss+", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "flux": { "name": "Flux", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index eddaf11541f..16a2d5a9452 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -985,6 +985,9 @@ flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 +# homeassistant.components.fluss +fluss-api==0.1.9.20 + # homeassistant.components.flux_led flux-led==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd3b464b850..8a762f8da27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -870,6 +870,9 @@ flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 +# homeassistant.components.fluss +fluss-api==0.1.9.20 + # homeassistant.components.flux_led flux-led==1.2.0 diff --git a/tests/components/fluss/__init__.py b/tests/components/fluss/__init__.py new file mode 100644 index 00000000000..1849ed37655 --- /dev/null +++ b/tests/components/fluss/__init__.py @@ -0,0 +1,102 @@ +"""Test Script for Fluss+ Initialisation.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from fluss_api import ( + FlussApiClient, + FlussApiClientAuthenticationError, + FlussApiClientCommunicationError, + FlussApiClientError, +) +import pytest + +from homeassistant.components.fluss import PLATFORMS +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("side_effect", "expected_exception"), + [ + (FlussApiClientAuthenticationError, ConfigEntryAuthFailed), + (FlussApiClientCommunicationError, ConfigEntryNotReady), + (FlussApiClientError, ConfigEntryNotReady), + ], +) +async def test_async_setup_entry_errors( + hass: HomeAssistant, + mock_config_entry: MagicMock, + side_effect: Exception, + expected_exception: type[Exception], +) -> None: + """Test setup errors.""" + with ( + patch("fluss_api.FlussApiClient", side_effect=side_effect), + pytest.raises(expected_exception), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.asyncio +async def test_async_setup_entry_success( + hass: HomeAssistant, + mock_config_entry: MagicMock, + mock_api_client: FlussApiClient, +) -> None: + """Test successful setup.""" + with patch("fluss_api.FlussApiClient", return_value=mock_api_client): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + hass.config_entries.async_forward_entry_setups.assert_called_once_with( + mock_config_entry, PLATFORMS + ) + + +@pytest.mark.asyncio +async def test_async_unload_entry( + hass: HomeAssistant, + mock_config_entry: MagicMock, + mock_api_client: FlussApiClient, +) -> None: + """Test unloading entry.""" + # Set up the config entry first to ensure it's in LOADED state + with patch("fluss_api.FlussApiClient", return_value=mock_api_client): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Test unloading + with patch( + "homeassistant.components.fluss.async_unload_platforms", return_value=True + ): + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.asyncio +async def test_platforms_forwarded( + hass: HomeAssistant, + mock_config_entry: MagicMock, + mock_api_client: FlussApiClient, +) -> None: + """Test platforms are forwarded correctly.""" + with patch("fluss_api.FlussApiClient", return_value=mock_api_client): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + hass.config_entries.async_forward_entry_setups.assert_called_with( + mock_config_entry, [Platform.BUTTON] + ) diff --git a/tests/components/fluss/conftest.py b/tests/components/fluss/conftest.py new file mode 100644 index 00000000000..72244f9da87 --- /dev/null +++ b/tests/components/fluss/conftest.py @@ -0,0 +1,55 @@ +"""Shared test fixtures for Fluss+ integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.fluss.const import DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="My Fluss+ Devices", + data={CONF_API_KEY: "test_api_key"}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.fluss.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_api_client() -> Generator[AsyncMock]: + """Mock Fluss API client with single device.""" + with ( + patch( + "homeassistant.components.fluss.coordinator.FlussApiClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.fluss.config_flow.FlussApiClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_devices.return_value = { + "devices": [ + {"deviceId": "2a303030sdj1", "deviceName": "Device 1"}, + {"deviceId": "ape93k9302j2", "deviceName": "Device 2"}, + ] + } + yield client diff --git a/tests/components/fluss/snapshots/test_button.ambr b/tests/components/fluss/snapshots/test_button.ambr new file mode 100644 index 00000000000..18d9da96b24 --- /dev/null +++ b/tests/components/fluss/snapshots/test_button.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_buttons[button.device_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.device_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'fluss', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2a303030sdj1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.device_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device 1', + }), + 'context': , + 'entity_id': 'button.device_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.device_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.device_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'fluss', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ape93k9302j2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.device_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device 2', + }), + 'context': , + 'entity_id': 'button.device_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fluss/test_button.py b/tests/components/fluss/test_button.py new file mode 100644 index 00000000000..f76346046c9 --- /dev/null +++ b/tests/components/fluss/test_button.py @@ -0,0 +1,69 @@ +"""Tests for the Fluss Buttons.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from fluss_api import FlussApiClient, FlussApiClientError +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.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_buttons( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup with multiple devices.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_button_press( + hass: HomeAssistant, + mock_api_client: FlussApiClient, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful button press.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.device_1"}, + blocking=True, + ) + + mock_api_client.async_trigger_device.assert_called_once_with("2a303030sdj1") + + +async def test_button_press_error( + hass: HomeAssistant, + mock_api_client: FlussApiClient, + mock_config_entry: MockConfigEntry, +) -> None: + """Test button press with API error.""" + await setup_integration(hass, mock_config_entry) + + mock_api_client.async_trigger_device.side_effect = FlussApiClientError("API Boom") + + with pytest.raises(HomeAssistantError, match="Failed to trigger device: API Boom"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.device_1"}, + blocking=True, + ) diff --git a/tests/components/fluss/test_config_flow.py b/tests/components/fluss/test_config_flow.py new file mode 100644 index 00000000000..e9717975f9e --- /dev/null +++ b/tests/components/fluss/test_config_flow.py @@ -0,0 +1,108 @@ +"""Tests for the Fluss+ config flow.""" + +from unittest.mock import AsyncMock + +from fluss_api import ( + FlussApiClientAuthenticationError, + FlussApiClientCommunicationError, +) +import pytest + +from homeassistant.components.fluss.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_api_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test full config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "valid_api_key"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My Fluss+ Devices" + assert result["data"] == {CONF_API_KEY: "valid_api_key"} + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (FlussApiClientAuthenticationError, "invalid_auth"), + (FlussApiClientCommunicationError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_step_user_errors( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test error cases for user step with recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + user_input = {CONF_API_KEY: "some_api_key"} + + mock_api_client.async_get_devices.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "valid_api_key"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + mock_api_client.async_get_devices.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error cases for user step with recovery.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "test_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/fluss/test_init.py b/tests/components/fluss/test_init.py new file mode 100644 index 00000000000..e7f6b3691de --- /dev/null +++ b/tests/components/fluss/test_init.py @@ -0,0 +1,56 @@ +"""Test script for Fluss+ integration initialization.""" + +from unittest.mock import AsyncMock + +from fluss_api import ( + FlussApiClientAuthenticationError, + FlussApiClientCommunicationError, + FlussApiClientError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: AsyncMock, +) -> None: + """Test the Fluss configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_api_client.async_get_devices.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (FlussApiClientAuthenticationError, ConfigEntryState.SETUP_ERROR), + (FlussApiClientCommunicationError, ConfigEntryState.SETUP_RETRY), + (FlussApiClientError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_async_setup_entry_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test that an authentication error during setup leads to SETUP_ERROR state.""" + mock_api_client.async_get_devices.side_effect = exception + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state