From d62d013fb6f0121474bfdb576f8bc4c807e3cdf5 Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Mon, 27 Dec 2021 23:02:48 +0100 Subject: [PATCH 01/18] Fix unique_id of nuki config entry (#62840) * fix(nuki): fixed naming of nuki integration * parse_id function * migration path * fixes from ci runs * don't update title if it was changed * move to dedicated helper * use dict of params * Update homeassistant/components/nuki/__init__.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/nuki/__init__.py | 9 +++++++++ homeassistant/components/nuki/config_flow.py | 14 ++++++++------ homeassistant/components/nuki/helpers.py | 6 ++++++ tests/components/nuki/mock.py | 3 ++- tests/components/nuki/test_config_flow.py | 4 ++-- 5 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/nuki/helpers.py diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 99def8d4117..09f36661c5d 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -27,6 +27,7 @@ from .const import ( DOMAIN, ERROR_STATES, ) +from .helpers import parse_id _LOGGER = logging.getLogger(__name__) @@ -80,6 +81,14 @@ async def async_setup_entry(hass, entry): hass.data.setdefault(DOMAIN, {}) + # Migration of entry unique_id + if isinstance(entry.unique_id, int): + new_id = parse_id(entry.unique_id) + params = {"unique_id": new_id} + if entry.title == entry.unique_id: + params["title"] = new_id + hass.config_entries.async_update_entry(entry, **params) + try: bridge = await hass.async_add_executor_job( NukiBridge, diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index bd2c5a0d750..54796d53890 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN +from .helpers import parse_id _LOGGER = logging.getLogger(__name__) @@ -69,7 +70,7 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Prepare configuration for a DHCP discovered Nuki bridge.""" - await self.async_set_unique_id(int(discovery_info.hostname[12:], 16)) + await self.async_set_unique_id(discovery_info.hostname[12:].upper()) self._abort_if_unique_id_configured() @@ -114,7 +115,9 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if not errors: - existing_entry = await self.async_set_unique_id(info["ids"]["hardwareId"]) + existing_entry = await self.async_set_unique_id( + parse_id(info["ids"]["hardwareId"]) + ) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=conf) self.hass.async_create_task( @@ -143,11 +146,10 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if "base" not in errors: - await self.async_set_unique_id(info["ids"]["hardwareId"]) + bridge_id = parse_id(info["ids"]["hardwareId"]) + await self.async_set_unique_id(bridge_id) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=info["ids"]["hardwareId"], data=user_input - ) + return self.async_create_entry(title=bridge_id, data=user_input) data_schema = self.discovery_schema or USER_SCHEMA return self.async_show_form( diff --git a/homeassistant/components/nuki/helpers.py b/homeassistant/components/nuki/helpers.py new file mode 100644 index 00000000000..3deedf9d8db --- /dev/null +++ b/homeassistant/components/nuki/helpers.py @@ -0,0 +1,6 @@ +"""nuki integration helpers.""" + + +def parse_id(hardware_id): + """Parse Nuki ID.""" + return hex(hardware_id).split("x")[-1].upper() diff --git a/tests/components/nuki/mock.py b/tests/components/nuki/mock.py index 30315915a73..e85d1de3933 100644 --- a/tests/components/nuki/mock.py +++ b/tests/components/nuki/mock.py @@ -7,6 +7,7 @@ HOST = "1.1.1.1" MAC = "01:23:45:67:89:ab" HW_ID = 123456789 +ID_HEX = "75BCD15" MOCK_INFO = {"ids": {"hardwareId": HW_ID}} @@ -16,7 +17,7 @@ async def setup_nuki_integration(hass): entry = MockConfigEntry( domain="nuki", - unique_id=HW_ID, + unique_id=ID_HEX, data={"host": HOST, "port": 8080, "token": "test-token"}, ) entry.add_to_hass(hass) diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index fd7bfa2137b..46f4a4b6e6b 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -41,7 +41,7 @@ async def test_form(hass): await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == 123456789 + assert result2["title"] == "75BCD15" assert result2["data"] == { "host": "1.1.1.1", "port": 8080, @@ -204,7 +204,7 @@ async def test_dhcp_flow(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == 123456789 + assert result2["title"] == "75BCD15" assert result2["data"] == { "host": "1.1.1.1", "port": 8080, From e3b1ab9cc4e25f5d7c45c4e818d269d5e0d23c55 Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Tue, 28 Dec 2021 16:26:53 -0800 Subject: [PATCH 02/18] Bump python-smarttub dependency to 0.0.29 (#62968) * Bump python-smarttub dependency to 0.0.29 * gen --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 5972d755e11..7d9a963b26c 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "dependencies": [], "codeowners": ["@mdz"], - "requirements": ["python-smarttub==0.0.28"], + "requirements": ["python-smarttub==0.0.29"], "quality_scale": "platinum", "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 41318a26a28..020e09769b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1929,7 +1929,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.smarttub -python-smarttub==0.0.28 +python-smarttub==0.0.29 # homeassistant.components.sochain python-sochain-api==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2e191885c9..6129743b932 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1157,7 +1157,7 @@ python-openzwave-mqtt[mqtt-client]==1.4.0 python-picnic-api==1.1.0 # homeassistant.components.smarttub -python-smarttub==0.0.28 +python-smarttub==0.0.29 # homeassistant.components.songpal python-songpal==0.12 From f612f7e7a9b58cbabbc0fb7680c9690143aaa21b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Dec 2021 16:22:53 -1000 Subject: [PATCH 03/18] Bump flux_led to 0.27.21 (#62971) --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 9b55c9f4549..9f1eaa5d4a4 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -3,7 +3,7 @@ "name": "Flux LED/MagicHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flux_led", - "requirements": ["flux_led==0.27.13"], + "requirements": ["flux_led==0.27.21"], "quality_scale": "platinum", "codeowners": ["@icemanch"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 020e09769b9..f2362e9a334 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -659,7 +659,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.27.13 +flux_led==0.27.21 # homeassistant.components.homekit fnvhash==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6129743b932..eefe9b24547 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.27.13 +flux_led==0.27.21 # homeassistant.components.homekit fnvhash==0.1.0 From 28278862fd42e7ba7319d5fda9c3bf27c9775075 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Dec 2021 07:20:20 +0100 Subject: [PATCH 04/18] Send commands to Hue grouped lights all at once (#62973) --- homeassistant/components/hue/v2/group.py | 49 ++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index add3336764d..7e0e50c3d61 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -1,6 +1,7 @@ """Support for Hue groups (room/zone).""" from __future__ import annotations +import asyncio from typing import Any from aiohue.v2 import HueBridgeV2 @@ -173,18 +174,22 @@ class GroupedHueLight(HueBaseEntity, LightEntity): # redirect all other feature commands to underlying lights # note that this silently ignores params sent to light that are not supported - for light in self.controller.get_lights(self.resource.id): - await self.bridge.async_request_call( - self.api.lights.set_state, - light.id, - on=True, - brightness=brightness if light.supports_dimming else None, - color_xy=xy_color if light.supports_color else None, - color_temp=color_temp if light.supports_color_temperature else None, - transition_time=transition, - alert=AlertEffectType.BREATHE if flash is not None else None, - allowed_errors=ALLOWED_ERRORS, - ) + await asyncio.gather( + *[ + self.bridge.async_request_call( + self.api.lights.set_state, + light.id, + on=True, + brightness=brightness if light.supports_dimming else None, + color_xy=xy_color if light.supports_color else None, + color_temp=color_temp if light.supports_color_temperature else None, + transition_time=transition, + alert=AlertEffectType.BREATHE if flash is not None else None, + allowed_errors=ALLOWED_ERRORS, + ) + for light in self.controller.get_lights(self.resource.id) + ] + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" @@ -202,14 +207,18 @@ class GroupedHueLight(HueBaseEntity, LightEntity): return # redirect all other feature commands to underlying lights - for light in self.controller.get_lights(self.resource.id): - await self.bridge.async_request_call( - self.api.lights.set_state, - light.id, - on=False, - transition_time=transition, - allowed_errors=ALLOWED_ERRORS, - ) + await asyncio.gather( + *[ + self.bridge.async_request_call( + self.api.lights.set_state, + light.id, + on=False, + transition_time=transition, + allowed_errors=ALLOWED_ERRORS, + ) + for light in self.controller.get_lights(self.resource.id) + ] + ) @callback def on_update(self) -> None: From 1e6a1a241d0924dfc1cbe5e47d871d84a3e42e23 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Dec 2021 07:21:23 +0100 Subject: [PATCH 05/18] Remove duplicate filter for Hue button events (#62974) --- homeassistant/components/hue/v2/hue_event.py | 21 +++----------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index 496507aff4d..1d45293012c 100644 --- a/homeassistant/components/hue/v2/hue_event.py +++ b/homeassistant/components/hue/v2/hue_event.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType -from aiohue.v2.models.button import Button, ButtonEvent +from aiohue.v2.models.button import Button from homeassistant.const import CONF_DEVICE_ID, CONF_ID, CONF_TYPE, CONF_UNIQUE_ID from homeassistant.core import callback @@ -27,11 +27,6 @@ async def async_setup_hue_events(bridge: "HueBridge"): api: HueBridgeV2 = bridge.api # to satisfy typing conf_entry = bridge.config_entry dev_reg = device_registry.async_get(hass) - last_state = { - x.id: x.button.last_event - for x in api.sensors.button.items - if x.button is not None - } # at this time the `button` resource is the only source of hue events btn_controller = api.sensors.button @@ -45,26 +40,16 @@ async def async_setup_hue_events(bridge: "HueBridge"): if hue_resource.button is None: return - cur_event = hue_resource.button.last_event - last_event = last_state.get(hue_resource.id) - # ignore the event if the last_event value is exactly the same - # this may happen if some other metadata of the button resource is adjusted - if cur_event == last_event: - return - if cur_event != ButtonEvent.REPEAT: - # do not store repeat event - last_state[hue_resource.id] = cur_event - hue_device = btn_controller.get_device(hue_resource.id) device = dev_reg.async_get_device({(DOMAIN, hue_device.id)}) # Fire event data = { # send slugified entity name as id = backwards compatibility with previous version - CONF_ID: slugify(f"{hue_device.metadata.name}: Button"), + CONF_ID: slugify(f"{hue_device.metadata.name} Button"), CONF_DEVICE_ID: device.id, # type: ignore CONF_UNIQUE_ID: hue_resource.id, - CONF_TYPE: cur_event.value, + CONF_TYPE: hue_resource.button.last_event.value, CONF_SUBTYPE: hue_resource.metadata.control_id, } hass.bus.async_fire(ATTR_HUE_EVENT, data) From fc2bcd964c3d80fdd0135f75421c58c0358b10df Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Dec 2021 12:15:52 +0100 Subject: [PATCH 06/18] Update frontend to 20211229.0 (#62981) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a2c7e49f6e6..c1d833ac169 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20211227.0" + "home-assistant-frontend==20211229.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 19b764ee32f..e90fdf9db12 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ ciso8601==2.2.0 cryptography==35.0.0 emoji==1.5.0 hass-nabucasa==0.50.0 -home-assistant-frontend==20211227.0 +home-assistant-frontend==20211229.0 httpx==0.21.0 ifaddr==0.1.7 jinja2==3.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index f2362e9a334..507d85dffe9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -820,7 +820,7 @@ hole==0.7.0 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211227.0 +home-assistant-frontend==20211229.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eefe9b24547..82a246d38cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -515,7 +515,7 @@ hole==0.7.0 holidays==0.11.3.1 # homeassistant.components.frontend -home-assistant-frontend==20211227.0 +home-assistant-frontend==20211229.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 7b797a2136e1191d2062aca9408b781a6c8360cb Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Dec 2021 12:01:57 +0100 Subject: [PATCH 07/18] Bump aiohue to 3.0.11 (#62983) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 7a69637fbb1..832592f3f1b 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==3.0.10"], + "requirements": ["aiohue==3.0.11"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 507d85dffe9..47d2b09b186 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -187,7 +187,7 @@ aiohomekit==0.6.4 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.10 +aiohue==3.0.11 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82a246d38cd..003ece83507 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -131,7 +131,7 @@ aiohomekit==0.6.4 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.10 +aiohue==3.0.11 # homeassistant.components.apache_kafka aiokafka==0.6.0 From 016e13154570d4d15ac196016b82bee464547497 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Dec 2021 14:52:08 +0100 Subject: [PATCH 08/18] Gracefully handle unknown HVAC mode in Tuya (#62984) Co-authored-by: Martin Hjelmare --- homeassistant/components/tuya/climate.py | 24 +++++++++++++++++++++--- homeassistant/components/tuya/const.py | 2 ++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index cb70fc5515a..83d635701db 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -32,7 +32,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData from .base import EnumTypeData, IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, DPCode TUYA_HVAC_TO_HA = { "auto": HVAC_MODE_HEAT_COOL, @@ -298,6 +298,21 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): if DPCode.SWITCH_VERTICAL in device.function: self._attr_swing_modes.append(SWING_VERTICAL) + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + + # Log unknown modes + if DPCode.MODE in self.device.function: + data_type = EnumTypeData.from_json(self.device.function[DPCode.MODE].values) + for tuya_mode in data_type.range: + if tuya_mode not in TUYA_HVAC_TO_HA: + LOGGER.warning( + "Unknown HVAC mode '%s' for device %s; assuming it as off", + tuya_mode, + self.device.name, + ) + def set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" commands = [{"code": DPCode.SWITCH, "value": hvac_mode != HVAC_MODE_OFF}] @@ -436,8 +451,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): return self.entity_description.switch_only_hvac_mode return HVAC_MODE_OFF - if self.device.status.get(DPCode.MODE) is not None: - return TUYA_HVAC_TO_HA[self.device.status[DPCode.MODE]] + if ( + mode := self.device.status.get(DPCode.MODE) + ) is not None and mode in TUYA_HVAC_TO_HA: + return TUYA_HVAC_TO_HA[mode] + return HVAC_MODE_OFF @property diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index fa69b76695c..02f8a8d356d 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum +import logging from tuya_iot import TuyaCloudOpenAPIEndpoint @@ -38,6 +39,7 @@ from homeassistant.const import ( ) DOMAIN = "tuya" +LOGGER = logging.getLogger(__package__) CONF_AUTH_TYPE = "auth_type" CONF_PROJECT_TYPE = "tuya_project_type" From f7e5f1cc23cbdda08037fc20cae08220c4af4fd8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Dec 2021 14:21:38 +0100 Subject: [PATCH 09/18] Fix short flash effect in Hue integration (#62988) --- homeassistant/components/hue/v2/group.py | 26 ++++++- homeassistant/components/hue/v2/light.py | 31 ++++++-- tests/components/hue/test_light_v2.py | 95 +++++++++++++++++++++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 7e0e50c3d61..52c1bc6117a 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -19,6 +19,7 @@ from homeassistant.components.light import ( COLOR_MODE_COLOR_TEMP, COLOR_MODE_ONOFF, COLOR_MODE_XY, + FLASH_SHORT, SUPPORT_FLASH, SUPPORT_TRANSITION, LightEntity, @@ -37,7 +38,6 @@ ALLOWED_ERRORS = [ 'device (groupedLight) is "soft off", command (on) may not have effect', "device (light) has communication issues, command (on) may not have effect", 'device (light) is "soft off", command (on) may not have effect', - "attribute (supportedAlertActions) cannot be written", ] @@ -155,6 +155,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity): brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) + if flash is not None: + await self.async_set_flash(flash) + # flash can not be sent with other commands at the same time + return + # NOTE: a grouped_light can only handle turn on/off # To set other features, you'll have to control the attached lights if ( @@ -194,6 +199,12 @@ class GroupedHueLight(HueBaseEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) + flash = kwargs.get(ATTR_FLASH) + + if flash is not None: + await self.async_set_flash(flash) + # flash can not be sent with other commands at the same time + return # NOTE: a grouped_light can only handle turn on/off # To set other features, you'll have to control the attached lights @@ -220,6 +231,19 @@ class GroupedHueLight(HueBaseEntity, LightEntity): ] ) + async def async_set_flash(self, flash: str) -> None: + """Send flash command to light.""" + await asyncio.gather( + *[ + self.bridge.async_request_call( + self.api.lights.set_flash, + id=light.id, + short=flash == FLASH_SHORT, + ) + for light in self.controller.get_lights(self.resource.id) + ] + ) + @callback def on_update(self) -> None: """Call on update event.""" diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index d6578c8ef9a..91afbe53e45 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -6,7 +6,6 @@ from typing import Any from aiohue import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.lights import LightsController -from aiohue.v2.models.feature import AlertEffectType from aiohue.v2.models.light import Light from homeassistant.components.light import ( @@ -19,6 +18,7 @@ from homeassistant.components.light import ( COLOR_MODE_COLOR_TEMP, COLOR_MODE_ONOFF, COLOR_MODE_XY, + FLASH_SHORT, SUPPORT_FLASH, SUPPORT_TRANSITION, LightEntity, @@ -35,7 +35,6 @@ from .helpers import normalize_hue_brightness, normalize_hue_transition ALLOWED_ERRORS = [ "device (light) has communication issues, command (on) may not have effect", 'device (light) is "soft off", command (on) may not have effect', - "attribute (supportedAlertActions) cannot be written", ] @@ -73,7 +72,8 @@ class HueLight(HueBaseEntity, LightEntity): ) -> None: """Initialize the light.""" super().__init__(bridge, controller, resource) - self._attr_supported_features |= SUPPORT_FLASH + if self.resource.alert and self.resource.alert.action_values: + self._attr_supported_features |= SUPPORT_FLASH self.resource = resource self.controller = controller self._supported_color_modes = set() @@ -162,6 +162,14 @@ class HueLight(HueBaseEntity, LightEntity): brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) + if flash is not None: + await self.async_set_flash(flash) + # flash can not be sent with other commands at the same time or result will be flaky + # Hue's default behavior is that a light returns to its previous state for short + # flash (identify) and the light is kept turned on for long flash (breathe effect) + # Why is this flash alert/effect hidden in the turn_on/off commands ? + return + await self.bridge.async_request_call( self.controller.set_state, id=self.resource.id, @@ -170,7 +178,6 @@ class HueLight(HueBaseEntity, LightEntity): color_xy=xy_color, color_temp=color_temp, transition_time=transition, - alert=AlertEffectType.BREATHE if flash is not None else None, allowed_errors=ALLOWED_ERRORS, ) @@ -179,11 +186,25 @@ class HueLight(HueBaseEntity, LightEntity): transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) flash = kwargs.get(ATTR_FLASH) + if flash is not None: + await self.async_set_flash(flash) + # flash can not be sent with other commands at the same time or result will be flaky + # Hue's default behavior is that a light returns to its previous state for short + # flash (identify) and the light is kept turned on for long flash (breathe effect) + return + await self.bridge.async_request_call( self.controller.set_state, id=self.resource.id, on=False, transition_time=transition, - alert=AlertEffectType.BREATHE if flash is not None else None, allowed_errors=ALLOWED_ERRORS, ) + + async def async_set_flash(self, flash: str) -> None: + """Send flash command to light.""" + await self.bridge.async_request_call( + self.controller.set_flash, + id=self.resource.id, + short=flash == FLASH_SHORT, + ) diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index f9277fad528..6bb084e6d06 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -121,7 +121,7 @@ async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_dat assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 200 - # test again with sending flash/alert + # test again with sending long flash await hass.services.async_call( "light", "turn_on", @@ -129,9 +129,18 @@ async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_dat blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 3 - assert mock_bridge_v2.mock_requests[2]["json"]["on"]["on"] is True assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + # test again with sending short flash + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "flash": "short"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 4 + assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): """Test calling the turn off service on a light.""" @@ -177,6 +186,26 @@ async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_da assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 200 + # test again with sending long flash + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id, "flash": "long"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 3 + assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + + # test again with sending short flash + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id, "flash": "short"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 4 + assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + async def test_light_added(hass, mock_bridge_v2): """Test new light added to bridge.""" @@ -386,3 +415,65 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): assert ( mock_bridge_v2.mock_requests[index]["json"]["dynamics"]["duration"] == 200 ) + + # Test sending short flash effect to a grouped light + mock_bridge_v2.mock_requests.clear() + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": test_light_id, + "flash": "short", + }, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert ( + mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] + == "identify" + ) + + # Test sending long flash effect to a grouped light + mock_bridge_v2.mock_requests.clear() + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": test_light_id, + "flash": "long", + }, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert ( + mock_bridge_v2.mock_requests[index]["json"]["alert"]["action"] == "breathe" + ) + + # Test sending flash effect in turn_off call + mock_bridge_v2.mock_requests.clear() + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_off", + { + "entity_id": test_light_id, + "flash": "short", + }, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert ( + mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] + == "identify" + ) From edea83f0039d166a3100e7ee697a86b242a13fdc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Dec 2021 15:35:51 +0100 Subject: [PATCH 10/18] Fix incorrect unit of measurement access in Tuya (#62989) --- homeassistant/components/tuya/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index d6870d4b9bb..07393b636e8 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -727,15 +727,15 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. if ( - self.unit_of_measurement is None + self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): self._attr_device_class = None return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.unit_of_measurement) or uoms.get( - self.unit_of_measurement.lower() + self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. From 846aeae40bc7476dbc74b1b6cff9254866dba6e5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Dec 2021 15:35:41 +0100 Subject: [PATCH 11/18] Fix Tuya data type information in lights (#62993) --- homeassistant/components/tuya/light.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 16eda1cc324..0669dee86c4 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -405,7 +405,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): if self._brightness_dpcode: self._attr_supported_color_modes.add(COLOR_MODE_BRIGHTNESS) self._brightness_type = IntegerTypeData.from_json( - device.status_range[self._brightness_dpcode].values + device.function[self._brightness_dpcode].values ) # Check if min/max capable @@ -416,17 +416,17 @@ class TuyaLightEntity(TuyaEntity, LightEntity): and description.brightness_min in device.function ): self._brightness_max_type = IntegerTypeData.from_json( - device.status_range[description.brightness_max].values + device.function[description.brightness_max].values ) self._brightness_min_type = IntegerTypeData.from_json( - device.status_range[description.brightness_min].values + device.function[description.brightness_min].values ) # Update internals based on found color temperature dpcode if self._color_temp_dpcode: self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) self._color_temp_type = IntegerTypeData.from_json( - device.status_range[self._color_temp_dpcode].values + device.function[self._color_temp_dpcode].values ) # Update internals based on found color data dpcode From ac92a7f4251bccc524116aefbd810d0f651c3401 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Dec 2021 15:34:56 +0100 Subject: [PATCH 12/18] Fix Tuya data type information in climate (#62994) --- homeassistant/components/tuya/climate.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 83d635701db..35e6f5814f3 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -182,10 +182,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): # it to define min, max & step temperatures if ( self._set_temperature_dpcode - and self._set_temperature_dpcode in device.status_range + and self._set_temperature_dpcode in device.function ): type_data = IntegerTypeData.from_json( - device.status_range[self._set_temperature_dpcode].values + device.function[self._set_temperature_dpcode].values ) self._attr_supported_features |= SUPPORT_TARGET_TEMPERATURE self._set_temperature_type = type_data @@ -232,14 +232,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ] # Determine dpcode to use for setting the humidity - if ( - DPCode.HUMIDITY_SET in device.status - and DPCode.HUMIDITY_SET in device.status_range - ): + if DPCode.HUMIDITY_SET in device.function: self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY self._set_humidity_dpcode = DPCode.HUMIDITY_SET type_data = IntegerTypeData.from_json( - device.status_range[DPCode.HUMIDITY_SET].values + device.function[DPCode.HUMIDITY_SET].values ) self._set_humidity_type = type_data self._attr_min_humidity = int(type_data.min_scaled) From e5c5a7334996eb5d95ac184eddab81ec45434497 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Dec 2021 15:34:36 +0100 Subject: [PATCH 13/18] Gracefully handle missing preset mode in Tuya fan (#62996) --- homeassistant/components/tuya/fan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 99d28f1b998..4ebed3cf9e0 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -160,9 +160,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity): return self.ha_preset_modes @property - def preset_mode(self) -> str: + def preset_mode(self) -> str | None: """Return the current preset_mode.""" - return self.device.status[DPCode.MODE] + return self.device.status.get(DPCode.MODE) @property def percentage(self) -> int | None: From 70c16d4fb7dbb09b84eabc69d8b410de236cf3ee Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Dec 2021 15:13:52 +0100 Subject: [PATCH 14/18] Normalize Hue colortemp if value outside of bounds (#62998) --- homeassistant/components/hue/v2/group.py | 8 ++++++-- homeassistant/components/hue/v2/helpers.py | 14 ++++++++++++-- homeassistant/components/hue/v2/light.py | 8 ++++++-- tests/components/hue/test_light_v2.py | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 52c1bc6117a..7ef91f684fe 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -31,7 +31,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from ..bridge import HueBridge from ..const import CONF_ALLOW_HUE_GROUPS, DOMAIN from .entity import HueBaseEntity -from .helpers import normalize_hue_brightness, normalize_hue_transition +from .helpers import ( + normalize_hue_brightness, + normalize_hue_colortemp, + normalize_hue_transition, +) ALLOWED_ERRORS = [ "device (groupedLight) has communication issues, command (on) may not have effect", @@ -151,7 +155,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): """Turn the light on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) diff --git a/homeassistant/components/hue/v2/helpers.py b/homeassistant/components/hue/v2/helpers.py index 307e7c55e03..97fdbe6160a 100644 --- a/homeassistant/components/hue/v2/helpers.py +++ b/homeassistant/components/hue/v2/helpers.py @@ -1,7 +1,8 @@ """Helper functions for Philips Hue v2.""" +from __future__ import annotations -def normalize_hue_brightness(brightness): +def normalize_hue_brightness(brightness: float | None) -> float | None: """Return calculated brightness values.""" if brightness is not None: # Hue uses a range of [0, 100] to control brightness. @@ -10,10 +11,19 @@ def normalize_hue_brightness(brightness): return brightness -def normalize_hue_transition(transition): +def normalize_hue_transition(transition: float | None) -> float | None: """Return rounded transition values.""" if transition is not None: # hue transition duration is in milliseconds and round them to 100ms transition = int(round(transition, 1) * 1000) return transition + + +def normalize_hue_colortemp(colortemp: int | None) -> int | None: + """Return color temperature within Hue's ranges.""" + if colortemp is not None: + # Hue only accepts a range between 153..500 + colortemp = min(colortemp, 500) + colortemp = max(colortemp, 153) + return colortemp diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 91afbe53e45..42444fd9ad0 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -30,7 +30,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from ..bridge import HueBridge from ..const import DOMAIN from .entity import HueBaseEntity -from .helpers import normalize_hue_brightness, normalize_hue_transition +from .helpers import ( + normalize_hue_brightness, + normalize_hue_colortemp, + normalize_hue_transition, +) ALLOWED_ERRORS = [ "device (light) has communication issues, command (on) may not have effect", @@ -158,7 +162,7 @@ class HueLight(HueBaseEntity, LightEntity): """Turn the device on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) xy_color = kwargs.get(ATTR_XY_COLOR) - color_temp = kwargs.get(ATTR_COLOR_TEMP) + color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 6bb084e6d06..8b811ffe7c6 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -141,6 +141,25 @@ async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_dat assert len(mock_bridge_v2.mock_requests) == 4 assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + # test again with sending a colortemperature which is out of range + # which should be normalized to the upper/lower bounds Hue can handle + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "color_temp": 50}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 5 + assert mock_bridge_v2.mock_requests[4]["json"]["color_temperature"]["mirek"] == 153 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "color_temp": 550}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 6 + assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500 + async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): """Test calling the turn off service on a light.""" From 65774ec3008b2475621c7f2adb0d21dcc5310907 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Dec 2021 16:01:54 +0100 Subject: [PATCH 15/18] Bumped version to 2021.12.7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f20d78f9224..f9131e0a4f3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from homeassistant.backports.enum import StrEnum MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "6" +PATCH_VERSION: Final = "7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From d315dc2ce4226e491494b1e0740bffd9f8b6efb7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Dec 2021 16:52:55 +0100 Subject: [PATCH 16/18] Hotfix for Nuki integration tests (#63007) --- tests/components/nuki/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index 46f4a4b6e6b..634902e054e 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -69,7 +69,7 @@ async def test_import(hass): data={"host": "1.1.1.1", "port": 8080, "token": "test-token"}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == 123456789 + assert result["title"] == "75BCD15" assert result["data"] == { "host": "1.1.1.1", "port": 8080, From 292ff974fa1a2ca442f0f437d80a64b1c1cbe880 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 29 Dec 2021 10:25:29 -0600 Subject: [PATCH 17/18] Fix night mode switch state on Sonos (#63009) --- homeassistant/components/sonos/speaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index ffc0d08a1b4..385af8224a6 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -482,7 +482,7 @@ class SonosSpeaker: for bool_var in ( "dialog_level", - "night_level", + "night_mode", "sub_enabled", "surround_enabled", ): From 05b2569621deeec9dcf0a31a27f57e422916bfe7 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 29 Dec 2021 10:31:04 -0600 Subject: [PATCH 18/18] Add missing migration for Sonos speech enhancement switch entities (#63010) --- homeassistant/components/sonos/switch.py | 54 +++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index ad98523258a..ce3130ceaf2 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -7,8 +7,9 @@ import logging from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity -from homeassistant.const import ATTR_TIME, ENTITY_CATEGORY_CONFIG -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TIME, ENTITY_CATEGORY_CONFIG, Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -113,6 +114,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): available_soco_attributes, speaker ) for feature_type in available_features: + if feature_type == ATTR_SPEECH_ENHANCEMENT: + async_migrate_speech_enhancement_entity_unique_id( + hass, config_entry, speaker + ) _LOGGER.debug( "Creating %s switch on %s", FRIENDLY_NAMES[feature_type], @@ -344,3 +349,48 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): await self.hass.async_add_executor_job(self.alarm.save) except (OSError, SoCoException, SoCoUPnPException) as exc: _LOGGER.error("Could not update %s: %s", self.entity_id, exc) + + +@callback +def async_migrate_speech_enhancement_entity_unique_id( + hass: HomeAssistant, + config_entry: ConfigEntry, + speaker: SonosSpeaker, +) -> None: + """Migrate Speech Enhancement switch entity unique_id.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + speech_enhancement_entries = [ + entry + for entry in registry_entries + if entry.domain == Platform.SWITCH + and entry.original_icon == FEATURE_ICONS[ATTR_SPEECH_ENHANCEMENT] + and entry.unique_id.startswith(speaker.soco.uid) + ] + + if len(speech_enhancement_entries) > 1: + _LOGGER.warning( + "Migration of Speech Enhancement switches on %s failed, manual cleanup required: %s", + speaker.zone_name, + [e.entity_id for e in speech_enhancement_entries], + ) + return + + if len(speech_enhancement_entries) == 1: + old_entry = speech_enhancement_entries[0] + if old_entry.unique_id.endswith("dialog_level"): + return + + new_unique_id = f"{speaker.soco.uid}-{ATTR_SPEECH_ENHANCEMENT}" + _LOGGER.debug( + "Migrating unique_id for %s from %s to %s", + old_entry.entity_id, + old_entry.unique_id, + new_unique_id, + ) + entity_registry.async_update_entity( + old_entry.entity_id, new_unique_id=new_unique_id + )