Fix missing device name with legacy flux_led discovery (#62785)

pull/62786/head^2
J. Nick Koston 2022-01-06 19:43:22 -10:00 committed by GitHub
parent ff3b7489ec
commit ad68d0795e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 215 additions and 39 deletions

View File

@ -32,6 +32,7 @@ from .const import (
)
from .coordinator import FluxLedUpdateCoordinator
from .discovery import (
async_build_cached_discovery,
async_clear_discovery_cache,
async_discover_device,
async_discover_devices,
@ -84,11 +85,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Flux LED/MagicLight from a config entry."""
host = entry.data[CONF_HOST]
directed_discovery = None
discovery_cached = True
if discovery := async_get_discovery(hass, host):
directed_discovery = False
discovery_cached = False
else:
discovery = async_build_cached_discovery(entry)
device: AIOWifiLedBulb = async_wifi_bulb_for_host(host, discovery=discovery)
signal = SIGNAL_STATE_UPDATED.format(device.ipaddr)
device.discovery = discovery
@callback
def _async_state_changed(*_: Any) -> None:
@ -103,23 +107,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) from ex
# UDP probe after successful connect only
if not discovery and (discovery := await async_discover_device(hass, host)):
directed_discovery = True
if discovery_cached:
if directed_discovery := await async_discover_device(hass, host):
device.discovery = discovery = directed_discovery
discovery_cached = False
if discovery:
if entry.unique_id:
assert discovery[ATTR_ID] is not None
mac = dr.format_mac(cast(str, discovery[ATTR_ID]))
if mac != entry.unique_id:
# The device is offline and another flux_led device is now using the ip address
raise ConfigEntryNotReady(
f"Unexpected device found at {host}; Expected {entry.unique_id}, found {mac}"
)
if directed_discovery:
# Only update the entry once we have verified the unique id
# is either missing or we have verified it matches
async_update_entry_from_discovery(hass, entry, discovery)
device.discovery = discovery
if entry.unique_id and discovery.get(ATTR_ID):
mac = dr.format_mac(cast(str, discovery[ATTR_ID]))
if mac != entry.unique_id:
# The device is offline and another flux_led device is now using the ip address
raise ConfigEntryNotReady(
f"Unexpected device found at {host}; Expected {entry.unique_id}, found {mac}"
)
if not discovery_cached:
# Only update the entry once we have verified the unique id
# is either missing or we have verified it matches
async_update_entry_from_discovery(hass, entry, discovery, device.model_num)
coordinator = FluxLedUpdateCoordinator(hass, device, entry)
hass.data[DOMAIN][entry.entry_id] = coordinator

View File

@ -1,9 +1,17 @@
"""Config flow for Flux LED/MagicLight."""
from __future__ import annotations
import contextlib
from typing import Any, Final, cast
from flux_led.const import ATTR_ID, ATTR_IPADDR, ATTR_MODEL, ATTR_MODEL_DESCRIPTION
from flux_led.const import (
ATTR_ID,
ATTR_IPADDR,
ATTR_MODEL,
ATTR_MODEL_DESCRIPTION,
ATTR_MODEL_INFO,
ATTR_VERSION_NUM,
)
from flux_led.scanner import FluxLEDDiscovery
import voluptuous as vol
@ -35,6 +43,7 @@ from .discovery import (
async_populate_data_from_discovery,
async_update_entry_from_discovery,
)
from .util import format_as_flux_mac
CONF_DEVICE: Final = "device"
@ -60,7 +69,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._discovered_device = FluxLEDDiscovery(
ipaddr=discovery_info.ip,
model=None,
id=discovery_info.macaddress.replace(":", ""),
id=format_as_flux_mac(discovery_info.macaddress),
model_num=None,
version_num=None,
firmware_date=None,
@ -90,7 +99,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(mac)
for entry in self._async_current_entries(include_ignore=False):
if entry.unique_id == mac or entry.data[CONF_HOST] == host:
if async_update_entry_from_discovery(self.hass, entry, device):
if async_update_entry_from_discovery(self.hass, entry, device, None):
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
@ -101,9 +110,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_in_progress")
if not device[ATTR_MODEL_DESCRIPTION]:
try:
device = await self._async_try_connect(
host, device[ATTR_ID], device[ATTR_MODEL]
)
device = await self._async_try_connect(host, device)
except FLUX_LED_EXCEPTIONS:
return self.async_abort(reason="cannot_connect")
else:
@ -157,7 +164,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not (host := user_input[CONF_HOST]):
return await self.async_step_pick_device()
try:
device = await self._async_try_connect(host, None, None)
device = await self._async_try_connect(host, None)
except FLUX_LED_EXCEPTIONS:
errors["base"] = "cannot_connect"
else:
@ -182,7 +189,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
mac = user_input[CONF_DEVICE]
await self.async_set_unique_id(mac, raise_on_progress=False)
return self._async_create_entry_from_device(self._discovered_devices[mac])
device = self._discovered_devices[mac]
if not device.get(ATTR_MODEL_DESCRIPTION):
with contextlib.suppress(*FLUX_LED_EXCEPTIONS):
device = await self._async_try_connect(device[ATTR_IPADDR], device)
return self._async_create_entry_from_device(device)
current_unique_ids = self._async_current_ids()
current_hosts = {
@ -212,7 +223,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
async def _async_try_connect(
self, host: str, mac_address: str | None, model: str | None
self, host: str, discovery: FluxLEDDiscovery | None
) -> FluxLEDDiscovery:
"""Try to connect."""
self._async_abort_entries_match({CONF_HOST: host})
@ -226,18 +237,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# AKA `HF-LPB100-ZJ200`
return device
bulb = async_wifi_bulb_for_host(host, discovery=device)
bulb.discovery = discovery
try:
await bulb.async_setup(lambda: None)
finally:
await bulb.async_stop()
return FluxLEDDiscovery(
ipaddr=host,
model=model,
id=mac_address,
model=discovery[ATTR_MODEL] if discovery else None,
id=discovery[ATTR_ID] if discovery else None,
model_num=bulb.model_num,
version_num=None, # This is the minor version number
version_num=discovery[ATTR_VERSION_NUM] if discovery else None,
firmware_date=None,
model_info=None,
model_info=discovery[ATTR_MODEL_INFO] if discovery else None,
model_description=bulb.model_data.description,
remote_access_enabled=None,
remote_access_host=None,

View File

@ -50,6 +50,9 @@ STARTUP_SCAN_TIMEOUT: Final = 5
DISCOVER_SCAN_TIMEOUT: Final = 10
CONF_MODEL: Final = "model"
CONF_MODEL_NUM: Final = "model_num"
CONF_MODEL_INFO: Final = "model_info"
CONF_MODEL_DESCRIPTION: Final = "model_description"
CONF_MINOR_VERSION: Final = "minor_version"
CONF_REMOTE_ACCESS_ENABLED: Final = "remote_access_enabled"
CONF_REMOTE_ACCESS_HOST: Final = "remote_access_host"

View File

@ -12,6 +12,8 @@ from flux_led.const import (
ATTR_IPADDR,
ATTR_MODEL,
ATTR_MODEL_DESCRIPTION,
ATTR_MODEL_INFO,
ATTR_MODEL_NUM,
ATTR_REMOTE_ACCESS_ENABLED,
ATTR_REMOTE_ACCESS_HOST,
ATTR_REMOTE_ACCESS_PORT,
@ -21,6 +23,7 @@ from flux_led.scanner import FluxLEDDiscovery
from homeassistant import config_entries
from homeassistant.components import network
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
@ -29,6 +32,9 @@ from homeassistant.util.network import is_ip_address
from .const import (
CONF_MINOR_VERSION,
CONF_MODEL,
CONF_MODEL_DESCRIPTION,
CONF_MODEL_INFO,
CONF_MODEL_NUM,
CONF_REMOTE_ACCESS_ENABLED,
CONF_REMOTE_ACCESS_HOST,
CONF_REMOTE_ACCESS_PORT,
@ -36,6 +42,7 @@ from .const import (
DOMAIN,
FLUX_LED_DISCOVERY,
)
from .util import format_as_flux_mac
_LOGGER = logging.getLogger(__name__)
@ -47,9 +54,31 @@ CONF_TO_DISCOVERY: Final = {
CONF_REMOTE_ACCESS_PORT: ATTR_REMOTE_ACCESS_PORT,
CONF_MINOR_VERSION: ATTR_VERSION_NUM,
CONF_MODEL: ATTR_MODEL,
CONF_MODEL_NUM: ATTR_MODEL_NUM,
CONF_MODEL_INFO: ATTR_MODEL_INFO,
CONF_MODEL_DESCRIPTION: ATTR_MODEL_DESCRIPTION,
}
@callback
def async_build_cached_discovery(entry: ConfigEntry) -> FluxLEDDiscovery:
"""When discovery is unavailable, load it from the config entry."""
data = entry.data
return FluxLEDDiscovery(
ipaddr=data[CONF_HOST],
model=data.get(CONF_MODEL),
id=format_as_flux_mac(entry.unique_id),
model_num=data.get(CONF_MODEL_NUM),
version_num=data.get(CONF_MINOR_VERSION),
firmware_date=None,
model_info=data.get(CONF_MODEL_INFO),
model_description=data.get(CONF_MODEL_DESCRIPTION),
remote_access_enabled=data.get(CONF_REMOTE_ACCESS_ENABLED),
remote_access_host=data.get(CONF_REMOTE_ACCESS_HOST),
remote_access_port=data.get(CONF_REMOTE_ACCESS_PORT),
)
@callback
def async_name_from_discovery(device: FluxLEDDiscovery) -> str:
"""Convert a flux_led discovery to a human readable name."""
@ -72,6 +101,8 @@ def async_populate_data_from_discovery(
for conf_key, discovery_key in CONF_TO_DISCOVERY.items():
if (
device.get(discovery_key) is not None
and conf_key
not in data_updates # Prefer the model num from TCP instead of UDP
and current_data.get(conf_key) != device[discovery_key] # type: ignore[misc]
):
data_updates[conf_key] = device[discovery_key] # type: ignore[misc]
@ -79,7 +110,10 @@ def async_populate_data_from_discovery(
@callback
def async_update_entry_from_discovery(
hass: HomeAssistant, entry: config_entries.ConfigEntry, device: FluxLEDDiscovery
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
device: FluxLEDDiscovery,
model_num: int | None,
) -> bool:
"""Update a config entry from a flux_led discovery."""
data_updates: dict[str, Any] = {}
@ -88,6 +122,8 @@ def async_update_entry_from_discovery(
updates: dict[str, Any] = {}
if not entry.unique_id:
updates["unique_id"] = dr.format_mac(mac_address)
if model_num and entry.data.get(CONF_MODEL_NUM) != model_num:
data_updates[CONF_MODEL_NUM] = model_num
async_populate_data_from_discovery(entry.data, data_updates, device)
if not entry.data.get(CONF_NAME) or is_ip_address(entry.data[CONF_NAME]):
updates["title"] = data_updates[CONF_NAME] = async_name_from_discovery(device)

View File

@ -24,7 +24,7 @@ def _async_device_info(
version_num = device.version_num
if minor_version := entry.data.get(CONF_MINOR_VERSION):
sw_version = version_num + int(hex(minor_version)[2:]) / 100
sw_version_str = f"{sw_version:0.3f}"
sw_version_str = f"{sw_version:0.2f}"
else:
sw_version_str = str(device.version_num)
return DeviceInfo(

View File

@ -18,6 +18,11 @@ def _hass_color_modes(device: AIOWifiLedBulb) -> set[str]:
return {_flux_color_mode_to_hass(mode, color_modes) for mode in color_modes}
def format_as_flux_mac(mac: str | None) -> str | None:
"""Convert a device registry formatted mac to flux mac."""
return None if mac is None else mac.replace(":", "").upper()
def _flux_color_mode_to_hass(
flux_color_mode: str | None, flux_color_modes: set[str]
) -> str:

View File

@ -24,11 +24,12 @@ MODULE = "homeassistant.components.flux_led"
MODULE_CONFIG_FLOW = "homeassistant.components.flux_led.config_flow"
IP_ADDRESS = "127.0.0.1"
MODEL_NUM_HEX = "0x35"
MODEL_NUM = 0x35
MODEL = "AZ120444"
MODEL_DESCRIPTION = "Bulb RGBCW"
MAC_ADDRESS = "aa:bb:cc:dd:ee:ff"
FLUX_MAC_ADDRESS = "aabbccddeeff"
SHORT_MAC_ADDRESS = "ddeeff"
FLUX_MAC_ADDRESS = "AABBCCDDEEFF"
SHORT_MAC_ADDRESS = "DDEEFF"
DEFAULT_ENTRY_TITLE = f"{MODEL_DESCRIPTION} {SHORT_MAC_ADDRESS}"
@ -52,7 +53,7 @@ FLUX_DISCOVERY = FluxLEDDiscovery(
ipaddr=IP_ADDRESS,
model=MODEL,
id=FLUX_MAC_ADDRESS,
model_num=0x25,
model_num=MODEL_NUM,
version_num=0x04,
firmware_date=datetime.date(2021, 5, 5),
model_info=MODEL,
@ -80,6 +81,16 @@ def _mocked_bulb() -> AIOWifiLedBulb:
bulb.async_set_effect = AsyncMock()
bulb.async_set_white_temp = AsyncMock()
bulb.async_set_brightness = AsyncMock()
bulb.pixels_per_segment = 300
bulb.segments = 2
bulb.music_pixels_per_segment = 150
bulb.music_segments = 4
bulb.operating_mode = "RGB&W"
bulb.operating_modes = ["RGB&W", "RGB/W"]
bulb.wirings = ["RGBW", "GRBW", "BGRW"]
bulb.wiring = "BGRW"
bulb.ic_types = ["WS2812B", "UCS1618"]
bulb.ic_type = "WS2812B"
bulb.async_stop = AsyncMock()
bulb.async_update = AsyncMock()
bulb.async_turn_off = AsyncMock()
@ -102,8 +113,8 @@ def _mocked_bulb() -> AIOWifiLedBulb:
bulb.color_temp = 2700
bulb.getWhiteTemperature = MagicMock(return_value=(2700, 128))
bulb.brightness = 128
bulb.model_num = 0x35
bulb.model_data = MODEL_MAP[0x35]
bulb.model_num = MODEL_NUM
bulb.model_data = MODEL_MAP[MODEL_NUM]
bulb.effect = None
bulb.speed = 50
bulb.model = "Bulb RGBCW (0x35)"

View File

@ -13,6 +13,9 @@ from homeassistant.components.flux_led.const import (
CONF_CUSTOM_EFFECT_TRANSITION,
CONF_MINOR_VERSION,
CONF_MODEL,
CONF_MODEL_DESCRIPTION,
CONF_MODEL_INFO,
CONF_MODEL_NUM,
CONF_REMOTE_ACCESS_ENABLED,
CONF_REMOTE_ACCESS_HOST,
CONF_REMOTE_ACCESS_PORT,
@ -32,6 +35,8 @@ from . import (
IP_ADDRESS,
MAC_ADDRESS,
MODEL,
MODEL_DESCRIPTION,
MODEL_NUM,
MODULE,
_patch_discovery,
_patch_wifibulb,
@ -91,6 +96,85 @@ async def test_discovery(hass: HomeAssistant):
CONF_HOST: IP_ADDRESS,
CONF_NAME: DEFAULT_ENTRY_TITLE,
CONF_MODEL: MODEL,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_INFO: MODEL,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_REMOTE_ACCESS_ENABLED: True,
CONF_REMOTE_ACCESS_HOST: "the.cloud",
CONF_REMOTE_ACCESS_PORT: 8816,
CONF_MINOR_VERSION: 0x04,
}
mock_setup.assert_called_once()
mock_setup_entry.assert_called_once()
# ignore configured devices
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert not result["errors"]
with _patch_discovery(), _patch_wifibulb():
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result2["type"] == "abort"
assert result2["reason"] == "no_devices_found"
async def test_discovery_legacy(hass: HomeAssistant):
"""Test setting up discovery with a legacy device."""
with _patch_discovery(device=FLUX_DISCOVERY_PARTIAL), _patch_wifibulb():
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.async_block_till_done()
assert result["type"] == "form"
assert result["step_id"] == "user"
assert not result["errors"]
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result2["type"] == "form"
assert result2["step_id"] == "pick_device"
assert not result2["errors"]
# test we can try again
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert not result["errors"]
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result2["type"] == "form"
assert result2["step_id"] == "pick_device"
assert not result2["errors"]
with _patch_discovery(), _patch_wifibulb(), patch(
f"{MODULE}.async_setup", return_value=True
) as mock_setup, patch(
f"{MODULE}.async_setup_entry", return_value=True
) as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_DEVICE: MAC_ADDRESS},
)
await hass.async_block_till_done()
assert result3["type"] == "create_entry"
assert result3["title"] == DEFAULT_ENTRY_TITLE
assert result3["data"] == {
CONF_MINOR_VERSION: 4,
CONF_HOST: IP_ADDRESS,
CONF_NAME: DEFAULT_ENTRY_TITLE,
CONF_MODEL: MODEL,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_INFO: MODEL,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_REMOTE_ACCESS_ENABLED: True,
CONF_REMOTE_ACCESS_HOST: "the.cloud",
CONF_REMOTE_ACCESS_PORT: 8816,
@ -171,6 +255,9 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant):
CONF_HOST: IP_ADDRESS,
CONF_NAME: DEFAULT_ENTRY_TITLE,
CONF_MODEL: MODEL,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_INFO: MODEL,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_REMOTE_ACCESS_ENABLED: True,
CONF_REMOTE_ACCESS_HOST: "the.cloud",
CONF_REMOTE_ACCESS_PORT: 8816,
@ -245,6 +332,9 @@ async def test_manual_working_discovery(hass: HomeAssistant):
CONF_HOST: IP_ADDRESS,
CONF_NAME: DEFAULT_ENTRY_TITLE,
CONF_MODEL: MODEL,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_INFO: MODEL,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_REMOTE_ACCESS_ENABLED: True,
CONF_REMOTE_ACCESS_HOST: "the.cloud",
CONF_REMOTE_ACCESS_PORT: 8816,
@ -283,7 +373,12 @@ async def test_manual_no_discovery_data(hass: HomeAssistant):
await hass.async_block_till_done()
assert result["type"] == "create_entry"
assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_NAME: IP_ADDRESS}
assert result["data"] == {
CONF_HOST: IP_ADDRESS,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_NAME: IP_ADDRESS,
}
async def test_discovered_by_discovery_and_dhcp(hass):
@ -352,6 +447,9 @@ async def test_discovered_by_discovery(hass):
CONF_HOST: IP_ADDRESS,
CONF_NAME: DEFAULT_ENTRY_TITLE,
CONF_MODEL: MODEL,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_INFO: MODEL,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_REMOTE_ACCESS_ENABLED: True,
CONF_REMOTE_ACCESS_HOST: "the.cloud",
CONF_REMOTE_ACCESS_PORT: 8816,
@ -387,6 +485,9 @@ async def test_discovered_by_dhcp_udp_responds(hass):
CONF_HOST: IP_ADDRESS,
CONF_NAME: DEFAULT_ENTRY_TITLE,
CONF_MODEL: MODEL,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_INFO: MODEL,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_REMOTE_ACCESS_ENABLED: True,
CONF_REMOTE_ACCESS_HOST: "the.cloud",
CONF_REMOTE_ACCESS_PORT: 8816,
@ -419,6 +520,8 @@ async def test_discovered_by_dhcp_no_udp_response(hass):
assert result2["type"] == "create_entry"
assert result2["data"] == {
CONF_HOST: IP_ADDRESS,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_NAME: DEFAULT_ENTRY_TITLE,
}
assert mock_async_setup.called
@ -448,6 +551,8 @@ async def test_discovered_by_dhcp_partial_udp_response_fallback_tcp(hass):
assert result2["type"] == "create_entry"
assert result2["data"] == {
CONF_HOST: IP_ADDRESS,
CONF_MODEL_NUM: MODEL_NUM,
CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION,
CONF_NAME: DEFAULT_ENTRY_TITLE,
}
assert mock_async_setup.called