Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add LedSC integration #116957

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a33bbb6
Add LedSC integration
PatrikKr010 May 6, 2024
b6f4d36
The code formatted using Ruff
PatrikKr010 May 8, 2024
d2f0009
Add docstrings and clean code.
PatrikKr010 May 8, 2024
78997ac
update manifest and call script.gen_requirements_all
PatrikKr010 May 8, 2024
24449bd
Merge branch 'dev' into dev
PatrikKr010 May 8, 2024
57564cb
LedSC: Update documentation link
PatrikKr010 May 8, 2024
cd78812
Merge remote-tracking branch 'origin/dev' into dev
PatrikKr010 May 8, 2024
88067b1
Merge branch 'dev' into dev
PatrikKr010 May 8, 2024
2103aab
Merge branch 'dev' into dev
PatrikKr010 May 8, 2024
a138c21
Merge branch 'dev' into dev
PatrikKr010 May 9, 2024
fbf365e
Merge branch 'dev' into dev
PatrikKr010 May 12, 2024
958b7ae
Merge branch 'dev' into dev
PatrikKr010 May 29, 2024
a01e9e9
Added abstraction layer for communication with WebSC
PatrikKr010 May 29, 2024
3b4c752
Use cv.port to validate port number
PatrikKr010 May 30, 2024
678243a
Fix docstrings & code formatting
PatrikKr010 May 30, 2024
50f5892
Fix docstrings & code formatting
PatrikKr010 May 30, 2024
2a5e9af
Add untested files to .coveragerc
PatrikKr010 May 30, 2024
b77ff3a
Set key in error as `cannot_connect`
PatrikKr010 May 31, 2024
06057d2
Optimize imports & Exceptions & up websc-client to version 1.0.2 & Fi…
PatrikKr010 May 31, 2024
823aecd
Remove default host in config flow.
PatrikKr010 May 31, 2024
bf0e38c
Fix tests && Up websc-client to 1.0.3
PatrikKr010 Jun 2, 2024
bc6eed7
Update config_flow && light id.
PatrikKr010 Jun 2, 2024
03b6e44
Add strings.json
PatrikKr010 Jun 2, 2024
5ef977e
Merge branch 'dev' into dev
PatrikKr010 Jun 2, 2024
2338692
Up websc-client to 1.0.4
PatrikKr010 Jun 2, 2024
f5cd6fe
Merge branch 'dev' into dev
PatrikKr010 Jun 10, 2024
4cb5e8e
Merge branch 'dev' into dev
PatrikKr010 Jun 18, 2024
deca64e
Update homeassistant/components/ledsc/strings.json
PatrikKr010 Jun 27, 2024
036288c
Cleaning & Docs
PatrikKr010 Jun 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
homeassistant/components/lg_soundbar/__init__.py
homeassistant/components/lg_soundbar/media_player.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions homeassistant/components/ledsc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Platform for LedSC integration."""

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.core import HomeAssistant
import homeassistant.helpers.config_validation as cv

PLATFORMS: list[str] = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved

# Validation of the user's configuration
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
}
)

PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved

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)
61 changes: 61 additions & 0 deletions homeassistant/components/ledsc/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""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."""
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
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"
else:
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
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
)
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 4 additions & 0 deletions homeassistant/components/ledsc/consts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Constants for the LedSC integration."""

DOMAIN = "ledsc"
DEFAULT_PORT = 8443
151 changes: 151 additions & 0 deletions homeassistant/components/ledsc/light.py
Original file line number Diff line number Diff line change
@@ -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")

Comment on lines +22 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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")
load the configured devices from WebSC Server and add them to hass.
"""
client = config.runtime_data

Initialization of the client should be done in init.py and then stored in the config entry. See https://developers.home-assistant.io/blog/2024/04/30/store-runtime-data-inside-config-entry on how to do this.

devices: list[LedSCLightEntity] = []
for websc in client.devices.values():
ledsc = LedSCLightEntity(
client_id=f"{config.data[CONF_HOST]}:{config.data[CONF_PORT]}",
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)

