-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add WLED integration #28542
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
Merged
Merged
Add WLED integration #28542
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
f58051e
Add WLED integration
frenck 4131186
Use f-string for uniq id in sensor platform
frenck 4e7e058
Typing improvements
frenck 6836460
Removes sensor & light platform
frenck 2e42d7e
Remove PARALLEL_UPDATES from integration level
frenck 6baeb09
Correct type in code comment 'themselves'
frenck c6bd8ea
Use async_track_time_interval in async context
frenck c4a2e3f
Remove stale code
frenck c17b52c
Remove decorator from Flow handler
frenck f46335d
Remove unused __init__ from config flow
frenck 82fe294
Move show form methods to sync
frenck 70a3c0b
Only wrap lines that can raise in try except block
frenck a1fd2bc
Remove domain and platform from uniq id
frenck 6f266ca
Wrap light state in bool object in is_on method
frenck 1f84360
Use async_schedule_update_ha_state in async context
frenck 4262ccf
Return empty dict in device state attributes instead of None
frenck 935acbe
Remove unneeded setdefault call in setup entry
frenck 8761383
Cancel update timer on entry unload
frenck 0eca44e
Restructure config flow code
frenck 9894aa3
Adjust tests for new uniq id
frenck b6c9386
Correct typo AdGuard Home -> WLED in config flow file comment
frenck 1e57586
Convert internal package imports to be relative
frenck 9ab71db
Reformat JSON files with Prettier
frenck 8723b5d
Improve tests based on review comments
frenck 9ccc7c4
Add test for zeroconf when no data is provided
frenck a7800cb
Cleanup and extended tests
frenck File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| { | ||
| "config": { | ||
| "title": "WLED", | ||
| "flow_title": "WLED: {name}", | ||
| "step": { | ||
| "user": { | ||
| "title": "Link your WLED", | ||
| "description": "Set up your WLED to integrate with Home Assistant.", | ||
| "data": { | ||
| "host": "Host or IP address" | ||
| } | ||
| }, | ||
| "zeroconf_confirm": { | ||
| "description": "Do you want to add the WLED named `{name}` to Home Assistant?", | ||
| "title": "Discovered WLED device" | ||
| } | ||
| }, | ||
| "error": { | ||
| "connection_error": "Failed to connect to WLED device." | ||
| }, | ||
| "abort": { | ||
| "already_configured": "This WLED device is already configured.", | ||
| "connection_error": "Failed to connect to WLED device." | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| """Support for WLED.""" | ||
| from datetime import timedelta | ||
| import logging | ||
| from typing import Any, Dict, Optional, Union | ||
|
|
||
| from wled import WLED, WLEDConnectionError, WLEDError | ||
|
|
||
| from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import ATTR_NAME, CONF_HOST | ||
| from homeassistant.core import HomeAssistant, callback | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
| from homeassistant.helpers.dispatcher import ( | ||
| async_dispatcher_connect, | ||
| async_dispatcher_send, | ||
| ) | ||
| from homeassistant.helpers.entity import Entity | ||
| from homeassistant.helpers.event import async_track_time_interval | ||
| from homeassistant.helpers.typing import ConfigType | ||
| from homeassistant.util import dt as dt_util | ||
|
|
||
| from .const import ( | ||
| ATTR_IDENTIFIERS, | ||
| ATTR_MANUFACTURER, | ||
| ATTR_MODEL, | ||
| ATTR_SOFTWARE_VERSION, | ||
| DATA_WLED_CLIENT, | ||
| DATA_WLED_TIMER, | ||
| DATA_WLED_UPDATED, | ||
| DOMAIN, | ||
| ) | ||
|
|
||
| SCAN_INTERVAL = timedelta(seconds=5) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: | ||
| """Set up the WLED components.""" | ||
| return True | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Set up WLED from a config entry.""" | ||
|
|
||
| # Create WLED instance for this entry | ||
| session = async_get_clientsession(hass) | ||
| wled = WLED(entry.data[CONF_HOST], loop=hass.loop, session=session) | ||
|
|
||
| # Ensure we can connect and talk to it | ||
| try: | ||
| await wled.update() | ||
| except WLEDConnectionError as exception: | ||
| raise ConfigEntryNotReady from exception | ||
|
|
||
| hass.data.setdefault(DOMAIN, {}) | ||
| hass.data[DOMAIN][entry.entry_id] = {DATA_WLED_CLIENT: wled} | ||
|
|
||
| # Set up all platforms for this device/entry. | ||
| hass.async_create_task( | ||
| hass.config_entries.async_forward_entry_setup(entry, LIGHT_DOMAIN) | ||
| ) | ||
|
|
||
| async def interval_update(now: dt_util.dt.datetime = None) -> None: | ||
| """Poll WLED device function, dispatches event after update.""" | ||
| try: | ||
| await wled.update() | ||
| except WLEDError: | ||
| _LOGGER.debug("An error occurred while updating WLED", exc_info=True) | ||
|
|
||
| # Even if the update failed, we still send out the event. | ||
| # To allow entities to make themselves unavailable. | ||
| async_dispatcher_send(hass, DATA_WLED_UPDATED, entry.entry_id) | ||
|
|
||
| # Schedule update interval | ||
| hass.data[DOMAIN][entry.entry_id][DATA_WLED_TIMER] = async_track_time_interval( | ||
| hass, interval_update, SCAN_INTERVAL | ||
| ) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Unload WLED config entry.""" | ||
|
|
||
| # Cancel update timer for this entry/device. | ||
| cancel_timer = hass.data[DOMAIN][entry.entry_id][DATA_WLED_TIMER] | ||
| cancel_timer() | ||
|
|
||
| # Unload entities for this entry/device. | ||
| await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN) | ||
|
|
||
| # Cleanup | ||
| del hass.data[DOMAIN][entry.entry_id] | ||
| if not hass.data[DOMAIN]: | ||
| del hass.data[DOMAIN] | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| class WLEDEntity(Entity): | ||
| """Defines a base WLED entity.""" | ||
|
|
||
| def __init__(self, entry_id: str, wled: WLED, name: str, icon: str) -> None: | ||
| """Initialize the WLED entity.""" | ||
| self._attributes: Dict[str, Union[str, int, float]] = {} | ||
| self._available = True | ||
| self._entry_id = entry_id | ||
| self._icon = icon | ||
| self._name = name | ||
| self._unsub_dispatcher = None | ||
| self.wled = wled | ||
|
|
||
| @property | ||
| def name(self) -> str: | ||
| """Return the name of the entity.""" | ||
| return self._name | ||
|
|
||
| @property | ||
| def icon(self) -> str: | ||
| """Return the mdi icon of the entity.""" | ||
| return self._icon | ||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Return True if entity is available.""" | ||
| return self._available | ||
|
|
||
| @property | ||
| def should_poll(self) -> bool: | ||
| """Return the polling requirement of the entity.""" | ||
| return False | ||
|
|
||
| @property | ||
| def device_state_attributes(self) -> Optional[Dict[str, Any]]: | ||
| """Return the state attributes of the entity.""" | ||
| return self._attributes | ||
|
|
||
| async def async_added_to_hass(self) -> None: | ||
| """Connect to dispatcher listening for entity data notifications.""" | ||
| self._unsub_dispatcher = async_dispatcher_connect( | ||
| self.hass, DATA_WLED_UPDATED, self._schedule_immediate_update | ||
| ) | ||
|
|
||
| async def async_will_remove_from_hass(self) -> None: | ||
| """Disconnect from update signal.""" | ||
| self._unsub_dispatcher() | ||
|
|
||
| @callback | ||
| def _schedule_immediate_update(self, entry_id: str) -> None: | ||
| """Schedule an immediate update of the entity.""" | ||
| if entry_id == self._entry_id: | ||
| self.async_schedule_update_ha_state(True) | ||
|
|
||
| async def async_update(self) -> None: | ||
| """Update WLED entity.""" | ||
| if self.wled.device is None: | ||
| self._available = False | ||
| return | ||
|
|
||
| self._available = True | ||
| await self._wled_update() | ||
|
|
||
| async def _wled_update(self) -> None: | ||
| """Update WLED entity.""" | ||
| raise NotImplementedError() | ||
|
|
||
|
|
||
| class WLEDDeviceEntity(WLEDEntity): | ||
| """Defines a WLED device entity.""" | ||
|
|
||
| @property | ||
| def device_info(self) -> Dict[str, Any]: | ||
| """Return device information about this WLED device.""" | ||
| return { | ||
| ATTR_IDENTIFIERS: {(DOMAIN, self.wled.device.info.mac_address)}, | ||
| ATTR_NAME: self.wled.device.info.name, | ||
| ATTR_MANUFACTURER: self.wled.device.info.brand, | ||
| ATTR_MODEL: self.wled.device.info.product, | ||
| ATTR_SOFTWARE_VERSION: self.wled.device.info.version, | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| """Config flow to configure the WLED integration.""" | ||
| import logging | ||
| from typing import Any, Dict, Optional | ||
|
|
||
| import voluptuous as vol | ||
| from wled import WLED, WLEDConnectionError | ||
|
|
||
| from homeassistant.config_entries import ( | ||
| CONN_CLASS_LOCAL_POLL, | ||
| SOURCE_ZEROCONF, | ||
| ConfigFlow, | ||
| ) | ||
| from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME | ||
| from homeassistant.helpers import ConfigType | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import DOMAIN # pylint: disable=W0611 | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): | ||
| """Handle a WLED config flow.""" | ||
|
|
||
| VERSION = 1 | ||
| CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: Optional[ConfigType] = None | ||
| ) -> Dict[str, Any]: | ||
| """Handle a flow initiated by the user.""" | ||
| return await self._handle_config_flow(user_input) | ||
|
|
||
| async def async_step_zeroconf( | ||
| self, user_input: Optional[ConfigType] = None | ||
| ) -> Dict[str, Any]: | ||
| """Handle zeroconf discovery.""" | ||
| if user_input is None: | ||
| return self.async_abort(reason="connection_error") | ||
|
frenck marked this conversation as resolved.
|
||
|
|
||
| # Hostname is format: wled-livingroom.local. | ||
| host = user_input["hostname"].rstrip(".") | ||
| name, _ = host.rsplit(".") | ||
|
frenck marked this conversation as resolved.
|
||
|
|
||
| # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 | ||
| self.context.update( | ||
| {CONF_HOST: host, CONF_NAME: name, "title_placeholders": {"name": name}} | ||
| ) | ||
|
|
||
| # Prepare configuration flow | ||
| return await self._handle_config_flow(user_input, True) | ||
|
|
||
| async def async_step_zeroconf_confirm( | ||
| self, user_input: ConfigType = None | ||
| ) -> Dict[str, Any]: | ||
| """Handle a flow initiated by zeroconf.""" | ||
| return await self._handle_config_flow(user_input) | ||
|
|
||
| async def _handle_config_flow( | ||
| self, user_input: Optional[ConfigType] = None, prepare: bool = False | ||
| ) -> Dict[str, Any]: | ||
| """Config flow handler for WLED.""" | ||
| # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 | ||
| source = self.context.get("source") | ||
|
|
||
| # Request user input, unless we are preparing discovery flow | ||
| if user_input is None and not prepare: | ||
| if source == SOURCE_ZEROCONF: | ||
| return self._show_confirm_dialog() | ||
| return self._show_setup_form() | ||
|
|
||
| if source == SOURCE_ZEROCONF: | ||
| # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 | ||
| user_input[CONF_HOST] = self.context.get(CONF_HOST) | ||
|
|
||
| errors = {} | ||
| session = async_get_clientsession(self.hass) | ||
| wled = WLED(user_input[CONF_HOST], loop=self.hass.loop, session=session) | ||
|
|
||
| try: | ||
| device = await wled.update() | ||
| except WLEDConnectionError: | ||
| if source == SOURCE_ZEROCONF: | ||
| return self.async_abort(reason="connection_error") | ||
| errors["base"] = "connection_error" | ||
| return self._show_setup_form(errors) | ||
|
|
||
| # Check if already configured | ||
| mac_address = device.info.mac_address | ||
| for entry in self._async_current_entries(): | ||
| if entry.data[CONF_MAC] == mac_address: | ||
| # This mac address is already configured | ||
| return self.async_abort(reason="already_configured") | ||
|
|
||
| title = user_input[CONF_HOST] | ||
| if source == SOURCE_ZEROCONF: | ||
| # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 | ||
| title = self.context.get(CONF_NAME) | ||
|
|
||
| if prepare: | ||
| return await self.async_step_zeroconf_confirm() | ||
|
|
||
| return self.async_create_entry( | ||
| title=title, data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: mac_address} | ||
| ) | ||
|
|
||
| def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: | ||
| """Show the setup form to the user.""" | ||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=vol.Schema({vol.Required(CONF_HOST): str}), | ||
| errors=errors or {}, | ||
| ) | ||
|
|
||
| def _show_confirm_dialog(self, errors: Optional[Dict] = None) -> Dict[str, Any]: | ||
| """Show the confirm dialog to the user.""" | ||
| # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 | ||
| name = self.context.get(CONF_NAME) | ||
| return self.async_show_form( | ||
| step_id="zeroconf_confirm", | ||
| description_placeholders={"name": name}, | ||
| errors=errors or {}, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| """Constants for the WLED integration.""" | ||
|
|
||
| # Integration domain | ||
| DOMAIN = "wled" | ||
|
|
||
| # Hass data keys | ||
| DATA_WLED_CLIENT = "wled_client" | ||
| DATA_WLED_TIMER = "wled_timer" | ||
| DATA_WLED_UPDATED = "wled_updated" | ||
|
|
||
| # Attributes | ||
| ATTR_COLOR_PRIMARY = "color_primary" | ||
| ATTR_DURATION = "duration" | ||
| ATTR_IDENTIFIERS = "identifiers" | ||
| ATTR_INTENSITY = "intensity" | ||
| ATTR_MANUFACTURER = "manufacturer" | ||
| ATTR_MODEL = "model" | ||
| ATTR_ON = "on" | ||
| ATTR_PALETTE = "palette" | ||
| ATTR_PLAYLIST = "playlist" | ||
| ATTR_PRESET = "preset" | ||
| ATTR_SEGMENT_ID = "segment_id" | ||
| ATTR_SOFTWARE_VERSION = "sw_version" | ||
| ATTR_SPEED = "speed" | ||
| ATTR_TARGET_BRIGHTNESS = "target_brightness" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.