add redgtech integration

This commit is contained in:
Jonh Sady 2025-01-30 16:19:06 -03:00
parent e0ea5bfc51
commit 8fdd8c0103
17 changed files with 750 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
DOMAIN = "redgtech"
API_URL = "https://redgtech-dev.com"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -506,6 +506,7 @@ FLOWS = {
"rapt_ble",
"rdw",
"recollect_waste",
"redgtech"
"refoss",
"renault",
"renson",

View File

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

10
mypy.ini generated
View File

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

View File

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

View File

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

View File

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

View File

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