├── icons ├── icon.png └── icon@2.png ├── .vscode └── settings.json ├── wireshark_pcaps ├── effects.pcapng └── onoffrgbbright.pcapng ├── custom_components └── elk_bledob │ ├── const.py │ ├── manifest.json │ ├── translations │ └── en.json │ ├── __init__.py │ ├── light.py │ ├── config_flow.py │ └── elkbledob.py ├── hacs.json ├── LICENSE └── README.md /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8none1/elk-bledob/HEAD/icons/icon.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "bledob" 4 | ] 5 | } -------------------------------------------------------------------------------- /icons/icon@2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8none1/elk-bledob/HEAD/icons/icon@2.png -------------------------------------------------------------------------------- /wireshark_pcaps/effects.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8none1/elk-bledob/HEAD/wireshark_pcaps/effects.pcapng -------------------------------------------------------------------------------- /wireshark_pcaps/onoffrgbbright.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8none1/elk-bledob/HEAD/wireshark_pcaps/onoffrgbbright.pcapng -------------------------------------------------------------------------------- /custom_components/elk_bledob/const.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | DOMAIN = "elk_bledob" 4 | CONF_RESET = "reset" 5 | CONF_DELAY = "delay" 6 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ELK BLEDOB", 3 | "content_in_root": false, 4 | "zip_release": false, 5 | "render_readme": true, 6 | "homeassistant": "2023.11.0", 7 | "hacs": "1.33.0" 8 | } 9 | -------------------------------------------------------------------------------- /custom_components/elk_bledob/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "elk_bledob", 3 | "name": "ELK BLEDOB", 4 | "bluetooth": [ 5 | { "local_name": "ELK-BLEDOB" } 6 | ], 7 | "codeowners": ["@8none1"], 8 | "config_flow": true, 9 | "dependencies": ["bluetooth"], 10 | "documentation": "https://github.com/8none1/elk-bledob", 11 | "iot_class": "local_polling", 12 | "issue_tracker": "https://github.com/8none1/elk-bledob/issues", 13 | "requirements": ["bleak-retry-connector>=1.17.1","bleak>=0.17.0"], 14 | "version": "0.0.1", 15 | "integration_type": "device" 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Will Cooke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/elk_bledob/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "mac": "Device:", 7 | "name": "Name" 8 | }, 9 | "title": "Choose a device." 10 | }, 11 | "validate": { 12 | "data": { 13 | "retry": "Retry validate connection?", 14 | "flicker": "Did the light blink?" 15 | }, 16 | "title": "Validate connection" 17 | }, 18 | "manual": { 19 | "data": { 20 | "mac": "ELK-BLEDOB device:", 21 | "name": "Name" 22 | }, 23 | "title": "Enter bluetooth MAC address" 24 | } 25 | }, 26 | "error": { 27 | "connect": "Unable to connect" 28 | }, 29 | "abort": { 30 | "cannot_validate": "Unable to validate", 31 | "cannot_connect": "Unable to connect" 32 | } 33 | }, 34 | "options": { 35 | "step": { 36 | "user": { 37 | "data": { 38 | "reset": "Reset color when led turn on", 39 | "delay": "Disconnect delay (0 equal never disconnect)" 40 | } 41 | } 42 | } 43 | }, 44 | "title": "ELK BLEDOB" 45 | } 46 | -------------------------------------------------------------------------------- /custom_components/elk_bledob/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from homeassistant.config_entries import ConfigEntry 4 | from homeassistant.core import HomeAssistant, Event 5 | from homeassistant.const import CONF_MAC, EVENT_HOMEASSISTANT_STOP 6 | 7 | from .const import DOMAIN, CONF_RESET, CONF_DELAY 8 | from .elkbledob import ELKBLEDOBInstance 9 | import logging 10 | 11 | LOGGER = logging.getLogger(__name__) 12 | PLATFORMS = ["light"] 13 | 14 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 15 | """Set up from a config entry.""" 16 | reset = entry.options.get(CONF_RESET, None) or entry.data.get(CONF_RESET, None) 17 | delay = entry.options.get(CONF_DELAY, None) or entry.data.get(CONF_DELAY, None) 18 | LOGGER.debug("Config Reset data: %s and config delay data: %s", reset, delay) 19 | 20 | instance = ELKBLEDOBInstance(entry.data[CONF_MAC], reset, delay, hass) 21 | hass.data.setdefault(DOMAIN, {}) 22 | hass.data[DOMAIN][entry.entry_id] = instance 23 | 24 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 25 | entry.async_on_unload(entry.add_update_listener(_async_update_listener)) 26 | 27 | async def _async_stop(event: Event) -> None: 28 | """Close the connection.""" 29 | await instance.stop() 30 | 31 | entry.async_on_unload( 32 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) 33 | ) 34 | return True 35 | 36 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 37 | """Unload a config entry.""" 38 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 39 | if unload_ok: 40 | instance = hass.data[DOMAIN][entry.entry_id] 41 | await instance.stop() 42 | hass.data[DOMAIN].pop(entry.entry_id) 43 | return unload_ok 44 | 45 | async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 46 | """Handle options update.""" 47 | instance = hass.data[DOMAIN][entry.entry_id] 48 | if entry.title != instance.name: 49 | await hass.config_entries.async_reload(entry.entry_id) 50 | -------------------------------------------------------------------------------- /custom_components/elk_bledob/light.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import voluptuous as vol 3 | from typing import Any, Optional, Tuple 4 | from .elkbledob import ELKBLEDOBInstance 5 | from .const import DOMAIN 6 | 7 | from homeassistant.const import CONF_MAC 8 | import homeassistant.helpers.config_validation as cv 9 | from homeassistant.helpers.entity import DeviceInfo 10 | from homeassistant.components.light import ( 11 | PLATFORM_SCHEMA, 12 | ATTR_BRIGHTNESS, 13 | ATTR_RGB_COLOR, 14 | ATTR_EFFECT, 15 | ColorMode, 16 | LightEntity, 17 | LightEntityFeature, 18 | ) 19 | 20 | from homeassistant.helpers import device_registry 21 | 22 | LOGGER = logging.getLogger(__name__) 23 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_MAC): cv.string}) 24 | 25 | async def async_setup_entry(hass, config_entry, async_add_devices): 26 | instance = hass.data[DOMAIN][config_entry.entry_id] 27 | await instance.update() 28 | async_add_devices( 29 | [ELKBLEDOBLight(instance, config_entry.data["name"], config_entry.entry_id)] 30 | ) 31 | 32 | class ELKBLEDOBLight(LightEntity): 33 | def __init__( 34 | self, elkbledobinstance: ELKBLEDOBInstance, name: str, entry_id: str 35 | ) -> None: 36 | self._instance = elkbledobinstance 37 | self._entry_id = entry_id 38 | self._attr_supported_color_modes = {ColorMode.RGB} 39 | self._attr_supported_features = LightEntityFeature.EFFECT 40 | self._attr_brightness_step_pct = 10 41 | self._attr_name = name 42 | self._attr_unique_id = self._instance.mac 43 | 44 | @property 45 | def available(self): 46 | # return self._instance.is_on != None 47 | return True # We don't know if this is working or not because there is zero feedback from the light, so assume yes 48 | 49 | @property 50 | def brightness(self): 51 | return self._instance.brightness 52 | 53 | @property 54 | def rgb_color(self): 55 | return self._instance.rgb_color 56 | 57 | @property 58 | def is_on(self) -> Optional[bool]: 59 | return self._instance.is_on 60 | 61 | @property 62 | def effect_list(self): 63 | return self._instance.effect_list 64 | 65 | @property 66 | def effect(self): 67 | return self._instance._effect 68 | 69 | @property 70 | def supported_features(self) -> int: 71 | """Flag supported features.""" 72 | return self._attr_supported_features 73 | 74 | @property 75 | def supported_color_modes(self) -> int: 76 | """Flag supported color modes.""" 77 | return self._attr_supported_color_modes 78 | 79 | @property 80 | def color_mode(self): 81 | """Return the color mode of the light.""" 82 | return self._instance._color_mode 83 | 84 | @property 85 | def device_info(self): 86 | """Return device info.""" 87 | return DeviceInfo( 88 | identifiers={ 89 | (DOMAIN, self._instance.mac) 90 | }, 91 | name=self.name, 92 | connections={(device_registry.CONNECTION_NETWORK_MAC, self._instance.mac)}, 93 | ) 94 | 95 | @property 96 | def should_poll(self): 97 | return False 98 | 99 | async def async_turn_on(self, **kwargs: Any) -> None: 100 | if not self.is_on: 101 | await self._instance.turn_on() 102 | 103 | if ATTR_BRIGHTNESS in kwargs and kwargs[ATTR_BRIGHTNESS] != self.brightness: 104 | self._brightness = kwargs[ATTR_BRIGHTNESS] 105 | await self._instance.set_brightness(kwargs[ATTR_BRIGHTNESS]) 106 | 107 | if ATTR_RGB_COLOR in kwargs: 108 | if kwargs[ATTR_RGB_COLOR] != self.rgb_color: 109 | self._effect = None 110 | await self._instance.set_rgb_color(kwargs[ATTR_RGB_COLOR]) 111 | 112 | if ATTR_EFFECT in kwargs: 113 | if kwargs[ATTR_EFFECT] != self.effect: 114 | self._effect = kwargs[ATTR_EFFECT] 115 | await self._instance.set_effect(kwargs[ATTR_EFFECT]) 116 | self.async_write_ha_state() 117 | 118 | async def async_turn_off(self, **kwargs: Any) -> None: 119 | await self._instance.turn_off() 120 | self.async_write_ha_state() 121 | 122 | async def async_set_effect(self, effect: str) -> None: 123 | self._effect = effect 124 | await self._instance.set_effect(effect) 125 | self.async_write_ha_state() 126 | 127 | async def async_update(self) -> None: 128 | await self._instance.update() 129 | self.async_write_ha_state() 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ELK BLEDOB 2 | 3 | Home Assistant custom integration for ELK BLEDOB devices controlled by the `LotusLamp X` app over Bluetooth LE. 4 | 5 | These are really cheap Bluetooth controlled LEDs available on AliExpress. 5M of LEDs for £2.36. The app is basic, but it works. The IR remote is basic, but it works. The lights connect to a USB port. 6 | 7 | Available here: https://www.aliexpress.com/item/1005005827818737.html 8 | 9 | ![image](https://github.com/8none1/elk-bledob/assets/6552931/5d98ff5b-39af-46da-84b9-4140c34f24fd) 10 | 11 | ![image](https://github.com/8none1/elk-bledob/assets/6552931/97a1cbcc-e807-45d3-b05d-55879c4905ce) 12 | 13 | ## Understanding the protocol 14 | 15 | This time around I'm taking a different route to finding the commands to operate the lights. Instead of pulling btsnoop files off an Android phone I am using an NRF 52840 dongle with BLE sniffer software running on it. The reason for this is that Android is making it harder and harder to get proper btsnoop logs off devices and on to your computer. Despite this working in the past, after a recent Android update I am now only getting `btsnooz` files which seem to truncate the data to the first 5 bytes or so. I haven't tried to fix this or work out why, I'm going for something which is going to work without as much messing about in to the foreseeable future. Using these NRF 52840 devices with Wireshark is well documented. 16 | 17 | There are some Wireshark pcap files in the `bt_snoops` folder if you want to examine them. 18 | 19 | ## Similar devices 20 | 21 | There are a number of integrations which support the ELK BLEDOM devices (note the M at the end not a B). The protocol for these devices is similar, but slightly different. 22 | 23 | ## Bluetooth LE commands 24 | 25 | ### Initial Connection 26 | 27 | The bytes `7e 06 83 0f 20 0c 06 00 ef` are sent at connection time. It is unknown what this does at the moment. 28 | 29 | ### On & Off 30 | 31 | `7e 07 04 ff 00 01 02 01 ef` - On 32 | 33 | `7e 07 04 00 00 00 02 01 ef` - Off 34 | 35 | ### Colours 36 | 37 | ``` 38 | |---------|--------------------- header 39 | | | ||------------------ red 40 | | | || ||--------------- green 41 | | | || || ||------------ blue 42 | | | || || || |---|------ footer 43 | 7e 07 05 03 ff 00 00 10 ef 44 | 7e 07 05 03 00 ff 00 10 ef 45 | 7e 07 05 03 00 00 ff 10 ef 46 | ``` 47 | ### Colours Brightness 48 | 49 | ``` 50 | |------| ----------------------- header 51 | | | ||--------------------- Brightness 0-100 52 | | | || |------------|------ Footer 53 | 7e 04 01 01 01 ff 02 01 ef 54 | 7e 04 01 32 01 ff 02 01 ef 55 | 7e 04 01 64 01 ff 02 01 ef 56 | ``` 57 | 58 | ### Mode / Effects 59 | 60 | ``` 61 | |------| ----------------------- header 62 | | | ||--------------------- Mode (135-156) 63 | | | || |------------|------ footer 64 | 7e 07 03 93 03 ff ff 00 ef 65 | 7e 07 03 98 03 ff ff 00 ef 66 | ``` 67 | 68 | Mode are numbered `0x87` to `0x9c`. 69 | 70 | #### Mode Speed 71 | 72 | ``` 73 | |------| ----------------------- header 74 | | | ||--------------------- Speed 75 | | | || |------------|------ footer 76 | 7e 07 02 64 ff ff ff 00 ef 77 | ``` 78 | 79 | #### Mode Brightness 80 | 81 | This is the same as colours brightness. 82 | 83 | ## Supported devices 84 | 85 | This has only been tested with a single generic LED strip from Ali Express. 86 | 87 | It reports itself as `ELK-BLEDOB` over Bluetooth LE. The app is called `LotusLamp X`. 88 | MAC address seem to start `BE:32:xx:xx:xx:xx`. 89 | 90 | ## Supported Features in this integration 91 | 92 | - On/Off 93 | - RGB colour 94 | - Brightness 95 | - Modes/effects 96 | - Automatic discovery of supported devices 97 | 98 | ## Not supported and not planned 99 | 100 | - Microphone interactivity 101 | - Timer / Clock functions 102 | - Discovery of current light state 103 | 104 | The timer/clock functions are understandable from the HCI Bluetooth logs but adding that functionality seems pointless and I don't think Home Assistant would support it any way. 105 | 106 | The discovery of the light's state requires that the device be able to tell us what state it is in. The BT controller on the device does report that it has `notify` capabilities but I have not been able to get it to report anything at all. Perhaps you will have more luck. Until this is solved, we have to use these lights in `optimistic` mode and assume everything just worked. Looking at HCI logs from the Android app it doesn't even try to enable notifications and never receives a packet from the light. 107 | 108 | ## Installation 109 | 110 | ### Requirements 111 | 112 | You need to have the bluetooth component configured and working in Home Assistant in order to use this integration. 113 | NB: If your lights are still connected to the App then they will not be automatically discovered until you disconnect. 114 | 115 | ### HACS 116 | 117 | Add this repo to HACS as a custom repo. Click through: 118 | 119 | - HACS -> Integrations -> Top right menu -> Custom Repositories 120 | - Paste the Github URL to this repo in to the Repository box 121 | - Choose category `Integration` 122 | - Click Add 123 | - Restart Home Assistant 124 | - ELK-BLEDOB devices should start to appear in your Integrations page 125 | 126 | ## Other projects that might be of interest 127 | 128 | - [iDotMatrix](https://github.com/8none1/idotmatrix) 129 | - [Zengge LEDnet WF](https://github.com/8none1/zengge_lednetwf) 130 | - [iDealLED](https://github.com/8none1/idealLED) 131 | - [BJ_LED](https://github.com/8none1/bj_led) 132 | - [ELK BLEDOB](https://github.com/8none1/elk-bledob) 133 | - [HiLighting LED](https://github.com/8none1/hilighting_homeassistant) 134 | - [BLELED LED Lamp](https://github.com/8none1/ledble-ledlamp) 135 | 136 | -------------------------------------------------------------------------------- /custom_components/elk_bledob/config_flow.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from .elkbledob import ELKBLEDOBInstance 3 | from typing import Any 4 | 5 | from bluetooth_data_tools import human_readable_name 6 | from homeassistant import config_entries 7 | from homeassistant.const import CONF_MAC 8 | import voluptuous as vol 9 | from homeassistant.helpers.device_registry import format_mac 10 | from homeassistant.data_entry_flow import FlowResult 11 | from homeassistant.core import callback 12 | from homeassistant.components.bluetooth import ( 13 | BluetoothServiceInfoBleak, 14 | async_discovered_service_info, 15 | ) 16 | from bluetooth_sensor_state_data import BluetoothData 17 | from home_assistant_bluetooth import BluetoothServiceInfo 18 | 19 | from .const import DOMAIN, CONF_RESET, CONF_DELAY 20 | import logging 21 | 22 | LOGGER = logging.getLogger(__name__) 23 | DATA_SCHEMA = vol.Schema({("host"): str}) 24 | 25 | class DeviceData(BluetoothData): 26 | def __init__(self, discovery_info) -> None: 27 | self._discovery = discovery_info 28 | LOGGER.debug("Discovered bluetooth devices, DeviceData, : %s , %s", self._discovery.address, self._discovery.name) 29 | 30 | def supported(self): 31 | return self._discovery.name.lower().startswith("elk-bledob") 32 | 33 | def address(self): 34 | return self._discovery.address 35 | 36 | def get_device_name(self): 37 | return human_readable_name(None, self._discovery.name, self._discovery.address) 38 | 39 | def name(self): 40 | return human_readable_name(None, self._discovery.name, self._discovery.address) 41 | 42 | def rssi(self): 43 | return self._discovery.rssi 44 | 45 | def _start_update(self, service_info: BluetoothServiceInfo) -> None: 46 | """Update from BLE advertisement data.""" 47 | LOGGER.debug("Parsing BLE advertisement data: %s", service_info) 48 | 49 | class ELKBLEDOBFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 50 | VERSION = 1 51 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 52 | 53 | def __init__(self) -> None: 54 | self.mac = None 55 | self._device = None 56 | self._instance = None 57 | self.name = None 58 | self._discovery_info: BluetoothServiceInfoBleak | None = None 59 | self._discovered_device: DeviceData | None = None 60 | self._discovered_devices = [] 61 | 62 | async def async_step_bluetooth( 63 | self, discovery_info: BluetoothServiceInfoBleak 64 | ) -> FlowResult: 65 | """Handle the bluetooth discovery step.""" 66 | LOGGER.debug("Discovered bluetooth devices, step bluetooth, : %s , %s", discovery_info.address, discovery_info.name) 67 | await self.async_set_unique_id(discovery_info.address) 68 | self._abort_if_unique_id_configured() 69 | device = DeviceData(discovery_info) 70 | self.context["title_placeholders"] = {"name": device.name()} 71 | if device.supported(): 72 | self._discovered_devices.append(device) 73 | return await self.async_step_bluetooth_confirm() 74 | else: 75 | return self.async_abort(reason="not_supported") 76 | 77 | async def async_step_bluetooth_confirm( 78 | self, user_input: dict[str, Any] | None = None 79 | ) -> FlowResult: 80 | """Confirm discovery.""" 81 | LOGGER.debug("Discovered bluetooth devices, step bluetooth confirm, : %s", user_input) 82 | self._set_confirm_only() 83 | return await self.async_step_user() 84 | 85 | async def async_step_user( 86 | self, user_input: dict[str, Any] | None = None 87 | ) -> FlowResult: 88 | """Handle the user step to pick discovered device.""" 89 | if user_input is not None: 90 | self.mac = user_input[CONF_MAC] 91 | if "title_placeholders" in self.context: 92 | self.name = self.context["title_placeholders"]["name"] 93 | if 'source' in self.context.keys() and self.context['source'] == "user": 94 | LOGGER.debug(f"User context. discovered devices: {self._discovered_devices}") 95 | for each in self._discovered_devices: 96 | LOGGER.debug(f"Address: {each.address()}") 97 | if each.address() == self.mac: 98 | self.name = each.get_device_name() 99 | if self.name is None: self.name = self.mac 100 | await self.async_set_unique_id(self.mac, raise_on_progress=False) 101 | self._abort_if_unique_id_configured() 102 | return await self.async_step_validate() 103 | 104 | current_addresses = self._async_current_ids() 105 | for discovery_info in async_discovered_service_info(self.hass): 106 | self.mac = discovery_info.address 107 | if self.mac in current_addresses: 108 | LOGGER.debug("Device %s in current_addresses", (self.mac)) 109 | continue 110 | if (device for device in self._discovered_devices if device.address == self.mac) == ([]): 111 | LOGGER.debug("Device %s in discovered_devices", (device)) 112 | continue 113 | device = DeviceData(discovery_info) 114 | if device.supported(): 115 | self._discovered_devices.append(device) 116 | 117 | if not self._discovered_devices: 118 | return await self.async_step_manual() 119 | 120 | LOGGER.debug("Discovered supported devices: %s - %s", self._discovered_devices[0].name(), self._discovered_devices[0].address()) 121 | 122 | mac_dict = { dev.address(): dev.name() for dev in self._discovered_devices } 123 | return self.async_show_form( 124 | step_id="user", data_schema=vol.Schema( 125 | { 126 | vol.Required(CONF_MAC): vol.In(mac_dict), 127 | } 128 | ), 129 | errors={}) 130 | 131 | async def async_step_validate(self, user_input: "dict[str, Any] | None" = None): 132 | if user_input is not None: 133 | if "flicker" in user_input: 134 | if user_input["flicker"]: 135 | return self.async_create_entry(title=self.name, data={CONF_MAC: self.mac, "name": self.name}) 136 | return self.async_abort(reason="cannot_validate") 137 | 138 | if "retry" in user_input and not user_input["retry"]: 139 | return self.async_abort(reason="cannot_connect") 140 | 141 | error = await self.toggle_light() 142 | 143 | if error: 144 | return self.async_show_form( 145 | step_id="validate", data_schema=vol.Schema( 146 | { 147 | vol.Required("retry"): bool 148 | } 149 | ), errors={"base": "connect"}) 150 | 151 | return self.async_show_form( 152 | step_id="validate", data_schema=vol.Schema( 153 | { 154 | vol.Required("flicker"): bool 155 | } 156 | ), errors={}) 157 | 158 | async def async_step_manual(self, user_input: "dict[str, Any] | None" = None): 159 | if user_input is not None: 160 | self.mac = user_input[CONF_MAC] 161 | self.name = user_input["name"] 162 | await self.async_set_unique_id(format_mac(self.mac)) 163 | return await self.async_step_validate() 164 | 165 | return self.async_show_form( 166 | step_id="manual", data_schema=vol.Schema( 167 | { 168 | vol.Required(CONF_MAC): str, 169 | vol.Required("name"): str 170 | } 171 | ), errors={}) 172 | 173 | async def toggle_light(self): 174 | if not self._instance: 175 | self._instance = ELKBLEDOBInstance(self.mac, False, 120, self.hass) 176 | try: 177 | await self._instance.update() 178 | await self._instance.turn_on() 179 | await asyncio.sleep(1) 180 | await self._instance.turn_off() 181 | await asyncio.sleep(1) 182 | await self._instance.turn_on() 183 | await asyncio.sleep(1) 184 | await self._instance.turn_off() 185 | except (Exception) as error: 186 | return error 187 | finally: 188 | await self._instance.stop() 189 | 190 | @staticmethod 191 | @callback 192 | def async_get_options_flow(entry: config_entries.ConfigEntry): 193 | return OptionsFlowHandler(entry) 194 | 195 | class OptionsFlowHandler(config_entries.OptionsFlow): 196 | 197 | def __init__(self, config_entry): 198 | """Initialize options flow.""" 199 | self.config_entry = config_entry 200 | 201 | async def async_step_init(self, _user_input=None): 202 | """Manage the options.""" 203 | return await self.async_step_user() 204 | 205 | async def async_step_user(self, user_input=None): 206 | """Handle a flow initialized by the user.""" 207 | errors = {} 208 | options = self.config_entry.options or {CONF_RESET: False,CONF_DELAY: 120} 209 | if user_input is not None: 210 | return self.async_create_entry(title="", data={CONF_RESET: user_input[CONF_RESET], CONF_DELAY: user_input[CONF_DELAY]}) 211 | 212 | return self.async_show_form( 213 | step_id="user", 214 | data_schema=vol.Schema( 215 | { 216 | vol.Optional(CONF_DELAY, default=options.get(CONF_DELAY)): int 217 | } 218 | ), errors=errors 219 | ) 220 | -------------------------------------------------------------------------------- /custom_components/elk_bledob/elkbledob.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from homeassistant.components import bluetooth 3 | from homeassistant.exceptions import ConfigEntryNotReady 4 | from homeassistant.components.light import (ColorMode) 5 | from bleak.backends.device import BLEDevice 6 | from bleak.backends.service import BleakGATTCharacteristic, BleakGATTServiceCollection 7 | from bleak.exc import BleakDBusError 8 | from bleak_retry_connector import BLEAK_RETRY_EXCEPTIONS as BLEAK_EXCEPTIONS 9 | from bleak_retry_connector import ( 10 | BleakClientWithServiceCache, 11 | #BleakError, 12 | BleakNotFoundError, 13 | #ble_device_has_changed, 14 | establish_connection, 15 | ) 16 | from typing import Any, TypeVar, cast, Tuple 17 | from collections.abc import Callable 18 | #import traceback 19 | import logging 20 | import colorsys 21 | 22 | 23 | LOGGER = logging.getLogger(__name__) 24 | 25 | EFFECT_0 = "three color jump" 26 | EFFECT_1 = "seven color jump" 27 | EFFECT_2 = "three color cross fade" 28 | EFFECT_3 = "seven color cross fade" 29 | EFFECT_4 = "red fade" 30 | EFFECT_5 = "green fade" 31 | EFFECT_6 = "blue fade" 32 | EFFECT_7 = "yellow fade" 33 | EFFECT_8 = "cyan fade" 34 | EFFECT_9 = "magenta fade" 35 | EFFECT_10 = "white fade" 36 | EFFECT_11 = "red green cross fade" 37 | EFFECT_12 = "red blue cross fade" 38 | EFFECT_13 = "green blue cross fade" 39 | EFFECT_14 = "seven color strobe flash" 40 | EFFECT_15 = "red strobe flash" 41 | EFFECT_16 = "green strobe flash" 42 | EFFECT_17 = "blue strobe flash" 43 | EFFECT_18 = "yellow strobe flash" 44 | EFFECT_19 = "cyan strobe flash" 45 | EFFECT_20 = "magenta strobe flash" 46 | EFFECT_21 = "white strobe flash" 47 | 48 | EFFECT_MAP = { 49 | EFFECT_0: 0x87, 50 | EFFECT_1: 0x88, 51 | EFFECT_2: 0x89, 52 | EFFECT_3: 0x8a, 53 | EFFECT_4: 0x8b, 54 | EFFECT_5: 0x8c, 55 | EFFECT_6: 0x8d, 56 | EFFECT_7: 0x8e, 57 | EFFECT_8: 0x8f, 58 | EFFECT_9: 0x90, 59 | EFFECT_10: 0x91, 60 | EFFECT_11: 0x92, 61 | EFFECT_12: 0x93, 62 | EFFECT_13: 0x94, 63 | EFFECT_14: 0x95, 64 | EFFECT_15: 0x96, 65 | EFFECT_16: 0x97, 66 | EFFECT_17: 0x98, 67 | EFFECT_18: 0x99, 68 | EFFECT_19: 0x9a, 69 | EFFECT_20: 0x9b, 70 | EFFECT_21: 0x9c 71 | } 72 | 73 | EFFECT_LIST = sorted(EFFECT_MAP) 74 | EFFECT_ID_NAME = {v: k for k, v in EFFECT_MAP.items()} 75 | 76 | NAME_ARRAY = ["ELK-BLEDOB"] 77 | WRITE_CHARACTERISTIC_UUIDS = ["0000fff3-0000-1000-8000-00805f9b34fb"] 78 | TURN_ON_CMD = [bytearray.fromhex("7e 07 04 ff 00 01 02 01 ef")] 79 | TURN_OFF_CMD = [bytearray.fromhex("7e 07 04 00 00 00 02 01 ef")] 80 | DEFAULT_ATTEMPTS = 3 81 | BLEAK_BACKOFF_TIME = 0.25 82 | RETRY_BACKOFF_EXCEPTIONS = (BleakDBusError) 83 | 84 | WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any]) 85 | 86 | def retry_bluetooth_connection_error(func: WrapFuncType) -> WrapFuncType: 87 | async def _async_wrap_retry_bluetooth_connection_error( 88 | self: "ELKBLEDOBInstance", *args: Any, **kwargs: Any 89 | ) -> Any: 90 | attempts = DEFAULT_ATTEMPTS 91 | max_attempts = attempts - 1 92 | 93 | for attempt in range(attempts): 94 | try: 95 | return await func(self, *args, **kwargs) 96 | except BleakNotFoundError: 97 | # The lock cannot be found so there is no 98 | # point in retrying. 99 | raise 100 | except RETRY_BACKOFF_EXCEPTIONS as err: 101 | if attempt >= max_attempts: 102 | LOGGER.debug( 103 | "%s: %s error calling %s, reach max attempts (%s/%s)", 104 | self.name, 105 | type(err), 106 | func, 107 | attempt, 108 | max_attempts, 109 | exc_info=True, 110 | ) 111 | raise 112 | LOGGER.debug( 113 | "%s: %s error calling %s, backing off %ss, retrying (%s/%s)...", 114 | self.name, 115 | type(err), 116 | func, 117 | BLEAK_BACKOFF_TIME, 118 | attempt, 119 | max_attempts, 120 | exc_info=True, 121 | ) 122 | await asyncio.sleep(BLEAK_BACKOFF_TIME) 123 | except BLEAK_EXCEPTIONS as err: 124 | if attempt >= max_attempts: 125 | LOGGER.debug( 126 | "%s: %s error calling %s, reach max attempts (%s/%s): %s", 127 | self.name, 128 | type(err), 129 | func, 130 | attempt, 131 | max_attempts, 132 | err, 133 | exc_info=True, 134 | ) 135 | raise 136 | LOGGER.debug( 137 | "%s: %s error calling %s, retrying (%s/%s)...: %s", 138 | self.name, 139 | type(err), 140 | func, 141 | attempt, 142 | max_attempts, 143 | err, 144 | exc_info=True, 145 | ) 146 | 147 | return cast(WrapFuncType, _async_wrap_retry_bluetooth_connection_error) 148 | 149 | 150 | class ELKBLEDOBInstance: 151 | def __init__(self, address, reset: bool, delay: int, hass) -> None: 152 | self.loop = asyncio.get_running_loop() 153 | self._mac = address 154 | self._reset = reset 155 | self._delay = delay 156 | self._hass = hass 157 | self._device: BLEDevice | None = None 158 | self._device = bluetooth.async_ble_device_from_address(self._hass, address) 159 | if not self._device: 160 | raise ConfigEntryNotReady( 161 | f"You need to add bluetooth integration (https://www.home-assistant.io/integrations/bluetooth) or couldn't find a nearby device with address: {address}" 162 | ) 163 | self._connect_lock: asyncio.Lock = asyncio.Lock() 164 | self._client: BleakClientWithServiceCache | None = None 165 | self._disconnect_timer: asyncio.TimerHandle | None = None 166 | self._cached_services: BleakGATTServiceCollection | None = None 167 | self._expected_disconnect = False 168 | self._is_on = None 169 | self._rgb_color = None 170 | self._brightness = 255 171 | self._effect = None 172 | self._effect_speed = 0x64 173 | self._color_mode = ColorMode.RGB 174 | self._write_uuid = None 175 | self._turn_on_cmd = None 176 | self._turn_off_cmd = None 177 | self._model = self._detect_model() 178 | 179 | LOGGER.debug( 180 | "Model information for device %s : ModelNo %s. MAC: %s", 181 | self._device.name, 182 | self._model, 183 | self._mac, 184 | ) 185 | 186 | def _detect_model(self): 187 | x = 0 188 | for name in NAME_ARRAY: 189 | if self._device.name.lower().startswith(name.lower()): 190 | self._turn_on_cmd = TURN_ON_CMD[x] 191 | self._turn_off_cmd = TURN_OFF_CMD[x] 192 | return x 193 | x = x + 1 194 | 195 | async def _write(self, data: bytearray): 196 | """Send command to device and read response.""" 197 | await self._ensure_connected() 198 | await self._write_while_connected(data) 199 | 200 | async def _write_while_connected(self, data: bytearray): 201 | LOGGER.debug(f"Writing data to {self.name}: {data.hex()}") 202 | await self._client.write_gatt_char(self._write_uuid, data, False) 203 | 204 | @property 205 | def mac(self): 206 | return self._device.address 207 | 208 | @property 209 | def reset(self): 210 | return self._reset 211 | 212 | @property 213 | def name(self): 214 | return self._device.name 215 | 216 | @property 217 | def rssi(self): 218 | return self._device.rssi 219 | 220 | @property 221 | def is_on(self): 222 | return self._is_on 223 | 224 | @property 225 | def brightness(self): 226 | return self._brightness 227 | 228 | @property 229 | def rgb_color(self): 230 | return self._rgb_color 231 | 232 | @property 233 | def effect_list(self) -> list[str]: 234 | return EFFECT_LIST 235 | 236 | @property 237 | def effect(self): 238 | return self._effect 239 | 240 | @property 241 | def color_mode(self): 242 | return self._color_mode 243 | 244 | @retry_bluetooth_connection_error 245 | async def turn_on(self): 246 | await self._write(self._turn_on_cmd) 247 | self._is_on = True 248 | 249 | @retry_bluetooth_connection_error 250 | async def turn_off(self): 251 | await self._write(self._turn_off_cmd) 252 | self._is_on = False 253 | 254 | @retry_bluetooth_connection_error 255 | async def set_rgb_color(self, rgb: Tuple[int, int, int]): 256 | """ 257 | |---------|--------------------- header 258 | | | ||------------------ red 259 | | | || ||--------------- green 260 | | | || || ||------------ blue 261 | | | || || || |---|------ footer 262 | 7e 07 05 03 ff 00 00 10 ef 263 | 7e 07 05 03 00 ff 00 10 ef 264 | 7e 07 05 03 00 00 ff 10 ef 265 | """ 266 | self._rgb_color = rgb 267 | red = int(rgb[0]) 268 | green = int(rgb[1]) 269 | blue = int(rgb[2]) 270 | rgb_packet = bytearray.fromhex("7e 07 05 03 ff 00 00 10 ef") 271 | rgb_packet[4] = red 272 | rgb_packet[5] = green 273 | rgb_packet[6] = blue 274 | await self._write(rgb_packet) 275 | 276 | @retry_bluetooth_connection_error 277 | async def set_brightness(self, brightness: int): 278 | self._brightness = brightness 279 | brightness_packet = bytearray.fromhex("7e 04 01 01 01 ff 02 01 ef") 280 | brightness_packet[3] = int((brightness / 255) * 100) 281 | await self._write(brightness_packet) 282 | 283 | @retry_bluetooth_connection_error 284 | async def set_effect(self, effect: str): 285 | if effect not in EFFECT_LIST: 286 | LOGGER.error("Effect %s not supported", effect) 287 | return 288 | self._effect = effect 289 | effect_packet = bytearray.fromhex("7e 07 03 93 03 ff ff 00 ef") 290 | effect_id = EFFECT_MAP.get(effect) 291 | LOGGER.debug('Effect ID: %s', effect_id) 292 | LOGGER.debug('Effect name: %s', effect) 293 | effect_packet[3] = effect_id 294 | await self._write(effect_packet) 295 | 296 | @retry_bluetooth_connection_error 297 | async def update(self): 298 | LOGGER.debug("%s: Update in elk_bledob called", self.name) 299 | # I dont think we have anything to update 300 | 301 | async def _ensure_connected(self) -> None: 302 | """Ensure connection to device is established.""" 303 | if self._connect_lock.locked(): 304 | LOGGER.debug( 305 | "%s: Connection already in progress, waiting for it to complete", 306 | self.name, 307 | ) 308 | if self._client and self._client.is_connected: 309 | self._reset_disconnect_timer() 310 | return 311 | async with self._connect_lock: 312 | # Check again while holding the lock 313 | if self._client and self._client.is_connected: 314 | self._reset_disconnect_timer() 315 | return 316 | LOGGER.debug("%s: Connecting", self.name) 317 | client = await establish_connection( 318 | BleakClientWithServiceCache, 319 | self._device, 320 | self.name, 321 | self._disconnected, 322 | cached_services=self._cached_services, 323 | ble_device_callback=lambda: self._device, 324 | ) 325 | LOGGER.debug("%s: Connected", self.name) 326 | resolved = self._resolve_characteristics(client.services) 327 | if not resolved: 328 | # Try to handle services failing to load 329 | #resolved = self._resolve_characteristics(await client.get_services()) 330 | resolved = self._resolve_characteristics(client.services) 331 | self._cached_services = client.services if resolved else None 332 | 333 | self._client = client 334 | self._reset_disconnect_timer() 335 | 336 | def _resolve_characteristics(self, services: BleakGATTServiceCollection) -> bool: 337 | """Resolve characteristics.""" 338 | for characteristic in WRITE_CHARACTERISTIC_UUIDS: 339 | if char := services.get_characteristic(characteristic): 340 | self._write_uuid = char 341 | break 342 | return bool(self._write_uuid) 343 | 344 | def _reset_disconnect_timer(self) -> None: 345 | """Reset disconnect timer.""" 346 | if self._disconnect_timer: 347 | self._disconnect_timer.cancel() 348 | self._expected_disconnect = False 349 | if self._delay is not None and self._delay != 0: 350 | LOGGER.debug( 351 | "%s: Configured disconnect from device in %s seconds", 352 | self.name, 353 | self._delay 354 | ) 355 | self._disconnect_timer = self.loop.call_later(self._delay, self._disconnect) 356 | 357 | def _disconnected(self, client: BleakClientWithServiceCache) -> None: 358 | """Disconnected callback.""" 359 | if self._expected_disconnect: 360 | LOGGER.debug("%s: Disconnected from device", self.name) 361 | return 362 | LOGGER.warning("%s: Device unexpectedly disconnected", self.name) 363 | 364 | def _disconnect(self) -> None: 365 | """Disconnect from device.""" 366 | self._disconnect_timer = None 367 | asyncio.create_task(self._execute_timed_disconnect()) 368 | 369 | async def stop(self) -> None: 370 | """Stop the LEDBLE.""" 371 | LOGGER.debug("%s: Stop", self.name) 372 | await self._execute_disconnect() 373 | 374 | async def _execute_timed_disconnect(self) -> None: 375 | """Execute timed disconnection.""" 376 | LOGGER.debug( 377 | "%s: Disconnecting after timeout of %s", 378 | self.name, 379 | self._delay 380 | ) 381 | await self._execute_disconnect() 382 | 383 | async def _execute_disconnect(self) -> None: 384 | """Execute disconnection.""" 385 | async with self._connect_lock: 386 | client = self._client 387 | self._expected_disconnect = True 388 | self._client = None 389 | self._write_uuid = None 390 | if client and client.is_connected: 391 | await client.disconnect() 392 | LOGGER.debug("%s: Disconnected", self.name) 393 | 394 | --------------------------------------------------------------------------------