Add redgtech integration (#136947)

Co-authored-by: luan-nvg <luannnvg@gmail.com>
This commit is contained in:
Jonathan Sady do Nascimento
2026-02-05 05:04:14 -03:00
committed by GitHub
parent c853fb2068
commit 184bea49e2
20 changed files with 1041 additions and 0 deletions

View File

@@ -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.*

2
CODEOWNERS generated
View File

@@ -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

View File

@@ -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)

View File

@@ -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},
)

View File

@@ -0,0 +1,4 @@
"""Constants for the Redgtech integration."""
DOMAIN = "redgtech"
INTEGRATION_NAME = "Redgtech"

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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)

View File

@@ -570,6 +570,7 @@ FLOWS = {
"rapt_ble",
"rdw",
"recollect_waste",
"redgtech",
"refoss",
"rehlko",
"remote_calendar",

View File

@@ -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",

10
mypy.ini generated
View File

@@ -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

3
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Tests for the Redgtech component."""

View File

@@ -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",
)

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.kitchen_switch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.kitchen_switch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities[switch.living_room_switch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.living_room_switch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.living_room_switch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -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

View File

@@ -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