diff --git a/.coveragerc b/.coveragerc index 6a2a0db3ea4..442432dd71c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -182,7 +182,6 @@ omit = homeassistant/components/crownstone/listeners.py homeassistant/components/cups/sensor.py homeassistant/components/currencylayer/sensor.py - homeassistant/components/daikin/__init__.py homeassistant/components/daikin/climate.py homeassistant/components/daikin/sensor.py homeassistant/components/daikin/switch.py diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 5d0ea67f31d..75a2644791e 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -42,6 +42,7 @@ class BlinkSyncModule(AlarmControlPanelEntity): _attr_icon = ICON _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_name = None + _attr_has_entity_name = True def __init__(self, data, name, sync): """Initialize the alarm control panel.""" diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index c7daf0ec1e1..1487c6a7b42 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -58,13 +58,14 @@ async def async_setup_entry( class BlinkBinarySensor(BinarySensorEntity): """Representation of a Blink binary sensor.""" + _attr_has_entity_name = True + def __init__( self, data, camera, description: BinarySensorEntityDescription ) -> None: """Initialize the sensor.""" self.data = data self.entity_description = description - self._attr_name = f"{DOMAIN} {camera} {description.name}" self._camera = data.cameras[camera] self._attr_unique_id = f"{self._camera.serial}-{description.key}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index e74555f8db9..9740e427e9c 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -38,6 +38,7 @@ async def async_setup_entry( class BlinkCamera(Camera): """An implementation of a Blink Camera.""" + _attr_has_entity_name = True _attr_name = None def __init__(self, data, name, camera): diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 91f4940a4e5..b38c1d3829b 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.12.0"] + "requirements": ["bthome-ble==2.12.1"] } diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index d7af3b66688..bac4e63e288 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -301,55 +301,55 @@ "name": "Condition 1d", "state": { "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", - "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", - "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", - "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "condition_2d": { "name": "Condition 2d", "state": { "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", - "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", - "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", - "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "condition_3d": { "name": "Condition 3d", "state": { "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", - "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", - "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", - "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "condition_4d": { "name": "Condition 4d", "state": { "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", - "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", - "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", - "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "condition_5d": { "name": "Condition 5d", "state": { "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", - "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", - "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", - "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "conditioncode_1d": { @@ -371,76 +371,76 @@ "name": "Detailed condition 1d", "state": { "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", - "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", - "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", - "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", - "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", - "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "conditiondetailed_2d": { "name": "Detailed condition 2d", "state": { "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", - "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", - "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", - "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", - "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", - "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "conditiondetailed_3d": { "name": "Detailed condition 3d", "state": { "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", - "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", - "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", - "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", - "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", - "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "conditiondetailed_4d": { "name": "Detailed condition 4d", "state": { "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", - "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", - "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", @@ -455,21 +455,21 @@ "name": "Detailed condition 5d", "state": { "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", - "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", - "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", - "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", - "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", - "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", - "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", - "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "conditionexact_1d": { diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 481a072bdb3..b0097f607d5 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -15,8 +15,9 @@ from homeassistant.const import ( CONF_UUID, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -52,6 +53,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not daikin_api: return False + await async_migrate_unique_id(hass, entry, daikin_api) + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -67,7 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def daikin_api_setup(hass, host, key, uuid, password): +async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password): """Create a Daikin instance only once.""" session = async_get_clientsession(hass) @@ -127,3 +130,82 @@ class DaikinApi: name=info.get("name"), sw_version=info.get("ver", "").replace("_", "."), ) + + +async def async_migrate_unique_id( + hass: HomeAssistant, config_entry: ConfigEntry, api: DaikinApi +) -> None: + """Migrate old entry.""" + dev_reg = dr.async_get(hass) + old_unique_id = config_entry.unique_id + new_unique_id = api.device.mac + new_name = api.device.values["name"] + + @callback + def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + return update_unique_id(entity_entry, new_unique_id) + + if new_unique_id == old_unique_id: + return + + # Migrate devices + for device_entry in dr.async_entries_for_config_entry( + dev_reg, config_entry.entry_id + ): + for connection in device_entry.connections: + if connection[1] == old_unique_id: + new_connections = { + (CONNECTION_NETWORK_MAC, dr.format_mac(new_unique_id)) + } + + _LOGGER.debug( + "Migrating device %s connections to %s", + device_entry.name, + new_connections, + ) + dev_reg.async_update_device( + device_entry.id, + merge_connections=new_connections, + ) + + if device_entry.name is None: + _LOGGER.debug( + "Migrating device name to %s", + new_name, + ) + dev_reg.async_update_device( + device_entry.id, + name=new_name, + ) + + # Migrate entities + await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id) + + new_data = {**config_entry.data, KEY_MAC: dr.format_mac(new_unique_id)} + + hass.config_entries.async_update_entry( + config_entry, unique_id=new_unique_id, data=new_data + ) + + +@callback +def update_unique_id( + entity_entry: er.RegistryEntry, unique_id: str +) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if entity_entry.unique_id.startswith(unique_id): + # Already correct, nothing to do + return None + + unique_id_parts = entity_entry.unique_id.split("-") + unique_id_parts[0] = unique_id + entity_new_unique_id = "-".join(unique_id_parts) + + _LOGGER.debug( + "Migrating entity %s from %s to new id %s", + entity_entry.entity_id, + entity_entry.unique_id, + entity_new_unique_id, + ) + return {"new_unique_id": entity_new_unique_id} diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 6f90b0cf5ef..c6334dfaeca 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["pydaikin"], "quality_scale": "platinum", - "requirements": ["pydaikin==2.9.0"], + "requirements": ["pydaikin==2.10.5"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 37b3ec45c4c..847f030fae5 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -42,7 +42,7 @@ async def async_setup_entry( [ DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone != ("-", "0") + if zone[0] != "-" ] ) if daikin_api.device.support_advanced_modes: diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 53b2478490d..79653e1c9bc 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple): SERVICE_HANDLERS = { SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), "yamaha": ServiceDetails("media_player", "yamaha"), - "openhome": ServiceDetails("media_player", "openhome"), "bluesound": ServiceDetails("media_player", "bluesound"), } @@ -87,6 +86,7 @@ MIGRATED_SERVICE_HANDLERS = [ SERVICE_MOBILE_APP, SERVICE_NETGEAR, SERVICE_OCTOPRINT, + "openhome", "philips_hue", SERVICE_SAMSUNG_PRINTER, "sonos", diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index df191afb859..0c85705a2a6 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -76,6 +76,7 @@ class ControllerEntity(ClimateEntity): _attr_fan_modes = list(_HA_FAN_TO_ESCEA) _attr_has_entity_name = True + _attr_name = None _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_icon = ICON _attr_precision = PRECISION_WHOLE diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index afaefe117ba..ed55180bc0e 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -388,6 +388,7 @@ async def async_setup_entry( # noqa: C901 assert cli.api_version is not None entry_data.api_version = cli.api_version entry_data.available = True + entry_data.expected_disconnect = True if entry_data.device_info.name: reconnect_logic.name = entry_data.device_info.name diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 731743e48c8..7f554901812 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -34,7 +34,7 @@ from .const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) -from .dashboard import async_get_dashboard, async_set_dashboard_info +from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" @@ -391,7 +391,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """ if ( self._device_name is None - or (dashboard := async_get_dashboard(self.hass)) is None + or (manager := await async_get_or_create_dashboard_manager(self.hass)) + is None + or (dashboard := manager.async_get()) is None ): return False diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 35e9cf74555..4cbb9cbe847 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -93,13 +93,6 @@ class ESPHomeDashboardManager: hass, addon_slug, url, async_get_clientsession(hass) ) await dashboard.async_request_refresh() - if not cur_dashboard and not dashboard.last_update_success: - # If there was no previous dashboard and the new one is not available, - # we skip setup and wait for discovery. - _LOGGER.error( - "Dashboard unavailable; skipping setup: %s", dashboard.last_exception - ) - return self._current_dashboard = dashboard @@ -143,7 +136,14 @@ class ESPHomeDashboardManager: @callback def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: - """Get an instance of the dashboard if set.""" + """Get an instance of the dashboard if set. + + This is only safe to call after `async_setup` has been completed. + + It should not be called from the config flow because there is a race + where manager can be an asyncio.Event instead of the actual manager + because the singleton decorator is not yet done. + """ manager: ESPHomeDashboardManager | None = hass.data.get(KEY_DASHBOARD_MANAGER) return manager.async_get() if manager else None diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index 597dd8ddb53..d14c562bd76 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -30,9 +30,6 @@ async def async_setup_entry( avm_wrapper.fritz_guest_wifi.get_info ) - if not guest_wifi_info.get("NewEnable"): - return - async_add_entities( [ FritzGuestWifiQRImage( diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index 88bcdd4987b..f1bfc7de876 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_polling", "loggers": ["goalzero"], "quality_scale": "silver", - "requirements": ["goalzero==0.2.1"] + "requirements": ["goalzero==0.2.2"] } diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index d0a88bf8249..2a9e2225e9f 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.5"], + "requirements": ["aiohomekit==2.6.7"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 22a52e61b63..94f445f1ec1 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -43,6 +43,7 @@ class LEDBLEEntity(CoordinatorEntity, LightEntity): _attr_supported_color_modes = {ColorMode.RGB, ColorMode.WHITE} _attr_has_entity_name = True + _attr_name = None _attr_supported_features = LightEntityFeature.EFFECT def __init__( diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 160cd0e8634..972db00e476 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -22,6 +22,24 @@ PLATFORMS_BULB = [Platform.LIGHT] _LOGGER = logging.getLogger(__name__) +async def _async_get_device_state( + device: MyStromSwitch | MyStromBulb, ip_address: str +) -> None: + try: + await device.get_state() + except MyStromConnectionError as err: + _LOGGER.error("No route to myStrom plug: %s", ip_address) + raise ConfigEntryNotReady() from err + + +def _get_mystrom_bulb(host: str, mac: str) -> MyStromBulb: + return MyStromBulb(host, mac) + + +def _get_mystrom_switch(host: str) -> MyStromSwitch: + return MyStromSwitch(host) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up myStrom from a config entry.""" host = entry.data[CONF_HOST] @@ -32,14 +50,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("No route to myStrom plug: %s", host) raise ConfigEntryNotReady() from err + info.setdefault("type", 101) + device_type = info["type"] if device_type in [101, 106, 107]: - device = MyStromSwitch(host) + device = _get_mystrom_switch(host) platforms = PLATFORMS_SWITCH - elif device_type == 102: + await _async_get_device_state(device, info["ip"]) + elif device_type in [102, 105]: mac = info["mac"] - device = MyStromBulb(host, mac) + device = _get_mystrom_bulb(host, mac) platforms = PLATFORMS_BULB + await _async_get_device_state(device, info["ip"]) if device.bulb_type not in ["rgblamp", "strip"]: _LOGGER.error( "Device %s (%s) is not a myStrom bulb nor myStrom LED Strip", @@ -51,12 +73,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Unsupported myStrom device type: %s", device_type) return False - try: - await device.get_state() - except MyStromConnectionError as err: - _LOGGER.error("No route to myStrom plug: %s", info["ip"]) - raise ConfigEntryNotReady() from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = MyStromData( device=device, info=info, @@ -69,10 +85,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" device_type = hass.data[DOMAIN][entry.entry_id].info["type"] + platforms = [] if device_type in [101, 106, 107]: - platforms = PLATFORMS_SWITCH - elif device_type == 102: - platforms = PLATFORMS_BULB + platforms.extend(PLATFORMS_SWITCH) + elif device_type in [102, 105]: + platforms.extend(PLATFORMS_BULB) if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 63aa2f2f916..ea153be11cf 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==6.0"] + "requirements": ["RestrictedPython==6.1"] } diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 14a81f2c665..2af0cb30f1e 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -2,10 +2,12 @@ from __future__ import annotations from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController +from pyrainbird.exceptions import RainbirdApiException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_SERIAL_NUMBER @@ -29,11 +31,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) ) + try: + model_info = await controller.get_model_and_version() + except RainbirdApiException as err: + raise ConfigEntryNotReady from err coordinator = RainbirdUpdateCoordinator( hass, name=entry.title, controller=controller, serial_number=entry.data[CONF_SERIAL_NUMBER], + model_info=model_info, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 14598921a61..e1b52c6ff7d 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -9,6 +9,7 @@ from typing import TypeVar import async_timeout from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException +from pyrainbird.data import ModelAndVersion from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo @@ -42,6 +43,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): name: str, controller: AsyncRainbirdController, serial_number: str, + model_info: ModelAndVersion, ) -> None: """Initialize ZoneStateUpdateCoordinator.""" super().__init__( @@ -54,6 +56,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): self._controller = controller self._serial_number = serial_number self._zones: set[int] | None = None + self._model_info = model_info @property def controller(self) -> AsyncRainbirdController: @@ -72,6 +75,8 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): default_name=f"{MANUFACTURER} Controller", identifiers={(DOMAIN, self._serial_number)}, manufacturer=MANUFACTURER, + model=self._model_info.model_name, + sw_version=f"{self._model_info.major}.{self._model_info.minor}", ) async def _async_update_data(self) -> RainbirdDeviceState: diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index a811894a0c2..f603cf0ccd7 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -62,6 +62,7 @@ class RainMachineUpdateEntity(RainMachineEntity, UpdateEntity): """Define a RainMachine update entity.""" _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_name = None _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 69b3d5db6f7..00f0e0f518b 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.1"] + "requirements": ["reolink-aio==0.7.3"] } diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 6303bc58131..2ae3442278e 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -49,7 +49,7 @@ SELECT_ENTITIES = ( icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, translation_key="floodlight_mode", - get_options=[mode.name for mode in SpotlightModeEnum], + get_options=lambda api, ch: api.whiteled_mode_list(ch), supported=lambda api, ch: api.supported(ch, "floodLight"), value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name, method=lambda api, ch, name: api.set_whiteled(ch, mode=name), diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index f208e3e4035..8abbbf23aad 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -62,7 +62,9 @@ "state": { "off": "Off", "auto": "Auto", - "schedule": "Schedule" + "schedule": "Schedule", + "adaptive": "Adaptive", + "autoadaptive": "Auto adaptive" } }, "day_night_mode": { diff --git a/homeassistant/components/ridwell/manifest.json b/homeassistant/components/ridwell/manifest.json index 5b9b443b65e..72a29182169 100644 --- a/homeassistant/components/ridwell/manifest.json +++ b/homeassistant/components/ridwell/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioridwell"], - "requirements": ["aioridwell==2023.01.0"] + "requirements": ["aioridwell==2023.07.0"] } diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index baab687e64a..0cf6db4ae81 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.29.2"] + "requirements": ["python-roborock==0.30.0"] } diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index c7c6585e002..9bd9f7668c8 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -90,6 +90,7 @@ class SlimProtoPlayer(MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_name = None def __init__(self, slimserver: SlimServer, player: SlimClient) -> None: """Initialize MediaPlayer entity.""" diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index cd84bec11b2..5b0bc4d4c63 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -33,6 +33,7 @@ class StookwijzerSensor(SensorEntity): _attr_attribution = "Data provided by stookwijzer.nu" _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True + _attr_name = None _attr_translation_key = "stookwijzer" def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 1da879cb02b..35083c4b089 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -122,6 +122,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION ) + _attr_name = None _attr_translation_key = "cover" CLOSED_UP_THRESHOLD = 80 CLOSED_DOWN_THRESHOLD = 20 diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index 47680714d19..4a71789423f 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -49,9 +49,10 @@ async def async_setup_entry( class UpbLight(UpbAttachedEntity, LightEntity): - """Representation of an UPB Light.""" + """Representation of a UPB Light.""" _attr_has_entity_name = True + _attr_name = None def __init__(self, element, unique_id, upb): """Initialize an UpbLight.""" diff --git a/homeassistant/components/upb/scene.py b/homeassistant/components/upb/scene.py index fe6f07199c4..d1272b7a1f6 100644 --- a/homeassistant/components/upb/scene.py +++ b/homeassistant/components/upb/scene.py @@ -47,7 +47,7 @@ async def async_setup_entry( class UpbLink(UpbEntity, Scene): - """Representation of an UPB Link.""" + """Representation of a UPB Link.""" def __init__(self, element, unique_id, upb): """Initialize the base of all UPB devices.""" diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 3a562296a50..bb19d2e1655 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pywemo"], - "requirements": ["pywemo==0.9.1"], + "requirements": ["pywemo==1.1.0"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 799949a462a..7ced3487269 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -43,6 +43,7 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_name = None def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: """Initialize the Yale Alarm Device.""" diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index fde08d08fbd..397a9cc8db1 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -40,6 +40,8 @@ async def async_setup_entry( class YaleDoorlock(YaleEntity, LockEntity): """Representation of a Yale doorlock.""" + _attr_name = None + def __init__( self, coordinator: YaleDataUpdateCoordinator, data: dict, code_format: int ) -> None: diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 9e97c2f080f..0ecf0e7b697 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -29,6 +29,7 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): """A yale xs ble lock.""" _attr_has_entity_name = True + _attr_name = None @callback def _async_update_state( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 293822987c3..7694a85b8ed 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -25,10 +25,10 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.101", "zigpy-deconz==0.21.0", - "zigpy==0.56.1", + "zigpy==0.56.2", "zigpy-xbee==0.18.1", "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.2" + "zigpy-znp==0.11.3" ], "usb": [ { diff --git a/homeassistant/const.py b/homeassistant/const.py index cc04180a618..30a7fc37c9e 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 = "1" +PATCH_VERSION: Final = "2" __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/core.py b/homeassistant/core.py index 47b52d9ff76..661f087fe6a 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -16,7 +16,6 @@ from collections.abc import ( ) import concurrent.futures from contextlib import suppress -from contextvars import ContextVar import datetime import enum import functools @@ -156,8 +155,6 @@ MAX_EXPECTED_ENTITY_IDS = 16384 _LOGGER = logging.getLogger(__name__) -_cv_hass: ContextVar[HomeAssistant] = ContextVar("hass") - @functools.lru_cache(MAX_EXPECTED_ENTITY_IDS) def split_entity_id(entity_id: str) -> tuple[str, str]: @@ -200,16 +197,27 @@ def is_callback(func: Callable[..., Any]) -> bool: return getattr(func, "_hass_callback", False) is True +class _Hass(threading.local): + """Container which makes a HomeAssistant instance available to the event loop.""" + + hass: HomeAssistant | None = None + + +_hass = _Hass() + + @callback def async_get_hass() -> HomeAssistant: """Return the HomeAssistant instance. - Raises LookupError if no HomeAssistant instance is available. + Raises HomeAssistantError when called from the wrong thread. This should be used where it's very cumbersome or downright impossible to pass hass to the code which needs it. """ - return _cv_hass.get() + if not _hass.hass: + raise HomeAssistantError("async_get_hass called from the wrong thread") + return _hass.hass @enum.unique @@ -293,9 +301,9 @@ class HomeAssistant: config_entries: ConfigEntries = None # type: ignore[assignment] def __new__(cls) -> HomeAssistant: - """Set the _cv_hass context variable.""" + """Set the _hass thread local data.""" hass = super().__new__(cls) - _cv_hass.set(hass) + _hass.hass = hass return hass def __init__(self) -> None: @@ -1760,7 +1768,7 @@ class ServiceRegistry: the context. Will return NONE if the service does not exist as there is other error handling when calling the service if it does not exist. """ - if not (handler := self._services[domain][service]): + if not (handler := self._services[domain.lower()][service.lower()]): return SupportsResponse.NONE return handler.supports_response diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index cea8a866f5c..e8f1e58615c 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -93,7 +93,7 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES @@ -609,7 +609,7 @@ def template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value should be a string") hass: HomeAssistant | None = None - with contextlib.suppress(LookupError): + with contextlib.suppress(HomeAssistantError): hass = async_get_hass() template_value = template_helper.Template(str(value), hass) @@ -631,7 +631,7 @@ def dynamic_template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value does not contain a dynamic template") hass: HomeAssistant | None = None - with contextlib.suppress(LookupError): + with contextlib.suppress(HomeAssistantError): hass = async_get_hass() template_value = template_helper.Template(str(value), hass) @@ -1098,7 +1098,7 @@ def _no_yaml_config_schema( # pylint: disable-next=import-outside-toplevel from .issue_registry import IssueSeverity, async_create_issue - with contextlib.suppress(LookupError): + with contextlib.suppress(HomeAssistantError): hass = async_get_hass() async_create_issue( hass, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 1164c2d8015..dcd7115f363 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -670,6 +670,9 @@ def async_set_service_schema( hass: HomeAssistant, domain: str, service: str, schema: dict[str, Any] ) -> None: """Register a description for a service.""" + domain = domain.lower() + service = service.lower() + descriptions_cache: dict[ tuple[str, str], dict[str, Any] | None ] = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) diff --git a/pyproject.toml b/pyproject.toml index 2de9c9de5d1..a902941213d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.7.1" +version = "2023.7.2" 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 2edbba41b01..5c0f16dae97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -124,7 +124,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.0 +RestrictedPython==6.1 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 @@ -252,7 +252,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.5 +aiohomekit==2.6.7 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -330,7 +330,7 @@ aioqsw==0.3.2 aiorecollect==1.0.8 # homeassistant.components.ridwell -aioridwell==2023.01.0 +aioridwell==2023.07.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -565,7 +565,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.12.0 +bthome-ble==2.12.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -864,7 +864,7 @@ gitterpy==0.1.7 glances-api==0.4.3 # homeassistant.components.goalzero -goalzero==0.2.1 +goalzero==0.2.2 # homeassistant.components.goodwe goodwe==0.2.31 @@ -1615,7 +1615,7 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.9.0 +pydaikin==2.10.5 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -2139,7 +2139,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.29.2 +python-roborock==0.30.0 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2213,7 +2213,7 @@ pyvolumio==0.1.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.9.1 +pywemo==1.1.0 # homeassistant.components.wilight pywilight==0.0.74 @@ -2267,7 +2267,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.1 +reolink-aio==0.7.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2759,10 +2759,10 @@ zigpy-xbee==0.18.1 zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.2 +zigpy-znp==0.11.3 # homeassistant.components.zha -zigpy==0.56.1 +zigpy==0.56.2 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 057a0e1d77d..2c50f874cf4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -108,7 +108,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.0 +RestrictedPython==6.1 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 @@ -227,7 +227,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.5 +aiohomekit==2.6.7 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -302,7 +302,7 @@ aioqsw==0.3.2 aiorecollect==1.0.8 # homeassistant.components.ridwell -aioridwell==2023.01.0 +aioridwell==2023.07.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -469,7 +469,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.12.0 +bthome-ble==2.12.1 # homeassistant.components.buienradar buienradar==1.0.5 @@ -677,7 +677,7 @@ gios==3.1.0 glances-api==0.4.3 # homeassistant.components.goalzero -goalzero==0.2.1 +goalzero==0.2.2 # homeassistant.components.goodwe goodwe==0.2.31 @@ -1197,7 +1197,7 @@ pycoolmasternet-async==0.1.5 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.9.0 +pydaikin==2.10.5 # homeassistant.components.deconz pydeconz==113 @@ -1565,7 +1565,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.29.2 +python-roborock==0.30.0 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1621,7 +1621,7 @@ pyvolumio==0.1.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.9.1 +pywemo==1.1.0 # homeassistant.components.wilight pywilight==0.0.74 @@ -1660,7 +1660,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.1 +reolink-aio==0.7.3 # homeassistant.components.rflink rflink==0.0.65 @@ -2023,10 +2023,10 @@ zigpy-xbee==0.18.1 zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.2 +zigpy-znp==0.11.3 # homeassistant.components.zha -zigpy==0.56.1 +zigpy==0.56.2 # homeassistant.components.zwave_js zwave-js-server-python==0.49.0 diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py new file mode 100644 index 00000000000..8145a7a1e99 --- /dev/null +++ b/tests/components/daikin/test_init.py @@ -0,0 +1,128 @@ +"""Define tests for the Daikin init.""" +import asyncio +from unittest.mock import AsyncMock, PropertyMock, patch + +from aiohttp import ClientConnectionError +import pytest + +from homeassistant.components.daikin import update_unique_id +from homeassistant.components.daikin.const import DOMAIN, KEY_MAC +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .test_config_flow import HOST, MAC + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_daikin(): + """Mock pydaikin.""" + + async def mock_daikin_factory(*args, **kwargs): + """Mock the init function in pydaikin.""" + return Appliance + + with patch("homeassistant.components.daikin.Appliance") as Appliance: + Appliance.factory.side_effect = mock_daikin_factory + type(Appliance).update_status = AsyncMock() + type(Appliance).inside_temperature = PropertyMock(return_value=22) + type(Appliance).target_temperature = PropertyMock(return_value=22) + type(Appliance).zones = PropertyMock(return_value=[("Zone 1", "0", 0)]) + type(Appliance).fan_rate = PropertyMock(return_value=[]) + type(Appliance).swing_modes = PropertyMock(return_value=[]) + yield Appliance + + +DATA = { + "ver": "1_1_8", + "name": "DaikinAP00000", + "mac": MAC, + "model": "NOTSUPPORT", +} + + +INVALID_DATA = {**DATA, "name": None, "mac": HOST} + + +async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: + """Test unique id migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HOST, + title=None, + data={CONF_HOST: HOST, KEY_MAC: HOST}, + ) + config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + type(mock_daikin).mac = PropertyMock(return_value=HOST) + type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.unique_id == HOST + + assert device_registry.async_get_device({}, {(KEY_MAC, HOST)}).name is None + + entity = entity_registry.async_get("climate.daikin_127_0_0_1") + assert entity.unique_id == HOST + assert update_unique_id(entity, MAC) is not None + + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(HOST) + + type(mock_daikin).mac = PropertyMock(return_value=MAC) + type(mock_daikin).values = PropertyMock(return_value=DATA) + + assert config_entry.unique_id != MAC + + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.unique_id == MAC + + assert ( + device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name == "DaikinAP00000" + ) + + entity = entity_registry.async_get("climate.daikin_127_0_0_1") + assert entity.unique_id == MAC + assert update_unique_id(entity, MAC) is None + + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) + + +async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None: + """Test unique id migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MAC, + data={CONF_HOST: HOST, KEY_MAC: MAC}, + ) + config_entry.add_to_hass(hass) + + mock_daikin.factory.side_effect = ClientConnectionError + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_timeout_error(hass: HomeAssistant, mock_daikin) -> None: + """Test unique id migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MAC, + data={CONF_HOST: HOST, KEY_MAC: MAC}, + ) + config_entry.add_to_hass(hass) + + mock_daikin.factory.side_effect = asyncio.TimeoutError + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 662816a53d8..86472a8aa57 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1308,3 +1308,45 @@ async def test_option_flow( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value} assert len(mock_reload.mock_calls) == int(option_value) + + +async def test_user_discovers_name_no_dashboard( + hass: HomeAssistant, + mock_client, + mock_zeroconf: None, + mock_setup_entry: None, +) -> None: + """Test user step can discover the name and the there is not dashboard.""" + mock_client.device_info.side_effect = [ + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), + DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ), + ] + + result = await hass.config_entries.flow.async_init( + "esphome", + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "encryption_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + assert mock_client.noise_psk == VALID_NOISE_PSK diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index d16bf7c4d00..d8732ea0453 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -58,7 +58,9 @@ async def test_setup_dashboard_fails( assert mock_config_entry.state == ConfigEntryState.LOADED assert mock_get_devices.call_count == 1 - assert dashboard.STORAGE_KEY not in hass_storage + # The dashboard addon might recover later so we still + # allow it to be set up. + assert dashboard.STORAGE_KEY in hass_storage async def test_setup_dashboard_fails_when_already_setup( diff --git a/tests/components/fritz/snapshots/test_image.ambr b/tests/components/fritz/snapshots/test_image.ambr index b64d8601a8a..452aab2a887 100644 --- a/tests/components/fritz/snapshots/test_image.ambr +++ b/tests/components/fritz/snapshots/test_image.ambr @@ -8,6 +8,9 @@ # name: test_image_entity[fc_data0] b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x94\x00\x00\x00\x94\x01\x00\x00\x00\x00]G=y\x00\x00\x00\xf5IDATx\xda\xedVQ\x0eC!\x0c"\xbb@\xef\x7fKn\xe0\x00\xfd\xdb\xcf6\xf9|\xc6\xc4\xc6\x0f\xd2\x02\xadb},\xe2\xb9\xfb\xe5\x0e\xc0(\x18\xf2\x84/|\xaeo\xef\x847\xda\x14\x1af\x1c\xde\xe3\x19(X\tKxN\xb2\x87\x17j9\x1d None: - """Test image entities.""" - - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.LOADED - - images = hass.states.async_all(IMAGE_DOMAIN) - assert len(images) == 0 diff --git a/tests/components/mystrom/__init__.py b/tests/components/mystrom/__init__.py index f0cc6224191..21f6bd7a549 100644 --- a/tests/components/mystrom/__init__.py +++ b/tests/components/mystrom/__init__.py @@ -1 +1,174 @@ """Tests for the myStrom integration.""" +from typing import Any, Optional + + +def get_default_device_response(device_type: int | None) -> dict[str, Any]: + """Return default device response.""" + response = { + "version": "2.59.32", + "mac": "6001940376EB", + "ssid": "personal", + "ip": "192.168.0.23", + "mask": "255.255.255.0", + "gw": "192.168.0.1", + "dns": "192.168.0.1", + "static": False, + "connected": True, + "signal": 94, + } + if device_type is not None: + response["type"] = device_type + return response + + +def get_default_bulb_state() -> dict[str, Any]: + """Get default bulb state.""" + return { + "type": "rgblamp", + "battery": False, + "reachable": True, + "meshroot": True, + "on": False, + "color": "46;18;100", + "mode": "hsv", + "ramp": 10, + "power": 0.45, + "fw_version": "2.58.0", + } + + +def get_default_switch_state() -> dict[str, Any]: + """Get default switch state.""" + return { + "power": 1.69, + "Ws": 0.81, + "relay": True, + "temperature": 24.87, + "version": "2.59.32", + "mac": "6001940376EB", + "ssid": "personal", + "ip": "192.168.0.23", + "mask": "255.255.255.0", + "gw": "192.168.0.1", + "dns": "192.168.0.1", + "static": False, + "connected": True, + "signal": 94, + } + + +class MyStromDeviceMock: + """Base device mock.""" + + def __init__(self, state: dict[str, Any]) -> None: + """Initialize device mock.""" + self._requested_state = False + self._state = state + + async def get_state(self) -> None: + """Set if state is requested.""" + self._requested_state = True + + +class MyStromBulbMock(MyStromDeviceMock): + """MyStrom Bulb mock.""" + + def __init__(self, mac: str, state: dict[str, Any]) -> None: + """Initialize bulb mock.""" + super().__init__(state) + self.mac = mac + + @property + def firmware(self) -> Optional[str]: + """Return current firmware.""" + if not self._requested_state: + return None + return self._state["fw_version"] + + @property + def consumption(self) -> Optional[float]: + """Return current firmware.""" + if not self._requested_state: + return None + return self._state["power"] + + @property + def color(self) -> Optional[str]: + """Return current color settings.""" + if not self._requested_state: + return None + return self._state["color"] + + @property + def mode(self) -> Optional[str]: + """Return current mode.""" + if not self._requested_state: + return None + return self._state["mode"] + + @property + def transition_time(self) -> Optional[int]: + """Return current transition time (ramp).""" + if not self._requested_state: + return None + return self._state["ramp"] + + @property + def bulb_type(self) -> Optional[str]: + """Return the type of the bulb.""" + if not self._requested_state: + return None + return self._state["type"] + + @property + def state(self) -> Optional[bool]: + """Return the current state of the bulb.""" + if not self._requested_state: + return None + return self._state["on"] + + +class MyStromSwitchMock(MyStromDeviceMock): + """MyStrom Switch mock.""" + + @property + def relay(self) -> Optional[bool]: + """Return the relay state.""" + if not self._requested_state: + return None + return self._state["on"] + + @property + def consumption(self) -> Optional[float]: + """Return the current power consumption in mWh.""" + if not self._requested_state: + return None + return self._state["power"] + + @property + def consumedWs(self) -> Optional[float]: + """The average of energy consumed per second since last report call.""" + if not self._requested_state: + return None + return self._state["Ws"] + + @property + def firmware(self) -> Optional[str]: + """Return the current firmware.""" + if not self._requested_state: + return None + return self._state["version"] + + @property + def mac(self) -> Optional[str]: + """Return the MAC address.""" + if not self._requested_state: + return None + return self._state["mac"] + + @property + def temperature(self) -> Optional[float]: + """Return the current temperature in celsius.""" + if not self._requested_state: + return None + return self._state["temperature"] diff --git a/tests/components/mystrom/test_init.py b/tests/components/mystrom/test_init.py index 01b52d2cb94..80011b47915 100644 --- a/tests/components/mystrom/test_init.py +++ b/tests/components/mystrom/test_init.py @@ -2,11 +2,19 @@ from unittest.mock import AsyncMock, PropertyMock, patch from pymystrom.exceptions import MyStromConnectionError +import pytest from homeassistant.components.mystrom.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import ( + MyStromBulbMock, + MyStromSwitchMock, + get_default_bulb_state, + get_default_device_response, + get_default_switch_state, +) from .conftest import DEVICE_MAC from tests.common import MockConfigEntry @@ -16,36 +24,27 @@ async def init_integration( hass: HomeAssistant, config_entry: MockConfigEntry, device_type: int, - bulb_type: str = "strip", ) -> None: """Inititialize integration for testing.""" with patch( "pymystrom.get_device_info", - side_effect=AsyncMock(return_value={"type": device_type, "mac": DEVICE_MAC}), - ), patch("pymystrom.switch.MyStromSwitch.get_state", return_value={}), patch( - "pymystrom.bulb.MyStromBulb.get_state", return_value={} + side_effect=AsyncMock(return_value=get_default_device_response(device_type)), ), patch( - "pymystrom.bulb.MyStromBulb.bulb_type", bulb_type + "homeassistant.components.mystrom._get_mystrom_bulb", + return_value=MyStromBulbMock("6001940376EB", get_default_bulb_state()), ), patch( - "pymystrom.switch.MyStromSwitch.mac", - new_callable=PropertyMock, - return_value=DEVICE_MAC, - ), patch( - "pymystrom.bulb.MyStromBulb.mac", - new_callable=PropertyMock, - return_value=DEVICE_MAC, + "homeassistant.components.mystrom._get_mystrom_switch", + return_value=MyStromSwitchMock(get_default_switch_state()), ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.LOADED - async def test_init_switch_and_unload( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test the initialization of a myStrom switch.""" - await init_integration(hass, config_entry, 101) + await init_integration(hass, config_entry, 106) state = hass.states.get("switch.mystrom_device") assert state is not None assert config_entry.state is ConfigEntryState.LOADED @@ -56,12 +55,35 @@ async def test_init_switch_and_unload( assert not hass.data.get(DOMAIN) -async def test_init_bulb(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +@pytest.mark.parametrize( + ("device_type", "platform", "entry_state", "entity_state_none"), + [ + (None, "switch", ConfigEntryState.LOADED, False), + (102, "light", ConfigEntryState.LOADED, False), + (103, "button", ConfigEntryState.SETUP_ERROR, True), + (104, "button", ConfigEntryState.SETUP_ERROR, True), + (105, "light", ConfigEntryState.LOADED, False), + (106, "switch", ConfigEntryState.LOADED, False), + (107, "switch", ConfigEntryState.LOADED, False), + (110, "sensor", ConfigEntryState.SETUP_ERROR, True), + (113, "switch", ConfigEntryState.SETUP_ERROR, True), + (118, "button", ConfigEntryState.SETUP_ERROR, True), + (120, "switch", ConfigEntryState.SETUP_ERROR, True), + ], +) +async def test_init_bulb( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_type: int, + platform: str, + entry_state: ConfigEntryState, + entity_state_none: bool, +) -> None: """Test the initialization of a myStrom bulb.""" - await init_integration(hass, config_entry, 102) - state = hass.states.get("light.mystrom_device") - assert state is not None - assert config_entry.state is ConfigEntryState.LOADED + await init_integration(hass, config_entry, device_type) + state = hass.states.get(f"{platform}.mystrom_device") + assert (state is None) == entity_state_none + assert config_entry.state is entry_state async def test_init_of_unknown_bulb( @@ -120,7 +142,7 @@ async def test_init_cannot_connect_because_of_get_state( """Test error handling for failing get_state.""" with patch( "pymystrom.get_device_info", - side_effect=AsyncMock(return_value={"type": 101, "mac": DEVICE_MAC}), + side_effect=AsyncMock(return_value=get_default_device_response(101)), ), patch( "pymystrom.switch.MyStromSwitch.get_state", side_effect=MyStromConnectionError() ), patch( @@ -129,4 +151,4 @@ async def test_init_cannot_connect_because_of_get_state( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 22f238ce553..21ad5230581 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -35,6 +35,8 @@ SERIAL_NUMBER = 0x12635436566 # Get serial number Command 0x85. Serial is 0x12635436566 SERIAL_RESPONSE = "850000012635436566" +# Model and version command 0x82 +MODEL_AND_VERSION_RESPONSE = "820006090C" # Get available stations command 0x83 AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones EMPTY_STATIONS_RESPONSE = "830000000000" @@ -183,7 +185,13 @@ def mock_api_responses( These are returned in the order they are requested by the update coordinator. """ - return [stations_response, zone_state_response, rain_response, rain_delay_response] + return [ + MODEL_AND_VERSION_RESPONSE, + stations_response, + zone_state_response, + rain_response, + rain_delay_response, + ] @pytest.fixture(name="responses") diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 4bf214c50f7..2ecdfcc537f 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -70,6 +70,8 @@ async def test_set_value( device = device_registry.async_get_device({(DOMAIN, SERIAL_NUMBER)}) assert device assert device.name == "Rain Bird Controller" + assert device.model == "ST8x-WiFi" + assert device.sw_version == "9.12" aioclient_mock.mock_calls.clear() responses.append(mock_response(ACK_ECHO)) diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 7346f1e5bcb..2df6c2be5db 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -1,10 +1,11 @@ """Test zha siren.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import ANY, call, patch import pytest from zigpy.const import SIG_EP_PROFILE import zigpy.profiles.zha as zha +import zigpy.zcl import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.security as security import zigpy.zcl.foundation as zcl_f @@ -85,48 +86,76 @@ async def test_siren(hass: HomeAssistant, siren) -> None: # turn on from HA with patch( - "zigpy.zcl.Cluster.request", + "zigpy.device.Device.request", return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ), patch( + "zigpy.zcl.Cluster.request", + side_effect=zigpy.zcl.Cluster.request, + autospec=True, ): # turn on via UI await hass.services.async_call( SIREN_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(cluster.request.mock_calls) == 1 - assert cluster.request.call_args[0][0] is False - assert cluster.request.call_args[0][1] == 0 - assert cluster.request.call_args[0][3] == 50 # bitmask for default args - assert cluster.request.call_args[0][4] == 5 # duration in seconds - assert cluster.request.call_args[0][5] == 0 - assert cluster.request.call_args[0][6] == 2 + assert cluster.request.mock_calls == [ + call( + cluster, + False, + 0, + ANY, + 50, # bitmask for default args + 5, # duration in seconds + 0, + 2, + manufacturer=None, + expect_reply=True, + tsn=None, + ) + ] # test that the state has changed to on assert hass.states.get(entity_id).state == STATE_ON # turn off from HA with patch( - "zigpy.zcl.Cluster.request", + "zigpy.device.Device.request", return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]), + ), patch( + "zigpy.zcl.Cluster.request", + side_effect=zigpy.zcl.Cluster.request, + autospec=True, ): # turn off via UI await hass.services.async_call( SIREN_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(cluster.request.mock_calls) == 1 - assert cluster.request.call_args[0][0] is False - assert cluster.request.call_args[0][1] == 0 - assert cluster.request.call_args[0][3] == 2 # bitmask for default args - assert cluster.request.call_args[0][4] == 5 # duration in seconds - assert cluster.request.call_args[0][5] == 0 - assert cluster.request.call_args[0][6] == 2 + assert cluster.request.mock_calls == [ + call( + cluster, + False, + 0, + ANY, + 2, # bitmask for default args + 5, # duration in seconds + 0, + 2, + manufacturer=None, + expect_reply=True, + tsn=None, + ) + ] # test that the state has changed to off assert hass.states.get(entity_id).state == STATE_OFF # turn on from HA with patch( - "zigpy.zcl.Cluster.request", + "zigpy.device.Device.request", return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ), patch( + "zigpy.zcl.Cluster.request", + side_effect=zigpy.zcl.Cluster.request, + autospec=True, ): # turn on via UI await hass.services.async_call( @@ -140,14 +169,21 @@ async def test_siren(hass: HomeAssistant, siren) -> None: }, blocking=True, ) - assert len(cluster.request.mock_calls) == 1 - assert cluster.request.call_args[0][0] is False - assert cluster.request.call_args[0][1] == 0 - assert cluster.request.call_args[0][3] == 97 # bitmask for passed args - assert cluster.request.call_args[0][4] == 10 # duration in seconds - assert cluster.request.call_args[0][5] == 0 - assert cluster.request.call_args[0][6] == 2 - + assert cluster.request.mock_calls == [ + call( + cluster, + False, + 0, + ANY, + 97, # bitmask for passed args + 10, # duration in seconds + 0, + 2, + manufacturer=None, + expect_reply=True, + tsn=None, + ) + ] # test that the state has changed to on assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/conftest.py b/tests/conftest.py index 56014d7a556..922e42c7a7e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -490,17 +490,7 @@ def hass_fixture_setup() -> list[bool]: @pytest.fixture -def hass(_hass: HomeAssistant) -> HomeAssistant: - """Fixture to provide a test instance of Home Assistant.""" - # This wraps the async _hass fixture inside a sync fixture, to ensure - # the `hass` context variable is set in the execution context in which - # the test itself is executed - ha._cv_hass.set(_hass) - return _hass - - -@pytest.fixture -async def _hass( +async def hass( hass_fixture_setup: list[bool], event_loop: asyncio.AbstractEventLoop, load_registries: bool, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 458774b748c..5ea6df42349 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -12,6 +12,7 @@ import voluptuous as vol import homeassistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, issue_registry as ir, @@ -383,7 +384,7 @@ def test_service() -> None: schema("homeassistant.turn_on") -def test_service_schema() -> None: +def test_service_schema(hass: HomeAssistant) -> None: """Test service_schema validation.""" options = ( {}, @@ -1550,10 +1551,10 @@ def test_config_entry_only_schema_cant_find_module() -> None: def test_config_entry_only_schema_no_hass( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Test if the the hass context var is not set in our context.""" + """Test if the the hass context is not set in our context.""" with patch( "homeassistant.helpers.config_validation.async_get_hass", - side_effect=LookupError, + side_effect=HomeAssistantError, ): cv.config_entry_only_config_schema("test_domain")( {"test_domain": {"foo": "bar"}} diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 6adec334bb0..36f87b7553b 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -724,6 +724,27 @@ async def test_async_get_all_descriptions_dynamically_created_services( } +async def test_register_with_mixed_case(hass: HomeAssistant) -> None: + """Test registering a service with mixed case. + + For backwards compatibility, we have historically allowed mixed case, + and automatically converted it to lowercase. + """ + logger = hass.components.logger + logger_config = {logger.DOMAIN: {}} + await async_setup_component(hass, logger.DOMAIN, logger_config) + logger_domain_mixed = "LoGgEr" + hass.services.async_register( + logger_domain_mixed, "NeW_SeRVICE", lambda x: None, None + ) + service.async_set_service_schema( + hass, logger_domain_mixed, "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" + + 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) diff --git a/tests/test_core.py b/tests/test_core.py index 8b63eab7b42..7e0766c8ac5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,10 +9,12 @@ import gc import logging import os from tempfile import TemporaryDirectory +import threading import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +import async_timeout import pytest import voluptuous as vol @@ -40,6 +42,7 @@ from homeassistant.core import ( ServiceResponse, State, SupportsResponse, + callback, ) from homeassistant.exceptions import ( HomeAssistantError, @@ -202,6 +205,184 @@ def test_async_run_hass_job_delegates_non_async() -> None: assert len(hass.async_add_hass_job.mock_calls) == 1 +async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: + """Test calling async_get_hass via different paths. + + The test asserts async_get_hass can be called from: + - Coroutines and callbacks + - Callbacks scheduled from callbacks, coroutines and threads + - Coroutines scheduled from callbacks, coroutines and threads + + The test also asserts async_get_hass can not be called from threads + other than the event loop. + """ + task_finished = asyncio.Event() + + def can_call_async_get_hass() -> bool: + """Test if it's possible to call async_get_hass.""" + try: + if ha.async_get_hass() is hass: + return True + raise Exception + except HomeAssistantError: + return False + + raise Exception + + # Test scheduling a coroutine which calls async_get_hass via hass.async_create_task + async def _async_create_task() -> None: + task_finished.set() + assert can_call_async_get_hass() + + hass.async_create_task(_async_create_task(), "create_task") + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a callback which calls async_get_hass via hass.async_add_job + @callback + def _add_job() -> None: + assert can_call_async_get_hass() + task_finished.set() + + hass.async_add_job(_add_job) + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a callback which calls async_get_hass from a callback + @callback + def _schedule_callback_from_callback() -> None: + @callback + def _callback(): + assert can_call_async_get_hass() + task_finished.set() + + # Test the scheduled callback itself can call async_get_hass + assert can_call_async_get_hass() + hass.async_add_job(_callback) + + _schedule_callback_from_callback() + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a coroutine which calls async_get_hass from a callback + @callback + def _schedule_coroutine_from_callback() -> None: + async def _coroutine(): + assert can_call_async_get_hass() + task_finished.set() + + # Test the scheduled callback itself can call async_get_hass + assert can_call_async_get_hass() + hass.async_add_job(_coroutine()) + + _schedule_coroutine_from_callback() + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a callback which calls async_get_hass from a coroutine + async def _schedule_callback_from_coroutine() -> None: + @callback + def _callback(): + assert can_call_async_get_hass() + task_finished.set() + + # Test the coroutine itself can call async_get_hass + assert can_call_async_get_hass() + hass.async_add_job(_callback) + + await _schedule_callback_from_coroutine() + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a coroutine which calls async_get_hass from a coroutine + async def _schedule_callback_from_coroutine() -> None: + async def _coroutine(): + assert can_call_async_get_hass() + task_finished.set() + + # Test the coroutine itself can call async_get_hass + assert can_call_async_get_hass() + await hass.async_create_task(_coroutine()) + + await _schedule_callback_from_coroutine() + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a callback which calls async_get_hass from an executor + def _async_add_executor_job_add_job() -> None: + @callback + def _async_add_job(): + assert can_call_async_get_hass() + task_finished.set() + + # Test the executor itself can not call async_get_hass + assert not can_call_async_get_hass() + hass.add_job(_async_add_job) + + await hass.async_add_executor_job(_async_add_executor_job_add_job) + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a coroutine which calls async_get_hass from an executor + def _async_add_executor_job_create_task() -> None: + async def _async_create_task() -> None: + assert can_call_async_get_hass() + task_finished.set() + + # Test the executor itself can not call async_get_hass + assert not can_call_async_get_hass() + hass.create_task(_async_create_task()) + + await hass.async_add_executor_job(_async_add_executor_job_create_task) + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + + # Test scheduling a callback which calls async_get_hass from a worker thread + class MyJobAddJob(threading.Thread): + @callback + def _my_threaded_job_add_job(self) -> None: + assert can_call_async_get_hass() + task_finished.set() + + def run(self) -> None: + # Test the worker thread itself can not call async_get_hass + assert not can_call_async_get_hass() + hass.add_job(self._my_threaded_job_add_job) + + my_job_add_job = MyJobAddJob() + my_job_add_job.start() + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + my_job_add_job.join() + + # Test scheduling a coroutine which calls async_get_hass from a worker thread + class MyJobCreateTask(threading.Thread): + async def _my_threaded_job_create_task(self) -> None: + assert can_call_async_get_hass() + task_finished.set() + + def run(self) -> None: + # Test the worker thread itself can not call async_get_hass + assert not can_call_async_get_hass() + hass.create_task(self._my_threaded_job_create_task()) + + my_job_create_task = MyJobCreateTask() + my_job_create_task.start() + async with async_timeout.timeout(1): + await task_finished.wait() + task_finished.clear() + my_job_create_task.join() + + async def test_stage_shutdown(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP)