diff --git a/custom_components/lelight/__init__.py b/custom_components/lelight/__init__.py index 7b980ca..3d4ab88 100644 --- a/custom_components/lelight/__init__.py +++ b/custom_components/lelight/__init__.py @@ -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 diff --git a/custom_components/lelight/config_flow.py b/custom_components/lelight/config_flow.py index 20623df..7dea88b 100644 --- a/custom_components/lelight/config_flow.py +++ b/custom_components/lelight/config_flow.py @@ -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 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): diff --git a/custom_components/lelight/connector.py b/custom_components/lelight/connector.py index 86fb256..4b955ec 100644 --- a/custom_components/lelight/connector.py +++ b/custom_components/lelight/connector.py @@ -1,5 +1,4 @@ from abc import abstractmethod -from asyncio import sleep from .encoder import Command, Encryptor, Message @@ -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)) diff --git a/custom_components/lelight/connector_bless.py b/custom_components/lelight/connector_bless.py index 05df281..86a11a8 100644 --- a/custom_components/lelight/connector_bless.py +++ b/custom_components/lelight/connector_bless.py @@ -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) diff --git a/custom_components/lelight/const.py b/custom_components/lelight/const.py index bc36a5f..68114a2 100644 --- a/custom_components/lelight/const.py +++ b/custom_components/lelight/const.py @@ -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}" diff --git a/custom_components/lelight/encoder.py b/custom_components/lelight/encoder.py index 2ca9314..b9a063e 100644 --- a/custom_components/lelight/encoder.py +++ b/custom_components/lelight/encoder.py @@ -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)) @@ -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]) @@ -54,7 +54,7 @@ def encode(salt: int, data: List[int]) -> [int]: @dataclass class Command: command: int - value: [int] + value: list[int] @dataclass @@ -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): @@ -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) diff --git a/custom_components/lelight/light.py b/custom_components/lelight/light.py index ec1b619..64283e9 100644 --- a/custom_components/lelight/light.py +++ b/custom_components/lelight/light.py @@ -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) @@ -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 @@ -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: @@ -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: @@ -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() diff --git a/custom_components/lelight/manifest.json b/custom_components/lelight/manifest.json index 050c43c..5288e31 100644 --- a/custom_components/lelight/manifest.json +++ b/custom_components/lelight/manifest.json @@ -6,15 +6,15 @@ ], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/v1ack/ha-lelight/README.md", + "documentation": "https://github.com/v1ack/lelight/blob/master/README.md", "iot_class": "local_push", - "issue_tracker": "https://github.com/v1ack/ha-lelight/issues", + "issue_tracker": "https://github.com/v1ack/lelight/issues", "loggers": [ "lelight" ], "requirements": [ - "bless~=0.2.6", + "bless==0.3.0", "dbus-next==0.2.3" ], - "version": "0.3.0" + "version": "0.4.0" } diff --git a/custom_components/lelight/strings.json b/custom_components/lelight/strings.json deleted file mode 100644 index 75dc2bb..0000000 --- a/custom_components/lelight/strings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "title": "LeLight ble connection", - "config": { - "step": { - "user": { - "title": "Init connection", - "data": { - "host": "ID from LeLight App" - } - } - } - } -} diff --git a/custom_components/lelight/translations/en.json b/custom_components/lelight/translations/en.json index d27ceda..12ff606 100644 --- a/custom_components/lelight/translations/en.json +++ b/custom_components/lelight/translations/en.json @@ -3,11 +3,22 @@ "config": { "step": { "user": { - "title": "Init connection", + "title": "Set up LeLight", "data": { - "host": "ID from LeLight App" + "host": "ID from LeLight app", + "group_name": "Room or group name", + "group_id": "Group ID", + "light_mode": "Light type" } } } + }, + "selector": { + "light_type": { + "options": { + "brightness": "Brightness only", + "color_temp": "Brightness and color temperature" + } + } } -} \ No newline at end of file +} diff --git a/custom_components/lelight/translations/ru.json b/custom_components/lelight/translations/ru.json new file mode 100644 index 0000000..7e79109 --- /dev/null +++ b/custom_components/lelight/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Настройка LeLight", + "data": { + "host": "ID из приложения LeLight", + "group_name": "Название комнаты или группы", + "group_id": "ID группы", + "light_mode": "Тип лампы" + } + } + } + }, + "selector": { + "light_type": { + "options": { + "brightness": "Только яркость", + "color_temp": "Яркость и цветовая температура" + } + } + } +} diff --git a/custom_components/lelight/translations/zh-Hans.json b/custom_components/lelight/translations/zh-Hans.json new file mode 100644 index 0000000..6ce3cfd --- /dev/null +++ b/custom_components/lelight/translations/zh-Hans.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "设置 LeLight", + "data": { + "host": "LeLight 应用中的 ID", + "group_name": "房间或分组名称", + "group_id": "分组 ID", + "light_mode": "灯具类型" + } + } + } + }, + "selector": { + "light_type": { + "options": { + "brightness": "调光灯", + "color_temp": "色温灯" + } + } + } +}