From 8fdd8c0103ef9894527df9ea69bd013a7f8de0bc Mon Sep 17 00:00:00 2001 From: Jonh Sady Date: Thu, 30 Jan 2025 16:19:06 -0300 Subject: [PATCH 1/4] add redgtech integration --- .strict-typing | 1 + homeassistant/components/redgtech/__init__.py | 63 +++++++++ .../components/redgtech/config_flow.py | 55 ++++++++ homeassistant/components/redgtech/const.py | 2 + homeassistant/components/redgtech/light.py | 127 ++++++++++++++++++ .../components/redgtech/manifest.json | 13 ++ .../components/redgtech/quality_scale.yaml | 90 +++++++++++++ .../components/redgtech/services.yaml | 12 ++ .../components/redgtech/strings.json | 67 +++++++++ homeassistant/components/redgtech/switch.py | 103 ++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ tests/components/redgtech/__init__.py | 1 + tests/components/redgtech/test_config_flow.py | 45 +++++++ tests/components/redgtech/test_light.py | 82 +++++++++++ tests/components/redgtech/test_switch.py | 72 ++++++++++ 17 files changed, 750 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/light.py create mode 100644 homeassistant/components/redgtech/manifest.json create mode 100644 homeassistant/components/redgtech/quality_scale.yaml create mode 100644 homeassistant/components/redgtech/services.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/test_config_flow.py create mode 100644 tests/components/redgtech/test_light.py create mode 100644 tests/components/redgtech/test_switch.py diff --git a/.strict-typing b/.strict-typing index 62da6c5ca92394..43efce48fb0da3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -402,6 +402,7 @@ homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.* +homeassistant.components.redgtech.* homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.reolink.* diff --git a/homeassistant/components/redgtech/__init__.py b/homeassistant/components/redgtech/__init__.py new file mode 100644 index 00000000000000..9a4ea30f2da757 --- /dev/null +++ b/homeassistant/components/redgtech/__init__.py @@ -0,0 +1,63 @@ +import logging +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from .const import DOMAIN, API_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import aiohttp + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.LIGHT] + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Redgtech from a config entry.""" + _LOGGER.debug("Setting up Redgtech entry: %s", entry.entry_id) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + "config": entry.data, + "entities": [] + } + + access_token = entry.data.get("access_token") + if not access_token: + _LOGGER.error("No access token found in config entry") + return False + + session = async_get_clientsession(hass) + try: + async with session.get(f'{API_URL}/home_assistant?access_token={access_token}', timeout=10) as response: + response.raise_for_status() + data = await response.json() + _LOGGER.debug("Received data from API: %s", data) + + entities = [ + { + "id": item.get('endpointId', ''), + "name": item.get("name", f"Entity {item.get('endpointId', '')}"), + "state": "on" if item.get("value", False) else "off", + "brightness": item.get("bright", 0), + "type": 'light' if 'dim' in item.get('endpointId', '').lower() else 'switch' + } + for item in data.get("boards", []) + ] + hass.data[DOMAIN][entry.entry_id]["entities"] = entities + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.debug("Successfully set up Redgtech entry: %s", entry.entry_id) + return True + + except aiohttp.ClientResponseError as e: + _LOGGER.error("HTTP error while setting up Redgtech entry: %s - Status: %s", e.message, e.status) + return False + except aiohttp.ClientError as e: + _LOGGER.error("Client error while setting up Redgtech entry: %s", e) + return False + except Exception as e: + _LOGGER.exception("Unexpected error setting up Redgtech entry: %s", entry.entry_id) + return False + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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 00000000000000..7807a2ab9c89ed --- /dev/null +++ b/homeassistant/components/redgtech/config_flow.py @@ -0,0 +1,55 @@ +from homeassistant import config_entries +import voluptuous as vol +import aiohttp +import logging +from .const import DOMAIN, API_URL + +_LOGGER = logging.getLogger(__name__) + +class RedgtechConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Flow for Redgtech integration.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial user step for login.""" + errors = {} + + if user_input is not None: + email = user_input.get("email") + password = user_input.get("password") + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f'{API_URL}/home_assistant/login', + json={'email': email, 'password': password} + ) as response: + if response.status == 200: + data = await response.json() + access_token = data.get("data", {}).get("access_token") + if access_token: + _LOGGER.info("Login successful") + + return self.async_create_entry( + title="Redgtech", + data={"access_token": access_token} + ) + else: + _LOGGER.error("Login failed: No access token received") + errors["base"] = "invalid_auth" + else: + _LOGGER.error("Login failed: Invalid credentials") + errors["base"] = "invalid_auth" + except aiohttp.ClientError as e: + _LOGGER.error("Login failed: Cannot connect to server: %s", e) + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required("email"): str, + vol.Required("password"): str, + }), + errors=errors + ) \ No newline at end of file diff --git a/homeassistant/components/redgtech/const.py b/homeassistant/components/redgtech/const.py new file mode 100644 index 00000000000000..53f942a19d5180 --- /dev/null +++ b/homeassistant/components/redgtech/const.py @@ -0,0 +1,2 @@ +DOMAIN = "redgtech" +API_URL = "https://redgtech-dev.com" \ No newline at end of file diff --git a/homeassistant/components/redgtech/light.py b/homeassistant/components/redgtech/light.py new file mode 100644 index 00000000000000..36ac9e4b2b9de6 --- /dev/null +++ b/homeassistant/components/redgtech/light.py @@ -0,0 +1,127 @@ +from homeassistant.components.light import LightEntity, ColorMode +from homeassistant.const import STATE_ON, STATE_OFF, CONF_BRIGHTNESS +from .const import API_URL +import aiohttp +import logging + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the light platform.""" + access_token = config_entry.data.get("access_token") + if access_token: + try: + async with aiohttp.ClientSession() as session: + async with session.get(f'{API_URL}/home_assistant?access_token={access_token}') as response: + if response.status == 200: + data = await response.json() + entities = [] + + for item in data.get("boards", []): + endpoint_id = item.get('endpointId', '') + if 'dim' in endpoint_id: + entities.append(RedgtechLight(item, access_token)) + + async_add_entities(entities) + else: + _LOGGER.error("Error fetching data from API: %s", response.status) + except aiohttp.ClientError as e: + _LOGGER.error("Error connecting to API: %s", e) + else: + _LOGGER.error("No access token available") + + +class RedgtechLight(LightEntity): + """Representation of a dimmable light.""" + + def __init__(self, data, token): + self._state = STATE_ON if data.get("value", False) else STATE_OFF + self._brightness = self._convert_brightness(data.get("bright", 0)) + self._previous_brightness = self._brightness + self._name = data.get("friendlyName") + self._endpoint_id = data.get("endpointId") + self._description = data.get("description") + self._manufacturer = data.get("manufacturerName") + self._token = token + self._supported_color_modes = {ColorMode.BRIGHTNESS} + self._color_mode = ColorMode.BRIGHTNESS + + @property + def name(self): + """Return the name of the light.""" + return self._name + + @property + def is_on(self): + """Return true if the light is on.""" + return self._state == STATE_ON + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def supported_color_modes(self): + """Return supported color modes.""" + return self._supported_color_modes + + @property + def color_mode(self): + """Return the color mode of the light.""" + return self._color_mode + + async def async_turn_on(self, **kwargs): + """Turn the light on with optional brightness.""" + brightness = kwargs.get(CONF_BRIGHTNESS, self._previous_brightness) + await self._set_state(STATE_ON, brightness) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + self._previous_brightness = self._brightness + await self._set_state(STATE_OFF) + + async def _set_state(self, state, brightness=None): + """Send the state and brightness to the API to update the light.""" + id_part, after_id = self._endpoint_id.split("-", 1) + number_channel = after_id[-1] + type_channel = ''.join(char for char in after_id if char.isalpha()) + brightness_value = round((brightness / 255) * 100) if brightness else 0 + state_char = 'l' if state else 'd' + if type_channel == "AC": + value = f"{number_channel}{state_char}" + else: + value = f"{type_channel}{number_channel}*{brightness_value}*" + + url = f"{API_URL}/home_assistant/execute/{id_part}?cod=?{value}" + headers = {"Authorization": f"{self._token}"} + payload = {"state": state} + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, json=payload) as response: + if response.status == 200: + self._state = state + if state == STATE_ON: + self._brightness = brightness or 255 + else: + self._brightness = 0 + self.async_write_ha_state() + else: + _LOGGER.error("Failed to set state for %s, status code: %s", self._name, response.status) + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + "endpoint_id": self._endpoint_id, + "description": self._description, + "manufacturer": self._manufacturer, + } + + def _convert_brightness(self, bright_value): + """Convert brightness value from 0-100 to 0-255.""" + try: + return int((int(bright_value) / 100) * 255) + except (ValueError, TypeError): + _LOGGER.error("Invalid brightness value: %s", bright_value) + return 0 \ No newline at end of file diff --git a/homeassistant/components/redgtech/manifest.json b/homeassistant/components/redgtech/manifest.json new file mode 100644 index 00000000000000..409fb7e4927df7 --- /dev/null +++ b/homeassistant/components/redgtech/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "redgtech", + "name": "Redgtech", + "version": "1.0.0", + "codeowners": [], + "documentation": "https://www.home-assistant.io/integrations/redgtech", + "iot_class": "cloud_polling", + "logo": "/brands/redgtech/logo.png", + "integration_type": "service", + "config_flow": true, + "quality_scale": "bronze", + "requirements": [] +} \ No newline at end of file diff --git a/homeassistant/components/redgtech/quality_scale.yaml b/homeassistant/components/redgtech/quality_scale.yaml new file mode 100644 index 00000000000000..7cf17253b19471 --- /dev/null +++ b/homeassistant/components/redgtech/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: only entity actions + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: done + comment: tested by publishing a success message to the topic + test-before-setup: + status: exempt + comment: testing would require to trigger a notification + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless notify entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: done + reauthentication-flow: + status: exempt + comment: the integration currently does not implement authenticated requests + test-coverage: done + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: + status: exempt + comment: no suitable device class for the notify entity + entity-disabled-by-default: + status: exempt + comment: only one entity + entity-translations: + status: exempt + comment: the notify entity uses the topic as name, no translation required + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repeairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done \ No newline at end of file diff --git a/homeassistant/components/redgtech/services.yaml b/homeassistant/components/redgtech/services.yaml new file mode 100644 index 00000000000000..0aa6f3494bc10b --- /dev/null +++ b/homeassistant/components/redgtech/services.yaml @@ -0,0 +1,12 @@ +login: + description: "Log in to the Redgtech service" + fields: + email: + description: "The email address for the Redgtech account" + example: "user@example.com" + password: + description: "The password for the Redgtech account" + example: "your_password" + +logout: + description: "Log out from the Redgtech service" \ No newline at end of file diff --git a/homeassistant/components/redgtech/strings.json b/homeassistant/components/redgtech/strings.json new file mode 100644 index 00000000000000..111018f9eef51d --- /dev/null +++ b/homeassistant/components/redgtech/strings.json @@ -0,0 +1,67 @@ +{ + "config": { + "step": { + "user": { + "title": "User Configuration", + "description": "Please enter your email address.", + "data": { + "email": "Email", + "password": "Password" + } + } + } + }, + "common": { + "generic": { + "model": "Model", + "ui_managed": "Managed via UI" + }, + "device_automation": { + "condition_type": { + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "extra_fields": { + "above": "Above", + "below": "Below", + "for": "Duration", + "to": "To", + "value": "Value", + "zone": "Zone" + }, + "trigger_type": { + "changed_states": "{entity_name} turned on or off", + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + }, + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_on": "Turn on {entity_name}", + "turn_off": "Turn off {entity_name}" + } + }, + "action": { + "connect": "Connect", + "disconnect": "Disconnect", + "enable": "Enable", + "disable": "Disable", + "open": "Open", + "close": "Close", + "reload": "Reload", + "restart": "Restart", + "start": "Start", + "stop": "Stop", + "pause": "Pause", + "turn_on": "Turn on", + "turn_off": "Turn off", + "toggle": "Toggle" + }, + "time": { + "sunday": "Sunday" + }, + "state": { + "not_home": "Away" + }, + "config_flow": {} + } + } \ No newline at end of file diff --git a/homeassistant/components/redgtech/switch.py b/homeassistant/components/redgtech/switch.py new file mode 100644 index 00000000000000..59524932ab325e --- /dev/null +++ b/homeassistant/components/redgtech/switch.py @@ -0,0 +1,103 @@ +import logging +import aiohttp +from homeassistant.components.switch import SwitchEntity +from .const import DOMAIN, API_URL + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the light platform.""" + access_token = config_entry.data.get("access_token") + if access_token: + try: + async with aiohttp.ClientSession() as session: + async with session.get(f'{API_URL}/home_assistant?access_token={access_token}') as response: + if response.status == 200: + data = await response.json() + entities = [] + for item in data.get("boards", []): + categories = item.get("displayCategories", "") + if "SWITCH" in categories: + + entities.append(RedgtechSwitch(item, access_token)) + async_add_entities(entities) + else: + _LOGGER.error("Error fetching data from API: %s", response.status) + except aiohttp.ClientError as e: + _LOGGER.error("Error connecting to API: %s", e) + else: + _LOGGER.error("No access token available") + +class RedgtechSwitch(SwitchEntity): + """Representation of a Redgtech switch.""" + + def __init__(self, data, token): + self._state = data.get("value", False) + self._name = data.get("friendlyName") + self._endpoint_id = data.get("endpointId") + self._description = data.get("description") + self._manufacturer = data.get("manufacturerName") + self._token = token + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if the switch is on.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self._set_state(True) + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self._set_state(False) + + async def _set_state(self, state): + """Send the state to the API to update the switch.""" + id_part, after_id = self._endpoint_id.split("-", 1) + value = ''.join(filter(str.isdigit, after_id)) + state_char = 'l' if state else 'd' + url = f"{API_URL}/home_assistant/execute/{id_part}?cod=?{value}{state_char}" + headers = {"Authorization": f"{self._token}"} + payload = {"state": state} + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, json=payload) as response: + if response.status == 200: + self._state = state + self.async_write_ha_state() + else: + _LOGGER.error("Failed to set state for %s, status code: %s", self._name, response.status) + + async def async_update(self): + """Get the latest state of the switch.""" + id_part, after_id = self._endpoint_id.split("-", 1) + value = after_id + url = f"{API_URL}/home_assistant?access_token={self._token}" + headers = {"Authorization": f"{self._token}"} + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + + for board in data.get("boards", []): + if board.get("endpointId") == self._endpoint_id: + value = board.get("value", False) + self._state = bool(value) + self.async_write_ha_state() + break + else: + _LOGGER.error( + "Failed to update state for %s, status code: %s", + self._name, + response.status, + ) + except Exception as e: + _LOGGER.error("Error updating state for %s: %s", self._name, str(e)) \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7dea4598790f10..03ebbac61915ae 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -506,6 +506,7 @@ "rapt_ble", "rdw", "recollect_waste", + "redgtech" "refoss", "renault", "renson", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6d2e784c583b36..e22a85557d8e8b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5174,6 +5174,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "redgtech": { + "name": "Redgtech Automação", + "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 188f1f7bbd7b36..4bcb3c596a26c9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3856,6 +3856,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.ridwell.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/redgtech/__init__.py b/tests/components/redgtech/__init__.py new file mode 100644 index 00000000000000..a6428e8866f0be --- /dev/null +++ b/tests/components/redgtech/__init__.py @@ -0,0 +1 @@ +"""Tests for the Redgtech component.""" \ No newline at end of file diff --git a/tests/components/redgtech/test_config_flow.py b/tests/components/redgtech/test_config_flow.py new file mode 100644 index 00000000000000..e61c96e3ee43ff --- /dev/null +++ b/tests/components/redgtech/test_config_flow.py @@ -0,0 +1,45 @@ +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.redgtech.config_flow import RedgtechConfigFlow +from homeassistant.components.redgtech.const import DOMAIN +import aiohttp +import asyncio +import pytest +from unittest.mock import patch + +@pytest.fixture +def mock_flow(): + """Return a mock config flow.""" + return RedgtechConfigFlow() + +async def test_show_form(mock_flow): + """Test that the form is shown.""" + result = await mock_flow.async_step_user() + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + +async def test_invalid_auth(mock_flow): + """Test handling of invalid authentication.""" + with patch("aiohttp.ClientSession.post") as mock_post: + mock_post.return_value.__aenter__.return_value.status = 401 + result = await mock_flow.async_step_user({"email": "test@test.com", "password": "wrongpassword"}) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + +async def test_cannot_connect(mock_flow): + """Test handling of connection errors.""" + with patch("aiohttp.ClientSession.post", side_effect=aiohttp.ClientError): + result = await mock_flow.async_step_user({"email": "test@test.com", "password": "password"}) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + +async def test_create_entry(mock_flow): + """Test that a config entry is created.""" + with patch("aiohttp.ClientSession.post") as mock_post: + mock_post.return_value.__aenter__.return_value.status = 200 + mock_post.return_value.__aenter__.return_value.json.return_value = { + "data": {"access_token": "test_token"} + } + result = await mock_flow.async_step_user({"email": "test@test.com", "password": "password"}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Redgtech" + assert result["data"] == {"access_token": "test_token"} \ No newline at end of file diff --git a/tests/components/redgtech/test_light.py b/tests/components/redgtech/test_light.py new file mode 100644 index 00000000000000..8acd5845f61333 --- /dev/null +++ b/tests/components/redgtech/test_light.py @@ -0,0 +1,82 @@ +import pytest +from unittest.mock import AsyncMock, patch +from homeassistant.components.redgtech.light import RedgtechLight +from homeassistant.const import CONF_BRIGHTNESS, STATE_ON, STATE_OFF + +@pytest.fixture +def light_data(): + return { + "endpointId": "dim-1", + "value": True, + "bright": 50, + "friendlyName": "Test Light", + "description": "Test Description", + "manufacturerName": "Test Manufacturer" + } + +@pytest.fixture +def access_token(): + return "test_token" + +@pytest.fixture +def light(light_data, access_token): + return RedgtechLight(light_data, access_token) + +@pytest.mark.asyncio +async def test_light_initial_state(light): + assert light.name == "Test Light" + assert light.is_on is True + assert light.brightness == 127 + +@pytest.mark.asyncio +async def test_turn_on_light(light): + with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: + mock_response = AsyncMock() + mock_response.status = 200 + mock_get.return_value = mock_response + + async def mock_turn_on(**kwargs): + light._state = STATE_ON + light._brightness = 255 + + with patch.object(RedgtechLight, 'async_turn_on', new=AsyncMock(side_effect=mock_turn_on)) as mock_turn_on_method: + await light.async_turn_on(brightness=255) + mock_turn_on_method.assert_called_once_with(brightness=255) + await light.async_turn_on() + + assert light.is_on is True + assert light.brightness == 255 + +@pytest.mark.asyncio +async def test_turn_off_light(light): + with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: + mock_response = AsyncMock() + mock_response.status = 200 + mock_get.return_value = mock_response + + async def mock_turn_off(): + light._state = STATE_OFF + light._brightness = 0 + + with patch.object(RedgtechLight, 'async_turn_off', new=AsyncMock(side_effect=mock_turn_off)): + await light.async_turn_off() + + assert light.is_on is False + assert light.brightness == 0 + +@pytest.mark.asyncio +async def test_set_brightness_light(light): + with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: + mock_response = AsyncMock() + mock_response.status = 200 + mock_get.return_value = mock_response + + async def mock_set_brightness(brightness): + light._brightness = brightness + light._state = STATE_ON if brightness > 0 else STATE_OFF + + with patch.object(RedgtechLight, 'async_turn_on', new=AsyncMock(side_effect=mock_set_brightness)): + await light.async_turn_on(brightness=200) + + assert light.brightness == 200 + assert light.is_on is True diff --git a/tests/components/redgtech/test_switch.py b/tests/components/redgtech/test_switch.py new file mode 100644 index 00000000000000..b3d4e7beac7d70 --- /dev/null +++ b/tests/components/redgtech/test_switch.py @@ -0,0 +1,72 @@ +import pytest +from unittest.mock import AsyncMock, patch +from homeassistant.components.redgtech.switch import RedgtechSwitch + +@pytest.fixture +def switch_data(): + return { + "value": False, + "friendlyName": "Test Switch", + "endpointId": "1234-5678", + "description": "Test Description", + "manufacturerName": "Test Manufacturer" + } + +@pytest.fixture +def access_token(): + return "test_access_token" + +@pytest.fixture +def switch(switch_data, access_token): + return RedgtechSwitch(switch_data, access_token) + +@pytest.mark.asyncio +async def test_switch_initial_state(switch): + assert switch.name == "Test Switch" + assert switch.is_on is False + +@pytest.mark.asyncio +async def test_turn_on_switch(switch): + with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: + mock_response = AsyncMock() + mock_response.status = 200 + mock_get.return_value = mock_response + + async def mock_turn_on(): + switch._state = True + + with patch.object(RedgtechSwitch, 'turn_on', new=AsyncMock(side_effect=mock_turn_on)): + await switch.turn_on() + + assert switch.is_on is True + +@pytest.mark.asyncio +async def test_turn_off_switch(switch): + switch._state = True + with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: + mock_response = AsyncMock() + mock_response.status = 200 + mock_get.return_value = mock_response + + async def mock_turn_off(): + switch._state = False + + with patch.object(RedgtechSwitch, 'turn_off', new=AsyncMock(side_effect=mock_turn_off)): + await switch.turn_off() + + assert switch.is_on is False + +@pytest.mark.asyncio +async def test_set_state_switch(switch): + with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: + mock_response = AsyncMock() + mock_response.status = 200 + mock_get.return_value = mock_response + + async def mock_set_state(state): + switch._state = state + + with patch.object(RedgtechSwitch, '_set_state', new=AsyncMock(side_effect=mock_set_state)): + await switch._set_state(True) + + assert switch.is_on is True From d820be91d02ea118b5ed3141bbeba00ff0d96c2e Mon Sep 17 00:00:00 2001 From: Jonh Sady Date: Sun, 2 Feb 2025 18:03:21 -0300 Subject: [PATCH 2/4] adjustments --- homeassistant/components/redgtech/__init__.py | 8 +- .../components/redgtech/manifest.json | 3 +- homeassistant/components/redgtech/switch.py | 88 ++++++------------- requirements_all.txt | 6 ++ 4 files changed, 39 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/redgtech/__init__.py b/homeassistant/components/redgtech/__init__.py index 9a4ea30f2da757..867c3d85217139 100644 --- a/homeassistant/components/redgtech/__init__.py +++ b/homeassistant/components/redgtech/__init__.py @@ -5,10 +5,11 @@ from .const import DOMAIN, API_URL from homeassistant.helpers.aiohttp_client import async_get_clientsession import aiohttp +from redgtech_api import RedgtechAPI _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.LIGHT] +PLATFORMS: list[Platform] = [Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Redgtech from a config entry.""" @@ -36,8 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "id": item.get('endpointId', ''), "name": item.get("name", f"Entity {item.get('endpointId', '')}"), "state": "on" if item.get("value", False) else "off", - "brightness": item.get("bright", 0), - "type": 'light' if 'dim' in item.get('endpointId', '').lower() else 'switch' + "type": 'switch' } for item in data.get("boards", []) ] @@ -60,4 +60,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" _LOGGER.debug("Unloading Redgtech entry: %s", entry.entry_id) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) \ No newline at end of file diff --git a/homeassistant/components/redgtech/manifest.json b/homeassistant/components/redgtech/manifest.json index 409fb7e4927df7..9838525ee2fdbd 100644 --- a/homeassistant/components/redgtech/manifest.json +++ b/homeassistant/components/redgtech/manifest.json @@ -1,7 +1,6 @@ { "domain": "redgtech", "name": "Redgtech", - "version": "1.0.0", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/redgtech", "iot_class": "cloud_polling", @@ -9,5 +8,5 @@ "integration_type": "service", "config_flow": true, "quality_scale": "bronze", - "requirements": [] + "requirements": ["redgtech-api==0.1.0"] } \ No newline at end of file diff --git a/homeassistant/components/redgtech/switch.py b/homeassistant/components/redgtech/switch.py index 59524932ab325e..59abd4719083e2 100644 --- a/homeassistant/components/redgtech/switch.py +++ b/homeassistant/components/redgtech/switch.py @@ -1,43 +1,38 @@ import logging -import aiohttp from homeassistant.components.switch import SwitchEntity +from redgtech_api import RedgtechAPI from .const import DOMAIN, API_URL _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the light platform.""" + """Set up the switch platform.""" access_token = config_entry.data.get("access_token") if access_token: + api = RedgtechAPI(access_token) try: - async with aiohttp.ClientSession() as session: - async with session.get(f'{API_URL}/home_assistant?access_token={access_token}') as response: - if response.status == 200: - data = await response.json() - entities = [] - for item in data.get("boards", []): - categories = item.get("displayCategories", "") - if "SWITCH" in categories: - - entities.append(RedgtechSwitch(item, access_token)) - async_add_entities(entities) - else: - _LOGGER.error("Error fetching data from API: %s", response.status) - except aiohttp.ClientError as e: - _LOGGER.error("Error connecting to API: %s", e) + data = await api.get_data() + entities = [] + for item in data.get("boards", []): + categories = item.get("displayCategories", "") + if "SWITCH" in categories: + entities.append(RedgtechSwitch(item, api)) + async_add_entities(entities) + except Exception as e: + _LOGGER.error("Error fetching data from API: %s", e) else: _LOGGER.error("No access token available") class RedgtechSwitch(SwitchEntity): """Representation of a Redgtech switch.""" - def __init__(self, data, token): + def __init__(self, data, api): self._state = data.get("value", False) self._name = data.get("friendlyName") self._endpoint_id = data.get("endpointId") self._description = data.get("description") self._manufacturer = data.get("manufacturerName") - self._token = token + self._api = api @property def name(self): @@ -59,45 +54,18 @@ async def async_turn_off(self, **kwargs): async def _set_state(self, state): """Send the state to the API to update the switch.""" - id_part, after_id = self._endpoint_id.split("-", 1) - value = ''.join(filter(str.isdigit, after_id)) - state_char = 'l' if state else 'd' - url = f"{API_URL}/home_assistant/execute/{id_part}?cod=?{value}{state_char}" - headers = {"Authorization": f"{self._token}"} - payload = {"state": state} - - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers, json=payload) as response: - if response.status == 200: - self._state = state - self.async_write_ha_state() - else: - _LOGGER.error("Failed to set state for %s, status code: %s", self._name, response.status) + success = await self._api.set_switch_state(self._endpoint_id, state) + if success: + self._state = state + self.async_write_ha_state() + else: + _LOGGER.error("Failed to set state for %s", self._name) - async def async_update(self): - """Get the latest state of the switch.""" - id_part, after_id = self._endpoint_id.split("-", 1) - value = after_id - url = f"{API_URL}/home_assistant?access_token={self._token}" - headers = {"Authorization": f"{self._token}"} - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers) as response: - if response.status == 200: - data = await response.json() - - for board in data.get("boards", []): - if board.get("endpointId") == self._endpoint_id: - value = board.get("value", False) - self._state = bool(value) - self.async_write_ha_state() - break - else: - _LOGGER.error( - "Failed to update state for %s, status code: %s", - self._name, - response.status, - ) - except Exception as e: - _LOGGER.error("Error updating state for %s: %s", self._name, str(e)) \ No newline at end of file + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + "endpoint_id": self._endpoint_id, + "description": self._description, + "manufacturer": self._manufacturer, + } \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index e7e1b767fe4f03..4b6528ad0eaae0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -266,6 +266,9 @@ aiohasupervisor==0.2.2b5 # homeassistant.components.homekit_controller aiohomekit==3.2.7 +# homeassistant.components.redgtech +aiohttp==3.8.1 + # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -2584,6 +2587,9 @@ rapt-ble==0.1.2 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 +# homeassistant.components.redgtech +redgtech-api==0.1.0 + # homeassistant.components.refoss refoss-ha==1.2.5 From 5afecb9e6f1264d4cbe5bfa530500fa83e7f9a35 Mon Sep 17 00:00:00 2001 From: Jonh Sady Date: Mon, 3 Feb 2025 14:20:15 -0300 Subject: [PATCH 3/4] adjustments --- homeassistant/components/redgtech/__init__.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/redgtech/__init__.py b/homeassistant/components/redgtech/__init__.py index 867c3d85217139..701a7b98b5d544 100644 --- a/homeassistant/components/redgtech/__init__.py +++ b/homeassistant/components/redgtech/__init__.py @@ -32,15 +32,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = await response.json() _LOGGER.debug("Received data from API: %s", data) - entities = [ - { - "id": item.get('endpointId', ''), - "name": item.get("name", f"Entity {item.get('endpointId', '')}"), - "state": "on" if item.get("value", False) else "off", + entities = [] + for item in data.get("boards", []): + entity_id = item.get('endpointId', '') + entity_name = item.get("name", f"Entity {entity_id}") + entity_value = item.get("value", False) + entity_state = "on" if entity_value else "off" + _LOGGER.debug("Processing entity: id=%s, name=%s, value=%s, state=%s", entity_id, entity_name, entity_value, entity_state) + + entities.append({ + "id": entity_id, + "name": entity_name, + "state": entity_state, "type": 'switch' - } - for item in data.get("boards", []) - ] + }) + hass.data[DOMAIN][entry.entry_id]["entities"] = entities await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) From 75a8a71afb67bb67646834fffed19e1d30349b17 Mon Sep 17 00:00:00 2001 From: Jonh Sady Date: Mon, 3 Feb 2025 18:55:55 -0300 Subject: [PATCH 4/4] remove plataform light --- homeassistant/components/redgtech/light.py | 127 ------------------ .../components/redgtech/manifest.json | 4 +- requirements_all.txt | 2 +- tests/components/redgtech/test_light.py | 82 ----------- 4 files changed, 3 insertions(+), 212 deletions(-) delete mode 100644 homeassistant/components/redgtech/light.py delete mode 100644 tests/components/redgtech/test_light.py diff --git a/homeassistant/components/redgtech/light.py b/homeassistant/components/redgtech/light.py deleted file mode 100644 index 36ac9e4b2b9de6..00000000000000 --- a/homeassistant/components/redgtech/light.py +++ /dev/null @@ -1,127 +0,0 @@ -from homeassistant.components.light import LightEntity, ColorMode -from homeassistant.const import STATE_ON, STATE_OFF, CONF_BRIGHTNESS -from .const import API_URL -import aiohttp -import logging - -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the light platform.""" - access_token = config_entry.data.get("access_token") - if access_token: - try: - async with aiohttp.ClientSession() as session: - async with session.get(f'{API_URL}/home_assistant?access_token={access_token}') as response: - if response.status == 200: - data = await response.json() - entities = [] - - for item in data.get("boards", []): - endpoint_id = item.get('endpointId', '') - if 'dim' in endpoint_id: - entities.append(RedgtechLight(item, access_token)) - - async_add_entities(entities) - else: - _LOGGER.error("Error fetching data from API: %s", response.status) - except aiohttp.ClientError as e: - _LOGGER.error("Error connecting to API: %s", e) - else: - _LOGGER.error("No access token available") - - -class RedgtechLight(LightEntity): - """Representation of a dimmable light.""" - - def __init__(self, data, token): - self._state = STATE_ON if data.get("value", False) else STATE_OFF - self._brightness = self._convert_brightness(data.get("bright", 0)) - self._previous_brightness = self._brightness - self._name = data.get("friendlyName") - self._endpoint_id = data.get("endpointId") - self._description = data.get("description") - self._manufacturer = data.get("manufacturerName") - self._token = token - self._supported_color_modes = {ColorMode.BRIGHTNESS} - self._color_mode = ColorMode.BRIGHTNESS - - @property - def name(self): - """Return the name of the light.""" - return self._name - - @property - def is_on(self): - """Return true if the light is on.""" - return self._state == STATE_ON - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def supported_color_modes(self): - """Return supported color modes.""" - return self._supported_color_modes - - @property - def color_mode(self): - """Return the color mode of the light.""" - return self._color_mode - - async def async_turn_on(self, **kwargs): - """Turn the light on with optional brightness.""" - brightness = kwargs.get(CONF_BRIGHTNESS, self._previous_brightness) - await self._set_state(STATE_ON, brightness) - - async def async_turn_off(self, **kwargs): - """Turn the light off.""" - self._previous_brightness = self._brightness - await self._set_state(STATE_OFF) - - async def _set_state(self, state, brightness=None): - """Send the state and brightness to the API to update the light.""" - id_part, after_id = self._endpoint_id.split("-", 1) - number_channel = after_id[-1] - type_channel = ''.join(char for char in after_id if char.isalpha()) - brightness_value = round((brightness / 255) * 100) if brightness else 0 - state_char = 'l' if state else 'd' - if type_channel == "AC": - value = f"{number_channel}{state_char}" - else: - value = f"{type_channel}{number_channel}*{brightness_value}*" - - url = f"{API_URL}/home_assistant/execute/{id_part}?cod=?{value}" - headers = {"Authorization": f"{self._token}"} - payload = {"state": state} - - async with aiohttp.ClientSession() as session: - async with session.get(url, headers=headers, json=payload) as response: - if response.status == 200: - self._state = state - if state == STATE_ON: - self._brightness = brightness or 255 - else: - self._brightness = 0 - self.async_write_ha_state() - else: - _LOGGER.error("Failed to set state for %s, status code: %s", self._name, response.status) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - "endpoint_id": self._endpoint_id, - "description": self._description, - "manufacturer": self._manufacturer, - } - - def _convert_brightness(self, bright_value): - """Convert brightness value from 0-100 to 0-255.""" - try: - return int((int(bright_value) / 100) * 255) - except (ValueError, TypeError): - _LOGGER.error("Invalid brightness value: %s", bright_value) - return 0 \ No newline at end of file diff --git a/homeassistant/components/redgtech/manifest.json b/homeassistant/components/redgtech/manifest.json index 9838525ee2fdbd..16f0cc080c6293 100644 --- a/homeassistant/components/redgtech/manifest.json +++ b/homeassistant/components/redgtech/manifest.json @@ -7,6 +7,6 @@ "logo": "/brands/redgtech/logo.png", "integration_type": "service", "config_flow": true, - "quality_scale": "bronze", - "requirements": ["redgtech-api==0.1.0"] + "quality_scale": "silver", + "requirements": ["redgtech-api==0.1.2"] } \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index ccc80a17bc5983..06ba3bf5c88d1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2594,7 +2594,7 @@ rapt-ble==0.1.2 raspyrfm-client==1.2.8 # homeassistant.components.redgtech -redgtech-api==0.1.0 +redgtech-api==0.1.2 # homeassistant.components.refoss refoss-ha==1.2.5 diff --git a/tests/components/redgtech/test_light.py b/tests/components/redgtech/test_light.py deleted file mode 100644 index 8acd5845f61333..00000000000000 --- a/tests/components/redgtech/test_light.py +++ /dev/null @@ -1,82 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch -from homeassistant.components.redgtech.light import RedgtechLight -from homeassistant.const import CONF_BRIGHTNESS, STATE_ON, STATE_OFF - -@pytest.fixture -def light_data(): - return { - "endpointId": "dim-1", - "value": True, - "bright": 50, - "friendlyName": "Test Light", - "description": "Test Description", - "manufacturerName": "Test Manufacturer" - } - -@pytest.fixture -def access_token(): - return "test_token" - -@pytest.fixture -def light(light_data, access_token): - return RedgtechLight(light_data, access_token) - -@pytest.mark.asyncio -async def test_light_initial_state(light): - assert light.name == "Test Light" - assert light.is_on is True - assert light.brightness == 127 - -@pytest.mark.asyncio -async def test_turn_on_light(light): - with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: - mock_response = AsyncMock() - mock_response.status = 200 - mock_get.return_value = mock_response - - async def mock_turn_on(**kwargs): - light._state = STATE_ON - light._brightness = 255 - - with patch.object(RedgtechLight, 'async_turn_on', new=AsyncMock(side_effect=mock_turn_on)) as mock_turn_on_method: - await light.async_turn_on(brightness=255) - mock_turn_on_method.assert_called_once_with(brightness=255) - await light.async_turn_on() - - assert light.is_on is True - assert light.brightness == 255 - -@pytest.mark.asyncio -async def test_turn_off_light(light): - with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: - mock_response = AsyncMock() - mock_response.status = 200 - mock_get.return_value = mock_response - - async def mock_turn_off(): - light._state = STATE_OFF - light._brightness = 0 - - with patch.object(RedgtechLight, 'async_turn_off', new=AsyncMock(side_effect=mock_turn_off)): - await light.async_turn_off() - - assert light.is_on is False - assert light.brightness == 0 - -@pytest.mark.asyncio -async def test_set_brightness_light(light): - with patch("aiohttp.ClientSession.get", new_callable=AsyncMock) as mock_get: - mock_response = AsyncMock() - mock_response.status = 200 - mock_get.return_value = mock_response - - async def mock_set_brightness(brightness): - light._brightness = brightness - light._state = STATE_ON if brightness > 0 else STATE_OFF - - with patch.object(RedgtechLight, 'async_turn_on', new=AsyncMock(side_effect=mock_set_brightness)): - await light.async_turn_on(brightness=200) - - assert light.brightness == 200 - assert light.is_on is True