mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 02:03:27 +01:00
Add redgtech integration (#136947)
Co-authored-by: luan-nvg <luannnvg@gmail.com>
This commit is contained in:
committed by
GitHub
parent
c853fb2068
commit
184bea49e2
@@ -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
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
35
homeassistant/components/redgtech/__init__.py
Normal file
35
homeassistant/components/redgtech/__init__.py
Normal 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)
|
||||
67
homeassistant/components/redgtech/config_flow.py
Normal file
67
homeassistant/components/redgtech/config_flow.py
Normal 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},
|
||||
)
|
||||
4
homeassistant/components/redgtech/const.py
Normal file
4
homeassistant/components/redgtech/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for the Redgtech integration."""
|
||||
|
||||
DOMAIN = "redgtech"
|
||||
INTEGRATION_NAME = "Redgtech"
|
||||
130
homeassistant/components/redgtech/coordinator.py
Normal file
130
homeassistant/components/redgtech/coordinator.py
Normal 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
|
||||
11
homeassistant/components/redgtech/manifest.json
Normal file
11
homeassistant/components/redgtech/manifest.json
Normal 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"]
|
||||
}
|
||||
72
homeassistant/components/redgtech/quality_scale.yaml
Normal file
72
homeassistant/components/redgtech/quality_scale.yaml
Normal 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
|
||||
40
homeassistant/components/redgtech/strings.json
Normal file
40
homeassistant/components/redgtech/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
95
homeassistant/components/redgtech/switch.py
Normal file
95
homeassistant/components/redgtech/switch.py
Normal 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)
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -570,6 +570,7 @@ FLOWS = {
|
||||
"rapt_ble",
|
||||
"rdw",
|
||||
"recollect_waste",
|
||||
"redgtech",
|
||||
"refoss",
|
||||
"rehlko",
|
||||
"remote_calendar",
|
||||
|
||||
@@ -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
10
mypy.ini
generated
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
1
tests/components/redgtech/__init__.py
Normal file
1
tests/components/redgtech/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Redgtech component."""
|
||||
70
tests/components/redgtech/conftest.py
Normal file
70
tests/components/redgtech/conftest.py
Normal 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",
|
||||
)
|
||||
97
tests/components/redgtech/snapshots/test_switch.ambr
Normal file
97
tests/components/redgtech/snapshots/test_switch.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
138
tests/components/redgtech/test_config_flow.py
Normal file
138
tests/components/redgtech/test_config_flow.py
Normal 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
|
||||
255
tests/components/redgtech/test_switch.py
Normal file
255
tests/components/redgtech/test_switch.py
Normal 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
|
||||
Reference in New Issue
Block a user