Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
83 changes: 81 additions & 2 deletions custom_components/lelight/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,93 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)

from .const import DOMAIN
from .const import (
CONF_GROUP_ID,
CONF_GROUP_NAME,
CONF_LIGHT_MODE,
DOMAIN,
LIGHT_MODE_COLOR_TEMP,
make_entity_unique_id,
LEGACY_ENTITY_UNIQUE_ID,
)
from .light import LeLight

PLATFORMS: list[str] = ["light"]

CONFIG_ENTRY_VERSION = 2


async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entries."""

if entry.version > CONFIG_ENTRY_VERSION:
return False

if entry.version == 1:
data = dict(entry.data)

data.setdefault(CONF_LIGHT_MODE, LIGHT_MODE_COLOR_TEMP)
data.setdefault(CONF_GROUP_ID, 0)
data.setdefault(CONF_GROUP_NAME, "")

host = data["host"]
group_id = data[CONF_GROUP_ID]

new_unique_id = make_entity_unique_id(host, group_id)

entity_registry = er.async_get(hass)

for entity in er.async_entries_for_config_entry(
entity_registry,
entry.entry_id,
):
if entity.unique_id == LEGACY_ENTITY_UNIQUE_ID:
entity_registry.async_update_entity(
entity.entity_id,
new_unique_id=new_unique_id,
)

device_registry = dr.async_get(hass)
old_identifier = (DOMAIN, host)
new_identifier = (DOMAIN, f"{host}_{group_id}")

for device in dr.async_entries_for_config_entry(
device_registry,
entry.entry_id,
):
if old_identifier not in device.identifiers:
continue

identifiers = set(device.identifiers)
identifiers.remove(old_identifier)
identifiers.add(new_identifier)

device_registry.async_update_device(
device.id,
new_identifiers=identifiers,
)

hass.config_entries.async_update_entry(
entry,
data=data,
version=CONFIG_ENTRY_VERSION,
)

return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LeLight(entry.data["host"])
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LeLight(
entry.data["host"],
entry.data.get(CONF_LIGHT_MODE, LIGHT_MODE_COLOR_TEMP),
entry.data.get(CONF_GROUP_ID, 0),
entry.data.get(CONF_GROUP_NAME, ""),
)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

Expand Down
62 changes: 57 additions & 5 deletions custom_components/lelight/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,80 @@
from logging import getLogger
from hashlib import sha1
from typing import Any

import voluptuous as vol
from homeassistant import config_entries, exceptions
from homeassistant.core import HomeAssistant
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectSelector,
SelectSelectorConfig,
)

from .const import DOMAIN
from .const import (
CONF_GROUP_ID,
CONF_GROUP_NAME,
CONF_LIGHT_MODE,
DOMAIN,
LIGHT_MODE_BRIGHTNESS,
LIGHT_MODE_COLOR_TEMP,
)

logger = getLogger(DOMAIN)

DATA_SCHEMA = vol.Schema({vol.Required("host"): str})
DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
vol.Optional(CONF_GROUP_NAME, default=""): str,
vol.Optional(CONF_GROUP_ID, default=0): NumberSelector(
NumberSelectorConfig(
min=0,
max=255,
step=1,
mode=NumberSelectorMode.BOX,
)
),
vol.Required(
CONF_LIGHT_MODE,
default=LIGHT_MODE_BRIGHTNESS,
): SelectSelector(
SelectSelectorConfig(
options=[
LIGHT_MODE_BRIGHTNESS,
LIGHT_MODE_COLOR_TEMP,
],
translation_key="light_type",
)
),
}
)


def group_id_from_name(group_name: str) -> int:
group_hash = sha1(group_name.strip().lower().encode("utf-8")).digest()
return group_hash[0] % 255 + 1
Comment on lines +55 to +57

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This part looks suspicious. Are you sure that original app works like this?



async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]:
if len(data["host"].strip()) != 8:
data["host"] = data["host"].strip()
data[CONF_GROUP_NAME] = data.get(CONF_GROUP_NAME, "").strip()

if len(data["host"]) != 8:
raise InvalidHost("Host name must be 8 characters long")

return {"title": data["host"].strip()}
if data.get(CONF_GROUP_ID, 0) == 0 and data[CONF_GROUP_NAME]:
data[CONF_GROUP_ID] = int(data.get(CONF_GROUP_ID, 0))
data[CONF_GROUP_ID] = group_id_from_name(data[CONF_GROUP_NAME])

return {"title": data["host"]}


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hello World."""

