diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 2210f44bf7a..a0844fe6cdb 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -34,6 +34,7 @@ class FlickPricingSensor(SensorEntity): _attr_attribution = "Data provided by Flick Electric" _attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}" + _attr_has_entity_name = True _attr_translation_key = "power_price" _attributes: dict[str, Any] = {} diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9f53aef8165..07c5585833d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230705.0"] + "requirements": ["home-assistant-frontend==20230705.1"] } diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index facdb6752d3..02919baa8f1 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -128,7 +128,7 @@ class MatterLight(MatterEntity, LightEntity): renormalize( brightness, (0, 255), - (level_control.minLevel, level_control.maxLevel), + (level_control.minLevel or 1, level_control.maxLevel or 254), ) ) @@ -220,7 +220,7 @@ class MatterLight(MatterEntity, LightEntity): return round( renormalize( level_control.currentLevel, - (level_control.minLevel or 0, level_control.maxLevel or 254), + (level_control.minLevel or 1, level_control.maxLevel or 254), (0, 255), ) ) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 55560d3bf8c..a1a75ef8260 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -72,6 +72,7 @@ class NukiDeviceEntity(NukiEntity[_NukiDeviceT], LockEntity): _attr_has_entity_name = True _attr_supported_features = LockEntityFeature.OPEN _attr_translation_key = "nuki_lock" + _attr_name = None @property def unique_id(self) -> str | None: diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 26ca5dedd34..36946b81c0c 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -19,5 +19,11 @@ "invalid_auth": "Bad authentication", "unknown": "Unknown error" } + }, + "issues": { + "deprecated_yaml": { + "title": "The QNAP YAML configuration is being removed", + "description": "Configuring QNAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the QNAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } } } diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 2216d060f29..a44cfb3ce13 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rainbird", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==2.0.0"] + "requirements": ["pyrainbird==2.1.0"] } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index f8d41db0e11..8530aa3b04c 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -608,7 +608,7 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): variables=service.data, context=service.context, wait=True ) if service.return_response: - return response + return response or {} return None async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 641d3b8ae4d..c7c6585e002 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -27,8 +27,10 @@ from homeassistant.util.dt import utcnow from .const import DEFAULT_NAME, DOMAIN, PLAYER_EVENT STATE_MAPPING = { - PlayerState.IDLE: MediaPlayerState.IDLE, + PlayerState.STOPPED: MediaPlayerState.IDLE, PlayerState.PLAYING: MediaPlayerState.PLAYING, + PlayerState.BUFFER_READY: MediaPlayerState.PLAYING, + PlayerState.BUFFERING: MediaPlayerState.PLAYING, PlayerState.PAUSED: MediaPlayerState.PAUSED, } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 2d31a883e4b..fb9f906527c 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -44,7 +44,7 @@ }, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "no_devices_found": "No supported SwitchBot devices found in range; If the device is in range, ensure the scanner has active scanning enabled, as SwitchBot devices cannot be discovered with passive scans. Active scans can be disabled once the device is configured. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the SwitchBot device is present.", "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "switchbot_unsupported_type": "Unsupported Switchbot Type." diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index a2cd2d5fc41..afa40f27afd 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -511,6 +511,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "rqbj": ( TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, + name=None, icon="mdi:gas-cylinder", state_class=SensorStateClass.MEASUREMENT, ), @@ -633,6 +634,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "ylcg": ( TuyaSensorEntityDescription( key=DPCode.PRESSURE_VALUE, + name=None, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 247b91d1b06..a3bb28e7a8b 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -292,6 +292,8 @@ async def async_setup_entry( class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Representation of a generic Xiaomi device.""" + _attr_name = None + def __init__(self, device, entry, unique_id, coordinator): """Initialize the generic Xiaomi device.""" super().__init__(device, entry, unique_id, coordinator) diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 82ede87848e..0438b606efd 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -118,6 +118,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): _attr_device_class = HumidifierDeviceClass.HUMIDIFIER _attr_supported_features = HumidifierEntityFeature.MODES + _attr_name = None def __init__(self, device, entry, unique_id, coordinator): """Initialize the generic Xiaomi device.""" diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index cf6feb44fbd..42549fb20d9 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -130,14 +130,11 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): @property def _is_netusb(self): - return ( - self.coordinator.data.netusb_input - == self.coordinator.data.zones[self._zone_id].input - ) + return self.coordinator.data.netusb_input == self.source_id @property def _is_tuner(self): - return self.coordinator.data.zones[self._zone_id].input == "tuner" + return self.source_id == "tuner" @property def media_content_id(self): @@ -516,10 +513,15 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): self._zone_id, self.reverse_source_mapping.get(source, source) ) + @property + def source_id(self): + """ID of the current input source.""" + return self.coordinator.data.zones[self._zone_id].input + @property def source(self): """Name of the current input source.""" - return self.source_mapping.get(self.coordinator.data.zones[self._zone_id].input) + return self.source_mapping.get(self.source_id) @property def source_list(self): @@ -597,7 +599,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): return ( self.coordinator.data.group_role == "client" and self.coordinator.data.group_id != NULL_GROUP - and self.source == ATTR_MC_LINK + and self.source_id == ATTR_MC_LINK ) @property @@ -606,7 +608,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): If the media player is not part of a group, False is returned. """ - return self.is_network_client or self.source == ATTR_MAIN_SYNC + return self.is_network_client or self.source_id == ATTR_MAIN_SYNC def get_all_mc_entities(self) -> list[MusicCastMediaPlayer]: """Return all media player entities of the musiccast system.""" @@ -639,11 +641,11 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): and self.coordinator.data.group_id == group_server.coordinator.data.group_id and self.ip_address != group_server.ip_address - and self.source == ATTR_MC_LINK + and self.source_id == ATTR_MC_LINK ) or ( self.ip_address == group_server.ip_address - and self.source == ATTR_MAIN_SYNC + and self.source_id == ATTR_MAIN_SYNC ) ) @@ -859,8 +861,12 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): """ _LOGGER.debug("%s client leave called", self.entity_id) if not force and ( - self.source == ATTR_MAIN_SYNC - or [entity for entity in self.other_zones if entity.source == ATTR_MC_LINK] + self.source_id == ATTR_MAIN_SYNC + or [ + entity + for entity in self.other_zones + if entity.source_id == ATTR_MC_LINK + ] ): await self.coordinator.musiccast.zone_unjoin(self._zone_id) else: diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 5607cabffea..8a81648b580 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -3,6 +3,7 @@ import asyncio import copy import logging import os +import re import voluptuous as vol from zhaquirks import setup as setup_quirks @@ -85,19 +86,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _clean_serial_port_path(path: str) -> str: + """Clean the serial port path, applying corrections where necessary.""" + + if path.startswith("socket://"): + path = path.strip() + + # Removes extraneous brackets from IP addresses (they don't parse in CPython 3.11.4) + if re.match(r"^socket://\[\d+\.\d+\.\d+\.\d+\]:\d+$", path): + path = path.replace("[", "").replace("]", "") + + return path + + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up ZHA. Will automatically load components to support devices found on the network. """ - # Strip whitespace around `socket://` URIs, this is no longer accepted by zigpy - # This will be removed in 2023.7.0 + # Remove brackets around IP addresses, this no longer works in CPython 3.11.4 + # This will be removed in 2023.11.0 path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + cleaned_path = _clean_serial_port_path(path) data = copy.deepcopy(dict(config_entry.data)) - if path.startswith("socket://") and path != path.strip(): - data[CONF_DEVICE][CONF_DEVICE_PATH] = path.strip() + if path != cleaned_path: + _LOGGER.debug("Cleaned serial port path %r -> %r", path, cleaned_path) + data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path hass.config_entries.async_update_entry(config_entry, data=data) zha_data = hass.data.setdefault(DATA_ZHA, {}) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7b85163fba7..cc04180a618 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 3d22e2538a3..e87eb15b954 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -772,9 +772,10 @@ class Entity(ABC): ): return name + device_name = device_entry.name_by_user or device_entry.name if self.use_device_name: - return device_entry.name_by_user or device_entry.name - return f"{device_entry.name_by_user or device_entry.name} {name}" + return device_name + return f"{device_name} {name}" if device_name else name @callback def _async_write_ha_state(self) -> None: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index fa0e57d501c..1164c2d8015 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -566,7 +566,9 @@ async def async_get_all_descriptions( hass: HomeAssistant, ) -> dict[str, dict[str, Any]]: """Return descriptions (i.e. user documentation) for all service calls.""" - descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) + descriptions_cache: dict[ + tuple[str, str], dict[str, Any] | None + ] = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) services = hass.services.async_services() # See if there are new services not seen before. @@ -574,59 +576,60 @@ async def async_get_all_descriptions( missing = set() all_services = [] for domain in services: - for service in services[domain]: - cache_key = (domain, service) + for service_name in services[domain]: + cache_key = (domain, service_name) all_services.append(cache_key) if cache_key not in descriptions_cache: missing.add(domain) # If we have a complete cache, check if it is still valid - if ALL_SERVICE_DESCRIPTIONS_CACHE in hass.data: - previous_all_services, previous_descriptions_cache = hass.data[ - ALL_SERVICE_DESCRIPTIONS_CACHE - ] + if all_cache := hass.data.get(ALL_SERVICE_DESCRIPTIONS_CACHE): + previous_all_services, previous_descriptions_cache = all_cache # If the services are the same, we can return the cache if previous_all_services == all_services: return cast(dict[str, dict[str, Any]], previous_descriptions_cache) # Files we loaded for missing descriptions - loaded = {} + loaded: dict[str, JSON_TYPE] = {} if missing: ints_or_excs = await async_get_integrations(hass, missing) - integrations = [ - int_or_exc - for int_or_exc in ints_or_excs.values() - if isinstance(int_or_exc, Integration) - ] - + integrations: list[Integration] = [] + for domain, int_or_exc in ints_or_excs.items(): + if type(int_or_exc) is Integration: # pylint: disable=unidiomatic-typecheck + integrations.append(int_or_exc) + continue + if TYPE_CHECKING: + assert isinstance(int_or_exc, Exception) + _LOGGER.error("Failed to load integration: %s", domain, exc_info=int_or_exc) contents = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - - for domain, content in zip(missing, contents): - loaded[domain] = content + loaded = dict(zip(missing, contents)) # Build response descriptions: dict[str, dict[str, Any]] = {} - for domain in services: + for domain, services_map in services.items(): descriptions[domain] = {} + domain_descriptions = descriptions[domain] - for service in services[domain]: - cache_key = (domain, service) + for service_name in services_map: + cache_key = (domain, service_name) description = descriptions_cache.get(cache_key) - # Cache missing descriptions if description is None: - domain_yaml = loaded[domain] + domain_yaml = loaded.get(domain) or {} + # The YAML may be empty for dynamically defined + # services (ie shell_command) that never call + # service.async_set_service_schema for the dynamic + # service yaml_description = domain_yaml.get( # type: ignore[union-attr] - service, {} + service_name, {} ) # Don't warn for missing services, because it triggers false # positives for things like scripts, that register as a service - description = { "name": yaml_description.get("name", ""), "description": yaml_description.get("description", ""), @@ -637,7 +640,7 @@ async def async_get_all_descriptions( description["target"] = yaml_description["target"] if ( - response := hass.services.supports_response(domain, service) + response := hass.services.supports_response(domain, service_name) ) != SupportsResponse.NONE: description["response"] = { "optional": response == SupportsResponse.OPTIONAL, @@ -645,7 +648,7 @@ async def async_get_all_descriptions( descriptions_cache[cache_key] = description - descriptions[domain][service] = description + domain_descriptions[service_name] = description hass.data[ALL_SERVICE_DESCRIPTIONS_CACHE] = (all_services, descriptions) return descriptions @@ -667,7 +670,9 @@ def async_set_service_schema( hass: HomeAssistant, domain: str, service: str, schema: dict[str, Any] ) -> None: """Register a description for a service.""" - hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) + descriptions_cache: dict[ + tuple[str, str], dict[str, Any] | None + ] = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) description = { "name": schema.get("name", ""), @@ -678,8 +683,15 @@ def async_set_service_schema( if "target" in schema: description["target"] = schema["target"] + if ( + response := hass.services.supports_response(domain, service) + ) != SupportsResponse.NONE: + description["response"] = { + "optional": response == SupportsResponse.OPTIONAL, + } + hass.data.pop(ALL_SERVICE_DESCRIPTIONS_CACHE, None) - hass.data[SERVICE_DESCRIPTION_CACHE][(domain, service)] = description + descriptions_cache[(domain, service)] = description @bind_hass diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1686f91bac3..71ca8fc4c3e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.0 hass-nabucasa==0.69.0 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230705.0 +home-assistant-frontend==20230705.1 home-assistant-intents==2023.6.28 httpx==0.24.1 ifaddr==0.2.0 diff --git a/pyproject.toml b/pyproject.toml index cd0e6eb47f0..2de9c9de5d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.7.0" +version = "2023.7.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 9ef98d4e402..2edbba41b01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -980,7 +980,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230705.0 +home-assistant-frontend==20230705.1 # homeassistant.components.conversation home-assistant-intents==2023.6.28 @@ -1938,7 +1938,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==2.0.0 +pyrainbird==2.1.0 # homeassistant.components.recswitch pyrecswitch==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c075eb1426..057a0e1d77d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -763,7 +763,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230705.0 +home-assistant-frontend==20230705.1 # homeassistant.components.conversation home-assistant-intents==2023.6.28 @@ -1439,7 +1439,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.rainbird -pyrainbird==2.0.0 +pyrainbird==2.1.0 # homeassistant.components.risco pyrisco==0.5.7 diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 199c3e08942..cc41b6c404c 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -26,7 +26,7 @@ from homeassistant.core import ( callback, split_entity_id, ) -from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import entity_registry as er, template from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import ( @@ -1625,7 +1625,7 @@ async def test_responses(hass: HomeAssistant, response: Any) -> None: ) -async def test_responses_error(hass: HomeAssistant) -> None: +async def test_responses_no_response(hass: HomeAssistant) -> None: """Test response variable not set.""" mock_restore_cache(hass, ()) assert await async_setup_component( @@ -1645,10 +1645,13 @@ async def test_responses_error(hass: HomeAssistant) -> None: }, ) - with pytest.raises(HomeAssistantError): - assert await hass.services.async_call( + # Validate we can call it with return_response + assert ( + await hass.services.async_call( DOMAIN, "test", {"greeting": "world"}, blocking=True, return_response=True ) + == {} + ) # Validate we can also call it without return_response assert ( await hass.services.async_call( diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 23a76de4c25..24ee63fb3d5 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -114,19 +114,27 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: @pytest.mark.parametrize( ("path", "cleaned_path"), [ + # No corrections ("/dev/path1", "/dev/path1"), + ("/dev/path1[asd]", "/dev/path1[asd]"), ("/dev/path1 ", "/dev/path1 "), + ("socket://1.2.3.4:5678", "socket://1.2.3.4:5678"), + # Brackets around URI + ("socket://[1.2.3.4]:5678", "socket://1.2.3.4:5678"), + # Spaces ("socket://dev/path1 ", "socket://dev/path1"), + # Both + ("socket://[1.2.3.4]:5678 ", "socket://1.2.3.4:5678"), ], ) @patch("homeassistant.components.zha.setup_quirks", Mock(return_value=True)) @patch( "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) ) -async def test_setup_with_v3_spaces_in_uri( +async def test_setup_with_v3_cleaning_uri( hass: HomeAssistant, path: str, cleaned_path: str ) -> None: - """Test migration of config entry from v3 with spaces after `socket://` URI.""" + """Test migration of config entry from v3, applying corrections to the port path.""" config_entry_v3 = MockConfigEntry( domain=DOMAIN, data={ diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 85a7932aef8..60d47ca9a44 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.typing import UNDEFINED +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import ( MockConfigEntry, @@ -989,12 +989,21 @@ async def _test_friendly_name( @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), + "has_entity_name", + "entity_name", + "device_name", + "expected_friendly_name", + "warn_implicit_name", + ), + ( + (False, "Entity Blu", "Device Bla", "Entity Blu", False), + (False, None, "Device Bla", None, False), + (True, "Entity Blu", "Device Bla", "Device Bla Entity Blu", False), + (True, None, "Device Bla", "Device Bla", False), + (True, "Entity Blu", UNDEFINED, "Entity Blu", False), + # Not valid on RC + # (True, "Entity Blu", None, "Mock Title Entity Blu", False), ), ) async def test_friendly_name_attr( @@ -1002,6 +1011,7 @@ async def test_friendly_name_attr( caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, + device_name: str | None | UndefinedType, expected_friendly_name: str | None, warn_implicit_name: bool, ) -> None: @@ -1012,7 +1022,7 @@ async def test_friendly_name_attr( device_info={ "identifiers": {("hue", "1234")}, "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, - "name": "Device Bla", + "name": device_name, }, ) ent._attr_has_entity_name = has_entity_name diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index f6299312b53..6adec334bb0 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -572,6 +572,98 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert "description" in descriptions[logger.DOMAIN]["new_service"] assert descriptions[logger.DOMAIN]["new_service"]["description"] == "new service" + hass.services.async_register( + logger.DOMAIN, "another_new_service", lambda x: None, None + ) + hass.services.async_register( + logger.DOMAIN, + "service_with_optional_response", + lambda x: None, + None, + SupportsResponse.OPTIONAL, + ) + hass.services.async_register( + logger.DOMAIN, + "service_with_only_response", + lambda x: None, + None, + SupportsResponse.ONLY, + ) + hass.services.async_register( + logger.DOMAIN, + "another_service_with_response", + lambda x: None, + None, + SupportsResponse.OPTIONAL, + ) + service.async_set_service_schema( + hass, + logger.DOMAIN, + "another_service_with_response", + {"description": "response service"}, + ) + + descriptions = await service.async_get_all_descriptions(hass) + assert "another_new_service" in descriptions[logger.DOMAIN] + assert "service_with_optional_response" in descriptions[logger.DOMAIN] + assert descriptions[logger.DOMAIN]["service_with_optional_response"][ + "response" + ] == {"optional": True} + assert "service_with_only_response" in descriptions[logger.DOMAIN] + assert descriptions[logger.DOMAIN]["service_with_only_response"]["response"] == { + "optional": False + } + assert "another_service_with_response" in descriptions[logger.DOMAIN] + assert descriptions[logger.DOMAIN]["another_service_with_response"]["response"] == { + "optional": True + } + + # Verify the cache returns the same object + assert await service.async_get_all_descriptions(hass) is descriptions + + +async def test_async_get_all_descriptions_failing_integration( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_get_all_descriptions when async_get_integrations returns an exception.""" + group = hass.components.group + group_config = {group.DOMAIN: {}} + await async_setup_component(hass, group.DOMAIN, group_config) + descriptions = await service.async_get_all_descriptions(hass) + + assert len(descriptions) == 1 + + assert "description" in descriptions["group"]["reload"] + assert "fields" in descriptions["group"]["reload"] + + logger = hass.components.logger + logger_config = {logger.DOMAIN: {}} + await async_setup_component(hass, logger.DOMAIN, logger_config) + with patch( + "homeassistant.helpers.service.async_get_integrations", + return_value={"logger": ImportError}, + ): + descriptions = await service.async_get_all_descriptions(hass) + + assert len(descriptions) == 2 + assert "Failed to load integration: logger" in caplog.text + + # Services are empty defaults if the load fails but should + # not raise + assert descriptions[logger.DOMAIN]["set_level"] == { + "description": "", + "fields": {}, + "name": "", + } + + hass.services.async_register(logger.DOMAIN, "new_service", lambda x: None, None) + service.async_set_service_schema( + hass, logger.DOMAIN, "new_service", {"description": "new service"} + ) + descriptions = await service.async_get_all_descriptions(hass) + assert "description" in descriptions[logger.DOMAIN]["new_service"] + assert descriptions[logger.DOMAIN]["new_service"]["description"] == "new service" + hass.services.async_register( logger.DOMAIN, "another_new_service", lambda x: None, None ) @@ -605,6 +697,33 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert await service.async_get_all_descriptions(hass) is descriptions +async def test_async_get_all_descriptions_dynamically_created_services( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_get_all_descriptions when async_get_integrations when services are dynamic.""" + group = hass.components.group + group_config = {group.DOMAIN: {}} + await async_setup_component(hass, group.DOMAIN, group_config) + descriptions = await service.async_get_all_descriptions(hass) + + assert len(descriptions) == 1 + + assert "description" in descriptions["group"]["reload"] + assert "fields" in descriptions["group"]["reload"] + + shell_command = hass.components.shell_command + shell_command_config = {shell_command.DOMAIN: {"test_service": "ls /bin"}} + await async_setup_component(hass, shell_command.DOMAIN, shell_command_config) + descriptions = await service.async_get_all_descriptions(hass) + + assert len(descriptions) == 2 + assert descriptions[shell_command.DOMAIN]["test_service"] == { + "description": "", + "fields": {}, + "name": "", + } + + async def test_call_with_required_features(hass: HomeAssistant, mock_entities) -> None: """Test service calls invoked only if entity has required features.""" test_service_mock = AsyncMock(return_value=None)