diff --git a/CODEOWNERS b/CODEOWNERS index 103c66d3994b49..6c6abb4880f713 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -774,6 +774,7 @@ build.json @home-assistant/supervisor /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco +/homeassistant/components/ledsc/ @PatrikKr010 /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lidarr/ @tkdrob diff --git a/homeassistant/components/ledsc/__init__.py b/homeassistant/components/ledsc/__init__.py new file mode 100644 index 00000000000000..43a8e45e14cf13 --- /dev/null +++ b/homeassistant/components/ledsc/__init__.py @@ -0,0 +1,20 @@ +"""Platform for LedSC integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[str] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up LedSC from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py new file mode 100644 index 00000000000000..3112ec499a69a1 --- /dev/null +++ b/homeassistant/components/ledsc/config_flow.py @@ -0,0 +1,69 @@ +"""Config flow for LedSC integration.""" + +from __future__ import annotations + +import logging +from typing import Any +import voluptuous as vol +from websc_client import WebSClientAsync as WebSClient +from websc_client.exceptions import WebSClientError + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv + +from .consts import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + host = data[CONF_HOST] + port = data[CONF_PORT] + + client = WebSClient(host, port) + await client.connect() + await client.disconnect() + + return {"title": f"LedSC server {host}:{port}"} + + +class LedSCConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LedSC.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input: + self._async_abort_entries_match(user_input) + try: + info = await validate_input(self.hass, user_input) + except WebSClientError: + errors["base"] = "cannot_connect" + except Exception as e: + _LOGGER.exception("Unexpected exception: {}", e) + errors["base"] = str(e) + else: + return self.async_create_entry(title=info["title"], data=user_input) # noqa + + return self.async_show_form( # noqa + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=DATA_SCHEMA, + suggested_values=user_input + ), + errors=errors, + ) \ No newline at end of file diff --git a/homeassistant/components/ledsc/consts.py b/homeassistant/components/ledsc/consts.py new file mode 100644 index 00000000000000..d1a3fe3c502d30 --- /dev/null +++ b/homeassistant/components/ledsc/consts.py @@ -0,0 +1,4 @@ +"""Constants for the LedSC integration.""" + +DOMAIN = "ledsc" +DEFAULT_PORT = 8443 diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py new file mode 100644 index 00000000000000..08c02ba61a99ac --- /dev/null +++ b/homeassistant/components/ledsc/light.py @@ -0,0 +1,151 @@ +"""LedSC light.""" + +import logging +from typing import Any + +from websc_client import WebSCAsync, WebSClientAsync as WebSClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, add_entities: AddEntitiesCallback +): + """Connect to WebSC. + + load the configured devices from WebSC Server and add them to hass. + """ + client = WebSClient(host=config.data[CONF_HOST], port=config.data[CONF_PORT]) + await client.connect() + hass.async_create_background_task(client.observer(), name="ledsc-observer") + + devices: list[LedSCLightEntity] = [] + for websc in client.devices.values(): + ledsc = LedSCLightEntity( + client_id=config.entry_id, + websc=websc, + hass=hass, + ) + websc.set_callback(__generate_callback(ledsc)) + devices.append(ledsc) + add_entities(devices, True) + + +class LedSCLightEntity(LightEntity): + """Representation of an LedSC Light.""" + + def __init__( + self, + client_id: str, + websc: WebSCAsync, + hass: HomeAssistant, + ) -> None: + """Initialize an LedSC Light.""" + self._hass: HomeAssistant = hass + self._websc: WebSCAsync = websc + self._attr_unique_id = f"{client_id}-{websc.name}" + _LOGGER.debug(f"LedSC '%s' initialized!", self.name) + + @property + def supported_color_modes(self) -> set[ColorMode] | set[str] | None: + """List of supported color modes.""" + return {ColorMode.RGBW} + + @property + def color_mode(self) -> ColorMode | str | None: + """Return the current color mode (static).""" + return ColorMode.RGBW + + @property + def available(self) -> bool: + """Check if light is available.""" + return not self._websc.is_lost + + @property + def name(self) -> str: + """Return the display name of this light.""" + return self._websc.name + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + return max(self._websc.rgbw) + + @brightness.setter + def brightness(self, value: int) -> None: + """Set brightness of the light.""" + actual = self.brightness + if actual is None or actual == 0: + self.hass.async_create_task( + self._websc.set_rgbw(red=value, green=value, blue=value, white=value) + ) + else: + diff = value - actual + ratio = diff / actual + self.hass.async_create_task( + self._websc.set_rgbw( + red=round(self._websc.red * (1 + ratio)), + green=round(self._websc.green * (1 + ratio)), + blue=round(self._websc.blue * (1 + ratio)), + white=round(self._websc.white * (1 + ratio)), + ) + ) + + @property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Get color.""" + return self._websc.rgbw + + @rgbw_color.setter + def rgbw_color(self, value: tuple[int, int, int, int]) -> None: + """Set color to WebSC.""" + self.hass.async_create_task( + self._websc.set_rgbw( + red=value[0], + green=value[1], + blue=value[2], + white=value[3], + ) + ) + + @property + def is_on(self) -> bool | None: + """Return true if light is on.""" + return bool( + self._websc.red + or self._websc.green + or self._websc.blue + or self._websc.white + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + if "brightness" in kwargs: + self.brightness = kwargs["brightness"] + elif "rgbw_color" in kwargs: + self.rgbw_color = kwargs["rgbw_color"] + elif not self.is_on: + await self.switch() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + if self.is_on: + await self.switch() + + async def switch(self) -> None: + """Send switch event to WebSC.""" + await self._websc.do_px_trigger() + + +def __generate_callback(ledsc: LedSCLightEntity): + """Generates a callback to respond to a LedSC state change.""" + async def on_device_change(data: dict[str, int]): + await ledsc.async_update_ha_state(force_refresh=True) + + return on_device_change diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json new file mode 100644 index 00000000000000..519c6ed9061ee3 --- /dev/null +++ b/homeassistant/components/ledsc/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "ledsc", + "name": "LedSC", + "codeowners": ["@PatrikKr010"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ledsc/", + "integration_type": "hub", + "iot_class": "local_polling", + "requirements": ["websc-client==1.0.4"] +} diff --git a/homeassistant/components/ledsc/strings.json b/homeassistant/components/ledsc/strings.json new file mode 100644 index 00000000000000..93d1438c95efff --- /dev/null +++ b/homeassistant/components/ledsc/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 745bad093d28a1..f28b000969702f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -297,6 +297,7 @@ "ld2410_ble", "leaone", "led_ble", + "ledsc", "lg_netcast", "lg_soundbar", "lidarr", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 425702562d031e..f1ff4c106de38f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3205,6 +3205,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "ledsc": { + "name": "LedSC", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "legrand": { "name": "Legrand", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 3f4085340d6ff8..cfb189e0f9f3ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2887,6 +2887,9 @@ webmin-xmlrpc==0.0.2 # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 +# homeassistant.components.ledsc +websc-client==1.0.4 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/tests/components/ledsc/__init__.py b/tests/components/ledsc/__init__.py new file mode 100644 index 00000000000000..c68d5ad89c7c29 --- /dev/null +++ b/tests/components/ledsc/__init__.py @@ -0,0 +1 @@ +"""Tests for LedSC integration""" \ No newline at end of file diff --git a/tests/components/ledsc/test_config_flow.py b/tests/components/ledsc/test_config_flow.py new file mode 100644 index 00000000000000..b9308ed28ed3f4 --- /dev/null +++ b/tests/components/ledsc/test_config_flow.py @@ -0,0 +1,89 @@ +"""Test the LedSc config flow.""" + +from unittest.mock import patch, AsyncMock + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.components.ledsc.consts import DOMAIN +from websc_client.exceptions import WebSClientConnectionError + +USER_INPUT = {"host": "127.0.0.1", "port": 8080} +IMPORT_CONFIG = {"host": "127.0.0.1", "port": 8080} +RESULT = { + "type": "create_entry", + "title": f"LedSC server {USER_INPUT['host']}:{USER_INPUT['port']}", + "data": USER_INPUT, +} +ENTRY_CONFIG = {"host": "127.0.0.1", "port": 8080} + + +async def test_form(hass) -> None: + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.ledsc.config_flow.validate_input", + return_value=RESULT, + ), patch( + "homeassistant.components.ledsc.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == RESULT["title"] + assert result["data"] == USER_INPUT + + +async def test_form_cannot_connect(hass) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unique_id_check(hass): + """Try to create 2 intance integration with the same configuration""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + + magic_websc = AsyncMock() + + with patch( + "homeassistant.components.ledsc.config_flow.WebSClient", + autospe=True, return_value=magic_websc, + ) as mock_websc: + mock_websc.connect.side_effect = WebSClientConnectionError + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"