Comment on lines +42 to +54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)
_attr_has_entity_name = True
_attr_translation_key: str = "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("LedSC %s initialized!", websc.name)
self._attr_device_info = DeviceInfo(
manufacturer="LedSC",
model="LedSC",
name=websc.name,
identifiers={(DOMAIN, f"{client_id}-{websc.name}")},
)

Ok, so I fiddled around a bit and this is what i came up with. No need to set entity_description. It will create a Device with the corresponding Name from the ledsc server. The entity will have a generic name light.

You also will have to add the following to the strings.json file

"entity": {
    "light": {
      "light": {
        "name": "[%key:component::light::title%]"
      }
    }
  }

and as LedSC are led-strips you may also want to add an icons.json and set the icon to a led-strip

{
  "entity": {
    "light": {
      "light": {
        "default": "mdi:led-strip-variant"
      }
    }
  }
}

@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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the entity_description attribute instead and use strings.json for device names instead

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can use entity_description for more detailed device information, but the entity name is dynamic based on the WebSC configuration.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you don't even need a name for the entity, if you leave it empty, and set the name of the device and the manufacturer in the DeviceInfo it will use the generic name "Light", the entity will then be named light.ledsc_devicename_light

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Do is not a very nice default name.
If you don't mind, I would like to leave this property. This name is set in WebSC.
Snímek obrazovky z 2024-05-30 18-33-19

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's tricky to get this right, haven't done this yet with the light platform. I would like to test it, is there some way to do this? The host demo.ledsc.eu did not work.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The demo.ledsc.eu domain is still under development. I am currently creating a way to create a virtual instance of WebSC so that anyone who wants to try the demo can run their own instance of WebSC for a limited time. However, for testing purposes I can run one static instance on this domain. Otherwise you can run your own instances of course.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""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
10 changes: 10 additions & 0 deletions homeassistant/components/ledsc/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "ledsc",
"name": "LedSC",
"codeowners": ["@PatrikKr010"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ledsc/",
"integration_type": "hub",
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
"iot_class": "local_polling",
"requirements": ["websc-client==1.0.2"]
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@
"ld2410_ble",
"leaone",
"led_ble",
"ledsc",
"lg_netcast",
"lg_soundbar",
"lidarr",
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -3193,6 +3193,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",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2875,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.2

# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.8

Expand Down
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
Empty file.
76 changes: 76 additions & 0 deletions tests/components/ledsc/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from unittest.mock import patch
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved

from homeassistant import config_entries, data_entry_flow
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
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):
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
"""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
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
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
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
assert result2["title"] == RESULT["title"]
assert result2["data"] == USER_INPUT


async def test_form_cannot_connect(hass):
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
"""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(
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
result["flow_id"],
USER_INPUT,
)

assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}

PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved

PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
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,
)
Comment on lines +64 to +68
Copy link
Contributor

@tr4nt0r tr4nt0r May 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=USER_INPUT,
)
MockConfigEntry(domain=DOMAIN, data=USER_INPUT).add_to_hass(hass)

You are initializing the config flow 2 times. Just add the config entry before testing the config flow.
Preferably, create it as fixture in the conftest.py file.

@pytest.fixture(name="ledsc_config_entry")
def mock_ledsc_config_entry() -> MockConfigEntry:
    """Mock ledsc configuration entry."""
    return MockConfigEntry(
        domain=DOMAIN, data= {"host": "127.0.0.1", "port": 8080}
    )

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But how do I get flow_id with this flow initialization structure? Under normal circumstances I get the result of the initialization with all the necessary data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are testing if the config flow aborts, if an entry already exists. To be able to test this, you set the precondition, that there is already an entry, you just add it to hass, then initialize the config flow.


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
PatrikKr010 marked this conversation as resolved.
Show resolved Hide resolved
assert result["reason"] == "already_configured"