From 184bea49e2295b4682cd07f9736eba5486a12fd2 Mon Sep 17 00:00:00 2001 From: Jonathan Sady do Nascimento <84106904+Jonhsady@users.noreply.github.com> Date: Thu, 5 Feb 2026 05:04:14 -0300 Subject: [PATCH] Add redgtech integration (#136947) Co-authored-by: luan-nvg --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/redgtech/__init__.py | 35 +++ .../components/redgtech/config_flow.py | 67 +++++ homeassistant/components/redgtech/const.py | 4 + .../components/redgtech/coordinator.py | 130 +++++++++ .../components/redgtech/manifest.json | 11 + .../components/redgtech/quality_scale.yaml | 72 +++++ .../components/redgtech/strings.json | 40 +++ homeassistant/components/redgtech/switch.py | 95 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/redgtech/__init__.py | 1 + tests/components/redgtech/conftest.py | 70 +++++ .../redgtech/snapshots/test_switch.ambr | 97 +++++++ tests/components/redgtech/test_config_flow.py | 138 ++++++++++ tests/components/redgtech/test_switch.py | 255 ++++++++++++++++++ 20 files changed, 1041 insertions(+) create mode 100644 homeassistant/components/redgtech/__init__.py create mode 100644 homeassistant/components/redgtech/config_flow.py create mode 100644 homeassistant/components/redgtech/const.py create mode 100644 homeassistant/components/redgtech/coordinator.py create mode 100644 homeassistant/components/redgtech/manifest.json create mode 100644 homeassistant/components/redgtech/quality_scale.yaml create mode 100644 homeassistant/components/redgtech/strings.json create mode 100644 homeassistant/components/redgtech/switch.py create mode 100644 tests/components/redgtech/__init__.py create mode 100644 tests/components/redgtech/conftest.py create mode 100644 tests/components/redgtech/snapshots/test_switch.ambr create mode 100644 tests/components/redgtech/test_config_flow.py create mode 100644 tests/components/redgtech/test_switch.py diff --git a/.strict-typing b/.strict-typing index 2274504721a..9a6d64fbbdc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -435,6 +435,7 @@ homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.* +homeassistant.components.redgtech.* homeassistant.components.remember_the_milk.* homeassistant.components.remote.* homeassistant.components.remote_calendar.* diff --git a/CODEOWNERS b/CODEOWNERS index ff0801b7208..6965954ffd2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1355,6 +1355,8 @@ build.json @home-assistant/supervisor /tests/components/recorder/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core +/homeassistant/components/redgtech/ @jonhsady @luan-nvg +/tests/components/redgtech/ @jonhsady @luan-nvg /homeassistant/components/refoss/ @ashionky /tests/components/refoss/ @ashionky /homeassistant/components/rehlko/ @bdraco @peterager diff --git a/homeassistant/components/redgtech/__init__.py b/homeassistant/components/redgtech/__init__.py new file mode 100644 index 00000000000..dd1a44ddfaa --- /dev/null +++ b/homeassistant/components/redgtech/__init__.py @@ -0,0 +1,35 @@ +"""Initialize the Redgtech integration for Home Assistant.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import RedgtechConfigEntry, RedgtechDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: RedgtechConfigEntry) -> bool: + """Set up Redgtech from a config entry.""" + _LOGGER.debug("Setting up Redgtech entry: %s", entry.entry_id) + coordinator = RedgtechDataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.debug("Successfully set up Redgtech entry: %s", entry.entry_id) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: RedgtechConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("Unloading Redgtech entry: %s", entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/redgtech/config_flow.py b/homeassistant/components/redgtech/config_flow.py new file mode 100644 index 00000000000..05cddd43ba3 --- /dev/null +++ b/homeassistant/components/redgtech/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for the Redgtech integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from redgtech_api.api import RedgtechAPI, RedgtechAuthError, RedgtechConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import DOMAIN, INTEGRATION_NAME + +_LOGGER = logging.getLogger(__name__) + + +class RedgtechConfigFlow(ConfigFlow, domain=DOMAIN): + """Config Flow for Redgtech integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial user step for login.""" + errors: dict[str, str] = {} + + if user_input is not None: + email = user_input[CONF_EMAIL] + password = user_input[CONF_PASSWORD] + + self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]}) + + api = RedgtechAPI() + try: + await api.login(email, password) + except RedgtechAuthError: + errors["base"] = "invalid_auth" + except RedgtechConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during login") + errors["base"] = "unknown" + else: + _LOGGER.debug("Login successful, token received") + return self.async_create_entry( + title=email, + data={ + CONF_EMAIL: email, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + user_input, + ), + errors=errors, + description_placeholders={"integration_name": INTEGRATION_NAME}, + ) diff --git a/homeassistant/components/redgtech/const.py b/homeassistant/components/redgtech/const.py new file mode 100644 index 00000000000..2a7b3486742 --- /dev/null +++ b/homeassistant/components/redgtech/const.py @@ -0,0 +1,4 @@ +"""Constants for the Redgtech integration.""" + +DOMAIN = "redgtech" +INTEGRATION_NAME = "Redgtech" diff --git a/homeassistant/components/redgtech/coordinator.py b/homeassistant/components/redgtech/coordinator.py new file mode 100644 index 00000000000..bbfdf79e306 --- /dev/null +++ b/homeassistant/components/redgtech/coordinator.py @@ -0,0 +1,130 @@ +"""Coordinator for Redgtech integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from redgtech_api.api import RedgtechAPI, RedgtechAuthError, RedgtechConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +UPDATE_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RedgtechDevice: + """Representation of a Redgtech device.""" + + unique_id: str + name: str + state: bool + + +type RedgtechConfigEntry = ConfigEntry[RedgtechDataUpdateCoordinator] + + +class RedgtechDataUpdateCoordinator(DataUpdateCoordinator[dict[str, RedgtechDevice]]): + """Coordinator to manage fetching data from the Redgtech API. + + Uses a dictionary keyed by unique_id for O(1) device lookup instead of O(n) list iteration. + """ + + config_entry: RedgtechConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: RedgtechConfigEntry) -> None: + """Initialize the coordinator.""" + self.api = RedgtechAPI() + self.access_token: str | None = None + self.email = config_entry.data[CONF_EMAIL] + self.password = config_entry.data[CONF_PASSWORD] + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + + async def login(self, email: str, password: str) -> str | None: + """Login to the Redgtech API and return the access token.""" + try: + self.access_token = await self.api.login(email, password) + except RedgtechAuthError as e: + raise ConfigEntryError("Authentication error during login") from e + except RedgtechConnectionError as e: + raise UpdateFailed("Connection error during login") from e + else: + _LOGGER.debug("Access token obtained successfully") + return self.access_token + + async def renew_token(self, email: str, password: str) -> None: + """Renew the access token.""" + self.access_token = await self.api.login(email, password) + _LOGGER.debug("Access token renewed successfully") + + async def call_api_with_valid_token[_R, *_Ts]( + self, api_call: Callable[[*_Ts], Coroutine[Any, Any, _R]], *args: *_Ts + ) -> _R: + """Make an API call with a valid token. + + Ensure we have a valid access token, renewing it if necessary. + """ + if not self.access_token: + _LOGGER.debug("No access token, logging in") + self.access_token = await self.login(self.email, self.password) + else: + _LOGGER.debug("Using existing access token") + try: + return await api_call(*args) + except RedgtechAuthError: + _LOGGER.debug("Auth failed, trying to renew token") + await self.renew_token( + self.config_entry.data[CONF_EMAIL], + self.config_entry.data[CONF_PASSWORD], + ) + return await api_call(*args) + + async def _async_update_data(self) -> dict[str, RedgtechDevice]: + """Fetch data from the API on demand. + + Returns a dictionary keyed by unique_id for efficient device lookup. + """ + _LOGGER.debug("Fetching data from Redgtech API on demand") + try: + data = await self.call_api_with_valid_token( + self.api.get_data, self.access_token + ) + except RedgtechAuthError as e: + raise ConfigEntryError("Authentication failed") from e + except RedgtechConnectionError as e: + raise UpdateFailed("Failed to connect to Redgtech API") from e + + devices: dict[str, RedgtechDevice] = {} + + for item in data["boards"]: + display_categories = {cat.lower() for cat in item["displayCategories"]} + + if "light" in display_categories or "switch" not in display_categories: + continue + + device = RedgtechDevice( + unique_id=item["endpointId"], + name=item["friendlyName"], + state=item["value"], + ) + _LOGGER.debug("Processing device: %s", device) + devices[device.unique_id] = device + + return devices diff --git a/homeassistant/components/redgtech/manifest.json b/homeassistant/components/redgtech/manifest.json new file mode 100644 index 00000000000..839d3b5995d --- /dev/null +++ b/homeassistant/components/redgtech/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "redgtech", + "name": "Redgtech", + "codeowners": ["@jonhsady", "@luan-nvg"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/redgtech", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["redgtech-api==0.1.38"] +} diff --git a/homeassistant/components/redgtech/quality_scale.yaml b/homeassistant/components/redgtech/quality_scale.yaml new file mode 100644 index 00000000000..7947c3c77e0 --- /dev/null +++ b/homeassistant/components/redgtech/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No explicit signature for events + 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: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: Only essential entities + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/redgtech/strings.json b/homeassistant/components/redgtech/strings.json new file mode 100644 index 00000000000..73cb7772534 --- /dev/null +++ b/homeassistant/components/redgtech/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "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": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Enter the email address associated with your {integration_name} account.", + "password": "Enter the password for your {integration_name} account." + }, + "description": "Please enter your credentials to connect to the {integration_name} API.", + "title": "Set up {integration_name}" + } + } + }, + "exceptions": { + "api_error": { + "message": "Error while communicating with the {integration_name} API" + }, + "authentication_failed": { + "message": "Authentication failed. Please check your credentials." + }, + "connection_error": { + "message": "Connection error with {integration_name} API" + }, + "switch_auth_error": { + "message": "Authentication failed when controlling {integration_name} switch" + } + } +} diff --git a/homeassistant/components/redgtech/switch.py b/homeassistant/components/redgtech/switch.py new file mode 100644 index 00000000000..6faf8ff0d59 --- /dev/null +++ b/homeassistant/components/redgtech/switch.py @@ -0,0 +1,95 @@ +"""Integration for Redgtech switches.""" + +from __future__ import annotations + +from typing import Any + +from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, INTEGRATION_NAME +from .coordinator import ( + RedgtechConfigEntry, + RedgtechDataUpdateCoordinator, + RedgtechDevice, +) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RedgtechConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + RedgtechSwitch(coordinator, device) for device in coordinator.data.values() + ) + + +class RedgtechSwitch(CoordinatorEntity[RedgtechDataUpdateCoordinator], SwitchEntity): + """Representation of a Redgtech switch.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, coordinator: RedgtechDataUpdateCoordinator, device: RedgtechDevice + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.device = device + self._attr_unique_id = device.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + name=device.name, + manufacturer=INTEGRATION_NAME, + ) + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + if device := self.coordinator.data.get(self.device.unique_id): + return bool(device.state) + return False + + async def _set_state(self, new_state: bool) -> None: + """Set state of the switch.""" + try: + await self.coordinator.call_api_with_valid_token( + self.coordinator.api.set_switch_state, + self.device.unique_id, + new_state, + self.coordinator.access_token, + ) + except RedgtechAuthError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_auth_error", + translation_placeholders={"integration_name": INTEGRATION_NAME}, + ) from err + except RedgtechConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"integration_name": INTEGRATION_NAME}, + ) from err + + await self.coordinator.async_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._set_state(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._set_state(False) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 28aa314e64c..18604233bc1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -570,6 +570,7 @@ FLOWS = { "rapt_ble", "rdw", "recollect_waste", + "redgtech", "refoss", "rehlko", "remote_calendar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 90fc79068bf..603f3aaf77b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5583,6 +5583,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "redgtech": { + "name": "Redgtech", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "refoss": { "name": "Refoss", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index f7fab927210..92967785df8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4106,6 +4106,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.redgtech.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.remember_the_milk.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 20a65bf0942..bbb331cd8e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2744,6 +2744,9 @@ rapt-ble==0.1.2 # homeassistant.components.raspyrfm raspyrfm-client==1.2.9 +# homeassistant.components.redgtech +redgtech-api==0.1.38 + # homeassistant.components.refoss refoss-ha==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80747aac00c..1bd17973ba5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2310,6 +2310,9 @@ radiotherm==2.1.0 # homeassistant.components.rapt_ble rapt-ble==0.1.2 +# homeassistant.components.redgtech +redgtech-api==0.1.38 + # homeassistant.components.refoss refoss-ha==1.2.5 diff --git a/tests/components/redgtech/__init__.py b/tests/components/redgtech/__init__.py new file mode 100644 index 00000000000..c5a1c741c96 --- /dev/null +++ b/tests/components/redgtech/__init__.py @@ -0,0 +1 @@ +"""Tests for the Redgtech component.""" diff --git a/tests/components/redgtech/conftest.py b/tests/components/redgtech/conftest.py new file mode 100644 index 00000000000..8bd0c982300 --- /dev/null +++ b/tests/components/redgtech/conftest.py @@ -0,0 +1,70 @@ +"""Test fixtures for Redgtech integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.redgtech.const import DOMAIN + +from tests.common import MockConfigEntry + +TEST_EMAIL = "test@example.com" +TEST_PASSWORD = "test_password" + + +@pytest.fixture +def mock_redgtech_api() -> Generator[MagicMock]: + """Return a mocked Redgtech API client.""" + with ( + patch( + "homeassistant.components.redgtech.coordinator.RedgtechAPI", autospec=True + ) as api_mock, + patch( + "homeassistant.components.redgtech.config_flow.RedgtechAPI", + new=api_mock, + ), + ): + api = api_mock.return_value + + api.login = AsyncMock(return_value="mock_access_token") + api.get_data = AsyncMock( + return_value={ + "boards": [ + { + "endpointId": "switch_001", + "friendlyName": "Living Room Switch", + "value": False, + "displayCategories": ["SWITCH"], + }, + { + "endpointId": "switch_002", + "friendlyName": "Kitchen Switch", + "value": True, + "displayCategories": ["SWITCH"], + }, + { + "endpointId": "light_switch_001", + "friendlyName": "Bedroom Light Switch", + "value": False, + "displayCategories": ["LIGHT", "SWITCH"], + }, + ] + } + ) + api.set_switch_state = AsyncMock() + + yield api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={"email": TEST_EMAIL, "password": TEST_PASSWORD}, + title="Mock Title", + entry_id="test_entry", + ) diff --git a/tests/components/redgtech/snapshots/test_switch.ambr b/tests/components/redgtech/snapshots/test_switch.ambr new file mode 100644 index 00000000000..62b9baa9286 --- /dev/null +++ b/tests/components/redgtech/snapshots/test_switch.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entities[switch.kitchen_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.kitchen_switch', + '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': 'redgtech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'switch_002', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.kitchen_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Switch', + }), + 'context': , + 'entity_id': 'switch.kitchen_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[switch.living_room_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.living_room_switch', + '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': 'redgtech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'switch_001', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.living_room_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Switch', + }), + 'context': , + 'entity_id': 'switch.living_room_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/redgtech/test_config_flow.py b/tests/components/redgtech/test_config_flow.py new file mode 100644 index 00000000000..9ca0b9abe7c --- /dev/null +++ b/tests/components/redgtech/test_config_flow.py @@ -0,0 +1,138 @@ +"""Tests Config flow for the Redgtech integration.""" + +from unittest.mock import MagicMock + +import pytest +from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError + +from homeassistant.components.redgtech.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_EMAIL = "test@example.com" +TEST_PASSWORD = "123456" +FAKE_TOKEN = "fake_token" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (RedgtechAuthError, "invalid_auth"), + (RedgtechConnectionError, "cannot_connect"), + (Exception("Generic error"), "unknown"), + ], +) +async def test_user_step_errors( + hass: HomeAssistant, + mock_redgtech_api: MagicMock, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test user step with various errors.""" + user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD} + mock_redgtech_api.login.side_effect = side_effect + mock_redgtech_api.login.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == expected_error + mock_redgtech_api.login.assert_called_once_with(TEST_EMAIL, TEST_PASSWORD) + + +async def test_user_step_creates_entry( + hass: HomeAssistant, + mock_redgtech_api: MagicMock, +) -> None: + """Tests the correct creation of the entry in the configuration.""" + user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD} + mock_redgtech_api.login.reset_mock() + mock_redgtech_api.login.return_value = FAKE_TOKEN + mock_redgtech_api.login.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == user_input + # Verify login was called at least once with correct parameters + mock_redgtech_api.login.assert_any_call(TEST_EMAIL, TEST_PASSWORD) + + +async def test_user_step_duplicate_entry( + hass: HomeAssistant, + mock_redgtech_api: MagicMock, +) -> None: + """Test attempt to add duplicate entry.""" + existing_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_EMAIL, + data={CONF_EMAIL: TEST_EMAIL}, + ) + existing_entry.add_to_hass(hass) + + user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=user_input + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_redgtech_api.login.assert_not_called() + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (RedgtechAuthError, "invalid_auth"), + (RedgtechConnectionError, "cannot_connect"), + (Exception("Generic error"), "unknown"), + ], +) +async def test_user_step_error_recovery( + hass: HomeAssistant, + mock_redgtech_api: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test that the flow can recover from errors and complete successfully.""" + user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD} + + # Reset mock to start fresh + mock_redgtech_api.login.reset_mock() + mock_redgtech_api.login.return_value = None + mock_redgtech_api.login.side_effect = None + + # First attempt fails with error + mock_redgtech_api.login.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == expected_error + # Verify login was called at least once for the first attempt + assert mock_redgtech_api.login.call_count >= 1 + first_call_count = mock_redgtech_api.login.call_count + + # Second attempt succeeds - flow recovers + mock_redgtech_api.login.side_effect = None + mock_redgtech_api.login.return_value = FAKE_TOKEN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_EMAIL + assert result["data"] == user_input + # Verify login was called again for the second attempt (recovery) + assert mock_redgtech_api.login.call_count > first_call_count diff --git a/tests/components/redgtech/test_switch.py b/tests/components/redgtech/test_switch.py new file mode 100644 index 00000000000..b337e88ec1b --- /dev/null +++ b/tests/components/redgtech/test_switch.py @@ -0,0 +1,255 @@ +"""Tests for the Redgtech switch platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory +import pytest +from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def freezer(): + """Provide a freezer fixture that works with freeze_time decorator.""" + with freeze_time() as frozen_time: + yield frozen_time + + +@pytest.fixture +async def setup_redgtech_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_redgtech_api: MagicMock, +) -> MagicMock: + """Set up the Redgtech integration with mocked API.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_redgtech_api + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + setup_redgtech_integration, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entity setup.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_switch_turn_on( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, +) -> None: + """Test turning a switch on.""" + mock_api = setup_redgtech_integration + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.living_room_switch"}, + blocking=True, + ) + + mock_api.set_switch_state.assert_called_once_with( + "switch_001", True, "mock_access_token" + ) + + +async def test_switch_turn_off( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, +) -> None: + """Test turning a switch off.""" + mock_api = setup_redgtech_integration + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.kitchen_switch"}, + blocking=True, + ) + + mock_api.set_switch_state.assert_called_once_with( + "switch_002", False, "mock_access_token" + ) + + +async def test_switch_toggle( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, +) -> None: + """Test toggling a switch.""" + mock_api = setup_redgtech_integration + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: "switch.living_room_switch"}, + blocking=True, + ) + + mock_api.set_switch_state.assert_called_once_with( + "switch_001", True, "mock_access_token" + ) + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + RedgtechConnectionError("Connection failed"), + "Connection error with Redgtech API", + ), + ( + RedgtechAuthError("Auth failed"), + "Authentication failed when controlling Redgtech switch", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling when controlling switches.""" + mock_api = setup_redgtech_integration + mock_api.set_switch_state.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.living_room_switch"}, + blocking=True, + ) + + +async def test_switch_auth_error_with_retry( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, +) -> None: + """Test handling auth errors with token renewal.""" + mock_api = setup_redgtech_integration + # Mock fails with auth error + mock_api.set_switch_state.side_effect = RedgtechAuthError("Auth failed") + + # Expect HomeAssistantError to be raised + with pytest.raises( + HomeAssistantError, + match="Authentication failed when controlling Redgtech switch", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.living_room_switch"}, + blocking=True, + ) + + +@freeze_time("2023-01-01 12:00:00") +async def test_coordinator_data_update_success( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test successful data update through coordinator.""" + mock_api = setup_redgtech_integration + + # Update mock data + mock_api.get_data.return_value = { + "boards": [ + { + "endpointId": "switch_001", + "friendlyName": "Living Room Switch", + "value": True, # Changed to True + "displayCategories": ["SWITCH"], + } + ] + } + + # Use freezer to advance time and trigger update + freezer.tick(delta=timedelta(minutes=2)) + await hass.async_block_till_done() + + # Verify the entity state was updated successfully + living_room_state = hass.states.get("switch.living_room_switch") + assert living_room_state is not None + assert living_room_state.state == "on" + + +@freeze_time("2023-01-01 12:00:00") +async def test_coordinator_connection_error_during_update( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator handling connection errors during data updates.""" + mock_api = setup_redgtech_integration + mock_api.get_data.side_effect = RedgtechConnectionError("Connection failed") + + # Use freezer to advance time and trigger update + freezer.tick(delta=timedelta(minutes=2)) + await hass.async_block_till_done() + + # Verify entities become unavailable due to coordinator error + living_room_state = hass.states.get("switch.living_room_switch") + kitchen_state = hass.states.get("switch.kitchen_switch") + + assert living_room_state.state == STATE_UNAVAILABLE + assert kitchen_state.state == STATE_UNAVAILABLE + + +@freeze_time("2023-01-01 12:00:00") +async def test_coordinator_auth_error_with_token_renewal( + hass: HomeAssistant, + setup_redgtech_integration: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator handling auth errors with token renewal.""" + mock_api = setup_redgtech_integration + + # First call fails with auth error, second succeeds after token renewal + mock_api.get_data.side_effect = [ + RedgtechAuthError("Auth failed"), + { + "boards": [ + { + "endpointId": "switch_001", + "friendlyName": "Living Room Switch", + "value": True, + "displayCategories": ["SWITCH"], + } + ] + }, + ] + + # Use freezer to advance time and trigger update + freezer.tick(delta=timedelta(minutes=2)) + await hass.async_block_till_done() + + # Verify token renewal was attempted + assert mock_api.login.call_count >= 2 + # Verify entity is available after successful token renewal + living_room_state = hass.states.get("switch.living_room_switch") + assert living_room_state is not None + assert living_room_state.state != STATE_UNAVAILABLE