VERSION = 1
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH

async def async_step_user(self, user_input=None):
Expand Down
6 changes: 3 additions & 3 deletions custom_components/lelight/connector.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from abc import abstractmethod
from asyncio import sleep

from .encoder import Command, Encryptor, Message

Expand All @@ -15,14 +14,15 @@ async def close(self):


class App:
def __init__(self, mac: str, backend: BtBackend):
def __init__(self, mac: str, backend: BtBackend, group_id: int = 0):
_mac = [int.from_bytes(x, "big", signed=True) for x in
(x.to_bytes(1, "little", signed=False) for x in bytearray.fromhex(mac))]
self.encryptor = Encryptor(_mac)
self.backend = backend
self.group_id = group_id

def _make_message(self, command: Command) -> Message:
return self.encryptor.message(command)
return self.encryptor.message(command, self.group_id)

async def send(self, command: Command):
await self.backend.send_message(self._make_message(command))
Expand Down
2 changes: 1 addition & 1 deletion custom_components/lelight/connector_bless.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class BlessServer(BlessServerBlueZDBus):
async def setup(self):
await super().setup()
self.adv = BlueZLEAdvertisement(Type.BROADCAST, 1, self.app)
self.adv._tx_power = 35
self.adv._tx_power = 20

self.app.advertisements = [self.adv]
self.bus.export(self.adv.path, self.adv)
Expand Down
11 changes: 11 additions & 0 deletions custom_components/lelight/const.py
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
DOMAIN = "lelight"
LEGACY_ENTITY_UNIQUE_ID = "lelight_light"

CONF_LIGHT_MODE = "light_mode"
CONF_GROUP_ID = "group_id"
CONF_GROUP_NAME = "group_name"
LIGHT_MODE_BRIGHTNESS = "brightness"
LIGHT_MODE_COLOR_TEMP = "color_temp"


def make_entity_unique_id(host: str, group_id: int) -> str:
return f"{DOMAIN}_{host}_{group_id}"
14 changes: 7 additions & 7 deletions custom_components/lelight/encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ def b(x: int) -> int:
return x & -1


def list_to_bytes_str(x: [int]) -> str:
def list_to_bytes_str(x: list[int]) -> str:
return "".join(int_to_bytes(i).hex() for i in x).upper()


def int_list_to_hex(x: [int]) -> bytearray:
def int_list_to_hex(x: list[int]) -> bytearray:
return bytearray.fromhex(list_to_bytes_str(x))


Expand All @@ -44,7 +44,7 @@ def checksum(data: List[int]) -> int:
return byte


def encode(salt: int, data: List[int]) -> [int]:
def encode(salt: int, data: List[int]) -> list[int]:
res = [0] * len(data)
for i in range(len(data)):
res[i] = data[i] if i < 10 else b(data[i] ^ ABC[salt & 15])
Expand All @@ -54,7 +54,7 @@ def encode(salt: int, data: List[int]) -> [int]:
@dataclass
class Command:
command: int
value: [int]
value: list[int]


@dataclass
Expand All @@ -81,8 +81,8 @@ def __init__(self, mac=None):
self.messageId = 0
self.messagesSalt = mac

def message(self, command: Command):
data = self._message(command.command, command.value)
def message(self, command: Command, group_id: int = 0):
data = self._message(command.command, command.value, group_id)
return Message(data=data)

