mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Split up yeelight code into multiple files (#59990)
This commit is contained in:
parent
6524cd4eb2
commit
881d35ab17
@ -2,19 +2,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
from datetime import timedelta
|
|
||||||
from ipaddress import IPv4Address, IPv6Address
|
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from async_upnp_client.search import SsdpSearchListener
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from yeelight import BulbException
|
from yeelight import BulbException
|
||||||
from yeelight.aio import KEY_CONNECTED, AsyncBulb
|
from yeelight.aio import AsyncBulb
|
||||||
|
|
||||||
from homeassistant import config_entries
|
|
||||||
from homeassistant.components import network
|
|
||||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_DEVICES,
|
CONF_DEVICES,
|
||||||
@ -25,82 +18,45 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
||||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ACTION_OFF,
|
||||||
|
ACTION_RECOVER,
|
||||||
|
ACTION_STAY,
|
||||||
|
ATTR_ACTION,
|
||||||
|
ATTR_COUNT,
|
||||||
|
ATTR_TRANSITIONS,
|
||||||
|
CONF_CUSTOM_EFFECTS,
|
||||||
|
CONF_DETECTED_MODEL,
|
||||||
|
CONF_FLOW_PARAMS,
|
||||||
|
CONF_MODE_MUSIC,
|
||||||
|
CONF_MODEL,
|
||||||
|
CONF_NIGHTLIGHT_SWITCH,
|
||||||
|
CONF_NIGHTLIGHT_SWITCH_TYPE,
|
||||||
|
CONF_SAVE_ON_CHANGE,
|
||||||
|
CONF_TRANSITION,
|
||||||
|
DATA_CONFIG_ENTRIES,
|
||||||
|
DATA_CUSTOM_EFFECTS,
|
||||||
|
DATA_DEVICE,
|
||||||
|
DEFAULT_MODE_MUSIC,
|
||||||
|
DEFAULT_NAME,
|
||||||
|
DEFAULT_NIGHTLIGHT_SWITCH,
|
||||||
|
DEFAULT_SAVE_ON_CHANGE,
|
||||||
|
DEFAULT_TRANSITION,
|
||||||
|
DOMAIN,
|
||||||
|
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
||||||
|
PLATFORMS,
|
||||||
|
YEELIGHT_HSV_TRANSACTION,
|
||||||
|
YEELIGHT_RGB_TRANSITION,
|
||||||
|
YEELIGHT_SLEEP_TRANSACTION,
|
||||||
|
YEELIGHT_TEMPERATURE_TRANSACTION,
|
||||||
|
)
|
||||||
|
from .device import YeelightDevice, async_format_id
|
||||||
|
from .scanner import YeelightScanner
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STATE_CHANGE_TIME = 0.40 # seconds
|
|
||||||
POWER_STATE_CHANGE_TIME = 1 # seconds
|
|
||||||
|
|
||||||
#
|
|
||||||
# These models do not transition correctly when turning on, and
|
|
||||||
# yeelight is no longer updating the firmware on older devices
|
|
||||||
#
|
|
||||||
# https://github.com/home-assistant/core/issues/58315
|
|
||||||
#
|
|
||||||
# The problem can be worked around by always setting the brightness
|
|
||||||
# even when the bulb is reporting the brightness is already at the
|
|
||||||
# desired level.
|
|
||||||
#
|
|
||||||
MODELS_WITH_DELAYED_ON_TRANSITION = {
|
|
||||||
"color", # YLDP02YL
|
|
||||||
}
|
|
||||||
|
|
||||||
DOMAIN = "yeelight"
|
|
||||||
DATA_YEELIGHT = DOMAIN
|
|
||||||
DATA_UPDATED = "yeelight_{}_data_updated"
|
|
||||||
|
|
||||||
DEFAULT_NAME = "Yeelight"
|
|
||||||
DEFAULT_TRANSITION = 350
|
|
||||||
DEFAULT_MODE_MUSIC = False
|
|
||||||
DEFAULT_SAVE_ON_CHANGE = False
|
|
||||||
DEFAULT_NIGHTLIGHT_SWITCH = False
|
|
||||||
|
|
||||||
CONF_MODEL = "model"
|
|
||||||
CONF_DETECTED_MODEL = "detected_model"
|
|
||||||
CONF_TRANSITION = "transition"
|
|
||||||
CONF_SAVE_ON_CHANGE = "save_on_change"
|
|
||||||
CONF_MODE_MUSIC = "use_music_mode"
|
|
||||||
CONF_FLOW_PARAMS = "flow_params"
|
|
||||||
CONF_CUSTOM_EFFECTS = "custom_effects"
|
|
||||||
CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type"
|
|
||||||
CONF_NIGHTLIGHT_SWITCH = "nightlight_switch"
|
|
||||||
|
|
||||||
DATA_CONFIG_ENTRIES = "config_entries"
|
|
||||||
DATA_CUSTOM_EFFECTS = "custom_effects"
|
|
||||||
DATA_DEVICE = "device"
|
|
||||||
DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher"
|
|
||||||
DATA_PLATFORMS_LOADED = "platforms_loaded"
|
|
||||||
|
|
||||||
ATTR_COUNT = "count"
|
|
||||||
ATTR_ACTION = "action"
|
|
||||||
ATTR_TRANSITIONS = "transitions"
|
|
||||||
ATTR_MODE_MUSIC = "music_mode"
|
|
||||||
|
|
||||||
ACTION_RECOVER = "recover"
|
|
||||||
ACTION_STAY = "stay"
|
|
||||||
ACTION_OFF = "off"
|
|
||||||
|
|
||||||
ACTIVE_MODE_NIGHTLIGHT = 1
|
|
||||||
ACTIVE_COLOR_FLOWING = 1
|
|
||||||
|
|
||||||
NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light"
|
|
||||||
|
|
||||||
DISCOVERY_INTERVAL = timedelta(seconds=60)
|
|
||||||
SSDP_TARGET = ("239.255.255.250", 1982)
|
|
||||||
SSDP_ST = "wifi_bulb"
|
|
||||||
DISCOVERY_ATTEMPTS = 3
|
|
||||||
DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2)
|
|
||||||
DISCOVERY_TIMEOUT = 8
|
|
||||||
|
|
||||||
|
|
||||||
YEELIGHT_RGB_TRANSITION = "RGBTransition"
|
|
||||||
YEELIGHT_HSV_TRANSACTION = "HSVTransition"
|
|
||||||
YEELIGHT_TEMPERATURE_TRANSACTION = "TemperatureTransition"
|
|
||||||
YEELIGHT_SLEEP_TRANSACTION = "SleepTransition"
|
|
||||||
|
|
||||||
YEELIGHT_FLOW_TRANSITION_SCHEMA = {
|
YEELIGHT_FLOW_TRANSITION_SCHEMA = {
|
||||||
vol.Optional(ATTR_COUNT, default=0): cv.positive_int,
|
vol.Optional(ATTR_COUNT, default=0): cv.positive_int,
|
||||||
@ -155,31 +111,6 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
UPDATE_REQUEST_PROPERTIES = [
|
|
||||||
"power",
|
|
||||||
"main_power",
|
|
||||||
"bright",
|
|
||||||
"ct",
|
|
||||||
"rgb",
|
|
||||||
"hue",
|
|
||||||
"sat",
|
|
||||||
"color_mode",
|
|
||||||
"flowing",
|
|
||||||
"bg_power",
|
|
||||||
"bg_lmode",
|
|
||||||
"bg_flowing",
|
|
||||||
"bg_ct",
|
|
||||||
"bg_bright",
|
|
||||||
"bg_hue",
|
|
||||||
"bg_sat",
|
|
||||||
"bg_rgb",
|
|
||||||
"nl_br",
|
|
||||||
"active_mode",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
PLATFORMS = ["binary_sensor", "light"]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Yeelight bulbs."""
|
"""Set up the Yeelight bulbs."""
|
||||||
@ -299,410 +230,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_format_model(model: str) -> str:
|
|
||||||
"""Generate a more human readable model."""
|
|
||||||
return model.replace("_", " ").title()
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_format_id(id_: str) -> str:
|
|
||||||
"""Generate a more human readable id."""
|
|
||||||
return hex(int(id_, 16)) if id_ else "None"
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_format_model_id(model: str, id_: str) -> str:
|
|
||||||
"""Generate a more human readable name."""
|
|
||||||
return f"{async_format_model(model)} {async_format_id(id_)}"
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_unique_name(capabilities: dict) -> str:
|
|
||||||
"""Generate name from capabilities."""
|
|
||||||
model_id = async_format_model_id(capabilities["model"], capabilities["id"])
|
|
||||||
return f"Yeelight {model_id}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Handle options update."""
|
"""Handle options update."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
class YeelightScanner:
|
|
||||||
"""Scan for Yeelight devices."""
|
|
||||||
|
|
||||||
_scanner = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@callback
|
|
||||||
def async_get(cls, hass: HomeAssistant):
|
|
||||||
"""Get scanner instance."""
|
|
||||||
if cls._scanner is None:
|
|
||||||
cls._scanner = cls(hass)
|
|
||||||
return cls._scanner
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
|
||||||
"""Initialize class."""
|
|
||||||
self._hass = hass
|
|
||||||
self._host_discovered_events = {}
|
|
||||||
self._unique_id_capabilities = {}
|
|
||||||
self._host_capabilities = {}
|
|
||||||
self._track_interval = None
|
|
||||||
self._listeners = []
|
|
||||||
self._connected_events = []
|
|
||||||
|
|
||||||
async def async_setup(self):
|
|
||||||
"""Set up the scanner."""
|
|
||||||
if self._connected_events:
|
|
||||||
await self._async_wait_connected()
|
|
||||||
return
|
|
||||||
|
|
||||||
for idx, source_ip in enumerate(await self._async_build_source_set()):
|
|
||||||
self._connected_events.append(asyncio.Event())
|
|
||||||
|
|
||||||
def _wrap_async_connected_idx(idx):
|
|
||||||
"""Create a function to capture the idx cell variable."""
|
|
||||||
|
|
||||||
async def _async_connected():
|
|
||||||
self._connected_events[idx].set()
|
|
||||||
|
|
||||||
return _async_connected
|
|
||||||
|
|
||||||
self._listeners.append(
|
|
||||||
SsdpSearchListener(
|
|
||||||
async_callback=self._async_process_entry,
|
|
||||||
service_type=SSDP_ST,
|
|
||||||
target=SSDP_TARGET,
|
|
||||||
source_ip=source_ip,
|
|
||||||
async_connect_callback=_wrap_async_connected_idx(idx),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
results = await asyncio.gather(
|
|
||||||
*(listener.async_start() for listener in self._listeners),
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
failed_listeners = []
|
|
||||||
for idx, result in enumerate(results):
|
|
||||||
if not isinstance(result, Exception):
|
|
||||||
continue
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Failed to setup listener for %s: %s",
|
|
||||||
self._listeners[idx].source_ip,
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
failed_listeners.append(self._listeners[idx])
|
|
||||||
self._connected_events[idx].set()
|
|
||||||
|
|
||||||
for listener in failed_listeners:
|
|
||||||
self._listeners.remove(listener)
|
|
||||||
|
|
||||||
await self._async_wait_connected()
|
|
||||||
self._track_interval = async_track_time_interval(
|
|
||||||
self._hass, self.async_scan, DISCOVERY_INTERVAL
|
|
||||||
)
|
|
||||||
self.async_scan()
|
|
||||||
|
|
||||||
async def _async_wait_connected(self):
|
|
||||||
"""Wait for the listeners to be up and connected."""
|
|
||||||
await asyncio.gather(*(event.wait() for event in self._connected_events))
|
|
||||||
|
|
||||||
async def _async_build_source_set(self) -> set[IPv4Address]:
|
|
||||||
"""Build the list of ssdp sources."""
|
|
||||||
adapters = await network.async_get_adapters(self._hass)
|
|
||||||
sources: set[IPv4Address] = set()
|
|
||||||
if network.async_only_default_interface_enabled(adapters):
|
|
||||||
sources.add(IPv4Address("0.0.0.0"))
|
|
||||||
return sources
|
|
||||||
|
|
||||||
return {
|
|
||||||
source_ip
|
|
||||||
for source_ip in await network.async_get_enabled_source_ips(self._hass)
|
|
||||||
if not source_ip.is_loopback and not isinstance(source_ip, IPv6Address)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def async_discover(self):
|
|
||||||
"""Discover bulbs."""
|
|
||||||
_LOGGER.debug("Yeelight discover with interval %s", DISCOVERY_SEARCH_INTERVAL)
|
|
||||||
await self.async_setup()
|
|
||||||
for _ in range(DISCOVERY_ATTEMPTS):
|
|
||||||
self.async_scan()
|
|
||||||
await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds())
|
|
||||||
return self._unique_id_capabilities.values()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_scan(self, *_):
|
|
||||||
"""Send discovery packets."""
|
|
||||||
_LOGGER.debug("Yeelight scanning")
|
|
||||||
for listener in self._listeners:
|
|
||||||
listener.async_search()
|
|
||||||
|
|
||||||
async def async_get_capabilities(self, host):
|
|
||||||
"""Get capabilities via SSDP."""
|
|
||||||
if host in self._host_capabilities:
|
|
||||||
return self._host_capabilities[host]
|
|
||||||
|
|
||||||
host_event = asyncio.Event()
|
|
||||||
self._host_discovered_events.setdefault(host, []).append(host_event)
|
|
||||||
await self.async_setup()
|
|
||||||
|
|
||||||
for listener in self._listeners:
|
|
||||||
listener.async_search((host, SSDP_TARGET[1]))
|
|
||||||
|
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
|
||||||
await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT)
|
|
||||||
|
|
||||||
self._host_discovered_events[host].remove(host_event)
|
|
||||||
return self._host_capabilities.get(host)
|
|
||||||
|
|
||||||
def _async_discovered_by_ssdp(self, response):
|
|
||||||
@callback
|
|
||||||
def _async_start_flow(*_):
|
|
||||||
asyncio.create_task(
|
|
||||||
self._hass.config_entries.flow.async_init(
|
|
||||||
DOMAIN,
|
|
||||||
context={"source": config_entries.SOURCE_SSDP},
|
|
||||||
data=response,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delay starting the flow in case the discovery is the result
|
|
||||||
# of another discovery
|
|
||||||
async_call_later(self._hass, 1, _async_start_flow)
|
|
||||||
|
|
||||||
async def _async_process_entry(self, response):
|
|
||||||
"""Process a discovery."""
|
|
||||||
_LOGGER.debug("Discovered via SSDP: %s", response)
|
|
||||||
unique_id = response["id"]
|
|
||||||
host = urlparse(response["location"]).hostname
|
|
||||||
current_entry = self._unique_id_capabilities.get(unique_id)
|
|
||||||
# Make sure we handle ip changes
|
|
||||||
if not current_entry or host != urlparse(current_entry["location"]).hostname:
|
|
||||||
_LOGGER.debug("Yeelight discovered with %s", response)
|
|
||||||
self._async_discovered_by_ssdp(response)
|
|
||||||
self._host_capabilities[host] = response
|
|
||||||
self._unique_id_capabilities[unique_id] = response
|
|
||||||
for event in self._host_discovered_events.get(host, []):
|
|
||||||
event.set()
|
|
||||||
|
|
||||||
|
|
||||||
def update_needs_bg_power_workaround(data):
|
|
||||||
"""Check if a push update needs the bg_power workaround.
|
|
||||||
|
|
||||||
Some devices will push the incorrect state for bg_power.
|
|
||||||
|
|
||||||
To work around this any time we are pushed an update
|
|
||||||
with bg_power, we force poll state which will be correct.
|
|
||||||
"""
|
|
||||||
return "bg_power" in data
|
|
||||||
|
|
||||||
|
|
||||||
class YeelightDevice:
|
|
||||||
"""Represents single Yeelight device."""
|
|
||||||
|
|
||||||
def __init__(self, hass, host, config, bulb):
|
|
||||||
"""Initialize device."""
|
|
||||||
self._hass = hass
|
|
||||||
self._config = config
|
|
||||||
self._host = host
|
|
||||||
self._bulb_device = bulb
|
|
||||||
self.capabilities = {}
|
|
||||||
self._device_type = None
|
|
||||||
self._available = True
|
|
||||||
self._initialized = False
|
|
||||||
self._name = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bulb(self):
|
|
||||||
"""Return bulb device."""
|
|
||||||
return self._bulb_device
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""Return the name of the device if any."""
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config(self):
|
|
||||||
"""Return device config."""
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
@property
|
|
||||||
def host(self):
|
|
||||||
"""Return hostname."""
|
|
||||||
return self._host
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self):
|
|
||||||
"""Return true is device is available."""
|
|
||||||
return self._available
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_mark_unavailable(self):
|
|
||||||
"""Set unavailable on api call failure due to a network issue."""
|
|
||||||
self._available = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model(self):
|
|
||||||
"""Return configured/autodetected device model."""
|
|
||||||
return self._bulb_device.model or self.capabilities.get("model")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fw_version(self):
|
|
||||||
"""Return the firmware version."""
|
|
||||||
return self.capabilities.get("fw_ver")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_nightlight_supported(self) -> bool:
|
|
||||||
"""
|
|
||||||
Return true / false if nightlight is supported.
|
|
||||||
|
|
||||||
Uses brightness as it appears to be supported in both ceiling and other lights.
|
|
||||||
"""
|
|
||||||
return self._nightlight_brightness is not None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_nightlight_enabled(self) -> bool:
|
|
||||||
"""Return true / false if nightlight is currently enabled."""
|
|
||||||
# Only ceiling lights have active_mode, from SDK docs:
|
|
||||||
# active_mode 0: daylight mode / 1: moonlight mode (ceiling light only)
|
|
||||||
if self._active_mode is not None:
|
|
||||||
return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT
|
|
||||||
|
|
||||||
if self._nightlight_brightness is not None:
|
|
||||||
return int(self._nightlight_brightness) > 0
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_color_flow_enabled(self) -> bool:
|
|
||||||
"""Return true / false if color flow is currently running."""
|
|
||||||
return self._color_flow and int(self._color_flow) == ACTIVE_COLOR_FLOWING
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _active_mode(self):
|
|
||||||
return self.bulb.last_properties.get("active_mode")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _color_flow(self):
|
|
||||||
return self.bulb.last_properties.get("flowing")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _nightlight_brightness(self):
|
|
||||||
return self.bulb.last_properties.get("nl_br")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def type(self):
|
|
||||||
"""Return bulb type."""
|
|
||||||
if not self._device_type:
|
|
||||||
self._device_type = self.bulb.bulb_type
|
|
||||||
|
|
||||||
return self._device_type
|
|
||||||
|
|
||||||
async def _async_update_properties(self):
|
|
||||||
"""Read new properties from the device."""
|
|
||||||
try:
|
|
||||||
await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES)
|
|
||||||
self._available = True
|
|
||||||
if not self._initialized:
|
|
||||||
self._initialized = True
|
|
||||||
except OSError as ex:
|
|
||||||
if self._available: # just inform once
|
|
||||||
_LOGGER.error(
|
|
||||||
"Unable to update device %s, %s: %s", self._host, self.name, ex
|
|
||||||
)
|
|
||||||
self._available = False
|
|
||||||
except asyncio.TimeoutError as ex:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"timed out while trying to update device %s, %s: %s",
|
|
||||||
self._host,
|
|
||||||
self.name,
|
|
||||||
ex,
|
|
||||||
)
|
|
||||||
except BulbException as ex:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Unable to update device %s, %s: %s", self._host, self.name, ex
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_setup(self):
|
|
||||||
"""Fetch capabilities and setup name if available."""
|
|
||||||
scanner = YeelightScanner.async_get(self._hass)
|
|
||||||
self.capabilities = await scanner.async_get_capabilities(self._host) or {}
|
|
||||||
if self.capabilities:
|
|
||||||
self._bulb_device.set_capabilities(self.capabilities)
|
|
||||||
if name := self._config.get(CONF_NAME):
|
|
||||||
# Override default name when name is set in config
|
|
||||||
self._name = name
|
|
||||||
elif self.capabilities:
|
|
||||||
# Generate name from model and id when capabilities is available
|
|
||||||
self._name = _async_unique_name(self.capabilities)
|
|
||||||
else:
|
|
||||||
self._name = self._host # Default name is host
|
|
||||||
|
|
||||||
async def async_update(self, force=False):
|
|
||||||
"""Update device properties and send data updated signal."""
|
|
||||||
if not force and self._initialized and self._available:
|
|
||||||
# No need to poll unless force, already connected
|
|
||||||
return
|
|
||||||
await self._async_update_properties()
|
|
||||||
async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host))
|
|
||||||
|
|
||||||
async def _async_forced_update(self, _now):
|
|
||||||
"""Call a forced update."""
|
|
||||||
await self.async_update(True)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_update_callback(self, data):
|
|
||||||
"""Update push from device."""
|
|
||||||
was_available = self._available
|
|
||||||
self._available = data.get(KEY_CONNECTED, True)
|
|
||||||
if update_needs_bg_power_workaround(data) or (
|
|
||||||
not was_available and self._available
|
|
||||||
):
|
|
||||||
# On reconnect the properties may be out of sync
|
|
||||||
#
|
|
||||||
# If the device drops the connection right away, we do not want to
|
|
||||||
# do a property resync via async_update since its about
|
|
||||||
# to be called when async_setup_entry reaches the end of the
|
|
||||||
# function
|
|
||||||
#
|
|
||||||
async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update)
|
|
||||||
async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host))
|
|
||||||
|
|
||||||
|
|
||||||
class YeelightEntity(Entity):
|
|
||||||
"""Represents single Yeelight entity."""
|
|
||||||
|
|
||||||
_attr_should_poll = False
|
|
||||||
|
|
||||||
def __init__(self, device: YeelightDevice, entry: ConfigEntry) -> None:
|
|
||||||
"""Initialize the entity."""
|
|
||||||
self._device = device
|
|
||||||
self._unique_id = entry.unique_id or entry.entry_id
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, self._unique_id)},
|
|
||||||
name=self._device.name,
|
|
||||||
manufacturer="Yeelight",
|
|
||||||
model=self._device.model,
|
|
||||||
sw_version=self._device.fw_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unique_id(self) -> str:
|
|
||||||
"""Return the unique ID."""
|
|
||||||
return self._unique_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
"""Return if bulb is available."""
|
|
||||||
return self._device.available
|
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
|
||||||
"""Update the entity."""
|
|
||||||
await self._device.async_update()
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_get_device(
|
async def _async_get_device(
|
||||||
hass: HomeAssistant, host: str, entry: ConfigEntry
|
hass: HomeAssistant, host: str, entry: ConfigEntry
|
||||||
) -> YeelightDevice:
|
) -> YeelightDevice:
|
||||||
|
@ -6,7 +6,8 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
|
||||||
from . import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN, YeelightEntity
|
from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN
|
||||||
|
from .entity import YeelightEntity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.core import callback
|
|||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
from . import (
|
from .const import (
|
||||||
CONF_DETECTED_MODEL,
|
CONF_DETECTED_MODEL,
|
||||||
CONF_MODE_MUSIC,
|
CONF_MODE_MUSIC,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
@ -25,12 +25,14 @@ from . import (
|
|||||||
CONF_TRANSITION,
|
CONF_TRANSITION,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
||||||
YeelightScanner,
|
)
|
||||||
|
from .device import (
|
||||||
_async_unique_name,
|
_async_unique_name,
|
||||||
async_format_id,
|
async_format_id,
|
||||||
async_format_model,
|
async_format_model,
|
||||||
async_format_model_id,
|
async_format_model_id,
|
||||||
)
|
)
|
||||||
|
from .scanner import YeelightScanner
|
||||||
|
|
||||||
MODEL_UNKNOWN = "unknown"
|
MODEL_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
103
homeassistant/components/yeelight/const.py
Normal file
103
homeassistant/components/yeelight/const.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"""Support for Xiaomi Yeelight WiFi color bulb."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
DOMAIN = "yeelight"
|
||||||
|
|
||||||
|
|
||||||
|
STATE_CHANGE_TIME = 0.40 # seconds
|
||||||
|
POWER_STATE_CHANGE_TIME = 1 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# These models do not transition correctly when turning on, and
|
||||||
|
# yeelight is no longer updating the firmware on older devices
|
||||||
|
#
|
||||||
|
# https://github.com/home-assistant/core/issues/58315
|
||||||
|
#
|
||||||
|
# The problem can be worked around by always setting the brightness
|
||||||
|
# even when the bulb is reporting the brightness is already at the
|
||||||
|
# desired level.
|
||||||
|
#
|
||||||
|
MODELS_WITH_DELAYED_ON_TRANSITION = {
|
||||||
|
"color", # YLDP02YL
|
||||||
|
}
|
||||||
|
|
||||||
|
DATA_UPDATED = "yeelight_{}_data_updated"
|
||||||
|
|
||||||
|
DEFAULT_NAME = "Yeelight"
|
||||||
|
DEFAULT_TRANSITION = 350
|
||||||
|
DEFAULT_MODE_MUSIC = False
|
||||||
|
DEFAULT_SAVE_ON_CHANGE = False
|
||||||
|
DEFAULT_NIGHTLIGHT_SWITCH = False
|
||||||
|
|
||||||
|
CONF_MODEL = "model"
|
||||||
|
CONF_DETECTED_MODEL = "detected_model"
|
||||||
|
CONF_TRANSITION = "transition"
|
||||||
|
|
||||||
|
CONF_SAVE_ON_CHANGE = "save_on_change"
|
||||||
|
CONF_MODE_MUSIC = "use_music_mode"
|
||||||
|
CONF_FLOW_PARAMS = "flow_params"
|
||||||
|
CONF_CUSTOM_EFFECTS = "custom_effects"
|
||||||
|
CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type"
|
||||||
|
CONF_NIGHTLIGHT_SWITCH = "nightlight_switch"
|
||||||
|
|
||||||
|
DATA_CONFIG_ENTRIES = "config_entries"
|
||||||
|
DATA_CUSTOM_EFFECTS = "custom_effects"
|
||||||
|
DATA_DEVICE = "device"
|
||||||
|
DATA_REMOVE_INIT_DISPATCHER = "remove_init_dispatcher"
|
||||||
|
DATA_PLATFORMS_LOADED = "platforms_loaded"
|
||||||
|
|
||||||
|
ATTR_COUNT = "count"
|
||||||
|
ATTR_ACTION = "action"
|
||||||
|
ATTR_TRANSITIONS = "transitions"
|
||||||
|
ATTR_MODE_MUSIC = "music_mode"
|
||||||
|
|
||||||
|
ACTION_RECOVER = "recover"
|
||||||
|
ACTION_STAY = "stay"
|
||||||
|
ACTION_OFF = "off"
|
||||||
|
|
||||||
|
ACTIVE_MODE_NIGHTLIGHT = 1
|
||||||
|
ACTIVE_COLOR_FLOWING = 1
|
||||||
|
|
||||||
|
|
||||||
|
NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light"
|
||||||
|
|
||||||
|
DISCOVERY_INTERVAL = timedelta(seconds=60)
|
||||||
|
SSDP_TARGET = ("239.255.255.250", 1982)
|
||||||
|
SSDP_ST = "wifi_bulb"
|
||||||
|
DISCOVERY_ATTEMPTS = 3
|
||||||
|
DISCOVERY_SEARCH_INTERVAL = timedelta(seconds=2)
|
||||||
|
DISCOVERY_TIMEOUT = 8
|
||||||
|
|
||||||
|
|
||||||
|
YEELIGHT_RGB_TRANSITION = "RGBTransition"
|
||||||
|
YEELIGHT_HSV_TRANSACTION = "HSVTransition"
|
||||||
|
YEELIGHT_TEMPERATURE_TRANSACTION = "TemperatureTransition"
|
||||||
|
YEELIGHT_SLEEP_TRANSACTION = "SleepTransition"
|
||||||
|
|
||||||
|
|
||||||
|
UPDATE_REQUEST_PROPERTIES = [
|
||||||
|
"power",
|
||||||
|
"main_power",
|
||||||
|
"bright",
|
||||||
|
"ct",
|
||||||
|
"rgb",
|
||||||
|
"hue",
|
||||||
|
"sat",
|
||||||
|
"color_mode",
|
||||||
|
"flowing",
|
||||||
|
"bg_power",
|
||||||
|
"bg_lmode",
|
||||||
|
"bg_flowing",
|
||||||
|
"bg_ct",
|
||||||
|
"bg_bright",
|
||||||
|
"bg_hue",
|
||||||
|
"bg_sat",
|
||||||
|
"bg_rgb",
|
||||||
|
"nl_br",
|
||||||
|
"active_mode",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORMS = ["binary_sensor", "light"]
|
233
homeassistant/components/yeelight/device.py
Normal file
233
homeassistant/components/yeelight/device.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
"""Support for Xiaomi Yeelight WiFi color bulb."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from yeelight import BulbException
|
||||||
|
from yeelight.aio import KEY_CONNECTED
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_NAME
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
from homeassistant.helpers.event import async_call_later
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ACTIVE_COLOR_FLOWING,
|
||||||
|
ACTIVE_MODE_NIGHTLIGHT,
|
||||||
|
DATA_UPDATED,
|
||||||
|
STATE_CHANGE_TIME,
|
||||||
|
UPDATE_REQUEST_PROPERTIES,
|
||||||
|
)
|
||||||
|
from .scanner import YeelightScanner
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_format_model(model: str) -> str:
|
||||||
|
"""Generate a more human readable model."""
|
||||||
|
return model.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_format_id(id_: str) -> str:
|
||||||
|
"""Generate a more human readable id."""
|
||||||
|
return hex(int(id_, 16)) if id_ else "None"
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_format_model_id(model: str, id_: str) -> str:
|
||||||
|
"""Generate a more human readable name."""
|
||||||
|
return f"{async_format_model(model)} {async_format_id(id_)}"
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_unique_name(capabilities: dict) -> str:
|
||||||
|
"""Generate name from capabilities."""
|
||||||
|
model_id = async_format_model_id(capabilities["model"], capabilities["id"])
|
||||||
|
return f"Yeelight {model_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def update_needs_bg_power_workaround(data):
|
||||||
|
"""Check if a push update needs the bg_power workaround.
|
||||||
|
|
||||||
|
Some devices will push the incorrect state for bg_power.
|
||||||
|
|
||||||
|
To work around this any time we are pushed an update
|
||||||
|
with bg_power, we force poll state which will be correct.
|
||||||
|
"""
|
||||||
|
return "bg_power" in data
|
||||||
|
|
||||||
|
|
||||||
|
class YeelightDevice:
|
||||||
|
"""Represents single Yeelight device."""
|
||||||
|
|
||||||
|
def __init__(self, hass, host, config, bulb):
|
||||||
|
"""Initialize device."""
|
||||||
|
self._hass = hass
|
||||||
|
self._config = config
|
||||||
|
self._host = host
|
||||||
|
self._bulb_device = bulb
|
||||||
|
self.capabilities = {}
|
||||||
|
self._device_type = None
|
||||||
|
self._available = True
|
||||||
|
self._initialized = False
|
||||||
|
self._name = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bulb(self):
|
||||||
|
"""Return bulb device."""
|
||||||
|
return self._bulb_device
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the name of the device if any."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self):
|
||||||
|
"""Return device config."""
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self):
|
||||||
|
"""Return hostname."""
|
||||||
|
return self._host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
"""Return true is device is available."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_mark_unavailable(self):
|
||||||
|
"""Set unavailable on api call failure due to a network issue."""
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self):
|
||||||
|
"""Return configured/autodetected device model."""
|
||||||
|
return self._bulb_device.model or self.capabilities.get("model")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fw_version(self):
|
||||||
|
"""Return the firmware version."""
|
||||||
|
return self.capabilities.get("fw_ver")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_nightlight_supported(self) -> bool:
|
||||||
|
"""
|
||||||
|
Return true / false if nightlight is supported.
|
||||||
|
|
||||||
|
Uses brightness as it appears to be supported in both ceiling and other lights.
|
||||||
|
"""
|
||||||
|
return self._nightlight_brightness is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_nightlight_enabled(self) -> bool:
|
||||||
|
"""Return true / false if nightlight is currently enabled."""
|
||||||
|
# Only ceiling lights have active_mode, from SDK docs:
|
||||||
|
# active_mode 0: daylight mode / 1: moonlight mode (ceiling light only)
|
||||||
|
if self._active_mode is not None:
|
||||||
|
return int(self._active_mode) == ACTIVE_MODE_NIGHTLIGHT
|
||||||
|
|
||||||
|
if self._nightlight_brightness is not None:
|
||||||
|
return int(self._nightlight_brightness) > 0
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_color_flow_enabled(self) -> bool:
|
||||||
|
"""Return true / false if color flow is currently running."""
|
||||||
|
return self._color_flow and int(self._color_flow) == ACTIVE_COLOR_FLOWING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _active_mode(self):
|
||||||
|
return self.bulb.last_properties.get("active_mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _color_flow(self):
|
||||||
|
return self.bulb.last_properties.get("flowing")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _nightlight_brightness(self):
|
||||||
|
return self.bulb.last_properties.get("nl_br")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
"""Return bulb type."""
|
||||||
|
if not self._device_type:
|
||||||
|
self._device_type = self.bulb.bulb_type
|
||||||
|
|
||||||
|
return self._device_type
|
||||||
|
|
||||||
|
async def _async_update_properties(self):
|
||||||
|
"""Read new properties from the device."""
|
||||||
|
try:
|
||||||
|
await self.bulb.async_get_properties(UPDATE_REQUEST_PROPERTIES)
|
||||||
|
self._available = True
|
||||||
|
if not self._initialized:
|
||||||
|
self._initialized = True
|
||||||
|
except OSError as ex:
|
||||||
|
if self._available: # just inform once
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to update device %s, %s: %s", self._host, self.name, ex
|
||||||
|
)
|
||||||
|
self._available = False
|
||||||
|
except asyncio.TimeoutError as ex:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"timed out while trying to update device %s, %s: %s",
|
||||||
|
self._host,
|
||||||
|
self.name,
|
||||||
|
ex,
|
||||||
|
)
|
||||||
|
except BulbException as ex:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Unable to update device %s, %s: %s", self._host, self.name, ex
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_setup(self):
|
||||||
|
"""Fetch capabilities and setup name if available."""
|
||||||
|
scanner = YeelightScanner.async_get(self._hass)
|
||||||
|
self.capabilities = await scanner.async_get_capabilities(self._host) or {}
|
||||||
|
if self.capabilities:
|
||||||
|
self._bulb_device.set_capabilities(self.capabilities)
|
||||||
|
if name := self._config.get(CONF_NAME):
|
||||||
|
# Override default name when name is set in config
|
||||||
|
self._name = name
|
||||||
|
elif self.capabilities:
|
||||||
|
# Generate name from model and id when capabilities is available
|
||||||
|
self._name = _async_unique_name(self.capabilities)
|
||||||
|
else:
|
||||||
|
self._name = self._host # Default name is host
|
||||||
|
|
||||||
|
async def async_update(self, force=False):
|
||||||
|
"""Update device properties and send data updated signal."""
|
||||||
|
if not force and self._initialized and self._available:
|
||||||
|
# No need to poll unless force, already connected
|
||||||
|
return
|
||||||
|
await self._async_update_properties()
|
||||||
|
async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host))
|
||||||
|
|
||||||
|
async def _async_forced_update(self, _now):
|
||||||
|
"""Call a forced update."""
|
||||||
|
await self.async_update(True)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_update_callback(self, data):
|
||||||
|
"""Update push from device."""
|
||||||
|
was_available = self._available
|
||||||
|
self._available = data.get(KEY_CONNECTED, True)
|
||||||
|
if update_needs_bg_power_workaround(data) or (
|
||||||
|
not was_available and self._available
|
||||||
|
):
|
||||||
|
# On reconnect the properties may be out of sync
|
||||||
|
#
|
||||||
|
# If the device drops the connection right away, we do not want to
|
||||||
|
# do a property resync via async_update since its about
|
||||||
|
# to be called when async_setup_entry reaches the end of the
|
||||||
|
# function
|
||||||
|
#
|
||||||
|
async_call_later(self._hass, STATE_CHANGE_TIME, self._async_forced_update)
|
||||||
|
async_dispatcher_send(self._hass, DATA_UPDATED.format(self._host))
|
40
homeassistant/components/yeelight/entity.py
Normal file
40
homeassistant/components/yeelight/entity.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Support for Xiaomi Yeelight WiFi color bulb."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .device import YeelightDevice
|
||||||
|
|
||||||
|
|
||||||
|
class YeelightEntity(Entity):
|
||||||
|
"""Represents single Yeelight entity."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(self, device: YeelightDevice, entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize the entity."""
|
||||||
|
self._device = device
|
||||||
|
self._unique_id = entry.unique_id or entry.entry_id
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._unique_id)},
|
||||||
|
name=self._device.name,
|
||||||
|
manufacturer="Yeelight",
|
||||||
|
model=self._device.model,
|
||||||
|
sw_version=self._device.fw_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
"""Return the unique ID."""
|
||||||
|
return self._unique_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return if bulb is available."""
|
||||||
|
return self._device.available
|
||||||
|
|
||||||
|
async def async_update(self) -> None:
|
||||||
|
"""Update the entity."""
|
||||||
|
await self._device.async_update()
|
@ -47,7 +47,8 @@ from homeassistant.util.color import (
|
|||||||
color_temperature_mired_to_kelvin as mired_to_kelvin,
|
color_temperature_mired_to_kelvin as mired_to_kelvin,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import (
|
from . import YEELIGHT_FLOW_TRANSITION_SCHEMA
|
||||||
|
from .const import (
|
||||||
ACTION_RECOVER,
|
ACTION_RECOVER,
|
||||||
ATTR_ACTION,
|
ATTR_ACTION,
|
||||||
ATTR_COUNT,
|
ATTR_COUNT,
|
||||||
@ -65,9 +66,8 @@ from . import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
MODELS_WITH_DELAYED_ON_TRANSITION,
|
MODELS_WITH_DELAYED_ON_TRANSITION,
|
||||||
POWER_STATE_CHANGE_TIME,
|
POWER_STATE_CHANGE_TIME,
|
||||||
YEELIGHT_FLOW_TRANSITION_SCHEMA,
|
|
||||||
YeelightEntity,
|
|
||||||
)
|
)
|
||||||
|
from .entity import YeelightEntity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
185
homeassistant/components/yeelight/scanner.py
Normal file
185
homeassistant/components/yeelight/scanner.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""Support for Xiaomi Yeelight WiFi color bulb."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from async_upnp_client.search import SsdpSearchListener
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components import network
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DISCOVERY_ATTEMPTS,
|
||||||
|
DISCOVERY_INTERVAL,
|
||||||
|
DISCOVERY_SEARCH_INTERVAL,
|
||||||
|
DISCOVERY_TIMEOUT,
|
||||||
|
DOMAIN,
|
||||||
|
SSDP_ST,
|
||||||
|
SSDP_TARGET,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class YeelightScanner:
|
||||||
|
"""Scan for Yeelight devices."""
|
||||||
|
|
||||||
|
_scanner = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@callback
|
||||||
|
def async_get(cls, hass: HomeAssistant):
|
||||||
|
"""Get scanner instance."""
|
||||||
|
if cls._scanner is None:
|
||||||
|
cls._scanner = cls(hass)
|
||||||
|
return cls._scanner
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize class."""
|
||||||
|
self._hass = hass
|
||||||
|
self._host_discovered_events = {}
|
||||||
|
self._unique_id_capabilities = {}
|
||||||
|
self._host_capabilities = {}
|
||||||
|
self._track_interval = None
|
||||||
|
self._listeners = []
|
||||||
|
self._connected_events = []
|
||||||
|
|
||||||
|
async def async_setup(self):
|
||||||
|
"""Set up the scanner."""
|
||||||
|
if self._connected_events:
|
||||||
|
await self._async_wait_connected()
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx, source_ip in enumerate(await self._async_build_source_set()):
|
||||||
|
self._connected_events.append(asyncio.Event())
|
||||||
|
|
||||||
|
def _wrap_async_connected_idx(idx):
|
||||||
|
"""Create a function to capture the idx cell variable."""
|
||||||
|
|
||||||
|
async def _async_connected():
|
||||||
|
self._connected_events[idx].set()
|
||||||
|
|
||||||
|
return _async_connected
|
||||||
|
|
||||||
|
self._listeners.append(
|
||||||
|
SsdpSearchListener(
|
||||||
|
async_callback=self._async_process_entry,
|
||||||
|
service_type=SSDP_ST,
|
||||||
|
target=SSDP_TARGET,
|
||||||
|
source_ip=source_ip,
|
||||||
|
async_connect_callback=_wrap_async_connected_idx(idx),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(listener.async_start() for listener in self._listeners),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
failed_listeners = []
|
||||||
|
for idx, result in enumerate(results):
|
||||||
|
if not isinstance(result, Exception):
|
||||||
|
continue
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Failed to setup listener for %s: %s",
|
||||||
|
self._listeners[idx].source_ip,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
failed_listeners.append(self._listeners[idx])
|
||||||
|
self._connected_events[idx].set()
|
||||||
|
|
||||||
|
for listener in failed_listeners:
|
||||||
|
self._listeners.remove(listener)
|
||||||
|
|
||||||
|
await self._async_wait_connected()
|
||||||
|
self._track_interval = async_track_time_interval(
|
||||||
|
self._hass, self.async_scan, DISCOVERY_INTERVAL
|
||||||
|
)
|
||||||
|
self.async_scan()
|
||||||
|
|
||||||
|
async def _async_wait_connected(self):
|
||||||
|
"""Wait for the listeners to be up and connected."""
|
||||||
|
await asyncio.gather(*(event.wait() for event in self._connected_events))
|
||||||
|
|
||||||
|
async def _async_build_source_set(self) -> set[IPv4Address]:
|
||||||
|
"""Build the list of ssdp sources."""
|
||||||
|
adapters = await network.async_get_adapters(self._hass)
|
||||||
|
sources: set[IPv4Address] = set()
|
||||||
|
if network.async_only_default_interface_enabled(adapters):
|
||||||
|
sources.add(IPv4Address("0.0.0.0"))
|
||||||
|
return sources
|
||||||
|
|
||||||
|
return {
|
||||||
|
source_ip
|
||||||
|
for source_ip in await network.async_get_enabled_source_ips(self._hass)
|
||||||
|
if not source_ip.is_loopback and not isinstance(source_ip, IPv6Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_discover(self):
|
||||||
|
"""Discover bulbs."""
|
||||||
|
_LOGGER.debug("Yeelight discover with interval %s", DISCOVERY_SEARCH_INTERVAL)
|
||||||
|
await self.async_setup()
|
||||||
|
for _ in range(DISCOVERY_ATTEMPTS):
|
||||||
|
self.async_scan()
|
||||||
|
await asyncio.sleep(DISCOVERY_SEARCH_INTERVAL.total_seconds())
|
||||||
|
return self._unique_id_capabilities.values()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_scan(self, *_):
|
||||||
|
"""Send discovery packets."""
|
||||||
|
_LOGGER.debug("Yeelight scanning")
|
||||||
|
for listener in self._listeners:
|
||||||
|
listener.async_search()
|
||||||
|
|
||||||
|
async def async_get_capabilities(self, host):
|
||||||
|
"""Get capabilities via SSDP."""
|
||||||
|
if host in self._host_capabilities:
|
||||||
|
return self._host_capabilities[host]
|
||||||
|
|
||||||
|
host_event = asyncio.Event()
|
||||||
|
self._host_discovered_events.setdefault(host, []).append(host_event)
|
||||||
|
await self.async_setup()
|
||||||
|
|
||||||
|
for listener in self._listeners:
|
||||||
|
listener.async_search((host, SSDP_TARGET[1]))
|
||||||
|
|
||||||
|
with contextlib.suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(host_event.wait(), timeout=DISCOVERY_TIMEOUT)
|
||||||
|
|
||||||
|
self._host_discovered_events[host].remove(host_event)
|
||||||
|
return self._host_capabilities.get(host)
|
||||||
|
|
||||||
|
def _async_discovered_by_ssdp(self, response):
|
||||||
|
@callback
|
||||||
|
def _async_start_flow(*_):
|
||||||
|
asyncio.create_task(
|
||||||
|
self._hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_SSDP},
|
||||||
|
data=response,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delay starting the flow in case the discovery is the result
|
||||||
|
# of another discovery
|
||||||
|
async_call_later(self._hass, 1, _async_start_flow)
|
||||||
|
|
||||||
|
async def _async_process_entry(self, response):
|
||||||
|
"""Process a discovery."""
|
||||||
|
_LOGGER.debug("Discovered via SSDP: %s", response)
|
||||||
|
unique_id = response["id"]
|
||||||
|
host = urlparse(response["location"]).hostname
|
||||||
|
current_entry = self._unique_id_capabilities.get(unique_id)
|
||||||
|
# Make sure we handle ip changes
|
||||||
|
if not current_entry or host != urlparse(current_entry["location"]).hostname:
|
||||||
|
_LOGGER.debug("Yeelight discovered with %s", response)
|
||||||
|
self._async_discovered_by_ssdp(response)
|
||||||
|
self._host_capabilities[host] = response
|
||||||
|
self._unique_id_capabilities[unique_id] = response
|
||||||
|
for event in self._host_discovered_events.get(host, []):
|
||||||
|
event.set()
|
@ -8,7 +8,7 @@ from async_upnp_client.search import SsdpSearchListener
|
|||||||
from yeelight import BulbException, BulbType
|
from yeelight import BulbException, BulbType
|
||||||
from yeelight.main import _MODEL_SPECS
|
from yeelight.main import _MODEL_SPECS
|
||||||
|
|
||||||
from homeassistant.components import yeelight as hass_yeelight, zeroconf
|
from homeassistant.components import zeroconf
|
||||||
from homeassistant.components.yeelight import (
|
from homeassistant.components.yeelight import (
|
||||||
CONF_MODE_MUSIC,
|
CONF_MODE_MUSIC,
|
||||||
CONF_NIGHTLIGHT_SWITCH_TYPE,
|
CONF_NIGHTLIGHT_SWITCH_TYPE,
|
||||||
@ -16,6 +16,7 @@ from homeassistant.components.yeelight import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
||||||
YeelightScanner,
|
YeelightScanner,
|
||||||
|
scanner,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME
|
from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
@ -185,16 +186,14 @@ def _patch_discovery(no_device=False, capabilities=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return patch(
|
return patch(
|
||||||
"homeassistant.components.yeelight.SsdpSearchListener",
|
"homeassistant.components.yeelight.scanner.SsdpSearchListener",
|
||||||
new=_generate_fake_ssdp_listener,
|
new=_generate_fake_ssdp_listener,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _patch_discovery_interval():
|
def _patch_discovery_interval():
|
||||||
return patch.object(
|
return patch.object(scanner, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0))
|
||||||
hass_yeelight, "DISCOVERY_SEARCH_INTERVAL", timedelta(seconds=0)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_discovery_timeout():
|
def _patch_discovery_timeout():
|
||||||
return patch.object(hass_yeelight, "DISCOVERY_TIMEOUT", 0.0001)
|
return patch.object(scanner, "DISCOVERY_TIMEOUT", 0.0001)
|
||||||
|
@ -5,7 +5,8 @@ import pytest
|
|||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.components import dhcp, zeroconf
|
from homeassistant.components import dhcp, zeroconf
|
||||||
from homeassistant.components.yeelight import (
|
from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect
|
||||||
|
from homeassistant.components.yeelight.const import (
|
||||||
CONF_DETECTED_MODEL,
|
CONF_DETECTED_MODEL,
|
||||||
CONF_MODE_MUSIC,
|
CONF_MODE_MUSIC,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
@ -21,7 +22,6 @@ from homeassistant.components.yeelight import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
|
||||||
)
|
)
|
||||||
from homeassistant.components.yeelight.config_flow import MODEL_UNKNOWN, CannotConnect
|
|
||||||
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME
|
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM
|
from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM
|
||||||
|
@ -7,7 +7,7 @@ import pytest
|
|||||||
from yeelight import BulbException, BulbType
|
from yeelight import BulbException, BulbType
|
||||||
from yeelight.aio import KEY_CONNECTED
|
from yeelight.aio import KEY_CONNECTED
|
||||||
|
|
||||||
from homeassistant.components.yeelight import (
|
from homeassistant.components.yeelight.const import (
|
||||||
CONF_DETECTED_MODEL,
|
CONF_DETECTED_MODEL,
|
||||||
CONF_NIGHTLIGHT_SWITCH,
|
CONF_NIGHTLIGHT_SWITCH,
|
||||||
CONF_NIGHTLIGHT_SWITCH_TYPE,
|
CONF_NIGHTLIGHT_SWITCH_TYPE,
|
||||||
|
@ -36,7 +36,7 @@ from homeassistant.components.light import (
|
|||||||
SERVICE_TURN_OFF,
|
SERVICE_TURN_OFF,
|
||||||
SERVICE_TURN_ON,
|
SERVICE_TURN_ON,
|
||||||
)
|
)
|
||||||
from homeassistant.components.yeelight import (
|
from homeassistant.components.yeelight.const import (
|
||||||
ATTR_COUNT,
|
ATTR_COUNT,
|
||||||
ATTR_MODE_MUSIC,
|
ATTR_MODE_MUSIC,
|
||||||
ATTR_TRANSITIONS,
|
ATTR_TRANSITIONS,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user