From a33bbb655b4ec2e2cad02f458bf94bee0e4d4d44 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Mon, 6 May 2024 22:46:47 +0200 Subject: [PATCH 01/19] Add LedSC integration --- homeassistant/components/ledsc/__init__.py | 42 +++++++ homeassistant/components/ledsc/config_flow.py | 65 ++++++++++ homeassistant/components/ledsc/consts.py | 6 + homeassistant/components/ledsc/exceptions.py | 5 + homeassistant/components/ledsc/ledsc.py | 119 ++++++++++++++++++ homeassistant/components/ledsc/light.py | 95 ++++++++++++++ homeassistant/components/ledsc/manifest.json | 11 ++ 7 files changed, 343 insertions(+) create mode 100644 homeassistant/components/ledsc/__init__.py create mode 100644 homeassistant/components/ledsc/config_flow.py create mode 100644 homeassistant/components/ledsc/consts.py create mode 100644 homeassistant/components/ledsc/exceptions.py create mode 100644 homeassistant/components/ledsc/ledsc.py create mode 100644 homeassistant/components/ledsc/light.py create mode 100644 homeassistant/components/ledsc/manifest.json diff --git a/homeassistant/components/ledsc/__init__.py b/homeassistant/components/ledsc/__init__.py new file mode 100644 index 00000000000000..d2d561fa741fd6 --- /dev/null +++ b/homeassistant/components/ledsc/__init__.py @@ -0,0 +1,42 @@ +"""Platform for light integration.""" +from __future__ import annotations + +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.light import PLATFORM_SCHEMA +from homeassistant.core import HomeAssistant + +from .exceptions import CannotConnect +from .consts import DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + +# Validation of the user's configuration +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.string, +}) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Hello World from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(options_update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return unload_ok + + +async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + _LOGGER.debug("Configuration options updated, reloading OneWire integration") + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py new file mode 100644 index 00000000000000..0a0fb089d4b643 --- /dev/null +++ b/homeassistant/components/ledsc/config_flow.py @@ -0,0 +1,65 @@ +"""Config flow for Hello World integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_HOST, CONF_PORT + +from .consts import DOMAIN, DEFAULT_HOST, DEFAULT_PORT +from .light import LedSClient +from .exceptions import CannotConnect + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + host = data[CONF_HOST] + port = data[CONF_PORT] + + client = LedSClient(hass) + await client.connect(host, port) + + return {"title": f"LedSC server {host}:{port}"} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hello World.""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input: + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "Cannot connect" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors) + diff --git a/homeassistant/components/ledsc/consts.py b/homeassistant/components/ledsc/consts.py new file mode 100644 index 00000000000000..3e182c11cc35cc --- /dev/null +++ b/homeassistant/components/ledsc/consts.py @@ -0,0 +1,6 @@ +from homeassistant.const import Platform + +DOMAIN = "ledsc" +PLATFORMS: list[str] = [Platform.LIGHT] +DEFAULT_HOST = "demo.ledsc.eu" +DEFAULT_PORT = 8443 diff --git a/homeassistant/components/ledsc/exceptions.py b/homeassistant/components/ledsc/exceptions.py new file mode 100644 index 00000000000000..87f692379bd2f4 --- /dev/null +++ b/homeassistant/components/ledsc/exceptions.py @@ -0,0 +1,5 @@ +from homeassistant.exceptions import HomeAssistantError + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/ledsc/ledsc.py b/homeassistant/components/ledsc/ledsc.py new file mode 100644 index 00000000000000..c4c616227aced0 --- /dev/null +++ b/homeassistant/components/ledsc/ledsc.py @@ -0,0 +1,119 @@ +import json +import logging +import math +from typing import Any +from homeassistant.components.light import LightEntity, ColorMode +from homeassistant.core import HomeAssistant +from websockets import WebSocketClientProtocol + +_LOGGER = logging.getLogger(__name__) + + +class LedSC(LightEntity): + """Representation of an Awesome Light.""" + + def __init__(self, client_id: str, name: str, data: dict, client: WebSocketClientProtocol, + hass: HomeAssistant) -> None: + """Initialize an AwesomeLight.""" + self._hass: HomeAssistant = hass + self._name = name + self._client = client + self._data = data + self.__id = f"{client_id}-{self._name}" + _LOGGER.info(f"LedSC {self._name} initialized: {data}") + + def send_request(self, data: dict) -> None: + self.hass.async_create_task(self._client.send(json.dumps({ + 'dev': {self._name: data} + }))) + + @property + def unique_id(self) -> str | None: + return self.__id + + @property + def supported_color_modes(self) -> set[ColorMode] | set[str] | None: + return {ColorMode.RGBW} + + @property + def color_mode(self) -> ColorMode | str | None: + return ColorMode.RGBW + + @property + def available(self) -> bool: + return True if self._data['is_lost'] == 0 else False + + @property + def name(self) -> str: + """Return the display name of this light.""" + return self._name + + @property + def brightness(self) -> int | None: + """Return the brightness of the light. + + This method is optional. Removing it indicates to Home Assistant + that brightness is not supported for this light. + """ + return max([self._data[k] for k in ["R", "G", "B", "W"]]) + + @brightness.setter + def brightness(self, value: int) -> None: + actual = self.brightness + if actual == 0: + self.send_request({k: value for k in ["R", "G", "B", "W"]}) + else: + diff = value - actual + ratio = diff / actual + self.send_request({k: round(self._data[k] * (1 + ratio)) for k in ["R", "G", "B", "W"]}) + + @property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + return ( + self._data['R'], + self._data['G'], + self._data['B'], + self._data['W'], + ) + + @rgbw_color.setter + def rgbw_color(self, value: tuple[int, int, int, int]) -> None: + self.send_request({ + "R": value[0], + "G": value[1], + "B": value[2], + "W": value[3], + }) + + @property + def is_on(self) -> bool | None: + """Return true if light is on.""" + return True if (self._data["R"] or self._data["G"] or self._data["B"] or self._data['W']) else False + pass + + def turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on. + + You can skip the brightness part if your light does not support + brightness control. + """ + if "brightness" in kwargs: + self.brightness = kwargs["brightness"] + elif 'rgbw_color' in kwargs: + self.rgbw_color = kwargs["rgbw_color"] + elif not self.is_on: + self.switch() + + def turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + if self.is_on: + self.switch() + + def switch(self) -> None: + self.send_request({"trigger": 1}) + + async def data(self, value: dict): + self._data.update(value) + await self.async_update_ha_state(force_refresh=True) + + diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py new file mode 100644 index 00000000000000..edf73101ef204f --- /dev/null +++ b/homeassistant/components/ledsc/light.py @@ -0,0 +1,95 @@ +import asyncio +import logging +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import websockets as websocket +from websockets import WebSocketClientProtocol +from websockets.exceptions import ConnectionClosedOK +import json + +from .ledsc import LedSC +from .exceptions import CannotConnect + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass: HomeAssistant, config, add_entities: AddEntitiesCallback, discovery_info=None): + __setup(hass, dict(config), add_entities) + + +async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry, add_entities: AddEntitiesCallback): + await __setup(hass, dict(config.data), add_entities) + + +async def __setup(hass: HomeAssistant, config: dict, add_entities: AddEntitiesCallback): + client = LedSClient(hass) + await client.connect(host=config['host'], port=config['port']) + add_entities(client.devices.values(), True) + + +class LedSClient: + def __init__(self, hass: HomeAssistant) -> None: + self.client: WebSocketClientProtocol | None = None + self.connection_setup: tuple[str, int] | None = None + self.devices: dict[str, LedSC] = {} + self.ws_service_running = False + self.hass = hass + + async def connect(self, host: str, port: int) -> bool: + self.connection_setup = (host, port) + if self.client is not None and not self.client.closed: + raise CannotConnect(f"LedSClient: Already connected to {host}:{port}") + _LOGGER.debug(f"LedSClient: Connecting to {host}:{port}") + + try: + self.client = await websocket.connect(f"ws://{host}:{port}", open_timeout=2) + except OSError: + raise CannotConnect(f"LedSClient: Could not connect to websocket at {host}:{port}") + _LOGGER.info(f"LedSClient: Connected to {host}:{port}") + initial_message = json.loads(await self.client.recv()) + + if 'dev' in initial_message: + for name, data in initial_message['dev'].items(): + if name in self.devices: + device = self.devices[name] + await device.data(value=data) + device._client = self.client + else: + self.devices[name] = LedSC(name=name, data=data, client_id=f"{host}:{port}", client=self.client, + hass=self.hass) + + _LOGGER.info(f"LedSClient: devices: {self.devices.keys()}") + + if not self.ws_service_running: + self.hass.async_create_background_task(self.ws_service(), name="ledsc-ws") + + return True + + async def ws_service(self): + try: + self.ws_service_running = True + while True: + try: + _data = json.loads(await self.client.recv()) + if 'dev' in _data: + for name, data in _data['dev'].items(): + if name in self.devices: + await self.devices[name].data(data) + except ConnectionClosedOK: + _LOGGER.warning(f"LedSClient: Connection closed. Reconnecting...") + for device in self.devices.values(): + await device.data({"is_lost": 1}) + while self.client.closed: + try: + await self.connect(*self.connection_setup) + await asyncio.sleep(1) + except CannotConnect: + await asyncio.sleep(5) + finally: + self.ws_service_running = False + await self.disconnect() + + async def disconnect(self) -> None: + if self.client: + await self.client.close() diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json new file mode 100644 index 00000000000000..fef11aa143ce12 --- /dev/null +++ b/homeassistant/components/ledsc/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ledsc", + "name": "LedSC", + "codeowners": [], + "documentation": "https://ledsc.eu/", + "iot_class": "local_polling", + "config_flow": true, + "integration_type": "hub", + "requirements": ["requests"], + "version": "1.0.0" +} From b6f4d36b72ba7e2ae4ea1fd672505e0e919ed5ee Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Wed, 8 May 2024 12:22:03 +0200 Subject: [PATCH 02/19] The code formatted using Ruff --- homeassistant/components/ledsc/__init__.py | 11 ++-- homeassistant/components/ledsc/config_flow.py | 6 +- homeassistant/components/ledsc/ledsc.py | 56 ++++++++++++------- homeassistant/components/ledsc/light.py | 31 ++++++---- 4 files changed, 67 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ledsc/__init__.py b/homeassistant/components/ledsc/__init__.py index d2d561fa741fd6..7cde7a5cd4be60 100644 --- a/homeassistant/components/ledsc/__init__.py +++ b/homeassistant/components/ledsc/__init__.py @@ -1,4 +1,5 @@ """Platform for light integration.""" + from __future__ import annotations import logging @@ -17,10 +18,12 @@ _LOGGER = logging.getLogger(__name__) # Validation of the user's configuration -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.string, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.string, + } +) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index 0a0fb089d4b643..8c5a88e71fba68 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Hello World integration.""" + from __future__ import annotations import logging @@ -61,5 +62,6 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None): else: return self.async_create_entry(title=info["title"], data=user_input) - return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors) - + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ledsc/ledsc.py b/homeassistant/components/ledsc/ledsc.py index c4c616227aced0..2cec63abaa8dee 100644 --- a/homeassistant/components/ledsc/ledsc.py +++ b/homeassistant/components/ledsc/ledsc.py @@ -12,8 +12,14 @@ class LedSC(LightEntity): """Representation of an Awesome Light.""" - def __init__(self, client_id: str, name: str, data: dict, client: WebSocketClientProtocol, - hass: HomeAssistant) -> None: + def __init__( + self, + client_id: str, + name: str, + data: dict, + client: WebSocketClientProtocol, + hass: HomeAssistant, + ) -> None: """Initialize an AwesomeLight.""" self._hass: HomeAssistant = hass self._name = name @@ -23,9 +29,9 @@ def __init__(self, client_id: str, name: str, data: dict, client: WebSocketClien _LOGGER.info(f"LedSC {self._name} initialized: {data}") def send_request(self, data: dict) -> None: - self.hass.async_create_task(self._client.send(json.dumps({ - 'dev': {self._name: data} - }))) + self.hass.async_create_task( + self._client.send(json.dumps({"dev": {self._name: data}})) + ) @property def unique_id(self) -> str | None: @@ -41,7 +47,7 @@ def color_mode(self) -> ColorMode | str | None: @property def available(self) -> bool: - return True if self._data['is_lost'] == 0 else False + return True if self._data["is_lost"] == 0 else False @property def name(self) -> str: @@ -65,30 +71,40 @@ def brightness(self, value: int) -> None: else: diff = value - actual ratio = diff / actual - self.send_request({k: round(self._data[k] * (1 + ratio)) for k in ["R", "G", "B", "W"]}) + self.send_request( + {k: round(self._data[k] * (1 + ratio)) for k in ["R", "G", "B", "W"]} + ) @property def rgbw_color(self) -> tuple[int, int, int, int] | None: return ( - self._data['R'], - self._data['G'], - self._data['B'], - self._data['W'], + self._data["R"], + self._data["G"], + self._data["B"], + self._data["W"], ) @rgbw_color.setter def rgbw_color(self, value: tuple[int, int, int, int]) -> None: - self.send_request({ - "R": value[0], - "G": value[1], - "B": value[2], - "W": value[3], - }) + self.send_request( + { + "R": value[0], + "G": value[1], + "B": value[2], + "W": value[3], + } + ) @property def is_on(self) -> bool | None: """Return true if light is on.""" - return True if (self._data["R"] or self._data["G"] or self._data["B"] or self._data['W']) else False + return ( + True + if ( + self._data["R"] or self._data["G"] or self._data["B"] or self._data["W"] + ) + else False + ) pass def turn_on(self, **kwargs: Any) -> None: @@ -99,7 +115,7 @@ def turn_on(self, **kwargs: Any) -> None: """ if "brightness" in kwargs: self.brightness = kwargs["brightness"] - elif 'rgbw_color' in kwargs: + elif "rgbw_color" in kwargs: self.rgbw_color = kwargs["rgbw_color"] elif not self.is_on: self.switch() @@ -115,5 +131,3 @@ def switch(self) -> None: async def data(self, value: dict): self._data.update(value) await self.async_update_ha_state(force_refresh=True) - - diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py index edf73101ef204f..bab5049f68f861 100644 --- a/homeassistant/components/ledsc/light.py +++ b/homeassistant/components/ledsc/light.py @@ -14,17 +14,21 @@ _LOGGER = logging.getLogger(__name__) -def setup_platform(hass: HomeAssistant, config, add_entities: AddEntitiesCallback, discovery_info=None): +def setup_platform( + hass: HomeAssistant, config, add_entities: AddEntitiesCallback, discovery_info=None +): __setup(hass, dict(config), add_entities) -async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry, add_entities: AddEntitiesCallback): +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, add_entities: AddEntitiesCallback +): await __setup(hass, dict(config.data), add_entities) async def __setup(hass: HomeAssistant, config: dict, add_entities: AddEntitiesCallback): client = LedSClient(hass) - await client.connect(host=config['host'], port=config['port']) + await client.connect(host=config["host"], port=config["port"]) add_entities(client.devices.values(), True) @@ -45,19 +49,26 @@ async def connect(self, host: str, port: int) -> bool: try: self.client = await websocket.connect(f"ws://{host}:{port}", open_timeout=2) except OSError: - raise CannotConnect(f"LedSClient: Could not connect to websocket at {host}:{port}") + raise CannotConnect( + f"LedSClient: Could not connect to websocket at {host}:{port}" + ) _LOGGER.info(f"LedSClient: Connected to {host}:{port}") initial_message = json.loads(await self.client.recv()) - if 'dev' in initial_message: - for name, data in initial_message['dev'].items(): + if "dev" in initial_message: + for name, data in initial_message["dev"].items(): if name in self.devices: device = self.devices[name] await device.data(value=data) device._client = self.client else: - self.devices[name] = LedSC(name=name, data=data, client_id=f"{host}:{port}", client=self.client, - hass=self.hass) + self.devices[name] = LedSC( + name=name, + data=data, + client_id=f"{host}:{port}", + client=self.client, + hass=self.hass, + ) _LOGGER.info(f"LedSClient: devices: {self.devices.keys()}") @@ -72,8 +83,8 @@ async def ws_service(self): while True: try: _data = json.loads(await self.client.recv()) - if 'dev' in _data: - for name, data in _data['dev'].items(): + if "dev" in _data: + for name, data in _data["dev"].items(): if name in self.devices: await self.devices[name].data(data) except ConnectionClosedOK: From d2f000909d16a6551e4f7dc537014cbcc58beef2 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Wed, 8 May 2024 15:01:17 +0200 Subject: [PATCH 03/19] Add docstrings and clean code. --- homeassistant/components/ledsc/__init__.py | 10 ++-- homeassistant/components/ledsc/config_flow.py | 9 ++-- homeassistant/components/ledsc/consts.py | 2 + homeassistant/components/ledsc/exceptions.py | 2 + homeassistant/components/ledsc/ledsc.py | 54 ++++++++++--------- homeassistant/components/ledsc/light.py | 48 ++++++++++++----- homeassistant/components/ledsc/manifest.json | 2 +- 7 files changed, 78 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/ledsc/__init__.py b/homeassistant/components/ledsc/__init__.py index 7cde7a5cd4be60..fb49a6b212c6be 100644 --- a/homeassistant/components/ledsc/__init__.py +++ b/homeassistant/components/ledsc/__init__.py @@ -6,14 +6,13 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import PLATFORM_SCHEMA from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.components.light import PLATFORM_SCHEMA from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv -from .exceptions import CannotConnect -from .consts import DOMAIN, PLATFORMS +from .consts import PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index 8c5a88e71fba68..7252fdd1a2f934 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -8,12 +8,12 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import HomeAssistant from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant -from .consts import DOMAIN, DEFAULT_HOST, DEFAULT_PORT -from .light import LedSClient +from .consts import DEFAULT_HOST, DEFAULT_PORT, DOMAIN from .exceptions import CannotConnect +from .light import LedSClient _LOGGER = logging.getLogger(__name__) @@ -26,7 +26,8 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: - """Validate the user input allows us to connect. + """ + Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ diff --git a/homeassistant/components/ledsc/consts.py b/homeassistant/components/ledsc/consts.py index 3e182c11cc35cc..98c4497ee3b158 100644 --- a/homeassistant/components/ledsc/consts.py +++ b/homeassistant/components/ledsc/consts.py @@ -1,3 +1,5 @@ +"""Constant variables.""" + from homeassistant.const import Platform DOMAIN = "ledsc" diff --git a/homeassistant/components/ledsc/exceptions.py b/homeassistant/components/ledsc/exceptions.py index 87f692379bd2f4..48078b898de41a 100644 --- a/homeassistant/components/ledsc/exceptions.py +++ b/homeassistant/components/ledsc/exceptions.py @@ -1,3 +1,5 @@ +"""LedSC exceptions.""" + from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/ledsc/ledsc.py b/homeassistant/components/ledsc/ledsc.py index 2cec63abaa8dee..f36e81eb81a4e0 100644 --- a/homeassistant/components/ledsc/ledsc.py +++ b/homeassistant/components/ledsc/ledsc.py @@ -1,11 +1,14 @@ +"""LedSC light entity.""" + import json import logging -import math from typing import Any -from homeassistant.components.light import LightEntity, ColorMode -from homeassistant.core import HomeAssistant + from websockets import WebSocketClientProtocol +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant + _LOGGER = logging.getLogger(__name__) @@ -23,31 +26,40 @@ def __init__( """Initialize an AwesomeLight.""" self._hass: HomeAssistant = hass self._name = name - self._client = client + self.client = client self._data = data self.__id = f"{client_id}-{self._name}" - _LOGGER.info(f"LedSC {self._name} initialized: {data}") + _LOGGER.info(f"LedSC '%s' initialized: %s", self.name, data) def send_request(self, data: dict) -> None: + """Sync operation for send data to WebSC.""" self.hass.async_create_task( - self._client.send(json.dumps({"dev": {self._name: data}})) + self.client.send(json.dumps({"dev": {self._name: data}})) ) @property def unique_id(self) -> str | None: + """Return id unique for client and entity name combination.""" return self.__id @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: - return True if self._data["is_lost"] == 0 else False + """ + Check if light is available. + + The information is from WebSC. + """ + return self._data["is_lost"] == 0 @property def name(self) -> str: @@ -56,17 +68,14 @@ def name(self) -> str: @property def brightness(self) -> int | None: - """Return the brightness of the light. - - This method is optional. Removing it indicates to Home Assistant - that brightness is not supported for this light. - """ + """Return the brightness of the light.""" return max([self._data[k] for k in ["R", "G", "B", "W"]]) @brightness.setter def brightness(self, value: int) -> None: + """Set brightness of the light.""" actual = self.brightness - if actual == 0: + if actual is None or actual == 0: self.send_request({k: value for k in ["R", "G", "B", "W"]}) else: diff = value - actual @@ -77,6 +86,7 @@ def brightness(self, value: int) -> None: @property def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Get color.""" return ( self._data["R"], self._data["G"], @@ -86,6 +96,7 @@ def rgbw_color(self) -> tuple[int, int, int, int] | None: @rgbw_color.setter def rgbw_color(self, value: tuple[int, int, int, int]) -> None: + """Set color to WebSC.""" self.send_request( { "R": value[0], @@ -98,21 +109,12 @@ def rgbw_color(self, value: tuple[int, int, int, int]) -> None: @property def is_on(self) -> bool | None: """Return true if light is on.""" - return ( - True - if ( - self._data["R"] or self._data["G"] or self._data["B"] or self._data["W"] - ) - else False + return bool( + self._data["R"] or self._data["G"] or self._data["B"] or self._data["W"] ) - pass def turn_on(self, **kwargs: Any) -> None: - """Instruct the light to turn on. - - You can skip the brightness part if your light does not support - brightness control. - """ + """Instruct the light to turn on.""" if "brightness" in kwargs: self.brightness = kwargs["brightness"] elif "rgbw_color" in kwargs: @@ -126,8 +128,10 @@ def turn_off(self, **kwargs: Any) -> None: self.switch() def switch(self) -> None: + """Send switch event to WebSC.""" self.send_request({"trigger": 1}) async def data(self, value: dict): + """For update data. This data must be received from WebSC.""" self._data.update(value) await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py index bab5049f68f861..b892a11589127d 100644 --- a/homeassistant/components/ledsc/light.py +++ b/homeassistant/components/ledsc/light.py @@ -1,15 +1,19 @@ +"""LedSC light.""" + import asyncio +import json import logging -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback + import websockets as websocket from websockets import WebSocketClientProtocol from websockets.exceptions import ConnectionClosedOK -import json -from .ledsc import LedSC +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + from .exceptions import CannotConnect +from .ledsc import LedSC _LOGGER = logging.getLogger(__name__) @@ -17,23 +21,33 @@ def setup_platform( hass: HomeAssistant, config, add_entities: AddEntitiesCallback, discovery_info=None ): - __setup(hass, dict(config), add_entities) + """Redirects to '__setup'.""" + hass.async_create_task(__setup(hass, dict(config), add_entities)) async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, add_entities: AddEntitiesCallback ): + """Redirects to '__setup'.""" await __setup(hass, dict(config.data), add_entities) async def __setup(hass: HomeAssistant, config: dict, add_entities: AddEntitiesCallback): + """ + Connect to WebSC. + + load the configured devices and add them to hass. + """ client = LedSClient(hass) await client.connect(host=config["host"], port=config["port"]) add_entities(client.devices.values(), True) class LedSClient: + """Client for LedSC devices. Mediates websocket communication with WebSC.""" + def __init__(self, hass: HomeAssistant) -> None: + """Set variables to default values.""" self.client: WebSocketClientProtocol | None = None self.connection_setup: tuple[str, int] | None = None self.devices: dict[str, LedSC] = {} @@ -41,18 +55,24 @@ def __init__(self, hass: HomeAssistant) -> None: self.hass = hass async def connect(self, host: str, port: int) -> bool: + """ + Connect to WebSC. + + Read configuration from initial message and create LedSC devices. + Create background task for websocket listening. + """ self.connection_setup = (host, port) if self.client is not None and not self.client.closed: raise CannotConnect(f"LedSClient: Already connected to {host}:{port}") - _LOGGER.debug(f"LedSClient: Connecting to {host}:{port}") + _LOGGER.debug(f"LedSClient: Connecting to %s:%s", host, port) try: self.client = await websocket.connect(f"ws://{host}:{port}", open_timeout=2) - except OSError: + except OSError as E: raise CannotConnect( f"LedSClient: Could not connect to websocket at {host}:{port}" - ) - _LOGGER.info(f"LedSClient: Connected to {host}:{port}") + ) from E + _LOGGER.info(f"LedSClient: Connected to %s:%s", host, port) initial_message = json.loads(await self.client.recv()) if "dev" in initial_message: @@ -60,7 +80,7 @@ async def connect(self, host: str, port: int) -> bool: if name in self.devices: device = self.devices[name] await device.data(value=data) - device._client = self.client + device.client = self.client else: self.devices[name] = LedSC( name=name, @@ -70,7 +90,7 @@ async def connect(self, host: str, port: int) -> bool: hass=self.hass, ) - _LOGGER.info(f"LedSClient: devices: {self.devices.keys()}") + _LOGGER.info(f"LedSClient: devices: %s", self.devices.keys()) if not self.ws_service_running: self.hass.async_create_background_task(self.ws_service(), name="ledsc-ws") @@ -78,6 +98,7 @@ async def connect(self, host: str, port: int) -> bool: return True async def ws_service(self): + """Listen on the WebSC and resending data to the LedSC devices.""" try: self.ws_service_running = True while True: @@ -88,7 +109,7 @@ async def ws_service(self): if name in self.devices: await self.devices[name].data(data) except ConnectionClosedOK: - _LOGGER.warning(f"LedSClient: Connection closed. Reconnecting...") + _LOGGER.warning("LedSClient: Connection closed. Reconnecting...") for device in self.devices.values(): await device.data({"is_lost": 1}) while self.client.closed: @@ -102,5 +123,6 @@ async def ws_service(self): await self.disconnect() async def disconnect(self) -> None: + """Disconnect from WebSC.""" if self.client: await self.client.close() diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json index fef11aa143ce12..4bfd090fba5c3f 100644 --- a/homeassistant/components/ledsc/manifest.json +++ b/homeassistant/components/ledsc/manifest.json @@ -6,6 +6,6 @@ "iot_class": "local_polling", "config_flow": true, "integration_type": "hub", - "requirements": ["requests"], + "requirements": ["requests==2.31.0"], "version": "1.0.0" } From 78997ac1a4ba083782aff8699b893072bec2408d Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Wed, 8 May 2024 15:38:26 +0200 Subject: [PATCH 04/19] update manifest and call script.gen_requirements_all --- homeassistant/components/ledsc/manifest.json | 9 ++++----- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++++++ requirements_all.txt | 3 +++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json index 4bfd090fba5c3f..e8f8646d5bd9d9 100644 --- a/homeassistant/components/ledsc/manifest.json +++ b/homeassistant/components/ledsc/manifest.json @@ -1,11 +1,10 @@ { "domain": "ledsc", "name": "LedSC", - "codeowners": [], - "documentation": "https://ledsc.eu/", - "iot_class": "local_polling", + "codeowners": ["@PatrikKr010"], "config_flow": true, + "documentation": "https://ledsc.eu/", "integration_type": "hub", - "requirements": ["requests==2.31.0"], - "version": "1.0.0" + "iot_class": "local_polling", + "requirements": ["requests==2.31.0"] } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1396a161befd0e..4cf6caed9d9512 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -289,6 +289,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 c5e7a842c45d85..386e1da31e3cce 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3174,6 +3174,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 b620be1bebb2ff..083de20cb0fe91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2441,6 +2441,9 @@ renson-endura-delta==1.7.1 # homeassistant.components.reolink reolink-aio==0.8.9 +# homeassistant.components.ledsc +requests==2.31.0 + # homeassistant.components.idteck_prox rfk101py==0.0.1 From 57564cb562efe38663d9969c0c8468a29933bc31 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Wed, 8 May 2024 17:55:40 +0200 Subject: [PATCH 05/19] LedSC: Update documentation link --- homeassistant/components/ledsc/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json index e8f8646d5bd9d9..25c0ae97b83890 100644 --- a/homeassistant/components/ledsc/manifest.json +++ b/homeassistant/components/ledsc/manifest.json @@ -3,7 +3,7 @@ "name": "LedSC", "codeowners": ["@PatrikKr010"], "config_flow": true, - "documentation": "https://ledsc.eu/", + "documentation": "(https://www.home-assistant.io/integrations/ledsc/", "integration_type": "hub", "iot_class": "local_polling", "requirements": ["requests==2.31.0"] From a01e9e9f1b3f6b02f3cb75f141270da7196cc4dd Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Thu, 30 May 2024 01:15:43 +0200 Subject: [PATCH 06/19] Added abstraction layer for communication with WebSC --- CODEOWNERS | 1 + homeassistant/components/ledsc/config_flow.py | 10 +- homeassistant/components/ledsc/exceptions.py | 7 - homeassistant/components/ledsc/ledsc.py | 137 ---------- homeassistant/components/ledsc/light.py | 235 +++++++++++------- homeassistant/components/ledsc/manifest.json | 4 +- requirements_all.txt | 6 +- 7 files changed, 152 insertions(+), 248 deletions(-) delete mode 100644 homeassistant/components/ledsc/exceptions.py delete mode 100644 homeassistant/components/ledsc/ledsc.py diff --git a/CODEOWNERS b/CODEOWNERS index 32f885f601561c..6465752954e3b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -768,6 +768,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/config_flow.py b/homeassistant/components/ledsc/config_flow.py index 7252fdd1a2f934..f3e306863dc563 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -12,8 +12,7 @@ from homeassistant.core import HomeAssistant from .consts import DEFAULT_HOST, DEFAULT_PORT, DOMAIN -from .exceptions import CannotConnect -from .light import LedSClient +from websc_client import WebSClientAsync as WebSClient _LOGGER = logging.getLogger(__name__) @@ -34,8 +33,9 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: host = data[CONF_HOST] port = data[CONF_PORT] - client = LedSClient(hass) - await client.connect(host, port) + client = WebSClient(host, port) + await client.connect() + await client.disconnect() return {"title": f"LedSC server {host}:{port}"} @@ -58,7 +58,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None): try: info = await validate_input(self.hass, user_input) - except CannotConnect: + except ConnectionError: errors["base"] = "Cannot connect" else: return self.async_create_entry(title=info["title"], data=user_input) diff --git a/homeassistant/components/ledsc/exceptions.py b/homeassistant/components/ledsc/exceptions.py deleted file mode 100644 index 48078b898de41a..00000000000000 --- a/homeassistant/components/ledsc/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -"""LedSC exceptions.""" - -from homeassistant.exceptions import HomeAssistantError - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/ledsc/ledsc.py b/homeassistant/components/ledsc/ledsc.py deleted file mode 100644 index f36e81eb81a4e0..00000000000000 --- a/homeassistant/components/ledsc/ledsc.py +++ /dev/null @@ -1,137 +0,0 @@ -"""LedSC light entity.""" - -import json -import logging -from typing import Any - -from websockets import WebSocketClientProtocol - -from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.core import HomeAssistant - -_LOGGER = logging.getLogger(__name__) - - -class LedSC(LightEntity): - """Representation of an Awesome Light.""" - - def __init__( - self, - client_id: str, - name: str, - data: dict, - client: WebSocketClientProtocol, - hass: HomeAssistant, - ) -> None: - """Initialize an AwesomeLight.""" - self._hass: HomeAssistant = hass - self._name = name - self.client = client - self._data = data - self.__id = f"{client_id}-{self._name}" - _LOGGER.info(f"LedSC '%s' initialized: %s", self.name, data) - - def send_request(self, data: dict) -> None: - """Sync operation for send data to WebSC.""" - self.hass.async_create_task( - self.client.send(json.dumps({"dev": {self._name: data}})) - ) - - @property - def unique_id(self) -> str | None: - """Return id unique for client and entity name combination.""" - return self.__id - - @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. - - The information is from WebSC. - """ - return self._data["is_lost"] == 0 - - @property - def name(self) -> str: - """Return the display name of this light.""" - return self._name - - @property - def brightness(self) -> int | None: - """Return the brightness of the light.""" - return max([self._data[k] for k in ["R", "G", "B", "W"]]) - - @brightness.setter - def brightness(self, value: int) -> None: - """Set brightness of the light.""" - actual = self.brightness - if actual is None or actual == 0: - self.send_request({k: value for k in ["R", "G", "B", "W"]}) - else: - diff = value - actual - ratio = diff / actual - self.send_request( - {k: round(self._data[k] * (1 + ratio)) for k in ["R", "G", "B", "W"]} - ) - - @property - def rgbw_color(self) -> tuple[int, int, int, int] | None: - """Get color.""" - return ( - self._data["R"], - self._data["G"], - self._data["B"], - self._data["W"], - ) - - @rgbw_color.setter - def rgbw_color(self, value: tuple[int, int, int, int]) -> None: - """Set color to WebSC.""" - self.send_request( - { - "R": value[0], - "G": value[1], - "B": value[2], - "W": value[3], - } - ) - - @property - def is_on(self) -> bool | None: - """Return true if light is on.""" - return bool( - self._data["R"] or self._data["G"] or self._data["B"] or self._data["W"] - ) - - def 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: - self.switch() - - def turn_off(self, **kwargs: Any) -> None: - """Instruct the light to turn off.""" - if self.is_on: - self.switch() - - def switch(self) -> None: - """Send switch event to WebSC.""" - self.send_request({"trigger": 1}) - - async def data(self, value: dict): - """For update data. This data must be received from WebSC.""" - self._data.update(value) - await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py index b892a11589127d..fb681a65ed09f3 100644 --- a/homeassistant/components/ledsc/light.py +++ b/homeassistant/components/ledsc/light.py @@ -1,19 +1,15 @@ """LedSC light.""" -import asyncio -import json import logging - -import websockets as websocket -from websockets import WebSocketClientProtocol -from websockets.exceptions import ConnectionClosedOK +from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant -from .exceptions import CannotConnect -from .ledsc import LedSC +from websc_client import WebSClientAsync as WebSClient +from websc_client import WebSCAsync _LOGGER = logging.getLogger(__name__) @@ -38,91 +34,142 @@ async def __setup(hass: HomeAssistant, config: dict, add_entities: AddEntitiesCa load the configured devices and add them to hass. """ - client = LedSClient(hass) - await client.connect(host=config["host"], port=config["port"]) - add_entities(client.devices.values(), True) - - -class LedSClient: - """Client for LedSC devices. Mediates websocket communication with WebSC.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Set variables to default values.""" - self.client: WebSocketClientProtocol | None = None - self.connection_setup: tuple[str, int] | None = None - self.devices: dict[str, LedSC] = {} - self.ws_service_running = False - self.hass = hass - - async def connect(self, host: str, port: int) -> bool: + host = config["host"] + port = config["port"] + + client = WebSClient(host=host, port=port) + await client.connect() + hass.async_create_background_task(client.observer(), name="ledsc-observer") + + devices: list[LedSC] = list() + for websc in client.devices.values(): + ledsc = LedSC( + client_id=f"{host}:{port}", + websc=websc, + hass=hass, + ) + websc.set_callback(__generate_callback(ledsc)) + devices.append(ledsc) + add_entities(devices, True) + + +class LedSC(LightEntity): + """Representation of an Awesome Light.""" + + def __init__( + self, + client_id: str, + websc: WebSCAsync, + hass: HomeAssistant, + ) -> None: + """Initialize an AwesomeLight.""" + self._hass: HomeAssistant = hass + self._websc: WebSCAsync = websc + self.__id = f"{client_id}-{websc.name}" + _LOGGER.info(f"LedSC '%s' initialized!", self.name) + + @property + def unique_id(self) -> str | None: + """Return id unique for client and entity name combination.""" + return self.__id + + @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: """ - Connect to WebSC. + Check if light is available. - Read configuration from initial message and create LedSC devices. - Create background task for websocket listening. + The information is from WebSC. """ - self.connection_setup = (host, port) - if self.client is not None and not self.client.closed: - raise CannotConnect(f"LedSClient: Already connected to {host}:{port}") - _LOGGER.debug(f"LedSClient: Connecting to %s:%s", host, port) - - try: - self.client = await websocket.connect(f"ws://{host}:{port}", open_timeout=2) - except OSError as E: - raise CannotConnect( - f"LedSClient: Could not connect to websocket at {host}:{port}" - ) from E - _LOGGER.info(f"LedSClient: Connected to %s:%s", host, port) - initial_message = json.loads(await self.client.recv()) - - if "dev" in initial_message: - for name, data in initial_message["dev"].items(): - if name in self.devices: - device = self.devices[name] - await device.data(value=data) - device.client = self.client - else: - self.devices[name] = LedSC( - name=name, - data=data, - client_id=f"{host}:{port}", - client=self.client, - hass=self.hass, - ) - - _LOGGER.info(f"LedSClient: devices: %s", self.devices.keys()) - - if not self.ws_service_running: - self.hass.async_create_background_task(self.ws_service(), name="ledsc-ws") - - return True - - async def ws_service(self): - """Listen on the WebSC and resending data to the LedSC devices.""" - try: - self.ws_service_running = True - while True: - try: - _data = json.loads(await self.client.recv()) - if "dev" in _data: - for name, data in _data["dev"].items(): - if name in self.devices: - await self.devices[name].data(data) - except ConnectionClosedOK: - _LOGGER.warning("LedSClient: Connection closed. Reconnecting...") - for device in self.devices.values(): - await device.data({"is_lost": 1}) - while self.client.closed: - try: - await self.connect(*self.connection_setup) - await asyncio.sleep(1) - except CannotConnect: - await asyncio.sleep(5) - finally: - self.ws_service_running = False - await self.disconnect() - - async def disconnect(self) -> None: - """Disconnect from WebSC.""" - if self.client: - await self.client.close() + 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: LedSC): + 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 index 25c0ae97b83890..edf6b90f390597 100644 --- a/homeassistant/components/ledsc/manifest.json +++ b/homeassistant/components/ledsc/manifest.json @@ -3,8 +3,8 @@ "name": "LedSC", "codeowners": ["@PatrikKr010"], "config_flow": true, - "documentation": "(https://www.home-assistant.io/integrations/ledsc/", + "documentation": "https://www.home-assistant.io/integrations/ledsc/", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["requests==2.31.0"] + "requirements": ["websc-client==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3e09e75a98f89f..da6ea75598bac4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2456,9 +2456,6 @@ renson-endura-delta==1.7.1 # homeassistant.components.reolink reolink-aio==0.8.11 -# homeassistant.components.ledsc -requests==2.31.0 - # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2878,6 +2875,9 @@ webmin-xmlrpc==0.0.2 # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 +# homeassistant.components.ledsc +websc-client==1.0.1 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 From 3b4c752577c024f639efba9292ab20da679f7248 Mon Sep 17 00:00:00 2001 From: PatrikKr010 <57815256+PatrikKr010@users.noreply.github.com> Date: Thu, 30 May 2024 08:04:00 +0200 Subject: [PATCH 07/19] Use cv.port to validate port number Co-authored-by: Mr. Bubbles --- homeassistant/components/ledsc/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index f3e306863dc563..db4193ac1a986e 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -13,13 +13,13 @@ from .consts import DEFAULT_HOST, DEFAULT_PORT, DOMAIN from websc_client import WebSClientAsync as WebSClient - +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST, default=DEFAULT_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, } ) From 678243a753d57b9d9747cfbf7fa5ee7691c46766 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Thu, 30 May 2024 08:44:21 +0200 Subject: [PATCH 08/19] Fix docstrings & code formatting --- homeassistant/components/ledsc/__init__.py | 6 ++--- homeassistant/components/ledsc/config_flow.py | 23 +++++++++---------- homeassistant/components/ledsc/light.py | 15 +++--------- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/ledsc/__init__.py b/homeassistant/components/ledsc/__init__.py index fb49a6b212c6be..48c852e4ce6772 100644 --- a/homeassistant/components/ledsc/__init__.py +++ b/homeassistant/components/ledsc/__init__.py @@ -1,4 +1,4 @@ -"""Platform for light integration.""" +"""Platform for LedSC integration.""" from __future__ import annotations @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Hello World from a config entry.""" + """Set up LedSC from a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(options_update_listener)) return True @@ -39,5 +39,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" - _LOGGER.debug("Configuration options updated, reloading OneWire integration") + _LOGGER.debug("Configuration options updated, reloading LedSC integration") await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index db4193ac1a986e..9a67b2b1663c03 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -1,19 +1,20 @@ -"""Config flow for Hello World integration.""" +"""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 homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigFlowResult +import homeassistant.helpers.config_validation as cv from .consts import DEFAULT_HOST, DEFAULT_PORT, DOMAIN -from websc_client import WebSClientAsync as WebSClient -import homeassistant.helpers.config_validation as cv + _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema( @@ -25,11 +26,7 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: - """ - Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ + """Validate the user input allows us to connect.""" host = data[CONF_HOST] port = data[CONF_PORT] @@ -41,11 +38,13 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Hello World.""" + """Handle a config flow for LedSC.""" VERSION = 1 - async def async_step_user(self, user_input: dict[str, Any] | None = None): + 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: @@ -59,7 +58,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None): try: info = await validate_input(self.hass, user_input) except ConnectionError: - errors["base"] = "Cannot connect" + errors["base"] = "cannot connect" else: return self.async_create_entry(title=info["title"], data=user_input) diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py index fb681a65ed09f3..f11268fcab308f 100644 --- a/homeassistant/components/ledsc/light.py +++ b/homeassistant/components/ledsc/light.py @@ -65,13 +65,8 @@ def __init__( """Initialize an AwesomeLight.""" self._hass: HomeAssistant = hass self._websc: WebSCAsync = websc - self.__id = f"{client_id}-{websc.name}" - _LOGGER.info(f"LedSC '%s' initialized!", self.name) - - @property - def unique_id(self) -> str | None: - """Return id unique for client and entity name combination.""" - return self.__id + 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: @@ -85,11 +80,7 @@ def color_mode(self) -> ColorMode | str | None: @property def available(self) -> bool: - """ - Check if light is available. - - The information is from WebSC. - """ + """Check if light is available.""" return not self._websc.is_lost @property From 50f5892f551d5879f616c1664360fb1137429b40 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Thu, 30 May 2024 17:33:57 +0200 Subject: [PATCH 09/19] Fix docstrings & code formatting --- homeassistant/components/ledsc/__init__.py | 14 ++------ homeassistant/components/ledsc/config_flow.py | 13 ++----- homeassistant/components/ledsc/consts.py | 5 +-- homeassistant/components/ledsc/light.py | 35 ++++++------------- 4 files changed, 18 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/ledsc/__init__.py b/homeassistant/components/ledsc/__init__.py index 48c852e4ce6772..3d745277dfb2cb 100644 --- a/homeassistant/components/ledsc/__init__.py +++ b/homeassistant/components/ledsc/__init__.py @@ -8,19 +8,18 @@ from homeassistant.components.light import PLATFORM_SCHEMA from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from .consts import PLATFORMS - +PLATFORMS: list[str] = [Platform.LIGHT] _LOGGER = logging.getLogger(__name__) # Validation of the user's configuration PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.string, + vol.Required(CONF_PORT): cv.port, } ) @@ -28,16 +27,9 @@ 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) - entry.async_on_unload(entry.add_update_listener(options_update_listener)) 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) - - -async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - _LOGGER.debug("Configuration options updated, reloading LedSC integration") - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index 9a67b2b1663c03..63869322e950a0 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -7,10 +7,9 @@ import voluptuous as vol from websc_client import WebSClientAsync as WebSClient -from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.config_entries import ConfigFlowResult import homeassistant.helpers.config_validation as cv from .consts import DEFAULT_HOST, DEFAULT_PORT, DOMAIN @@ -37,7 +36,7 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: return {"title": f"LedSC server {host}:{port}"} -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class LedSCConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for LedSC.""" VERSION = 1 @@ -48,13 +47,7 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} if user_input: - self._async_abort_entries_match( - { - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } - ) - + self._async_abort_entries_match(user_input) try: info = await validate_input(self.hass, user_input) except ConnectionError: diff --git a/homeassistant/components/ledsc/consts.py b/homeassistant/components/ledsc/consts.py index 98c4497ee3b158..8fb3ee2a471c47 100644 --- a/homeassistant/components/ledsc/consts.py +++ b/homeassistant/components/ledsc/consts.py @@ -1,8 +1,5 @@ -"""Constant variables.""" - -from homeassistant.const import Platform +"""Constants for the LedSC integration.""" DOMAIN = "ledsc" -PLATFORMS: list[str] = [Platform.LIGHT] DEFAULT_HOST = "demo.ledsc.eu" DEFAULT_PORT = 8443 diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py index f11268fcab308f..c7337f13c98406 100644 --- a/homeassistant/components/ledsc/light.py +++ b/homeassistant/components/ledsc/light.py @@ -4,6 +4,7 @@ from typing import Any 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 @@ -14,37 +15,22 @@ _LOGGER = logging.getLogger(__name__) -def setup_platform( - hass: HomeAssistant, config, add_entities: AddEntitiesCallback, discovery_info=None -): - """Redirects to '__setup'.""" - hass.async_create_task(__setup(hass, dict(config), add_entities)) - - async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, add_entities: AddEntitiesCallback ): - """Redirects to '__setup'.""" - await __setup(hass, dict(config.data), add_entities) - - -async def __setup(hass: HomeAssistant, config: dict, add_entities: AddEntitiesCallback): """ Connect to WebSC. - load the configured devices and add them to hass. + load the configured devices from WebSC Server and add them to hass. """ - host = config["host"] - port = config["port"] - - client = WebSClient(host=host, port=port) + 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[LedSC] = list() + devices: list[LedSCLightEntity] = [] for websc in client.devices.values(): - ledsc = LedSC( - client_id=f"{host}:{port}", + ledsc = LedSCLightEntity( + client_id=f"{config.data[CONF_HOST]}:{config.data[CONF_PORT]}", websc=websc, hass=hass, ) @@ -53,8 +39,8 @@ async def __setup(hass: HomeAssistant, config: dict, add_entities: AddEntitiesCa add_entities(devices, True) -class LedSC(LightEntity): - """Representation of an Awesome Light.""" +class LedSCLightEntity(LightEntity): + """Representation of an LedSC Light.""" def __init__( self, @@ -62,7 +48,7 @@ def __init__( websc: WebSCAsync, hass: HomeAssistant, ) -> None: - """Initialize an AwesomeLight.""" + """Initialize an LedSC Light.""" self._hass: HomeAssistant = hass self._websc: WebSCAsync = websc self._attr_unique_id = f"{client_id}-{websc.name}" @@ -159,7 +145,8 @@ async def switch(self) -> None: await self._websc.do_px_trigger() -def __generate_callback(ledsc: LedSC): +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) From 2a5e9af3fe5818b3e31babdb95491489aad2d6cd Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Thu, 30 May 2024 20:03:53 +0200 Subject: [PATCH 10/19] Add untested files to .coveragerc --- .coveragerc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.coveragerc b/.coveragerc index 7594d2d2d989b8..cb39c58168b891 100644 --- a/.coveragerc +++ b/.coveragerc @@ -700,6 +700,10 @@ omit = homeassistant/components/ld2410_ble/sensor.py homeassistant/components/led_ble/__init__.py homeassistant/components/led_ble/light.py + homeassistant/components/ledsc/__init__.py + homeassistant/components/ledsc/light.py + homeassistant/components/ledsc/config_flow.py + homeassistant/components/ledsc/consts.py homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py From b77ff3a15d88be12ce9c518ab67f2a0feaadfee3 Mon Sep 17 00:00:00 2001 From: PatrikKr010 <57815256+PatrikKr010@users.noreply.github.com> Date: Fri, 31 May 2024 08:10:02 +0200 Subject: [PATCH 11/19] Set key in error as `cannot_connect` Co-authored-by: Mr. Bubbles --- homeassistant/components/ledsc/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index 63869322e950a0..e9ccf6de29091b 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -51,7 +51,7 @@ async def async_step_user( try: info = await validate_input(self.hass, user_input) except ConnectionError: - errors["base"] = "cannot connect" + errors["base"] = "cannot_connect" else: return self.async_create_entry(title=info["title"], data=user_input) From 06057d2f1d36a8c62c42ac73257bd48ec0d2cbf9 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Fri, 31 May 2024 09:09:37 +0200 Subject: [PATCH 12/19] Optimize imports & Exceptions & up websc-client to version 1.0.2 & Fix docstrings. --- homeassistant/components/ledsc/config_flow.py | 3 +- homeassistant/components/ledsc/light.py | 8 +- homeassistant/components/ledsc/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/ledsc/__init__.py | 0 tests/components/ledsc/test_config_flow.py | 76 +++++++++++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 tests/components/ledsc/__init__.py create mode 100644 tests/components/ledsc/test_config_flow.py diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index e9ccf6de29091b..091f99b7010ad3 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -6,6 +6,7 @@ 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 @@ -50,7 +51,7 @@ async def async_step_user( self._async_abort_entries_match(user_input) try: info = await validate_input(self.hass, user_input) - except ConnectionError: + except WebSClientError: errors["base"] = "cannot_connect" else: return self.async_create_entry(title=info["title"], data=user_input) diff --git a/homeassistant/components/ledsc/light.py b/homeassistant/components/ledsc/light.py index c7337f13c98406..6cfb1c79797a51 100644 --- a/homeassistant/components/ledsc/light.py +++ b/homeassistant/components/ledsc/light.py @@ -3,23 +3,21 @@ 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 -from websc_client import WebSClientAsync as WebSClient -from websc_client import WebSCAsync - _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, add_entities: AddEntitiesCallback ): - """ - Connect to WebSC. + """Connect to WebSC. load the configured devices from WebSC Server and add them to hass. """ diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json index edf6b90f390597..1dd900ef536c2e 100644 --- a/homeassistant/components/ledsc/manifest.json +++ b/homeassistant/components/ledsc/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ledsc/", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["websc-client==1.0.1"] + "requirements": ["websc-client==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index da6ea75598bac4..b2d9502cd09552 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2876,7 +2876,7 @@ webmin-xmlrpc==0.0.2 webrtc-noise-gain==1.2.3 # homeassistant.components.ledsc -websc-client==1.0.1 +websc-client==1.0.2 # 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..e69de29bb2d1d6 diff --git a/tests/components/ledsc/test_config_flow.py b/tests/components/ledsc/test_config_flow.py new file mode 100644 index 00000000000000..10b2f52e751554 --- /dev/null +++ b/tests/components/ledsc/test_config_flow.py @@ -0,0 +1,76 @@ +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.ledsc.consts import DOMAIN + +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): + """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"] == data_entry_flow.RESULT_TYPE_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, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == RESULT["title"] + assert result2["data"] == USER_INPUT + + +async def test_form_cannot_connect(hass): + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + with patch( + "homeassistant.components.ledsc.config_flow.validate_input", + side_effect=ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unique_id_check(hass): + """Test we handle unique id check""" + 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"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" \ No newline at end of file From 823aecd5aab7877322f5d8d9fe14cc8cb2d6983b Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Fri, 31 May 2024 09:14:50 +0200 Subject: [PATCH 13/19] Remove default host in config flow. --- homeassistant/components/ledsc/config_flow.py | 4 ++-- homeassistant/components/ledsc/consts.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index 091f99b7010ad3..cdf0077502b9e5 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -13,13 +13,13 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from .consts import DEFAULT_HOST, DEFAULT_PORT, DOMAIN +from .consts import DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, } ) diff --git a/homeassistant/components/ledsc/consts.py b/homeassistant/components/ledsc/consts.py index 8fb3ee2a471c47..d1a3fe3c502d30 100644 --- a/homeassistant/components/ledsc/consts.py +++ b/homeassistant/components/ledsc/consts.py @@ -1,5 +1,4 @@ """Constants for the LedSC integration.""" DOMAIN = "ledsc" -DEFAULT_HOST = "demo.ledsc.eu" DEFAULT_PORT = 8443 From bf0e38c49ea6c0185a5de8e69844df21bdb075e2 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Sun, 2 Jun 2024 13:25:22 +0200 Subject: [PATCH 14/19] Fix tests && Up websc-client to 1.0.3 --- .coveragerc | 4 -- homeassistant/components/ledsc/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/ledsc/test_config_flow.py | 67 ++++++++++++-------- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/.coveragerc b/.coveragerc index cb39c58168b891..7594d2d2d989b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -700,10 +700,6 @@ omit = homeassistant/components/ld2410_ble/sensor.py homeassistant/components/led_ble/__init__.py homeassistant/components/led_ble/light.py - homeassistant/components/ledsc/__init__.py - homeassistant/components/ledsc/light.py - homeassistant/components/ledsc/config_flow.py - homeassistant/components/ledsc/consts.py homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json index 1dd900ef536c2e..07cdd7b6ff3696 100644 --- a/homeassistant/components/ledsc/manifest.json +++ b/homeassistant/components/ledsc/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ledsc/", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["websc-client==1.0.2"] + "requirements": ["websc-client==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b2d9502cd09552..6550f3a1279160 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2876,7 +2876,7 @@ webmin-xmlrpc==0.0.2 webrtc-noise-gain==1.2.3 # homeassistant.components.ledsc -websc-client==1.0.2 +websc-client==1.0.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/tests/components/ledsc/test_config_flow.py b/tests/components/ledsc/test_config_flow.py index 10b2f52e751554..b9308ed28ed3f4 100644 --- a/tests/components/ledsc/test_config_flow.py +++ b/tests/components/ledsc/test_config_flow.py @@ -1,7 +1,11 @@ -from unittest.mock import patch +"""Test the LedSc config flow.""" -from homeassistant import config_entries, data_entry_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} @@ -13,14 +17,14 @@ ENTRY_CONFIG = {"host": "127.0.0.1", "port": 8080} -async def test_form(hass): +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"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} with patch( @@ -30,47 +34,56 @@ async def test_form(hass): "homeassistant.components.ledsc.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == RESULT["title"] - assert result2["data"] == 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): +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}, ) - with patch( - "homeassistant.components.ledsc.config_flow.validate_input", - side_effect=ConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} async def test_form_unique_id_check(hass): - """Test we handle unique id check""" + """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, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=USER_INPUT, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" \ No newline at end of file + 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" From bc6eed79fae0e86d5361213ced9d46866ed0f2a5 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Sun, 2 Jun 2024 13:52:07 +0200 Subject: [PATCH 15/19] Update config_flow && light id. --- homeassistant/components/ledsc/config_flow.py | 9 +++++++-- homeassistant/components/ledsc/light.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index cdf0077502b9e5..fea1678db209a8 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -57,5 +57,10 @@ async def async_step_user( return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) + 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/light.py b/homeassistant/components/ledsc/light.py index 6cfb1c79797a51..08c02ba61a99ac 100644 --- a/homeassistant/components/ledsc/light.py +++ b/homeassistant/components/ledsc/light.py @@ -28,7 +28,7 @@ async def async_setup_entry( devices: list[LedSCLightEntity] = [] for websc in client.devices.values(): ledsc = LedSCLightEntity( - client_id=f"{config.data[CONF_HOST]}:{config.data[CONF_PORT]}", + client_id=config.entry_id, websc=websc, hass=hass, ) From 03b6e448c04151756ae4e15c6fd1040dd920f33f Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Sun, 2 Jun 2024 14:42:00 +0200 Subject: [PATCH 16/19] Add strings.json --- homeassistant/components/ledsc/strings.json | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 homeassistant/components/ledsc/strings.json diff --git a/homeassistant/components/ledsc/strings.json b/homeassistant/components/ledsc/strings.json new file mode 100644 index 00000000000000..cb73a629e0b958 --- /dev/null +++ b/homeassistant/components/ledsc/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Connect to your WebSC Server", + "description": "For more information click [here](https://ledsc.eu/websc/)", + "data": { + "host": "Host", + "port": "Port" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please verify the connection details", + "unknown": "Unexpected Error" + }, + "abort": { + "already_configured": "This WebSC server is already configured", + "cannot_connect": "Failed to connect, please verify the connection details" + } + } +} \ No newline at end of file From 2338692fdb3fb9f5e2ebbd8091cd76602d0fc502 Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Sun, 2 Jun 2024 20:07:09 +0200 Subject: [PATCH 17/19] Up websc-client to 1.0.4 --- homeassistant/components/ledsc/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ledsc/manifest.json b/homeassistant/components/ledsc/manifest.json index 07cdd7b6ff3696..519c6ed9061ee3 100644 --- a/homeassistant/components/ledsc/manifest.json +++ b/homeassistant/components/ledsc/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ledsc/", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["websc-client==1.0.3"] + "requirements": ["websc-client==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index faab8903300d75..579a05368e7757 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2876,7 +2876,7 @@ webmin-xmlrpc==0.0.2 webrtc-noise-gain==1.2.3 # homeassistant.components.ledsc -websc-client==1.0.3 +websc-client==1.0.4 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 From deca64eec50491a6a160ccac303c944f603a11dd Mon Sep 17 00:00:00 2001 From: PatrikKr010 <57815256+PatrikKr010@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:33:31 +0200 Subject: [PATCH 18/19] Update homeassistant/components/ledsc/strings.json Co-authored-by: Mr. Bubbles --- homeassistant/components/ledsc/strings.json | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ledsc/strings.json b/homeassistant/components/ledsc/strings.json index cb73a629e0b958..93d1438c95efff 100644 --- a/homeassistant/components/ledsc/strings.json +++ b/homeassistant/components/ledsc/strings.json @@ -1,23 +1,19 @@ { "config": { - "flow_title": "{name}", "step": { "user": { - "title": "Connect to your WebSC Server", - "description": "For more information click [here](https://ledsc.eu/websc/)", "data": { - "host": "Host", - "port": "Port" + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" } } }, "error": { - "cannot_connect": "Failed to connect, please verify the connection details", - "unknown": "Unexpected Error" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "This WebSC server is already configured", - "cannot_connect": "Failed to connect, please verify the connection details" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } } \ No newline at end of file From 036288ced2600c3a082e8dc4fae2f7f2ad8c7eba Mon Sep 17 00:00:00 2001 From: Patrik Kratochvil Date: Thu, 27 Jun 2024 17:36:38 +0200 Subject: [PATCH 19/19] Cleaning & Docs --- homeassistant/components/ledsc/__init__.py | 17 +---------------- homeassistant/components/ledsc/config_flow.py | 7 +++++-- tests/components/ledsc/__init__.py | 1 + 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/ledsc/__init__.py b/homeassistant/components/ledsc/__init__.py index 3d745277dfb2cb..43a8e45e14cf13 100644 --- a/homeassistant/components/ledsc/__init__.py +++ b/homeassistant/components/ledsc/__init__.py @@ -2,26 +2,11 @@ from __future__ import annotations -import logging - -import voluptuous as vol - -from homeassistant.components.light import PLATFORM_SCHEMA from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv PLATFORMS: list[str] = [Platform.LIGHT] -_LOGGER = logging.getLogger(__name__) - -# Validation of the user's configuration -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.port, - } -) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ledsc/config_flow.py b/homeassistant/components/ledsc/config_flow.py index fea1678db209a8..3112ec499a69a1 100644 --- a/homeassistant/components/ledsc/config_flow.py +++ b/homeassistant/components/ledsc/config_flow.py @@ -53,10 +53,13 @@ async def async_step_user( 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) + return self.async_create_entry(title=info["title"], data=user_input) # noqa - return self.async_show_form( + return self.async_show_form( # noqa step_id="user", data_schema=self.add_suggested_values_to_schema( data_schema=DATA_SCHEMA, diff --git a/tests/components/ledsc/__init__.py b/tests/components/ledsc/__init__.py index e69de29bb2d1d6..c68d5ad89c7c29 100644 --- a/tests/components/ledsc/__init__.py +++ b/tests/components/ledsc/__init__.py @@ -0,0 +1 @@ +"""Tests for LedSC integration""" \ No newline at end of file