def _message(self, command: int, message: List[int], group_id: int = 0):
Expand All @@ -101,7 +101,7 @@ def _message(self, command: int, message: List[int], group_id: int = 0):
if group_id == -1:
group_id = 0

data[6] = group_id
data[6] = b(group_id)
data[7] = b(self.messageId)
data[8] = b(command)
data[9] = len(message)
Expand Down
61 changes: 42 additions & 19 deletions custom_components/lelight/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
)
from homeassistant.helpers.entity import DeviceInfo

from . import make_entity_unique_id
from .connector import App
from .connector_bless import BlessBackend
from .const import DOMAIN
from .const import DOMAIN, LIGHT_MODE_COLOR_TEMP
from .encoder import Commands

logger = getLogger(DOMAIN)
Expand All @@ -33,16 +34,15 @@ class LeLight(LightEntity):
min_color_temp_kelvin = 3000
max_color_temp_kelvin = 6400

unique_id = "lelight_light"
color_mode = ColorMode.COLOR_TEMP
supported_color_modes = {
ColorMode.COLOR_TEMP,
}

def __init__(self, host: str) -> None:
def __init__(
self, host: str, light_mode: str, group_id: int, group_name: str
) -> None:
self._host = host
self._light_mode = light_mode
self._group_id = group_id
self._group_name = group_name
self._state = False
self.app = App(host, BlessBackend())
self.app = App(host, BlessBackend(), group_id)

# brightness from 0 to 1000 (device format)
self._brightness = 1000
Expand All @@ -52,15 +52,35 @@ def __init__(self, host: str) -> None:

@cached_property
def name(self) -> str:
if self._group_name:
return f"LeLight {self._group_name}"
return f"LeLight {self._host}"

@property
def unique_id(self) -> str:
return make_entity_unique_id(self._host, self._group_id)

@property
def color_mode(self) -> ColorMode:
if self._light_mode == LIGHT_MODE_COLOR_TEMP:
return ColorMode.COLOR_TEMP
return ColorMode.BRIGHTNESS

@property
def supported_color_modes(self) -> set[ColorMode]:
if self._light_mode == LIGHT_MODE_COLOR_TEMP:
return {ColorMode.COLOR_TEMP}
return {ColorMode.BRIGHTNESS}

@property
def brightness(self):
return normalize_value(self._brightness, 1000, 255)

@property
def color_temp_kelvin(self) -> int | None:
return self._temp
if self._light_mode == LIGHT_MODE_COLOR_TEMP:
return self._temp
return None

async def async_turn_on(self, **kwargs: Any) -> None:
if not self._state:
Expand All @@ -72,9 +92,11 @@ async def async_turn_on(self, **kwargs: Any) -> None:
self._brightness = normalize_value(kwargs[ATTR_BRIGHTNESS], 255, 1000)
await self.app.send(Commands.bright(self._brightness))

if ATTR_COLOR_TEMP_KELVIN in kwargs:
if (
self._light_mode == LIGHT_MODE_COLOR_TEMP
and ATTR_COLOR_TEMP_KELVIN in kwargs
):
self._temp = kwargs[ATTR_COLOR_TEMP_KELVIN]

await self.app.send(Commands.temp(self._temp))

async def async_turn_off(self, **kwargs: Any) -> None:
Expand All @@ -93,13 +115,14 @@ def is_on(self) -> bool:

@cached_property
def device_info(self) -> DeviceInfo | None:
return {
"identifiers": {(DOMAIN, self._host)},
"name": f"lelight lamp {self._host}",
"sw_version": "none",
"model": "lelight lamp",
"manufacturer": "lelight",
}
return DeviceInfo(
identifiers={(DOMAIN, f"{self._host}_{self._group_id}")},
name=f"lelight lamp {self._host} group {self._group_id}",
suggested_area=self._group_name or None,
sw_version="none",
model="lelight lamp",
manufacturer="lelight",
)

async def async_removed_from_registry(self) -> None:
await self.app.backend.close()
Loading