diff --git a/.strict-typing b/.strict-typing index 77e853262a10ea..626fc10a4c23a9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -535,6 +535,7 @@ homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* homeassistant.components.uptime.* +homeassistant.components.uptime_kuma.* homeassistant.components.uptimerobot.* homeassistant.components.usb.* homeassistant.components.uvc.* diff --git a/CODEOWNERS b/CODEOWNERS index 74c066a96c9229..a6ab083e07d0d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1658,6 +1658,8 @@ build.json @home-assistant/supervisor /tests/components/upnp/ @StevenLooman /homeassistant/components/uptime/ @frenck /tests/components/uptime/ @frenck +/homeassistant/components/uptime_kuma/ @tr4nt0r +/tests/components/uptime_kuma/ @tr4nt0r /homeassistant/components/uptimerobot/ @ludeeus @chemelli74 /tests/components/uptimerobot/ @ludeeus @chemelli74 /homeassistant/components/usb/ @bdraco diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index c9b17128b79a57..602a3693b7b355 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -11,6 +11,7 @@ Platform.SELECT: {"HYS"}, Platform.SENSOR: { "A1", + "A2", "MP1S", "RM4MINI", "RM4PRO", diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index e7d420f0c0e214..5323a08d227287 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -10,6 +10,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -34,6 +35,24 @@ key="air_quality", device_class=SensorDeviceClass.AQI, ), + SensorEntityDescription( + key="pm10", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm2_5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 8e0a521e182cfb..7c1644fff540ca 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -25,6 +25,7 @@ def get_update_manager(device: BroadlinkDevice[_ApiT]) -> BroadlinkUpdateManager """Return an update manager for a given Broadlink device.""" update_managers: dict[str, type[BroadlinkUpdateManager]] = { "A1": BroadlinkA1UpdateManager, + "A2": BroadlinkA2UpdateManager, "BG1": BroadlinkBG1UpdateManager, "HYS": BroadlinkThermostatUpdateManager, "LB1": BroadlinkLB1UpdateManager, @@ -118,6 +119,16 @@ async def async_fetch_data(self) -> dict[str, Any]: return await self.device.async_request(self.device.api.check_sensors_raw) +class BroadlinkA2UpdateManager(BroadlinkUpdateManager[blk.a2]): + """Manages updates for Broadlink A2 devices.""" + + SCAN_INTERVAL = timedelta(seconds=10) + + async def async_fetch_data(self) -> dict[str, Any]: + """Fetch data from the device.""" + return await self.device.async_request(self.device.api.check_sensors_raw) + + class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]): """Manages updates for Broadlink MP1 devices.""" diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index a1d7d6d403a86b..6abfe57a4ae3c6 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN @@ -21,12 +22,15 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str - port: int - mac: str - passkey: str | None = None - username: str | None = None - password: str | None = None + def __init__(self) -> None: + """Initialize BSBLan flow.""" + self.host: str | None = None + self.port: int = DEFAULT_PORT + self.mac: str | None = None + self.passkey: str | None = None + self.username: str | None = None + self.password: str | None = None + self._auth_required = True async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,9 +45,111 @@ async def async_step_user( self.username = user_input.get(CONF_USERNAME) self.password = user_input.get(CONF_PASSWORD) + return await self._validate_and_create() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle Zeroconf discovery.""" + + self.host = str(discovery_info.ip_address) + self.port = discovery_info.port or DEFAULT_PORT + + # Get MAC from properties + self.mac = discovery_info.properties.get("mac") + + # If MAC was found in zeroconf, use it immediately + if self.mac: + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + else: + # MAC not available from zeroconf - check for existing host/port first + self._async_abort_entries_match( + {CONF_HOST: self.host, CONF_PORT: self.port} + ) + + # Try to get device info without authentication to minimize discovery popup + config = BSBLANConfig(host=self.host, port=self.port) + session = async_get_clientsession(self.hass) + bsblan = BSBLAN(config, session) + try: + device = await bsblan.device() + except BSBLANError: + # Device requires authentication - proceed to discovery confirm + self.mac = None + else: + self.mac = device.MAC + + # Got MAC without auth - set unique ID and check for existing device + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + # No auth needed, so we can proceed to a confirmation step without fields + self._auth_required = False + + # Proceed to get credentials + self.context["title_placeholders"] = {"name": f"BSBLAN {self.host}"} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle getting credentials for discovered device.""" + if user_input is None: + data_schema = vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ) + if not self._auth_required: + data_schema = vol.Schema({}) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=data_schema, + description_placeholders={"host": str(self.host)}, + ) + + if not self._auth_required: + return self._async_create_entry() + + self.passkey = user_input.get(CONF_PASSKEY) + self.username = user_input.get(CONF_USERNAME) + self.password = user_input.get(CONF_PASSWORD) + + return await self._validate_and_create(is_discovery=True) + + async def _validate_and_create( + self, is_discovery: bool = False + ) -> ConfigFlowResult: + """Validate device connection and create entry.""" try: - await self._get_bsblan_info() + await self._get_bsblan_info(is_discovery=is_discovery) except BSBLANError: + if is_discovery: + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors={"base": "cannot_connect"}, + description_placeholders={"host": str(self.host)}, + ) return self._show_setup_form({"base": "cannot_connect"}) return self._async_create_entry() @@ -67,6 +173,7 @@ def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: @callback def _async_create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" return self.async_create_entry( title=format_mac(self.mac), data={ @@ -78,8 +185,10 @@ def _async_create_entry(self) -> ConfigFlowResult: }, ) - async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None: - """Get device information from an BSBLAN device.""" + async def _get_bsblan_info( + self, raise_on_progress: bool = True, is_discovery: bool = False + ) -> None: + """Get device information from a BSBLAN device.""" config = BSBLANConfig( host=self.host, passkey=self.passkey, @@ -90,11 +199,18 @@ async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None: session = async_get_clientsession(self.hass) bsblan = BSBLAN(config, session) device = await bsblan.device() - self.mac = device.MAC - - await self.async_set_unique_id( - format_mac(self.mac), raise_on_progress=raise_on_progress - ) + retrieved_mac = device.MAC + + # Handle unique ID assignment based on whether MAC was available from zeroconf + if not self.mac: + # MAC wasn't available from zeroconf, now we have it from API + self.mac = retrieved_mac + await self.async_set_unique_id( + format_mac(self.mac), raise_on_progress=raise_on_progress + ) + + # Always allow updating host/port for both user and discovery flows + # This ensures connectivity is maintained when devices change IP addresses self._abort_if_unique_id_configured( updates={ CONF_HOST: self.host, diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 8ea339f76c47b4..c5245524e286f6 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,11 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==2.1.0"] + "requirements": ["python-bsblan==2.1.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "bsb-lan*" + } + ] } diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index 6a6784a4542694..7f3f7f48afcb76 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -20,6 +20,8 @@ from .coordinator import BSBLanCoordinatorData from .entity import BSBLanEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class BSBLanSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 935627639991a2..cd4633dfb86a4f 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -13,7 +13,25 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your BSB-Lan device." + "host": "The hostname or IP address of your BSB-Lan device.", + "port": "The port number of your BSB-Lan device.", + "passkey": "The passkey for your BSB-Lan device.", + "username": "The username for your BSB-Lan device.", + "password": "The password for your BSB-Lan device." + } + }, + "discovery_confirm": { + "title": "BSB-Lan device discovered", + "description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.", + "data": { + "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]" } } }, diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index a12994c1a754bb..d907f863988363 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -1,5 +1,7 @@ """The generic_hygrostat component.""" +import logging + import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass @@ -16,7 +18,10 @@ async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType DOMAIN = "generic_hygrostat" @@ -70,6 +75,8 @@ extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Generic Hygrostat component.""" @@ -89,6 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -101,23 +109,19 @@ def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: options={**entry.options, CONF_HUMIDIFIER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the humidifer, # but not the humidity sensor because the generic_hygrostat adds itself to the # humidifier's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HUMIDIFIER] ), source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER], - source_entity_removed=source_entity_removed, ) ) @@ -148,6 +152,40 @@ async def async_sensor_updated( return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_hygrostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HUMIDIFIER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py index 7c35b0e9317bdf..449fa49b713861 100644 --- a/homeassistant/components/generic_hygrostat/config_flow.py +++ b/homeassistant/components/generic_hygrostat/config_flow.py @@ -92,6 +92,8 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 6e6997452795f1..7746346d01043e 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -42,7 +42,7 @@ callback, ) from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -145,22 +145,22 @@ async def _async_setup_config( [ GenericHygrostat( hass, - name, - switch_entity_id, - sensor_entity_id, - min_humidity, - max_humidity, - target_humidity, - device_class, - min_cycle_duration, - dry_tolerance, - wet_tolerance, - keep_alive, - initial_state, - away_humidity, - away_fixed, - sensor_stale_duration, - unique_id, + name=name, + switch_entity_id=switch_entity_id, + sensor_entity_id=sensor_entity_id, + min_humidity=min_humidity, + max_humidity=max_humidity, + target_humidity=target_humidity, + device_class=device_class, + min_cycle_duration=min_cycle_duration, + dry_tolerance=dry_tolerance, + wet_tolerance=wet_tolerance, + keep_alive=keep_alive, + initial_state=initial_state, + away_humidity=away_humidity, + away_fixed=away_fixed, + sensor_stale_duration=sensor_stale_duration, + unique_id=unique_id, ) ] ) @@ -174,6 +174,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, switch_entity_id: str, sensor_entity_id: str, @@ -195,7 +196,7 @@ def __init__( self._name = name self._switch_entity_id = switch_entity_id self._sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, switch_entity_id, ) diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index 3e2af8598def7a..98cd9a02baa54b 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -1,5 +1,7 @@ """The generic_thermostat component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import entity_registry as er @@ -8,14 +10,20 @@ async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -28,23 +36,19 @@ def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: options={**entry.options, CONF_HEATER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the heater, but # not the temperature sensor because the generic_hygrostat adds itself to the # heater's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HEATER] ), source_entity_id_or_uuid=entry.options[CONF_HEATER], - source_entity_removed=source_entity_removed, ) ) @@ -75,6 +79,40 @@ async def async_sensor_updated( return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_thermostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HEATER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 185040f02c95f3..76fcc4acdde519 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -48,7 +48,7 @@ ) from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -182,23 +182,23 @@ async def _async_setup_config( [ GenericThermostat( hass, - name, - heater_entity_id, - sensor_entity_id, - min_temp, - max_temp, - target_temp, - ac_mode, - min_cycle_duration, - cold_tolerance, - hot_tolerance, - keep_alive, - initial_hvac_mode, - presets, - precision, - target_temperature_step, - unit, - unique_id, + name=name, + heater_entity_id=heater_entity_id, + sensor_entity_id=sensor_entity_id, + min_temp=min_temp, + max_temp=max_temp, + target_temp=target_temp, + ac_mode=ac_mode, + min_cycle_duration=min_cycle_duration, + cold_tolerance=cold_tolerance, + hot_tolerance=hot_tolerance, + keep_alive=keep_alive, + initial_hvac_mode=initial_hvac_mode, + presets=presets, + precision=precision, + target_temperature_step=target_temperature_step, + unit=unit, + unique_id=unique_id, ) ] ) @@ -212,6 +212,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, heater_entity_id: str, sensor_entity_id: str, @@ -234,7 +235,7 @@ def __init__( self._attr_name = name self.heater_entity_id = heater_entity_id self.sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, heater_entity_id, ) diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 1fbeaefde6bc18..b69106597d12cb 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -100,6 +100,8 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index a3565f9ed771c3..efddabd180cec2 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, CONF_STATE @@ -11,7 +12,10 @@ async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.template import Template from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS @@ -20,6 +24,8 @@ type HistoryStatsConfigEntry = ConfigEntry[HistoryStatsUpdateCoordinator] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry @@ -47,6 +53,7 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -67,6 +74,7 @@ async def source_entity_removed() -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -83,6 +91,40 @@ async def source_entity_removed() -> None: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the history_stats config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry ) -> bool: diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index 996c7ba0d0c1c8..750180bf3f6b02 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -124,6 +124,8 @@ async def validate_options( class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for History stats.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -229,7 +231,12 @@ def async_preview_updated( coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True) await coordinator.async_refresh() preview_entity = HistoryStatsSensor( - hass, coordinator, sensor_type, name, None, entity_id + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=None, + source_entity_id=entity_id, ) preview_entity.hass = hass diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 780bff14eb1f75..0cfe82e09fb65f 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -27,7 +27,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -113,7 +113,16 @@ async def async_setup_platform( if not coordinator.last_update_success: raise PlatformNotReady from coordinator.last_exception async_add_entities( - [HistoryStatsSensor(hass, coordinator, sensor_type, name, unique_id, entity_id)] + [ + HistoryStatsSensor( + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=unique_id, + source_entity_id=entity_id, + ) + ] ) @@ -130,7 +139,12 @@ async def async_setup_entry( async_add_entities( [ HistoryStatsSensor( - hass, coordinator, sensor_type, entry.title, entry.entry_id, entity_id + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=entry.title, + unique_id=entry.entry_id, + source_entity_id=entity_id, ) ] ) @@ -176,6 +190,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase): def __init__( self, hass: HomeAssistant, + *, coordinator: HistoryStatsUpdateCoordinator, sensor_type: str, name: str, @@ -190,10 +205,11 @@ def __init__( self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type self._attr_unique_id = unique_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index ca591adbf5e4a7..5da2989f93f90c 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -18,6 +18,9 @@ PrintedCircuitBoardSwitch2, PrintedCircuitBoardSwitchBattery, SwitchMeasuring, + WiredInput32, + WiredInputSwitch6, + WiredSwitch4, WiredSwitch8, ) from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup @@ -51,6 +54,7 @@ async def async_setup_entry( elif isinstance( device, ( + WiredSwitch4, WiredSwitch8, OpenCollector8Module, BrandSwitch2, @@ -60,6 +64,8 @@ async def async_setup_entry( MotionDetectorSwitchOutdoor, DinRailSwitch, DinRailSwitch4, + WiredInput32, + WiredInputSwitch6, ), ): channel_indices = [ diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 70af5219d045c9..342f6892b2ee86 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -60,15 +60,7 @@ def __init__( self._devices_last_update: set[str] = set() self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - - def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None: - """Add/remove devices and dynamic entities, when amount of devices changed.""" - self._async_add_remove_devices(data) - for mower_id in data: - if data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones(data) - if data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas(data) + self.async_add_listener(self._on_data_update) async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" @@ -82,14 +74,38 @@ async def _async_update_data(self) -> MowerDictionary: raise UpdateFailed(err) from err except AuthError as err: raise ConfigEntryAuthFailed(err) from err - self._async_add_remove_devices_and_entities(data) return data + @callback + def _on_data_update(self) -> None: + """Handle data updates and process dynamic entity management.""" + if self.data is not None: + self._async_add_remove_devices() + for mower_id in self.data: + if self.data[mower_id].capabilities.stay_out_zones: + self._async_add_remove_stay_out_zones() + if self.data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas() + @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" + self.hass.async_create_task(self._process_websocket_update(ws_data)) + + async def _process_websocket_update(self, ws_data: MowerDictionary) -> None: + """Handle incoming websocket update and update coordinator data.""" + for data in ws_data.values(): + existing_areas = data.work_areas or {} + for task in data.calendar.tasks: + work_area_id = task.work_area_id + if work_area_id is not None and work_area_id not in existing_areas: + _LOGGER.debug( + "New work area %s detected, refreshing data", work_area_id + ) + await self.async_request_refresh() + return + self.async_set_updated_data(ws_data) - self._async_add_remove_devices_and_entities(ws_data) @callback def async_set_updated_data(self, data: MowerDictionary) -> None: @@ -138,9 +154,9 @@ async def client_listen( "reconnect_task", ) - def _async_add_remove_devices(self, data: MowerDictionary) -> None: + def _async_add_remove_devices(self) -> None: """Add new device, remove non-existing device.""" - current_devices = set(data) + current_devices = set(self.data) # Skip update if no changes if current_devices == self._devices_last_update: @@ -155,7 +171,6 @@ def _async_add_remove_devices(self, data: MowerDictionary) -> None: # Process new device new_devices = current_devices - self._devices_last_update if new_devices: - self.data = data _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) self._add_new_devices(new_devices) @@ -179,11 +194,11 @@ def _add_new_devices(self, new_devices: set[str]) -> None: for mower_callback in self.new_devices_callbacks: mower_callback(new_devices) - def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: + def _async_add_remove_stay_out_zones(self) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" current_zones = { mower_id: set(mower_data.stay_out_zones.zones) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.stay_out_zones and mower_data.stay_out_zones is not None } @@ -225,11 +240,11 @@ def _update_stay_out_zones( return current_zones - def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: + def _async_add_remove_work_areas(self) -> None: """Add new work areas, remove non-existing work areas.""" current_areas = { mower_id: set(mower_data.work_areas) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.work_areas and mower_data.work_areas is not None } diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 84173260d047e5..bbeb50a2b72bfd 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -112,16 +112,8 @@ async def async_set_temperature(self, **kwargs: Any) -> None: await self._turn_on(temperature) async def async_update(self) -> None: - """Get the latest status data. - - We get the latest status first from the status endpoints of the sauna. - If that data does not include the temperature, that means that the sauna - is off, we then call the off command which will in turn return the temperature. - This is a workaround for getting the temperature as the Huum API does not - return the target temperature of a sauna that is off, even if it can have - a target temperature at that time. - """ - self._status = await self._huum_handler.status_from_status_or_stop() + """Get the latest status data.""" + self._status = await self._huum_handler.status() if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: self._target_temperature = self._status.target_temperature diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 38562e1a072943..82b863e4e42a9b 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.12"] + "requirements": ["huum==0.8.0"] } diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 0a64ce7140f8aa..82f44578aed5ae 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_SOURCE_SENSOR +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Integration from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -29,20 +37,16 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def source_entity_removed() -> None: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the integration config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" # Remove device link for entry, the source device may have changed. diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 28cd280f7f8046..329abdbea875b0 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -147,6 +147,8 @@ async def _get_config_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Integration.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index df5342111a7726..25181ac61491f8 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -40,8 +40,7 @@ callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -246,11 +245,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None @@ -265,6 +259,7 @@ async def async_setup_entry( round_digits = int(round_digits) integral = IntegrationSensor( + hass, integration_method=config_entry.options[CONF_METHOD], name=config_entry.title, round_digits=round_digits, @@ -272,7 +267,6 @@ async def async_setup_entry( unique_id=config_entry.entry_id, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -287,6 +281,7 @@ async def async_setup_platform( ) -> None: """Set up the integration sensor.""" integral = IntegrationSensor( + hass, integration_method=config[CONF_METHOD], name=config.get(CONF_NAME), round_digits=config.get(CONF_ROUND_DIGITS), @@ -308,6 +303,7 @@ class IntegrationSensor(RestoreSensor): def __init__( self, + hass: HomeAssistant, *, integration_method: str, name: str | None, @@ -317,7 +313,6 @@ def __init__( unit_prefix: str | None, unit_time: UnitOfTime, max_sub_interval: timedelta | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the integration sensor.""" self._attr_unique_id = unique_id @@ -335,7 +330,10 @@ def __init__( self._attr_icon = "mdi:chart-histogram" self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._max_sub_interval: timedelta | None = ( None # disable time based integration if max_sub_interval is None or max_sub_interval.total_seconds() == 0 diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 79b49050cc2319..d5097df962f5c8 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -13,8 +13,7 @@ BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -23,36 +22,29 @@ PARALLEL_UPDATES = 0 -@dataclass(frozen=True) -class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): - """Binary Sensor description mixin class for Jewish Calendar.""" - - is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False - - -@dataclass(frozen=True) -class JewishCalendarBinarySensorEntityDescription( - JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class JewishCalendarBinarySensorEntityDescription(BinarySensorEntityDescription): """Binary Sensor Entity description for Jewish Calendar.""" + is_on: Callable[[Zmanim], Callable[[dt.datetime], bool]] + BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( JewishCalendarBinarySensorEntityDescription( key="issur_melacha_in_effect", translation_key="issur_melacha_in_effect", - is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), + is_on=lambda state: state.issur_melacha_in_effect, ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", translation_key="erev_shabbat_hag", - is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), + is_on=lambda state: state.erev_shabbat_chag, entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", translation_key="motzei_shabbat_hag", - is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), + is_on=lambda state: state.motzei_shabbat_chag, entity_registry_enabled_default=False, ), ) @@ -73,9 +65,7 @@ async def async_setup_entry( class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): """Representation of an Jewish Calendar binary sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _update_unsub: CALLBACK_TYPE | None = None entity_description: JewishCalendarBinarySensorEntityDescription @@ -83,40 +73,12 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if sensor is on.""" zmanim = self.make_zmanim(dt.date.today()) - return self.entity_description.is_on(zmanim, dt_util.now()) - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - self._schedule_update() - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._update_unsub: - self._update_unsub() - self._update_unsub = None - return await super().async_will_remove_from_hass() - - @callback - def _update(self, now: dt.datetime | None = None) -> None: - """Update the state of the sensor.""" - self._update_unsub = None - self._schedule_update() - self.async_write_ha_state() - - def _schedule_update(self) -> None: - """Schedule the next update of the sensor.""" - now = dt_util.now() - zmanim = self.make_zmanim(dt.date.today()) - update = zmanim.netz_hachama.local + dt.timedelta(days=1) - candle_lighting = zmanim.candle_lighting - if candle_lighting is not None and now < candle_lighting < update: - update = candle_lighting - havdalah = zmanim.havdalah - if havdalah is not None and now < havdalah < update: - update = havdalah - if self._update_unsub: - self._update_unsub() - self._update_unsub = event.async_track_point_in_time( - self.hass, self._update, update - ) + return self.entity_description.is_on(zmanim)(dt_util.now()) + + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + return [ + zmanim.netz_hachama.local + dt.timedelta(days=1), + zmanim.candle_lighting, + zmanim.havdalah, + ] diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index 9d713aad0ebd58..d5e4112907575a 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,17 +1,24 @@ """Entity representing a Jewish Calendar sensor.""" +from abc import abstractmethod from dataclasses import dataclass import datetime as dt +import logging from hdate import HDateInfo, Location, Zmanim from hdate.translator import Language, set_language from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.util import dt as dt_util from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] @@ -39,6 +46,8 @@ class JewishCalendarEntity(Entity): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True + _attr_should_poll = False + _update_unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -63,3 +72,55 @@ def make_zmanim(self, date: dt.date) -> Zmanim: candle_lighting_offset=self.data.candle_lighting_offset, havdalah_offset=self.data.havdalah_offset, ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._schedule_update() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + + @abstractmethod + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + + def _schedule_update(self) -> None: + """Schedule the next update of the sensor.""" + now = dt_util.now() + zmanim = self.make_zmanim(now.date()) + update = dt_util.start_of_local_day() + dt.timedelta(days=1) + + for update_time in self._update_times(zmanim): + if update_time is not None and now < update_time < update: + update = update_time + + if self._update_unsub: + self._update_unsub() + self._update_unsub = event.async_track_point_in_time( + self.hass, self._update, update + ) + + @callback + def _update(self, now: dt.datetime | None = None) -> None: + """Update the sensor data.""" + self._update_unsub = None + self._schedule_update() + self.create_results(now) + self.async_write_ha_state() + + def create_results(self, now: dt.datetime | None = None) -> None: + """Create the results for the sensor.""" + if now is None: + now = dt_util.now() + + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + + today = now.date() + zmanim = self.make_zmanim(today) + dateinfo = HDateInfo(today, diaspora=self.data.diaspora) + self.data.results = JewishCalendarDataResults(dateinfo, zmanim) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 6479a61c713183..d9ad89237f53cc 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -17,16 +17,11 @@ SensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .entity import ( - JewishCalendarConfigEntry, - JewishCalendarDataResults, - JewishCalendarEntity, -) +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -217,7 +212,7 @@ async def async_setup_entry( config_entry: JewishCalendarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Jewish calendar sensors .""" + """Set up the Jewish calendar sensors.""" sensors: list[JewishCalendarBaseSensor] = [ JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS ] @@ -231,59 +226,15 @@ async def async_setup_entry( class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): """Base class for Jewish calendar sensors.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _update_unsub: CALLBACK_TYPE | None = None entity_description: JewishCalendarBaseSensorDescription - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - self._schedule_update() - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._update_unsub: - self._update_unsub() - self._update_unsub = None - return await super().async_will_remove_from_hass() - - def _schedule_update(self) -> None: - """Schedule the next update of the sensor.""" - now = dt_util.now() - zmanim = self.make_zmanim(now.date()) - update = None - if self.entity_description.next_update_fn: - update = self.entity_description.next_update_fn(zmanim) - next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) - if update is None or now > update: - update = next_midnight - if self._update_unsub: - self._update_unsub() - self._update_unsub = event.async_track_point_in_time( - self.hass, self._update_data, update - ) - - @callback - def _update_data(self, now: dt.datetime | None = None) -> None: - """Update the sensor data.""" - self._update_unsub = None - self._schedule_update() - self.create_results(now) - self.async_write_ha_state() - - def create_results(self, now: dt.datetime | None = None) -> None: - """Create the results for the sensor.""" - if now is None: - now = dt_util.now() - - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) - - today = now.date() - zmanim = self.make_zmanim(today) - dateinfo = HDateInfo(today, diaspora=self.data.diaspora) - self.data.results = JewishCalendarDataResults(dateinfo, zmanim) + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + if self.entity_description.next_update_fn is None: + return [] + return [self.entity_description.next_update_fn(zmanim)] def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: """Get the next date info.""" diff --git a/homeassistant/components/jewish_calendar/services.py b/homeassistant/components/jewish_calendar/services.py index 6fdebe6f74d1a7..f77f9be4e64c89 100644 --- a/homeassistant/components/jewish_calendar/services.py +++ b/homeassistant/components/jewish_calendar/services.py @@ -50,7 +50,6 @@ def is_after_sunset(hass: HomeAssistant) -> bool: today = now.date() event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) if event_date is None: - _LOGGER.error("Can't get sunset event date for %s", today) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="sunset_event" ) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 14a9016bcb9a72..796c4c60201697 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from typing import Any, Final, Literal @@ -20,8 +19,8 @@ from xknx.secure.keyring import Keyring, XMLInterface from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, - ConfigEntryBaseFlow, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -103,12 +102,14 @@ ) -class KNXCommonFlow(ABC, ConfigEntryBaseFlow): - """Base class for KNX flows.""" +class KNXConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a KNX config flow.""" + + VERSION = 1 - def __init__(self, initial_data: KNXConfigEntryData) -> None: - """Initialize KNXCommonFlow.""" - self.initial_data = initial_data + def __init__(self) -> None: + """Initialize KNX config flow.""" + self.initial_data = DEFAULT_ENTRY_DATA self.new_entry_data = KNXConfigEntryData() self.new_title: str | None = None @@ -121,19 +122,21 @@ def __init__(self, initial_data: KNXConfigEntryData) -> None: self._gatewayscanner: GatewayScanner | None = None self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: + """Get the options flow for this handler.""" + return KNXOptionsFlow(config_entry) + @property def _xknx(self) -> XKNX: """Return XKNX instance.""" - if isinstance(self, OptionsFlow) and ( + if (self.source == SOURCE_RECONFIGURE) and ( knx_module := self.hass.data.get(KNX_MODULE_KEY) ): return knx_module.xknx return XKNX() - @abstractmethod - def finish_flow(self) -> ConfigFlowResult: - """Finish the flow.""" - @property def connection_type(self) -> str: """Return the configured connection type.""" @@ -150,6 +153,61 @@ def tunnel_endpoint_ia(self) -> str | None: self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), ) + @callback + def finish_flow(self) -> ConfigFlowResult: + """Create or update the ConfigEntry.""" + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + _tunnel_endpoint_str = self.initial_data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, "Tunneling" + ) + if self.new_title and not entry.title.startswith( + # Overwrite standard titles, but not user defined ones + ( + f"KNX {self.initial_data[CONF_KNX_CONNECTION_TYPE]}", + CONF_KNX_AUTOMATIC.capitalize(), + "Tunneling @ ", + f"{_tunnel_endpoint_str} @", + "Tunneling UDP @ ", + "Tunneling TCP @ ", + "Secure Tunneling", + "Routing as ", + "Secure Routing as ", + ) + ): + self.new_title = None + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self.new_entry_data, + title=self.new_title or UNDEFINED, + ) + + title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" + return self.async_create_entry( + title=title, + data=DEFAULT_ENTRY_DATA | self.new_entry_data, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_connection_type() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of existing entry.""" + entry = self._get_reconfigure_entry() + self.initial_data = dict(entry.data) # type: ignore[assignment] + return self.async_show_menu( + step_id="reconfigure", + menu_options=[ + "connection_type", + "secure_knxkeys", + ], + ) + async def async_step_connection_type( self, user_input: dict | None = None ) -> ConfigFlowResult: @@ -441,7 +499,7 @@ async def async_step_manual_tunnel( ) ip_address: str | None if ( # initial attempt on ConfigFlow or coming from automatic / routing - (isinstance(self, ConfigFlow) or not _reconfiguring_existing_tunnel) + not _reconfiguring_existing_tunnel and not user_input and self._selected_tunnel is not None ): # default to first found tunnel @@ -841,52 +899,20 @@ async def async_step_secure_key_source_menu_routing( ) -class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): - """Handle a KNX config flow.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize KNX options flow.""" - super().__init__(initial_data=DEFAULT_ENTRY_DATA) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: - """Get the options flow for this handler.""" - return KNXOptionsFlow(config_entry) - - @callback - def finish_flow(self) -> ConfigFlowResult: - """Create the ConfigEntry.""" - title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" - return self.async_create_entry( - title=title, - data=DEFAULT_ENTRY_DATA | self.new_entry_data, - ) - - async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - return await self.async_step_connection_type() - - -class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): +class KNXOptionsFlow(OptionsFlow): """Handle KNX options.""" - general_settings: dict - def __init__(self, config_entry: ConfigEntry) -> None: """Initialize KNX options flow.""" - super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] + self.initial_data = dict(config_entry.data) @callback - def finish_flow(self) -> ConfigFlowResult: + def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult: """Update the ConfigEntry and finish the flow.""" - new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data + new_data = self.initial_data | new_entry_data self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, - title=self.new_title or UNDEFINED, ) return self.async_create_entry(title="", data={}) @@ -894,26 +920,20 @@ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX options.""" - return self.async_show_menu( - step_id="init", - menu_options=[ - "connection_type", - "communication_settings", - "secure_knxkeys", - ], - ) + return await self.async_step_communication_settings() async def async_step_communication_settings( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX communication settings.""" if user_input is not None: - self.new_entry_data = KNXConfigEntryData( - state_updater=user_input[CONF_KNX_STATE_UPDATER], - rate_limit=user_input[CONF_KNX_RATE_LIMIT], - telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + return self.finish_flow( + KNXConfigEntryData( + state_updater=user_input[CONF_KNX_STATE_UPDATER], + rate_limit=user_input[CONF_KNX_RATE_LIMIT], + telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + ) ) - return self.finish_flow() data_schema = { vol.Required( diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index b4b36213c43cd7..9e24cc1ce5b8ac 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -104,7 +104,7 @@ rules: Since all entities are configured manually, names are user-defined. exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index dc4d7de42fff51..921fc2c5288a63 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reconfigure": { + "title": "KNX connection settings", + "menu_options": { + "connection_type": "Reconfigure KNX connection", + "secure_knxkeys": "Import KNX keyring file" + } + }, "connection_type": { "title": "KNX connection", "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", @@ -65,7 +72,7 @@ }, "secure_knxkeys": { "title": "Import KNX Keyring", - "description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.", + "description": "The keyring is used to encrypt and decrypt KNX IP Secure communication. You can import a new keyring file or re-import to update existing keys if your configuration has changed.", "data": { "knxkeys_file": "Keyring file", "knxkeys_password": "Keyring password" @@ -129,6 +136,9 @@ } } }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.", @@ -159,16 +169,8 @@ }, "options": { "step": { - "init": { - "title": "KNX Settings", - "menu_options": { - "connection_type": "Configure KNX interface", - "communication_settings": "Communication settings", - "secure_knxkeys": "Import a `.knxkeys` file" - } - }, "communication_settings": { - "title": "[%key:component::knx::options::step::init::menu_options::communication_settings%]", + "title": "Communication settings", "data": { "state_updater": "State updater", "rate_limit": "Rate limit", @@ -179,147 +181,7 @@ "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } - }, - "connection_type": { - "title": "[%key:component::knx::config::step::connection_type::title%]", - "description": "[%key:component::knx::config::step::connection_type::description%]", - "data": { - "connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]" - }, - "data_description": { - "connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]" - } - }, - "tunnel": { - "title": "[%key:component::knx::config::step::tunnel::title%]", - "data": { - "gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]" - }, - "data_description": { - "gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]" - } - }, - "tcp_tunnel_endpoint": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "manual_tunnel": { - "title": "[%key:component::knx::config::step::manual_tunnel::title%]", - "description": "[%key:component::knx::config::step::manual_tunnel::description%]", - "data": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data::tunneling_type%]", - "port": "[%key:common::config_flow::data::port%]", - "host": "[%key:common::config_flow::data::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]", - "port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]", - "host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } - }, - "secure_key_source_menu_tunnel": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_tunnel_manual%]" - } - }, - "secure_key_source_menu_routing": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]" - } - }, - "secure_knxkeys": { - "title": "[%key:component::knx::config::step::secure_knxkeys::title%]", - "description": "[%key:component::knx::config::step::secure_knxkeys::description%]", - "data": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" - }, - "data_description": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" - } - }, - "knxkeys_tunnel_select": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "secure_tunnel_manual": { - "title": "[%key:component::knx::config::step::secure_tunnel_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data::device_authentication%]" - }, - "data_description": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::device_authentication%]" - } - }, - "secure_routing_manual": { - "title": "[%key:component::knx::config::step::secure_routing_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data::sync_latency_tolerance%]" - }, - "data_description": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data_description::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data_description::sync_latency_tolerance%]" - } - }, - "routing": { - "title": "[%key:component::knx::config::step::routing::title%]", - "description": "[%key:component::knx::config::step::routing::description%]", - "data": { - "individual_address": "[%key:component::knx::config::step::routing::data::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_backbone_key": "[%key:component::knx::config::error::invalid_backbone_key%]", - "invalid_individual_address": "[%key:component::knx::config::error::invalid_individual_address%]", - "invalid_ip_address": "[%key:component::knx::config::error::invalid_ip_address%]", - "keyfile_no_backbone_key": "[%key:component::knx::config::error::keyfile_no_backbone_key%]", - "keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]", - "keyfile_no_tunnel_for_host": "[%key:component::knx::config::error::keyfile_no_tunnel_for_host%]", - "keyfile_not_found": "[%key:component::knx::config::error::keyfile_not_found%]", - "no_router_discovered": "[%key:component::knx::config::error::no_router_discovered%]", - "no_tunnel_discovered": "[%key:component::knx::config::error::no_tunnel_discovered%]", - "unsupported_tunnel_type": "[%key:component::knx::config::error::unsupported_tunnel_type%]" } }, "entity": { diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 31c5e8297e0b55..b40dc2246b8ccd 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -2,9 +2,9 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from functools import wraps +import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel @@ -116,7 +116,7 @@ def _send_not_loaded_error( "KNX integration not loaded.", ) - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @wraps(func) async def with_knx( diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index feb598a646a2ca..e5b98d00726ee7 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -6,7 +6,12 @@ from homeassistant.core import HomeAssistant from .const import CONF_NPSSO -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkRuntimeData, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .helpers import PlaystationNetwork PLATFORMS: list[Platform] = [ @@ -23,9 +28,12 @@ async def async_setup_entry( psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO]) - coordinator = PlaystationNetworkCoordinator(hass, psn, entry) + coordinator = PlaystationNetworkUserDataCoordinator(hass, psn, entry) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + + trophy_titles = PlaystationNetworkTrophyTitlesCoordinator(hass, psn, entry) + + entry.runtime_data = PlaystationNetworkRuntimeData(coordinator, trophy_titles) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py index fcecd1d1ee18aa..453cfb373474c7 100644 --- a/homeassistant/components/playstation_network/binary_sensor.py +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data async_add_entities( PlaystationNetworkBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index b4a4a9374faa6f..0e69abf10800d7 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -10,7 +10,6 @@ PSNAWPInvalidTokenError, PSNAWPNotFoundError, ) -from psnawp_api.models.user import User from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol @@ -42,7 +41,7 @@ async def async_step_user( else: psn = PlaystationNetwork(self.hass, npsso) try: - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except PSNAWPNotFoundError: @@ -98,7 +97,7 @@ async def async_step_reauth_confirm( try: npsso = parse_npsso_token(user_input[CONF_NPSSO]) psn = PlaystationNetwork(self.hass, npsso) - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except (PSNAWPNotFoundError, PSNAWPInvalidTokenError): diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index 77b43af3b7361f..f4c5c7a3e5b621 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -8,9 +8,10 @@ CONF_NPSSO: Final = "npsso" SUPPORTED_PLATFORMS = { - PlatformType.PS5, - PlatformType.PS4, + PlatformType.PS_VITA, PlatformType.PS3, + PlatformType.PS4, + PlatformType.PS5, PlatformType.PSPC, } diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 69cc95d1d491e1..a9f49f7f7bb93f 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -2,6 +2,8 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging @@ -10,6 +12,7 @@ PSNAWPClientError, PSNAWPServerError, ) +from psnawp_api.models.trophies import TrophyTitle from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,13 +24,22 @@ _LOGGER = logging.getLogger(__name__) -type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator] +type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkRuntimeData] -class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]): - """Data update coordinator for PSN.""" +@dataclass +class PlaystationNetworkRuntimeData: + """Dataclass holding PSN runtime data.""" + + user_data: PlaystationNetworkUserDataCoordinator + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator + + +class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for PSN.""" config_entry: PlaystationNetworkConfigEntry + _update_inverval: timedelta def __init__( self, @@ -41,38 +53,70 @@ def __init__( name=DOMAIN, logger=_LOGGER, config_entry=config_entry, - update_interval=timedelta(seconds=30), + update_interval=self._update_interval, ) self.psn = psn - async def _async_setup(self) -> None: - """Set up the coordinator.""" + @abstractmethod + async def update_data(self) -> _DataT: + """Update coordinator data.""" + async def _async_update_data(self) -> _DataT: + """Get the latest data from the PSN.""" try: - await self.psn.get_user() + return await self.update_data() except PSNAWPAuthenticationError as error: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error except (PSNAWPServerError, PSNAWPClientError) as error: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", ) from error - async def _async_update_data(self) -> PlaystationNetworkData: - """Get the latest data from the PSN.""" + +class PlaystationNetworkUserDataCoordinator( + PlayStationNetworkBaseCoordinator[PlaystationNetworkData] +): + """Data update coordinator for PSN.""" + + _update_interval = timedelta(seconds=30) + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: - return await self.psn.get_data() + await self.psn.async_setup() except PSNAWPAuthenticationError as error: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error except (PSNAWPServerError, PSNAWPClientError) as error: - raise UpdateFailed( + raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="update_failed", ) from error + + async def update_data(self) -> PlaystationNetworkData: + """Get the latest data from the PSN.""" + return await self.psn.get_data() + + +class PlaystationNetworkTrophyTitlesCoordinator( + PlayStationNetworkBaseCoordinator[list[TrophyTitle]] +): + """Trophy titles data update coordinator for PSN.""" + + _update_interval = timedelta(days=1) + + async def update_data(self) -> list[TrophyTitle]: + """Update trophy titles data.""" + self.psn.trophy_titles = await self.hass.async_add_executor_job( + lambda: list(self.psn.user.trophy_titles()) + ) + await self.config_entry.runtime_data.user_data.async_request_refresh() + return self.psn.trophy_titles diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py index 8332572177d8d7..7b5c762db12a64 100644 --- a/homeassistant/components/playstation_network/diagnostics.py +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -10,7 +10,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .coordinator import PlaystationNetworkConfigEntry TO_REDACT = { "account_id", @@ -27,12 +27,12 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: PlaystationNetworkConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PlaystationNetworkCoordinator = entry.runtime_data + coordinator = entry.runtime_data.user_data return { "data": async_redact_data( _serialize_platform_types(asdict(coordinator.data)), TO_REDACT - ), + ) } @@ -46,10 +46,12 @@ def _serialize_platform_types(data: Any) -> Any: for platform, record in data.items() } if isinstance(data, set): - return [ - record.value if isinstance(record, PlatformType) else record - for record in data - ] + return sorted( + [ + record.value if isinstance(record, PlatformType) else record + for record in data + ] + ) if isinstance(data, PlatformType): return data.value return data diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index 54f5fd5db704b1..660c77dc30f15c 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -7,17 +7,19 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PlaystationNetworkCoordinator +from .coordinator import PlaystationNetworkUserDataCoordinator -class PlaystationNetworkServiceEntity(CoordinatorEntity[PlaystationNetworkCoordinator]): +class PlaystationNetworkServiceEntity( + CoordinatorEntity[PlaystationNetworkUserDataCoordinator] +): """Common entity class for PlayStationNetwork Service entities.""" _attr_has_entity_name = True def __init__( self, - coordinator: PlaystationNetworkCoordinator, + coordinator: PlaystationNetworkUserDataCoordinator, entity_description: EntityDescription, ) -> None: """Initialize PlayStation Network Service Entity.""" diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 9c7dac29a81890..debe7a338e2a2a 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -8,7 +8,7 @@ from psnawp_api import PSNAWP from psnawp_api.models.client import Client -from psnawp_api.models.trophies import PlatformType, TrophySummary +from psnawp_api.models.trophies import PlatformType, TrophySummary, TrophyTitle from psnawp_api.models.user import User from pyrate_limiter import Duration, Rate @@ -16,7 +16,7 @@ from .const import SUPPORTED_PLATFORMS -LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4} +LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4, PlatformType.PS_VITA} @dataclass @@ -52,10 +52,22 @@ def __init__(self, hass: HomeAssistant, npsso: str) -> None: """Initialize the class with the npsso token.""" rate = Rate(300, Duration.MINUTE * 15) self.psn = PSNAWP(npsso, rate_limit=rate) - self.client: Client | None = None + self.client: Client self.hass = hass self.user: User self.legacy_profile: dict[str, Any] | None = None + self.trophy_titles: list[TrophyTitle] = [] + self._title_icon_urls: dict[str, str] = {} + + def _setup(self) -> None: + """Setup PSN.""" + self.user = self.psn.user(online_id="me") + self.client = self.psn.me() + self.trophy_titles = list(self.user.trophy_titles()) + + async def async_setup(self) -> None: + """Setup PSN.""" + await self.hass.async_add_executor_job(self._setup) async def get_user(self) -> User: """Get the user object from the PlayStation Network.""" @@ -68,9 +80,6 @@ def retrieve_psn_data(self) -> PlaystationNetworkData: """Bundle api calls to retrieve data from the PlayStation Network.""" data = PlaystationNetworkData() - if not self.client: - self.client = self.psn.me() - data.registered_platforms = { PlatformType(device["deviceType"]) for device in self.client.get_account_devices() @@ -123,7 +132,7 @@ async def get_data(self) -> PlaystationNetworkData: presence = self.legacy_profile["profile"].get("presences", []) if (game_title_info := presence[0] if presence else {}) and game_title_info[ "onlineStatus" - ] == "online": + ] != "offline": platform = PlatformType(game_title_info["platform"]) if platform is PlatformType.PS4: @@ -135,6 +144,10 @@ async def get_data(self) -> PlaystationNetworkData: account_id="me", np_communication_id="", ).get_title_icon_url() + elif platform is PlatformType.PS_VITA and game_title_info.get( + "npTitleId" + ): + media_image_url = self.get_psvita_title_icon_url(game_title_info) else: media_image_url = None @@ -147,3 +160,28 @@ async def get_data(self) -> PlaystationNetworkData: status=game_title_info["onlineStatus"], ) return data + + def get_psvita_title_icon_url(self, game_title_info: dict[str, Any]) -> str | None: + """Look up title_icon_url from trophy titles data.""" + + if url := self._title_icon_urls.get(game_title_info["npTitleId"]): + return url + + url = next( + ( + title.title_icon_url + for title in self.trophy_titles + if game_title_info["titleName"] + == normalize_title(title.title_name or "") + and next(iter(title.title_platform)) == PlatformType.PS_VITA + ), + None, + ) + if url is not None: + self._title_icon_urls[game_title_info["npTitleId"]] = url + return url + + +def normalize_title(name: str) -> str: + """Normalize trophy title.""" + return name.removesuffix("Trophies").removesuffix("Trophy Set").strip() diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 3e55e565460bc6..0a9b8fe6162978 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -17,13 +17,18 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from . import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .const import DOMAIN, SUPPORTED_PLATFORMS _LOGGER = logging.getLogger(__name__) PLATFORM_MAP = { + PlatformType.PS_VITA: "PlayStation Vita", PlatformType.PS5: "PlayStation 5", PlatformType.PS4: "PlayStation 4", PlatformType.PS3: "PlayStation 3", @@ -38,7 +43,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Media Player Entity Setup.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data + trophy_titles = config_entry.runtime_data.trophy_titles devices_added: set[PlatformType] = set() device_reg = dr.async_get(hass) entities = [] @@ -50,10 +56,12 @@ def add_entities() -> None: if not SUPPORTED_PLATFORMS - devices_added: remove_listener() - new_platforms = set(coordinator.data.active_sessions.keys()) - devices_added + new_platforms = ( + set(coordinator.data.active_sessions.keys()) & SUPPORTED_PLATFORMS + ) - devices_added if new_platforms: async_add_entities( - PsnMediaPlayerEntity(coordinator, platform_type) + PsnMediaPlayerEntity(coordinator, platform_type, trophy_titles) for platform_type in new_platforms ) devices_added |= new_platforms @@ -64,7 +72,7 @@ def add_entities() -> None: (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}") } ): - entities.append(PsnMediaPlayerEntity(coordinator, platform)) + entities.append(PsnMediaPlayerEntity(coordinator, platform, trophy_titles)) devices_added.add(platform) if entities: async_add_entities(entities) @@ -74,7 +82,7 @@ def add_entities() -> None: class PsnMediaPlayerEntity( - CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity + CoordinatorEntity[PlaystationNetworkUserDataCoordinator], MediaPlayerEntity ): """Media player entity representing currently playing game.""" @@ -86,7 +94,10 @@ class PsnMediaPlayerEntity( _attr_name = None def __init__( - self, coordinator: PlaystationNetworkCoordinator, platform: PlatformType + self, + coordinator: PlaystationNetworkUserDataCoordinator, + platform: PlatformType, + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator, ) -> None: """Initialize PSN MediaPlayer.""" super().__init__(coordinator) @@ -101,15 +112,21 @@ def __init__( model=PLATFORM_MAP[platform], via_device=(DOMAIN, coordinator.config_entry.unique_id), ) + self.trophy_titles = trophy_titles @property def state(self) -> MediaPlayerState: """Media Player state getter.""" session = self.coordinator.data.active_sessions.get(self.key) - if session and session.status == "online": - if session.title_id is not None: - return MediaPlayerState.PLAYING - return MediaPlayerState.ON + if session: + if session.status == "online": + return ( + MediaPlayerState.PLAYING + if session.title_id is not None + else MediaPlayerState.ON + ) + if session.status == "standby": + return MediaPlayerState.STANDBY return MediaPlayerState.OFF @property @@ -129,3 +146,12 @@ def media_image_url(self) -> str | None: """Media image url getter.""" session = self.coordinator.data.active_sessions.get(self.key) return session.media_image_url if session else None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + if self.key is PlatformType.PS_VITA: + self.async_on_remove( + self.trophy_titles.async_add_listener(self._handle_coordinator_update) + ) diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index cfd81fe4033f1c..b17b4c04ab7ce1 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -131,7 +131,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data async_add_entities( PlaystationNetworkSensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 50d354d345d54c..1a1481f9c264f6 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -26,6 +26,7 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -97,6 +98,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + _hassio_discovery: HassioServiceInfo | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -211,3 +214,58 @@ async def async_step_reconfigure( description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for pyLoad add-on. + + This flow is triggered by the discovery component. + """ + url = URL(discovery_info.config[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + discovery_info.config[CONF_URL] = url + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery.""" + assert self._hassio_discovery + errors: dict[str, str] = {} + + data = {**self._hassio_discovery.config, CONF_VERIFY_SSL: False} + + if user_input is not None: + data.update(user_input) + + try: + await validate_input(self.hass, data) + except (CannotConnect, ParserError): + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders=self._hassio_discovery.config, + ) + return self.async_create_entry(title=self._hassio_discovery.slug, data=data) + + return self.async_show_form( + step_id="hassio_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=REAUTH_SCHEMA, suggested_values=data + ), + description_placeholders=self._hassio_discovery.config, + errors=errors if user_input is not None else None, + ) diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 9414f7f7bb8589..66435fd2806185 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -39,6 +39,18 @@ "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]" } + }, + "hassio_confirm": { + "title": "pyLoad via Home Assistant add-on", + "description": "Do you want to configure Home Assistant to connect to the pyLoad service provided by the add-on: {addon}?", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" + } } }, "error": { diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index d8d7ddb832af9c..2ee41ba20381e7 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -12,6 +12,7 @@ from sqlalchemy.pool import ( ConnectionPoolEntry, NullPool, + PoolProxiedConnection, SingletonThreadPool, StaticPool, ) @@ -119,6 +120,12 @@ def _do_get_db_connection_protected(self) -> ConnectionPoolEntry: ) return NullPool._create_connection(self) # noqa: SLF001 + def connect(self) -> PoolProxiedConnection: + """Return a connection from the pool.""" + if threading.get_ident() in self.recorder_and_worker_thread_ids: + return super().connect() + return NullPool.connect(self) + class MutexPool(StaticPool): """A pool which prevents concurrent accesses from multiple threads. diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 3a6f5f221c52bd..cefcbb86a98ead 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -868,8 +868,8 @@ def __init__( native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - available=lambda status: (status and status["n_current"]) is not None, - removal_condition=lambda _config, status, _key: "n_current" not in status, + removal_condition=lambda _config, status, key: status[key].get("n_current") + is None, entity_registry_enabled_default=False, ), "total_current": RpcSensorDescription( diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 2c4974a65672dc..35354570f232b6 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.7"] + "requirements": ["pysmartthings==3.2.8"] } diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 8bd0e2fca5227d..c6cb04b5ffbf23 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,7 +1,7 @@ """The Squeezebox integration.""" from asyncio import timeout -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from http import HTTPStatus import logging @@ -37,8 +37,6 @@ DISCOVERY_INTERVAL, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, SERVER_MANUFACTURER, SERVER_MODEL, SERVER_MODEL_ID, @@ -73,6 +71,7 @@ class SqueezeboxData: coordinator: LMSStatusDataUpdateCoordinator server: Server + known_player_ids: set[str] = field(default_factory=set) type SqueezeboxConfigEntry = ConfigEntry[SqueezeboxData] @@ -187,16 +186,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms) - # set up player discovery - known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {}) - known_players = known_servers.setdefault(lms.uuid, {}).setdefault(KNOWN_PLAYERS, []) - async def _player_discovery(now: datetime | None = None) -> None: """Discover squeezebox players by polling server.""" async def _discovered_player(player: Player) -> None: """Handle a (re)discovered player.""" - if player.player_id in known_players: + if player.player_id in entry.runtime_data.known_player_ids: await player.async_update() async_dispatcher_send( hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected @@ -207,7 +202,7 @@ async def _discovered_player(player: Player) -> None: hass, entry, player, lms.uuid ) await player_coordinator.async_refresh() - known_players.append(player.player_id) + entry.runtime_data.known_player_ids.add(player.player_id) async_dispatcher_send( hass, SIGNAL_PLAYER_DISCOVERED, player_coordinator ) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 9d78605aee1c65..091ef4d1bbda53 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -4,8 +4,6 @@ DISCOVERY_TASK = "discovery_task" DOMAIN = "squeezebox" DEFAULT_PORT = 9000 -KNOWN_PLAYERS = "known_players" -KNOWN_SERVERS = "known_servers" PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" SENSOR_UPDATE_INTERVAL = 60 SERVER_MANUFACTURER = "https://lyrion.org/" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 8cf945cd7e9c47..f37faa4e115a16 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -60,8 +60,6 @@ DEFAULT_VOLUME_STEP, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, SERVER_MANUFACTURER, SERVER_MODEL, SERVER_MODEL_ID, @@ -316,9 +314,9 @@ def state(self) -> MediaPlayerState | None: async def async_will_remove_from_hass(self) -> None: """Remove from list of known players when removed from hass.""" - known_servers = self.hass.data[DOMAIN][KNOWN_SERVERS] - known_players = known_servers[self.coordinator.server_uuid][KNOWN_PLAYERS] - known_players.remove(self.coordinator.player.player_id) + self.coordinator.config_entry.runtime_data.known_player_ids.remove( + self.coordinator.player.player_id + ) @property def volume_level(self) -> float | None: diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index 9adfc09de0ec25..e51f3d76c7cdc3 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -8,13 +8,27 @@ from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator +from .services import setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Stookwijzer component.""" + setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 1b0be86d375ce4..7b4c28540fc10d 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -5,3 +5,6 @@ DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +SERVICE_GET_FORECAST = "get_forecast" diff --git a/homeassistant/components/stookwijzer/icons.json b/homeassistant/components/stookwijzer/icons.json new file mode 100644 index 00000000000000..19fda370796320 --- /dev/null +++ b/homeassistant/components/stookwijzer/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_forecast": { + "service": "mdi:clock-plus-outline" + } + } +} diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py new file mode 100644 index 00000000000000..e8c12717a21b7f --- /dev/null +++ b/homeassistant/components/stookwijzer/services.py @@ -0,0 +1,76 @@ +"""Define services for the Stookwijzer integration.""" + +from typing import Required, TypedDict, cast + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError + +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST +from .coordinator import StookwijzerConfigEntry + +SERVICE_GET_FORECAST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + } +) + + +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + +def async_get_entry( + hass: HomeAssistant, config_entry_id: str +) -> StookwijzerConfigEntry: + """Get the Overseerr config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(StookwijzerConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Stookwijzer integration.""" + + async def async_get_forecast(call: ServiceCall) -> ServiceResponse | None: + """Get the forecast from API endpoint.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + + return cast( + ServiceResponse, + { + "forecast": cast( + list[Forecast], await client.async_get_forecast() or [] + ), + }, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECAST, + async_get_forecast, + schema=SERVICE_GET_FORECAST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/stookwijzer/services.yaml b/homeassistant/components/stookwijzer/services.yaml new file mode 100644 index 00000000000000..49e1f7b2927a4d --- /dev/null +++ b/homeassistant/components/stookwijzer/services.yaml @@ -0,0 +1,7 @@ +get_forecast: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: stookwijzer diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index a028f1f19c52b4..160387ed8aaa78 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -27,6 +27,18 @@ } } }, + "services": { + "get_forecast": { + "name": "Get forecast", + "description": "Retrieves the advice forecast from Stookwijzer.", + "fields": { + "config_entry_id": { + "name": "Stookwijzer instance", + "description": "The Stookwijzer instance to get the forecast from." + } + } + } + }, "issues": { "location_migration_failed": { "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", @@ -36,6 +48,12 @@ "exceptions": { "no_data_received": { "message": "No data received from Stookwijzer." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } } } diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 8c05e8e259295c..256955e70a82ee 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -32,6 +32,7 @@ from . import TriggerUpdateCoordinator from .const import DOMAIN +from .entity import AbstractTemplateEntity from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -45,7 +46,7 @@ SELECT_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_STATE): cv.template, vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, vol.Required(ATTR_OPTIONS): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -116,7 +117,37 @@ async def async_setup_entry( async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) -class TemplateSelect(TemplateEntity, SelectEntity): +class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): + """Representation of a template select features.""" + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + self._template = config.get(CONF_STATE) + + self._options_template = config[ATTR_OPTIONS] + + self._attr_assumed_state = self._optimistic = ( + self._template is None or config.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC) + ) + self._attr_options = [] + self._attr_current_option = None + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if self._optimistic: + self._attr_current_option = option + self.async_write_ha_state() + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): + await self.async_run_script( + select_option, + run_variables={ATTR_OPTION: option}, + context=self._context, + ) + + +class TemplateSelect(TemplateEntity, AbstractTemplateSelect): """Representation of a template select.""" _attr_should_poll = False @@ -128,16 +159,16 @@ def __init__( unique_id: str | None, ) -> None: """Initialize the select.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None - self._value_template = config[CONF_STATE] - # Scripts can be an empty list, therefore we need to check for None + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + AbstractTemplateSelect.__init__(self, config) + + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: - self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) - self._options_template = config[ATTR_OPTIONS] - self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) - self._attr_options = [] - self._attr_current_option = None + self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) + self._attr_device_info = async_device_info_to_link_from_device_id( hass, config.get(CONF_DEVICE_ID), @@ -146,12 +177,13 @@ def __init__( @callback def _async_setup_templates(self) -> None: """Set up templates.""" - self.add_template_attribute( - "_attr_current_option", - self._value_template, - validator=cv.string, - none_on_template_error=True, - ) + if self._template is not None: + self.add_template_attribute( + "_attr_current_option", + self._template, + validator=cv.string, + none_on_template_error=True, + ) self.add_template_attribute( "_attr_options", self._options_template, @@ -160,24 +192,11 @@ def _async_setup_templates(self) -> None: ) super()._async_setup_templates() - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - if self._optimistic: - self._attr_current_option = option - self.async_write_ha_state() - if select_option := self._action_scripts.get(CONF_SELECT_OPTION): - await self.async_run_script( - select_option, - run_variables={ATTR_OPTION: option}, - context=self._context, - ) - -class TriggerSelectEntity(TriggerEntity, SelectEntity): +class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect): """Select entity based on trigger data.""" domain = SELECT_DOMAIN - extra_template_keys = (CONF_STATE,) extra_template_keys_complex = (ATTR_OPTIONS,) def __init__( @@ -187,7 +206,12 @@ def __init__( config: dict, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateSelect.__init__(self, config) + + if CONF_STATE in config: + self._to_render_simple.append(CONF_STATE) + # Scripts can be an empty list, therefore we need to check for None if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script( @@ -197,24 +221,26 @@ def __init__( DOMAIN, ) - @property - def current_option(self) -> str | None: - """Return the currently selected option.""" - return self._rendered.get(CONF_STATE) + def _handle_coordinator_update(self): + """Handle updated data from the coordinator.""" + self._process_data() - @property - def options(self) -> list[str]: - """Return the list of available options.""" - return self._rendered.get(ATTR_OPTIONS, []) + if not self.available: + self.async_write_ha_state() + return - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - if self._config[CONF_OPTIMISTIC]: - self._attr_current_option = option + write_ha_state = False + if (options := self._rendered.get(ATTR_OPTIONS)) is not None: + self._attr_options = vol.All(cv.ensure_list, [cv.string])(options) + write_ha_state = True + + if (state := self._rendered.get(CONF_STATE)) is not None: + self._attr_current_option = cv.string(state) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: self.async_write_ha_state() - if select_option := self._action_scripts.get(CONF_SELECT_OPTION): - await self.async_run_script( - select_option, - run_variables={ATTR_OPTION: option}, - context=self._context, - ) diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 9460a50db80fbf..56d51f4f1e0c95 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -1,5 +1,7 @@ """The threshold component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,12 +9,18 @@ async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Min/Max from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -25,20 +33,16 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: options={**entry.options, CONF_ENTITY_ID: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_ENTITY_ID] ), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def source_entity_removed() -> None: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the threshold config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3227f030812c03..88fd2784f96d09 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -31,8 +31,7 @@ callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -102,11 +101,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_ENTITY_ID] ) - device_info = async_device_info_to_link_from_entity( - hass, - entity_id, - ) - hysteresis = config_entry.options[CONF_HYSTERESIS] lower = config_entry.options[CONF_LOWER] name = config_entry.title @@ -116,14 +110,14 @@ async def async_setup_entry( async_add_entities( [ ThresholdSensor( - entity_id, - name, - lower, - upper, - hysteresis, - device_class, - unique_id, - device_info=device_info, + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=unique_id, ) ] ) @@ -146,7 +140,14 @@ async def async_setup_platform( async_add_entities( [ ThresholdSensor( - entity_id, name, lower, upper, hysteresis, device_class, None + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=None, ) ], ) @@ -171,6 +172,8 @@ class ThresholdSensor(BinarySensorEntity): def __init__( self, + hass: HomeAssistant, + *, entity_id: str, name: str, lower: float | None, @@ -178,12 +181,15 @@ def __init__( hysteresis: float, device_class: BinarySensorDeviceClass | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the Threshold sensor.""" self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None self._attr_unique_id = unique_id - self._attr_device_info = device_info + if entity_id: # Guard against empty entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) self._entity_id = entity_id self._attr_name = name if lower is not None: diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index 24f5833378241e..29f4a0986c1496 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -80,6 +80,8 @@ async def _validate_mode( class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Threshold.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -131,13 +133,14 @@ def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: ) preview_entity = ThresholdSensor( - entity_id, - name, - msg["user_input"].get(CONF_LOWER), - msg["user_input"].get(CONF_UPPER), - msg["user_input"].get(CONF_HYSTERESIS), - None, - None, + hass, + entity_id=entity_id, + name=name, + lower=msg["user_input"].get(CONF_LOWER), + upper=msg["user_input"].get(CONF_UPPER), + hysteresis=msg["user_input"].get(CONF_HYSTERESIS), + device_class=None, + unique_id=None, ) preview_entity.hass = hass diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index 086ac818c8ee1d..332ec9455eb9dc 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) PLATFORMS = [Platform.BINARY_SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trend from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -37,6 +45,7 @@ async def source_entity_removed() -> None: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -53,6 +62,40 @@ async def source_entity_removed() -> None: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the trend config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle an Trend options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 30058bb056c845..5a7046c2125d8c 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -33,8 +33,8 @@ STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -114,6 +114,7 @@ async def async_setup_platform( for sensor_name, sensor_config in config[CONF_SENSORS].items(): entities.append( SensorTrend( + hass, name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name), entity_id=sensor_config[CONF_ENTITY_ID], attribute=sensor_config.get(CONF_ATTRIBUTE), @@ -140,14 +141,10 @@ async def async_setup_entry( ) -> None: """Set up trend sensor from config entry.""" - device_info = async_device_info_to_link_from_entity( - hass, - entry.options[CONF_ENTITY_ID], - ) - async_add_entities( [ SensorTrend( + hass, name=entry.title, entity_id=entry.options[CONF_ENTITY_ID], attribute=entry.options.get(CONF_ATTRIBUTE), @@ -159,7 +156,6 @@ async def async_setup_entry( min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES), max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES), unique_id=entry.entry_id, - device_info=device_info, ) ] ) @@ -174,6 +170,8 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, + *, name: str, entity_id: str, attribute: str | None, @@ -185,7 +183,6 @@ def __init__( unique_id: str | None = None, device_class: BinarySensorDeviceClass | None = None, sensor_entity_id: str | None = None, - device_info: dr.DeviceInfo | None = None, ) -> None: """Initialize the sensor.""" self._entity_id = entity_id @@ -199,7 +196,10 @@ def __init__( self._attr_name = name self._attr_device_class = device_class self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) if sensor_entity_id: self.entity_id = sensor_entity_id diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py index 756b9536d19b04..3bb06ae30420dd 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -101,6 +101,8 @@ async def get_extended_options_schema(handler: SchemaCommonFlowHandler) -> vol.S class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Trend.""" + MINOR_VERSION = 2 + config_flow = { "user": SchemaFlowFormStep(schema=CONFIG_SCHEMA, next_step="settings"), "settings": SchemaFlowFormStep(get_base_options_schema), diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index c8e6e0f67fbce6..cf9099448df31e 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -382,7 +382,7 @@ async def write_input() -> None: assert process.stderr stderr_data = await process.stderr.read() _LOGGER.error(stderr_data.decode()) - raise RuntimeError( + raise HomeAssistantError( f"Unexpected error while running ffmpeg with arguments: {command}. " "See log for details." ) @@ -976,7 +976,7 @@ async def _async_generate_tts_audio( if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider) or isinstance(message_or_stream, str): + if isinstance(engine_instance, Provider): if isinstance(message_or_stream, str): message = message_or_stream else: @@ -996,8 +996,18 @@ async def make_data_generator(data: bytes) -> AsyncGenerator[bytes]: data_gen = make_data_generator(data) else: + if isinstance(message_or_stream, str): + + async def gen_stream() -> AsyncGenerator[str]: + yield message_or_stream + + stream = gen_stream() + + else: + stream = message_or_stream + tts_result = await engine_instance.internal_async_stream_tts_audio( - TTSAudioRequest(language, options, message_or_stream) + TTSAudioRequest(language, options, stream) ) extension = tts_result.extension data_gen = tts_result.data_gen diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000000..0215c83f0cc000 --- /dev/null +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -0,0 +1,27 @@ +"""The Uptime Kuma integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Set up Uptime Kuma from a config entry.""" + + coordinator = UptimeKumaDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py new file mode 100644 index 00000000000000..9866f08bef3470 --- /dev/null +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for the Uptime Kuma integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pythonkuma import ( + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Optional(CONF_API_KEY, default=""): str, + } +) + + +class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Uptime Kuma.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + uptime_kuma = UptimeKuma(session, url, user_input[CONF_API_KEY]) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=url.host or "", + data={**user_input, CONF_URL: url.human_repr()}, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py new file mode 100644 index 00000000000000..2bd4b1f91659cf --- /dev/null +++ b/homeassistant/components/uptime_kuma/const.py @@ -0,0 +1,26 @@ +"""Constants for the Uptime Kuma integration.""" + +from pythonkuma import MonitorType + +DOMAIN = "uptime_kuma" + +HAS_CERT = { + MonitorType.HTTP, + MonitorType.KEYWORD, + MonitorType.JSON_QUERY, +} +HAS_URL = HAS_CERT | {MonitorType.REAL_BROWSER} +HAS_PORT = { + MonitorType.PORT, + MonitorType.STEAM, + MonitorType.GAMEDIG, + MonitorType.MQTT, + MonitorType.RADIUS, + MonitorType.SNMP, + MonitorType.SMTP, +} +HAS_HOST = HAS_PORT | { + MonitorType.PING, + MonitorType.TAILSCALE_PING, + MonitorType.DNS, +} diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py new file mode 100644 index 00000000000000..788d37cfb84eb8 --- /dev/null +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -0,0 +1,107 @@ +"""Coordinator for the Uptime Kuma integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pythonkuma import ( + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, + UptimeKumaMonitor, + UptimeKumaVersion, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] + + +class UptimeKumaDataUpdateCoordinator( + DataUpdateCoordinator[dict[str | int, UptimeKumaMonitor]] +): + """Update coordinator for Uptime Kuma.""" + + config_entry: UptimeKumaConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: UptimeKumaConfigEntry + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) + self.api = UptimeKuma( + session, config_entry.data[CONF_URL], config_entry.data[CONF_API_KEY] + ) + self.version: UptimeKumaVersion | None = None + + async def _async_update_data(self) -> dict[str | int, UptimeKumaMonitor]: + """Fetch the latest data from Uptime Kuma.""" + + try: + metrics = await self.api.metrics() + except UptimeKumaAuthenticationException as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="auth_failed_exception", + ) from e + except UptimeKumaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_failed_exception", + ) from e + else: + async_migrate_entities_unique_ids(self.hass, self, metrics) + self.version = self.api.version + + return metrics + + +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, + coordinator: UptimeKumaDataUpdateCoordinator, + metrics: dict[str | int, UptimeKumaMonitor], +) -> None: + """Migrate unique_ids in the entity registry after updating Uptime Kuma.""" + + if ( + coordinator.version is coordinator.api.version + or int(coordinator.api.version.major) < 2 + ): + return + + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, coordinator.config_entry.entry_id + ) + + for registry_entry in registry_entries: + name = registry_entry.unique_id.removeprefix( + f"{registry_entry.config_entry_id}_" + ).removesuffix(f"_{registry_entry.translation_key}") + if monitor := next( + (m for m in metrics.values() if m.monitor_name == name), None + ): + entity_registry.async_update_entity( + registry_entry.entity_id, + new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", + ) diff --git a/homeassistant/components/uptime_kuma/icons.json b/homeassistant/components/uptime_kuma/icons.json new file mode 100644 index 00000000000000..73f5fd63661d06 --- /dev/null +++ b/homeassistant/components/uptime_kuma/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "sensor": { + "cert_days_remaining": { + "default": "mdi:certificate" + }, + "response_time": { + "default": "mdi:timeline-clock-outline" + }, + "status": { + "default": "mdi:lan-connect", + "state": { + "down": "mdi:lan-disconnect", + "pending": "mdi:lan-pending", + "maintenance": "mdi:account-hard-hat-outline" + } + }, + "type": { + "default": "mdi:protocol" + }, + "url": { + "default": "mdi:web" + }, + "hostname": { + "default": "mdi:ip-outline" + }, + "port": { + "default": "mdi:ip-outline" + } + } + } +} diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json new file mode 100644 index 00000000000000..6f20d4ae20ffb5 --- /dev/null +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "uptime_kuma", + "name": "Uptime Kuma", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/uptime_kuma", + "iot_class": "cloud_polling", + "loggers": ["pythonkuma"], + "quality_scale": "bronze", + "requirements": ["pythonkuma==0.3.0"] +} diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml new file mode 100644 index 00000000000000..145cbf58448b5f --- /dev/null +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: integration has no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: integration has no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: integration has no events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: integration has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: integration has no options + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: is not locally discoverable + discovery: + status: exempt + comment: is not locally discoverable + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: integration is a service + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: has no repairs + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py new file mode 100644 index 00000000000000..c76fbcae04c3eb --- /dev/null +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -0,0 +1,178 @@ +"""Sensor platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from pythonkuma import MonitorType, UptimeKumaMonitor +from pythonkuma.models import MonitorStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_URL, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL +from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +class UptimeKumaSensor(StrEnum): + """Uptime Kuma sensors.""" + + CERT_DAYS_REMAINING = "cert_days_remaining" + RESPONSE_TIME = "response_time" + STATUS = "status" + TYPE = "type" + URL = "url" + HOSTNAME = "hostname" + PORT = "port" + + +@dataclass(kw_only=True, frozen=True) +class UptimeKumaSensorEntityDescription(SensorEntityDescription): + """Uptime Kuma sensor description.""" + + value_fn: Callable[[UptimeKumaMonitor], StateType] + create_entity: Callable[[MonitorType], bool] + + +SENSOR_DESCRIPTIONS: tuple[UptimeKumaSensorEntityDescription, ...] = ( + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.CERT_DAYS_REMAINING, + translation_key=UptimeKumaSensor.CERT_DAYS_REMAINING, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda m: m.monitor_cert_days_remaining, + create_entity=lambda t: t in HAS_CERT, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.RESPONSE_TIME, + translation_key=UptimeKumaSensor.RESPONSE_TIME, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + value_fn=( + lambda m: m.monitor_response_time if m.monitor_response_time > -1 else None + ), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.STATUS, + translation_key=UptimeKumaSensor.STATUS, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorStatus], + value_fn=lambda m: m.monitor_status.name.lower(), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.TYPE, + translation_key=UptimeKumaSensor.TYPE, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorType], + value_fn=lambda m: m.monitor_type.name.lower(), + entity_category=EntityCategory.DIAGNOSTIC, + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.URL, + translation_key=UptimeKumaSensor.URL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_url, + create_entity=lambda t: t in HAS_URL, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.HOSTNAME, + translation_key=UptimeKumaSensor.HOSTNAME, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_hostname, + create_entity=lambda t: t in HAS_HOST, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.PORT, + translation_key=UptimeKumaSensor.PORT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_port, + create_entity=lambda t: t in HAS_PORT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + monitor_added: set[str | int] = set() + + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal monitor_added + + if new_monitor := set(coordinator.data.keys()) - monitor_added: + async_add_entities( + UptimeKumaSensorEntity(coordinator, monitor, description) + for description in SENSOR_DESCRIPTIONS + for monitor in new_monitor + if description.create_entity(coordinator.data[monitor].monitor_type) + ) + monitor_added |= new_monitor + + coordinator.async_add_listener(add_entities) + add_entities() + + +class UptimeKumaSensorEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], SensorEntity +): + """An Uptime Kuma sensor entity.""" + + entity_description: UptimeKumaSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + monitor: str | int, + entity_description: UptimeKumaSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + self.monitor = monitor + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{monitor!s}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data[monitor].monitor_name, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")}, + manufacturer="Uptime Kuma", + configuration_url=coordinator.config_entry.data[CONF_URL], + sw_version=coordinator.api.version.version, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data[self.monitor]) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.monitor in self.coordinator.data diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json new file mode 100644 index 00000000000000..8cd361cccea349 --- /dev/null +++ b/homeassistant/components/uptime_kuma/strings.json @@ -0,0 +1,94 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up **Uptime Kuma** monitoring service", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "Enter the full URL of your Uptime Kuma instance. Be sure to include the protocol (`http` or `https`), the hostname or IP address, the port number (if it is a non-default port), and any path prefix if applicable. Example: `https://uptime.example.com`", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", + "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "cert_days_remaining": { + "name": "Certificate expiry" + }, + "response_time": { + "name": "Response time" + }, + "status": { + "name": "Status", + "state": { + "up": "Up", + "down": "Down", + "pending": "Pending", + "maintenance": "Maintenance" + } + }, + "type": { + "name": "Monitor type", + "state": { + "http": "HTTP(s)", + "port": "TCP port", + "ping": "Ping", + "keyword": "HTTP(s) - Keyword", + "dns": "DNS", + "push": "Push", + "steam": "Steam Game Server", + "mqtt": "MQTT", + "sqlserver": "Microsoft SQL Server", + "json_query": "HTTP(s) - JSON query", + "group": "Group", + "docker": "Docker", + "grpc_keyword": "gRPC(s) - Keyword", + "real_browser": "HTTP(s) - Browser engine", + "gamedig": "GameDig", + "kafka_producer": "Kafka Producer", + "postgres": "PostgreSQL", + "mysql": "MySQL/MariaDB", + "mongodb": "MongoDB", + "radius": "Radius", + "redis": "Redis", + "tailscale_ping": "Tailscale Ping", + "snmp": "SNMP", + "smtp": "SMTP", + "rabbit_mq": "RabbitMQ", + "manual": "Manual" + } + }, + "url": { + "name": "Monitored URL" + }, + "hostname": { + "name": "Monitored hostname" + }, + "port": { + "name": "Monitored port" + } + } + }, + "exceptions": { + "auth_failed_exception": { + "message": "Authentication with Uptime Kuma failed. Please check that your API key is correct and still valid" + }, + "request_failed_exception": { + "message": "Connection to Uptime Kuma failed" + } + } +} diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 64fa3342c085ad..8a388058b19030 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,7 +21,10 @@ async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -199,6 +202,7 @@ async def async_reset_meters(service_call): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Utility Meter from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE_SENSOR] ) @@ -225,20 +229,16 @@ def set_source_entity_id_or_uuid(source_entity_id: str) -> None: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -286,13 +286,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False if config_entry.version == 1: new = {**config_entry.options} new[CONF_METER_PERIODICALLY_RESETTING] = True hass.config_entries.async_update_entry(config_entry, options=new, version=2) - _LOGGER.info("Migration to version %s successful", config_entry.version) + if config_entry.version == 2: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the utility_meter config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index db7cea6ecf2955..933a04accbac27 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -130,6 +130,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" VERSION = 2 + MINOR_VERSION = 2 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 0c818525c8d922..280a1fd7b1a4da 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -33,7 +33,7 @@ async def async_setup_entry( unique_id = config_entry.entry_id - device_info = async_device_info_to_link_from_entity( + device = async_entity_id_to_device( hass, config_entry.options[CONF_SOURCE_SENSOR], ) @@ -42,7 +42,7 @@ async def async_setup_entry( name=name, tariffs=tariffs, unique_id=unique_id, - device_info=device_info, + device=device, ) async_add_entities([tariff_select]) @@ -91,14 +91,14 @@ def __init__( *, yaml_slug: str | None = None, unique_id: str | None = None, - device_info: DeviceInfo | None = None, + device: DeviceEntry | None = None, ) -> None: """Initialize a tariff selector.""" self._attr_name = name if yaml_slug: # Backwards compatibility with YAML configuration entries self.entity_id = f"select.{yaml_slug}" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = device self._current_tariff: str | None = None self._tariffs = tariffs self._attr_should_poll = False diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index d424692ac95b77..457b02c2b50dd7 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -39,7 +39,7 @@ callback, ) from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -129,11 +129,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - cron_pattern = None delta_values = config_entry.options[CONF_METER_DELTA_VALUES] meter_offset = timedelta(days=config_entry.options[CONF_METER_OFFSET]) @@ -154,6 +149,7 @@ async def async_setup_entry( if not tariffs: # Add single sensor, not gated by a tariff selector meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -166,7 +162,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=None, unique_id=entry_id, - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -175,6 +170,7 @@ async def async_setup_entry( # Add sensors for each tariff for tariff in tariffs: meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -187,7 +183,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=tariff, unique_id=f"{entry_id}_{tariff}", - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -259,6 +254,7 @@ async def async_setup_platform( CONF_SENSOR_ALWAYS_AVAILABLE ] meter_sensor = UtilityMeterSensor( + hass, cron_pattern=conf_cron_pattern, delta_values=conf_meter_delta_values, meter_offset=conf_meter_offset, @@ -359,6 +355,7 @@ class UtilityMeterSensor(RestoreSensor): def __init__( self, + hass, *, cron_pattern, delta_values, @@ -374,11 +371,13 @@ def __init__( unique_id, sensor_always_available, suggested_entity_id=None, - device_info=None, ): """Initialize the Utility Meter sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self.entity_id = suggested_entity_id self._parent_meter = parent_meter self._sensor_source_id = source_entity diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 92b25389450f89..a676c77688d042 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -124,7 +124,7 @@ async def async_step_pick_device( data={CONF_HOST: device.ip_address}, ) - current_unique_ids = self._async_current_ids() + current_unique_ids = self._async_current_ids(include_ignore=False) current_hosts = { entry.data[CONF_HOST] for entry in self._async_current_entries(include_ignore=False) diff --git a/homeassistant/core.py b/homeassistant/core.py index 469acd5dae81d7..8ffabf561719c5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -384,7 +384,7 @@ def get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: while isinstance(check_target, functools.partial): check_target = check_target.func - if asyncio.iscoroutinefunction(check_target): + if inspect.iscoroutinefunction(check_target): return HassJobType.Coroutinefunction if is_callback(check_target): return HassJobType.Callback diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 97e7929d3173df..92319af96173e5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -680,6 +680,7 @@ "upcloud", "upnp", "uptime", + "uptime_kuma", "uptimerobot", "v2c", "vallox", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ec790549519506..277400bec02958 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7080,6 +7080,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "uptime_kuma": { + "name": "Uptime Kuma", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "uptimerobot": { "name": "UptimeRobot", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 47522a69c415ad..a3668acee8d09f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -568,6 +568,10 @@ "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "bsblan", + "name": "bsb-lan*", + }, { "domain": "eheimdigital", "name": "eheimdigital._http._tcp.local.", diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 37ff9b22ff7d3f..3c6120f523f7b9 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,12 +3,12 @@ from __future__ import annotations import abc -import asyncio from collections import deque from collections.abc import Callable, Container, Coroutine, Generator, Iterable from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft +import inspect import logging import re import sys @@ -359,7 +359,7 @@ def disabled_condition( while isinstance(check_factory, ft.partial): check_factory = check_factory.func - if asyncio.iscoroutinefunction(check_factory): + if inspect.iscoroutinefunction(check_factory): return cast(ConditionCheckerType, await factory(hass, config)) return cast(ConditionCheckerType, factory(config)) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 8f0741b5166537..2d9b368254a42e 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -2,11 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass import enum import functools +import inspect import linecache import logging import sys @@ -397,7 +397,7 @@ def _report_usage_no_integration( def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @functools.wraps(func) async def report_use(*args: Any, **kwargs: Any) -> None: diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 68daf5c793915b..e890a8ed087ac1 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from contextvars import ContextVar from http import HTTPStatus +import inspect import logging from typing import Any, Final @@ -45,7 +45,7 @@ def request_handler_factory( hass: HomeAssistant, view: HomeAssistantView, handler: Callable ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" - is_coroutinefunction = asyncio.iscoroutinefunction(handler) + is_coroutinefunction = inspect.iscoroutinefunction(handler) assert is_coroutinefunction or is_callback(handler), ( "Handler should be a coroutine or a callback." ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 1d4dac10c27d0b..3186c211eaa42a 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -7,6 +7,7 @@ import dataclasses from enum import Enum from functools import cache, partial +import inspect import logging from types import ModuleType from typing import TYPE_CHECKING, Any, TypedDict, cast, override @@ -997,7 +998,7 @@ def decorator( service_handler: Callable[[ServiceCall], Any], ) -> Callable[[ServiceCall], Any]: """Decorate.""" - if not asyncio.iscoroutinefunction(service_handler): + if not inspect.iscoroutinefunction(service_handler): raise HomeAssistantError("Can only decorate async functions.") async def check_permissions(call: ServiceCall) -> Any: diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 075fc50b49af86..dac2e5832f6347 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable, Coroutine import functools +import inspect from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant @@ -47,7 +48,7 @@ def wrapper(func: _FuncType[_U]) -> _FuncType[_U]: ... def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index c16819235b9baf..239d1e66336273 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -2,9 +2,11 @@ from __future__ import annotations +from collections.abc import Callable import dataclasses +import logging from logging import Logger -from typing import TypeGuard +from typing import Any, TypeGuard from homeassistant.const import ( ATTR_AREA_ID, @@ -14,7 +16,14 @@ ATTR_LABEL_ID, ENTITY_MATCH_NONE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError from . import ( area_registry as ar, @@ -25,8 +34,11 @@ group, label_registry as lr, ) +from .event import async_track_state_change_event from .typing import ConfigType +_LOGGER = logging.getLogger(__name__) + def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: """Check if ids can match anything.""" @@ -238,3 +250,102 @@ def async_extract_referenced_entity_ids( ) return selected + + +class TargetStateChangeTracker: + """Helper class to manage state change tracking for targets.""" + + def __init__( + self, + hass: HomeAssistant, + selector_data: TargetSelectorData, + action: Callable[[Event[EventStateChangedData]], Any], + ) -> None: + """Initialize the state change tracker.""" + self._hass = hass + self._selector_data = selector_data + self._action = action + + self._state_change_unsub: CALLBACK_TYPE | None = None + self._registry_unsubs: list[CALLBACK_TYPE] = [] + + def async_setup(self) -> Callable[[], None]: + """Set up the state change tracking.""" + self._setup_registry_listeners() + self._track_entities_state_change() + return self._unsubscribe + + def _track_entities_state_change(self) -> None: + """Set up state change tracking for currently selected entities.""" + selected = async_extract_referenced_entity_ids( + self._hass, self._selector_data, expand_group=False + ) + + @callback + def state_change_listener(event: Event[EventStateChangedData]) -> None: + """Handle state change events.""" + if ( + event.data["entity_id"] in selected.referenced + or event.data["entity_id"] in selected.indirectly_referenced + ): + self._action(event) + + tracked_entities = selected.referenced.union(selected.indirectly_referenced) + + _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) + self._state_change_unsub = async_track_state_change_event( + self._hass, tracked_entities, state_change_listener + ) + + def _setup_registry_listeners(self) -> None: + """Set up listeners for registry changes that require resubscription.""" + + @callback + def resubscribe_state_change_event(event: Event[Any] | None = None) -> None: + """Resubscribe to state change events when registry changes.""" + if self._state_change_unsub: + self._state_change_unsub() + self._track_entities_state_change() + + # Subscribe to registry updates that can change the entities to track: + # - Entity registry: entity added/removed; entity labels changed; entity area changed. + # - Device registry: device labels changed; device area changed. + # - Area registry: area floor changed. + # + # We don't track other registries (like floor or label registries) because their + # changes don't affect which entities are tracked. + self._registry_unsubs = [ + self._hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + ar.EVENT_AREA_REGISTRY_UPDATED, resubscribe_state_change_event + ), + ] + + def _unsubscribe(self) -> None: + """Unsubscribe from all events.""" + for registry_unsub in self._registry_unsubs: + registry_unsub() + self._registry_unsubs.clear() + if self._state_change_unsub: + self._state_change_unsub() + self._state_change_unsub = None + + +def async_track_target_selector_state_change_event( + hass: HomeAssistant, + target_selector_config: ConfigType, + action: Callable[[Event[EventStateChangedData]], Any], +) -> CALLBACK_TYPE: + """Track state changes for entities referenced directly or indirectly in a target selector.""" + selector_data = TargetSelectorData(target_selector_config) + if not selector_data.has_any_selector: + raise HomeAssistantError( + f"Target selector {target_selector_config} does not have any selectors defined" + ) + tracker = TargetStateChangeTracker(hass, selector_data, action) + return tracker.async_setup() diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 57ee6b99029463..46b3d8838654b0 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import functools +import inspect import logging from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -407,7 +408,7 @@ def _trigger_action_wrapper( check_func = check_func.func wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] - if asyncio.iscoroutinefunction(check_func): + if inspect.iscoroutinefunction(check_func): async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) @functools.wraps(async_action) diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 19515fd794517b..17a4a86f106f64 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine, Iterable, KeysView, Mapping from datetime import datetime, timedelta from functools import wraps +import inspect import random import re import string @@ -125,7 +125,7 @@ def __init__( def __call__(self, method: Callable) -> Callable: """Caller for the throttle.""" # Make sure we return a coroutine if the method is async. - if asyncio.iscoroutinefunction(method): + if inspect.iscoroutinefunction(method): async def throttled_value() -> None: """Stand-in function for when real func is being throttled.""" diff --git a/mypy.ini b/mypy.ini index 48432118fa8acc..25039f7f386ed6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5109,6 +5109,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.uptime_kuma.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.uptimerobot.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index a0f903370b4a77..53bc939f5882dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.0 # homeassistant.components.hyperion hyperion-py==0.7.6 @@ -2348,7 +2348,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -2525,6 +2525,9 @@ python-vlc==3.0.18122 # homeassistant.components.egardia pythonegardia==1.0.52 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.0 + # homeassistant.components.tile pytile==2024.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aee0dc556a1d9f..a18908ffe973ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1032,7 +1032,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.0 # homeassistant.components.hyperion hyperion-py==0.7.6 @@ -1951,7 +1951,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -2089,6 +2089,9 @@ python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.0 + # homeassistant.components.tile pytile==2024.12.0 diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index 7f760d069e6edc..95415ddb902ef2 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_chat_log_tts_streaming[to_stream_deltas0-0-] +# name: test_chat_log_tts_streaming[to_stream_deltas0-1-hello, how are you?] list([ dict({ 'data': dict({ diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 3a4895440dce4d..5bc7b86c38c0b5 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1550,9 +1550,9 @@ async def test_pipeline_language_used_instead_of_conversation_language( "?", ], ), - # We are not streaming, so 0 chunks via streaming method - 0, - "", + # We always stream when possible, so 1 chunk via streaming method + 1, + "hello, how are you?", ), # Size above STREAM_RESPONSE_CHUNKS ( diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 91e4338d68894d..72360ece687000 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -1,19 +1,124 @@ """Tests for the BSBLan device config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from bsblan import BSBLANConnectionError +import pytest -from homeassistant.components.bsblan import config_flow from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry +# ZeroconfServiceInfo fixtures for different discovery scenarios + + +@pytest.fixture +def zeroconf_discovery_info() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device with MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "00:80:41:19:69:90"}, + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_no_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device without MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={}, # No MAC in properties + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_different_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info with a different MAC than the device API returns.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "aa:bb:cc:dd:ee:ff"}, # Different MAC than in device.json + port=80, + hostname="BSB-LAN.local.", + ) + + +# Helper functions to reduce repetition + + +async def _init_user_flow(hass: HomeAssistant, user_input: dict | None = None): + """Initialize a user config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + +async def _init_zeroconf_flow(hass: HomeAssistant, discovery_info): + """Initialize a zeroconf config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + +async def _configure_flow(hass: HomeAssistant, flow_id: str, user_input: dict): + """Configure a flow with user input.""" + return await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input, + ) + + +def _assert_create_entry_result( + result, expected_title: str, expected_data: dict, expected_unique_id: str +): + """Assert that result is a successful CREATE_ENTRY.""" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == expected_title + assert result.get("data") == expected_data + assert "result" in result + assert result["result"].unique_id == expected_unique_id + + +def _assert_form_result( + result, expected_step_id: str, expected_errors: dict | None = None +): + """Assert that result is a FORM with correct step and optional errors.""" + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == expected_step_id + if expected_errors is None: + # Handle both None and {} as valid "no errors" states (like other integrations) + assert result.get("errors") in ({}, None) + else: + assert result.get("errors") == expected_errors + + +def _assert_abort_result(result, expected_reason: str): + """Assert that result is an ABORT with correct reason.""" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + async def test_full_user_flow_implementation( hass: HomeAssistant, @@ -21,17 +126,13 @@ async def test_full_user_flow_implementation( mock_setup_entry: AsyncMock, ) -> None: """Test the full manual user flow from start to finish.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" + result = await _init_user_flow(hass) + _assert_form_result(result, "user") - result2 = await hass.config_entries.flow.async_configure( + result2 = await _configure_flow( + hass, result["flow_id"], - user_input={ + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -40,17 +141,18 @@ async def test_full_user_flow_implementation( }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == format_mac("00:80:41:19:69:90") - assert result2.get("data") == { - CONF_HOST: "127.0.0.1", - CONF_PORT: 80, - CONF_PASSKEY: "1234", - CONF_USERNAME: "admin", - CONF_PASSWORD: "admin1234", - } - assert "result" in result2 - assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_bsblan.device.mock_calls) == 1 @@ -58,13 +160,8 @@ async def test_full_user_flow_implementation( async def test_show_user_form(hass: HomeAssistant) -> None: """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM + result = await _init_user_flow(hass) + _assert_form_result(result, "user") async def test_connection_error( @@ -74,10 +171,9 @@ async def test_connection_error( """Test we show user form on BSBLan connection error.""" mock_bsblan.device.side_effect = BSBLANConnectionError - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + result = await _init_user_flow( + hass, + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -86,9 +182,7 @@ async def test_connection_error( }, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} - assert result.get("step_id") == "user" + _assert_form_result(result, "user", {"base": "cannot_connect"}) async def test_user_device_exists_abort( @@ -98,17 +192,378 @@ async def test_user_device_exists_abort( ) -> None: """Test we abort flow if BSBLAN device already configured.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test the Zeroconf discovery flow.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_abort_if_existing_entry_for_zeroconf( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test we abort if the same host/port already exists during zeroconf discovery.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, data={ CONF_HOST: "127.0.0.1", CONF_PORT: 80, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery_no_mac_requires_auth( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement and device requires auth.""" + # Make the first API call (without auth) fail, second call (with auth) succeed + mock_bsblan.device.side_effect = [ + BSBLANConnectionError, + mock_bsblan.device.return_value, + ] + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_form_result(result, "discovery_confirm") + + # Reset side_effect for the second call to succeed + mock_bsblan.device.side_effect = None + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + ) + + _assert_create_entry_result( + result2, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + "00:80:41:19:69:90", + ) + + # Should be called 3 times: once without auth (fails), twice with auth (in _validate_and_create) + assert len(mock_bsblan.device.mock_calls) == 3 + + +async def test_zeroconf_discovery_no_mac_no_auth_required( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement but device accessible without auth.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + + # Should now show the discovery_confirm form to the user + _assert_form_result(result, "discovery_confirm") + + # User confirms the discovery + result2 = await _configure_flow(hass, result["flow_id"], {}) + + _assert_create_entry_result( + result2, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: None, + CONF_PASSWORD: None, + }, + "00:80:41:19:69:90", + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should be called once in zeroconf step, as _validate_and_create is skipped + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_zeroconf_discovery_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery shows the correct form.""" + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + + +async def test_zeroconf_discovery_updates_host_port_on_existing_entry( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test that discovered devices update host/port of existing entries.""" + # Create an existing entry with different host/port + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", # Different IP + CONF_PORT: 8080, # Different port + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port from discovery + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host from discovery + assert entry.data[CONF_PORT] == 80 # Updated port from discovery + + +async def test_user_flow_can_update_existing_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test that manual user configuration can update host/port of existing entries.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Try to configure the same device with different host/port via user flow + result = await _init_user_flow( + hass, + { + CONF_HOST: "10.0.2.60", # Different IP + CONF_PORT: 80, # Different port CONF_PASSKEY: "1234", CONF_USERNAME: "admin", CONF_PASSWORD: "admin1234", }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port (user flow behavior) + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host + assert entry.data[CONF_PORT] == 80 # Updated port + + +async def test_zeroconf_discovery_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery can be recovered from.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result3 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result3, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can recover from BSBLan connection error in user flow.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "user", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_zeroconf_discovery_no_mac_duplicate_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery aborts when no MAC and same host/port already configured.""" + # Create an existing entry with same host/port but no unique_id + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.2.60", # Same IP as discovery + CONF_PORT: 80, # Same port as discovery + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id=None, # Old entry without unique_id + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_abort_result(result, "already_configured") + + # Should not call device API since we abort early + assert len(mock_bsblan.device.mock_calls) == 0 diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 254d4da5806cdf..64db21eab8cb89 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -9,8 +9,8 @@ from homeassistant.components import generic_hygrostat from homeassistant.components.generic_hygrostat import DOMAIN from homeassistant.components.generic_hygrostat.config_flow import ConfigFlowHandler -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -119,10 +119,20 @@ def generic_hygrostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -201,7 +211,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -216,9 +226,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -229,8 +237,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -239,7 +251,83 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_hygrostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_hygrostat config entry is removed when the source entity is removed.""" @@ -263,9 +351,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -284,6 +370,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -305,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -315,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -333,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -352,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +479,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +489,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +511,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries @@ -427,13 +530,18 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_hygrostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -452,10 +560,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "humidifier"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "humidifier"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -466,7 +574,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -483,9 +590,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -505,11 +610,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_hygrostat config entry is updated with the new entity ID assert generic_hygrostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -518,3 +621,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + + generic_hygrostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": switch_entity_entry.entity_id, + "name": "My generic hygrostat", + "target_sensor": sensor_entity_entry.entity_id, + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=1, + minor_version=1, + ) + generic_hygrostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_hygrostat_config_entry.version == 1 + assert generic_hygrostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": "switch.test", + "name": "My generic hygrostat", + "target_sensor": "sensor.test", + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 9131e3ffdd4e25..ceca7ecc4446f0 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -9,8 +9,8 @@ from homeassistant.components import generic_thermostat from homeassistant.components.generic_thermostat.config_flow import ConfigFlowHandler from homeassistant.components.generic_thermostat.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -117,10 +117,20 @@ def generic_thermostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +209,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -214,9 +224,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -227,8 +235,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -237,7 +249,84 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_thermostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_thermostat config entry is removed when the source entity is removed.""" @@ -261,9 +350,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -282,6 +369,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -304,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -314,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -332,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -351,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +480,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +490,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +512,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( generic_thermostat_config_entry.entry_id not in source_device_2.config_entries @@ -429,13 +533,20 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_thermostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( - generic_thermostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + generic_thermostat_config_entry.entry_id not in source_device_2.config_entries + ) # Check that the generic_thermostat config entry is not removed assert ( @@ -455,10 +566,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "heater"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "heater"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -469,7 +580,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -486,9 +596,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -508,11 +616,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_thermostat config entry is updated with the new entity ID assert generic_thermostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed assert ( @@ -522,3 +628,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_thermostat config entry from device.""" + + generic_thermostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": switch_entity_entry.entity_id, + "target_sensor": sensor_entity_entry.entity_id, + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=1, + minor_version=1, + ) + generic_thermostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_thermostat_config_entry.version == 1 + assert generic_thermostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": "switch.test", + "target_sensor": "sensor.test", + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index cb3350f497f268..7f81fe6625f271 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -18,7 +18,7 @@ ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -92,6 +92,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -181,7 +182,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(history_stats_config_entry.entry_id) @@ -196,9 +197,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures("recorder_mock") @@ -210,6 +209,56 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the history_stats config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the history_stats config entry is removed + assert ( + history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the history_stats config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -226,7 +275,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -243,7 +292,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -273,7 +325,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -288,7 +340,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the entity is no longer linked to the source device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id is None + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -322,7 +378,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries @@ -339,11 +395,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is moved to the other device + # Check that the entity is linked to the other device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert history_stats_config_entry.entry_id in sensor_device_2.config_entries + assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -369,7 +429,7 @@ async def test_async_handle_source_entity_new_entity_id( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -387,12 +447,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the history_stats config entry is updated with the new entity ID assert history_stats_config_entry.options[CONF_ENTITY_ID] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes history_stats config entry from device.""" + + history_stats_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: sensor_entity_entry.entity_id, + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=1, + minor_version=1, + ) + history_stats_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + assert history_stats_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + assert history_stats_config_entry.version == 1 + assert history_stats_config_entry.minor_version == 2 + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test", + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 9a45b2ad42ddcf..f54250a3336e4b 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -3,7 +3,7 @@ from asyncio import Event from collections.abc import Callable from copy import deepcopy -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta import http import time from unittest.mock import AsyncMock, patch @@ -14,7 +14,7 @@ HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerAttributes, WorkArea +from aioautomower.model import Calendar, MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -384,14 +384,45 @@ async def test_add_and_remove_work_area( values: dict[str, MowerAttributes], ) -> None: """Test adding a work area in runtime.""" + websocket_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] current_entites_start = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) - values[TEST_MOWER_ID].work_area_names.append("new work area") - values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) - values[TEST_MOWER_ID].work_areas.update( + await hass.async_block_till_done() + + assert mock_automower_client.register_data_callback.called + assert "cb" in callback_holder + + new_task = Calendar( + start=dt_time(hour=11), + duration=timedelta(60), + monday=True, + tuesday=True, + wednesday=True, + thursday=True, + friday=True, + saturday=True, + sunday=True, + work_area_id=1, + ) + websocket_values[TEST_MOWER_ID].calendar.tasks.append(new_task) + poll_values = deepcopy(websocket_values) + poll_values[TEST_MOWER_ID].work_area_names.append("new work area") + poll_values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) + poll_values[TEST_MOWER_ID].work_areas.update( { 1: WorkArea( name="new work area", @@ -404,10 +435,15 @@ async def test_add_and_remove_work_area( ) } ) - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) + mock_automower_client.get_status.return_value = poll_values + + callback_holder["cb"](websocket_values) await hass.async_block_till_done() + assert mock_automower_client.get_status.called + + state = hass.states.get("sensor.test_mower_1_new_work_area_progress") + assert state is not None + assert state.state == "12" current_entites_after_addition = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) @@ -419,15 +455,15 @@ async def test_add_and_remove_work_area( + ADDITIONAL_SWITCH_ENTITIES ) - values[TEST_MOWER_ID].work_area_names.remove("new work area") - del values[TEST_MOWER_ID].work_area_dict[1] - del values[TEST_MOWER_ID].work_areas[1] - values[TEST_MOWER_ID].work_area_names.remove("Front lawn") - del values[TEST_MOWER_ID].work_area_dict[123456] - del values[TEST_MOWER_ID].work_areas[123456] - del values[TEST_MOWER_ID].calendar.tasks[:2] - values[TEST_MOWER_ID].mower.work_area_id = 654321 - mock_automower_client.get_status.return_value = values + poll_values[TEST_MOWER_ID].work_area_names.remove("new work area") + del poll_values[TEST_MOWER_ID].work_area_dict[1] + del poll_values[TEST_MOWER_ID].work_areas[1] + poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn") + del poll_values[TEST_MOWER_ID].work_area_dict[123456] + del poll_values[TEST_MOWER_ID].work_areas[123456] + del poll_values[TEST_MOWER_ID].calendar.tasks[:2] + poll_values[TEST_MOWER_ID].mower.work_area_id = 654321 + mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 0ce3297a2ff211..50243551d370e8 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -7,8 +7,8 @@ from homeassistant.components import integration from homeassistant.components.integration.config_flow import ConfigFlowHandler from homeassistant.components.integration.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -83,6 +83,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -176,6 +177,7 @@ def _get_device_config_entries(entry: er.RegistryEntry) -> set[str]: # Set up entities, with backing devices and config entries input_entry = _create_mock_entity("sensor", "input") valid_entry = _create_mock_entity("sensor", "valid") + assert input_entry.device_id != valid_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -193,17 +195,21 @@ def _get_device_config_entries(entry: er.RegistryEntry) -> set[str]: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(input_entry) + assert config_entry.entry_id not in _get_device_config_entries(input_entry) assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == input_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "source": "sensor.valid"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(input_entry) - assert config_entry.entry_id in _get_device_config_entries(valid_entry) + assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == valid_entry.device_id async def test_device_cleaning( @@ -276,7 +282,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(integration_config_entry.entry_id) @@ -291,7 +297,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -302,6 +308,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the integration config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the integration config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -318,7 +372,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -335,7 +389,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +420,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -377,7 +435,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -410,7 +472,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert integration_config_entry.entry_id not in sensor_device_2.config_entries @@ -427,11 +489,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is moved to the other device + # Check that the entity is linked to the other device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert integration_config_entry.entry_id in sensor_device_2.config_entries + assert integration_config_entry.entry_id not in sensor_device_2.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -456,7 +522,7 @@ async def test_async_handle_source_entity_new_entity_id( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -474,12 +540,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the integration config entry is updated with the new entity ID assert integration_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes integration config entry from device.""" + + integration_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=1, + minor_version=1, + ) + integration_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=integration_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + assert integration_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + assert integration_config_entry.version == 1 + assert integration_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": "sensor.test", + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 46f5fdfcc7d724..a4c9fd02be3cf0 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -6,11 +6,8 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import async_fire_time_changed @@ -140,17 +137,3 @@ async def test_issur_melacha_sensor_update( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results[1] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert BINARY_SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - {BINARY_SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert BINARY_SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 7a8b6b8df1ea66..a63d9abb9a7313 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -28,19 +28,18 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test user config.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DIASPORA: DEFAULT_DIASPORA, CONF_LANGUAGE: DEFAULT_LANGUAGE}, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 38a3dd122068f8..ab24d35f932330 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -8,11 +8,7 @@ from hdate.parasha import Parasha import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -569,17 +565,3 @@ async def test_sensor_does_not_update_on_time_change( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results["new_state"] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py index 4b3f31d11d41fe..ce5ccf2af37d3e 100644 --- a/tests/components/jewish_calendar/test_service.py +++ b/tests/components/jewish_calendar/test_service.py @@ -4,7 +4,13 @@ import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.jewish_calendar.const import ( + ATTR_AFTER_SUNSET, + ATTR_DATE, + ATTR_NUSACH, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE from homeassistant.core import HomeAssistant @@ -14,10 +20,10 @@ pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 3, 20), - "nusach": "sfarad", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 3, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "", id="no_blessing", @@ -25,10 +31,10 @@ pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "ashkenaz", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "ashkenaz", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", id="ahskenaz-hebrew", @@ -36,10 +42,10 @@ pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": True, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: True, }, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset", @@ -47,23 +53,23 @@ pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: False, }, "Today is the thirty-seventh day, which are five weeks and two days of the Omer", id="sefarad-english-before-sunset", ), pytest.param( dt.datetime(2025, 5, 20, 21, 0), - {"nusach": "sfarad", "language": "en"}, + {ATTR_NUSACH: "sfarad", CONF_LANGUAGE: "en"}, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset-without-date", ), pytest.param( dt.datetime(2025, 5, 20, 6, 0), - {"nusach": "sfarad"}, + {ATTR_NUSACH: "sfarad"}, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים לעומר", id="sefarad-english-before-sunset-without-date", ), diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 26683ced66e348..32f7745a6e038f 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -309,6 +309,9 @@ def mock_config_entry() -> MockConfigEntry: title="KNX", domain=DOMAIN, data={ + # homeassistant.components.knx.config_flow.DEFAULT_ENTRY_DATA has additional keys + # there are installations out there without these keys so we test with legacy data + # to ensure backwards compatibility (local_ip, telegram_log_size) CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_RATE_LIMIT: CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 6ebe8192f69e5e..6457d099eb26f1 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -48,7 +48,7 @@ ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, get_fixture_path @@ -174,27 +174,27 @@ async def test_routing_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -227,19 +227,19 @@ async def test_routing_setup_advanced( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} # invalid user input result_invalid_input = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_MCAST_GRP: "10.1.2.3", # no valid multicast group CONF_KNX_MCAST_PORT: 3675, @@ -257,8 +257,8 @@ async def test_routing_setup_advanced( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, @@ -266,9 +266,9 @@ async def test_routing_setup_advanced( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -297,18 +297,18 @@ async def test_routing_secure_manual_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -316,19 +316,19 @@ async def test_routing_secure_manual_setup( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_routing_manual"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_routing_manual" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_routing_manual" + assert not result["errors"] result_invalid_key1 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "xxaacc44bbaacc44bbaacc44bbaaccyy", # invalid hex string CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -339,7 +339,7 @@ async def test_routing_secure_manual_setup( assert result_invalid_key1["errors"] == {"backbone_key": "invalid_backbone_key"} result_invalid_key2 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44", # invalid length CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -386,18 +386,18 @@ async def test_routing_secure_keyfile( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -405,20 +405,20 @@ async def test_routing_secure_keyfile( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): routing_secure_knxkeys = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", @@ -532,15 +532,15 @@ async def test_tunneling_setup_manual( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} with patch( "homeassistant.components.knx.config_flow.request_description", @@ -552,13 +552,13 @@ async def test_tunneling_setup_manual( ), ), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == title - assert result3["data"] == config_entry_data + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == title + assert result["data"] == config_entry_data knx_setup.assert_called_once() @@ -724,19 +724,19 @@ async def test_tunneling_setup_for_local_ip( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} # invalid host ip address result_invalid_host = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: DEFAULT_MCAST_GRP, # multicast addresses are invalid @@ -752,7 +752,7 @@ async def test_tunneling_setup_for_local_ip( } # invalid local ip address result_invalid_local = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -768,8 +768,8 @@ async def test_tunneling_setup_for_local_ip( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -777,9 +777,9 @@ async def test_tunneling_setup_for_local_ip( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Tunneling UDP @ 192.168.0.2" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Tunneling UDP @ 192.168.0.2" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -1008,15 +1008,15 @@ async def test_form_with_automatic_connection_handling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { # don't use **DEFAULT_ENTRY_DATA here to check for correct usage of defaults CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", @@ -1032,7 +1032,9 @@ async def test_form_with_automatic_connection_handling( knx_setup.assert_called_once() -async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: +async def _get_menu_step_secure_tunnel( + hass: HomeAssistant, +) -> config_entries.ConfigFlowResult: """Return flow in secure_tunnel menu step.""" gateway = _gateway_descriptor( "192.168.0.1", @@ -1050,23 +1052,23 @@ async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" - return result3 + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" + return result @patch( @@ -1099,24 +1101,24 @@ async def test_get_secure_menu_step_manual_tunnelling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] manual_tunnel_flow = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_GATEWAY: OPTION_MANUAL_TUNNEL, }, ) - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( manual_tunnel_flow["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1124,8 +1126,8 @@ async def test_get_secure_menu_step_manual_tunnelling( CONF_PORT: 3675, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: @@ -1269,52 +1271,51 @@ async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) assert secure_knxkeys["errors"] == {"base": "keyfile_no_tunnel_for_host"} -async def test_options_flow_connection_type( +async def test_reconfigure_flow_connection_type( hass: HomeAssistant, knx, mock_config_entry: MockConfigEntry ) -> None: - """Test options flow changing interface.""" - # run one option flow test with a set up integration (knx fixture) + """Test reconfigure flow changing interface.""" + # run one flow test with a set up integration (knx fixture) # instead of mocking async_setup_entry (knx_setup fixture) to test # usage of the already running XKNX instance for gateway scanner gateway = _gateway_descriptor("192.168.0.1", 3675) await knx.setup_integration() - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + menu_step = await knx.mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ CONF_KNX_GATEWAY: str(gateway), }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3["data"] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_HOST: "192.168.0.1", CONF_PORT: 3675, - CONF_KNX_LOCAL_IP: None, CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_RATE_LIMIT: 0, @@ -1324,14 +1325,13 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, - CONF_KNX_TELEGRAM_LOG_SIZE: 1000, } -async def test_options_flow_secure_manual_to_keyfile( +async def test_reconfigure_flow_secure_manual_to_keyfile( hass: HomeAssistant, knx_setup ) -> None: - """Test options flow changing secure credential source.""" + """Test reconfigure flow changing secure credential source.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1359,46 +1359,47 @@ async def test_options_flow_secure_manual_to_keyfile( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): - secure_knxkeys = await hass.config_entries.options.async_configure( - result4["flow_id"], + secure_knxkeys = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "test", @@ -1407,12 +1408,13 @@ async def test_options_flow_secure_manual_to_keyfile( assert result["type"] is FlowResultType.FORM assert secure_knxkeys["step_id"] == "knxkeys_tunnel_select" assert not result["errors"] - secure_knxkeys = await hass.config_entries.options.async_configure( + secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys["flow_id"], {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"}, ) - assert secure_knxkeys["type"] is FlowResultType.CREATE_ENTRY + assert secure_knxkeys["type"] is FlowResultType.ABORT + assert secure_knxkeys["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1433,8 +1435,8 @@ async def test_options_flow_secure_manual_to_keyfile( knx_setup.assert_called_once() -async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: - """Test options flow changing routing settings.""" +async def test_reconfigure_flow_routing(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow changing routing settings.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1446,36 +1448,38 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: gateway = _gateway_descriptor("192.168.0.1", 3676) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {} - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, @@ -1491,43 +1495,8 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_communication_settings( - hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry -) -> None: - """Test options flow changing communication settings.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - - result = await hass.config_entries.options.async_configure( - menu_step["flow_id"], - {"next_step_id": "communication_settings"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "communication_settings" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") - assert mock_config_entry.data == { - **DEFAULT_ENTRY_DATA, - CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - } - knx_setup.assert_called_once() - - -async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: - """Test options flow updating keyfile when tunnel endpoint is already configured.""" +async def test_reconfigure_update_keyfile(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow updating keyfile when tunnel endpoint is already configured.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1549,9 +1518,10 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1559,15 +1529,15 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1578,8 +1548,8 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: - """Test options flow uploading a keyfile for the first time.""" +async def test_reconfigure_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow uploading a keyfile for the first time.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, @@ -1596,9 +1566,10 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1606,7 +1577,7 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, @@ -1614,17 +1585,17 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "knxkeys_tunnel_select" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "knxkeys_tunnel_select" - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1637,3 +1608,35 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, } knx_setup.assert_called_once() + + +async def test_options_communication_settings( + hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow changing communication settings.""" + initial_data = dict(mock_config_entry.data) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "communication_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert not result.get("data") + assert initial_data != dict(mock_config_entry.data) + assert mock_config_entry.data == { + **initial_data, + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + } + knx_setup.assert_called_once() diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index a98ae82fbe18c1..072b1ece1a19ea 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +import inspect from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -191,7 +191,7 @@ async def trigger_subscription_callback( object_id=object_id, data=data, ) - if asyncio.iscoroutinefunction(cb_func): + if inspect.iscoroutinefunction(cb_func): await cb_func(event) else: cb_func(event) diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 431a30ba7f7f25..5f6f3436699c71 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -1,9 +1,15 @@ """Common fixtures for the Playstation Network tests.""" from collections.abc import Generator +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch -from psnawp_api.models.trophies import TrophySet, TrophySummary +from psnawp_api.models.trophies import ( + PlatformType, + TrophySet, + TrophySummary, + TrophyTitle, +) import pytest from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN @@ -83,13 +89,14 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: client.user.return_value = mock_user client.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PSVITA"}, { "deviceId": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", "deviceType": "PS5", "activationType": "PRIMARY", "activationDate": "2021-01-14T18:00:00.000Z", "accountDeviceVector": "abcdefghijklmnopqrstuv", - } + }, ] client.me.return_value.trophy_summary.return_value = TrophySummary( PSN_ID, 1079, 19, 10, TrophySet(14450, 8722, 11754, 1398) @@ -118,7 +125,37 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: "isOfficiallyVerified": False, "isMe": True, } - + client.user.return_value.trophy_titles.return_value = [ + TrophyTitle( + np_service_name="trophy", + np_communication_id="NPWR03134_00", + trophy_set_version="01.03", + title_name="Assassin's Creed® III Liberation", + title_detail="Assassin's Creed® III Liberation", + title_icon_url="https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG", + title_platform=frozenset({PlatformType.PS_VITA}), + has_trophy_groups=False, + progress=28, + hidden_flag=False, + earned_trophies=TrophySet(bronze=4, silver=8, gold=0, platinum=0), + defined_trophies=TrophySet(bronze=22, silver=21, gold=1, platinum=1), + last_updated_datetime=datetime(2016, 10, 6, 18, 5, 8, tzinfo=UTC), + np_title_id=None, + ) + ] + client.me.return_value.get_profile_legacy.return_value = { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + } yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index f320eea4b7c198..ebf8d9e927f9bd 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -12,6 +12,14 @@ 'title_id': 'PPSA07784_00', 'title_name': 'STAR WARS Jedi: Survivor™', }), + 'PSVITA': dict({ + 'format': 'PSVITA', + 'media_image_url': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'platform': 'PSVITA', + 'status': 'online', + 'title_id': 'PCSB00074_00', + 'title_name': "Assassin's Creed® III Liberation", + }), }), 'availability': 'availableToPlay', 'presence': dict({ @@ -61,6 +69,7 @@ }), 'registered_platforms': list([ 'PS5', + 'PSVITA', ]), 'trophy_summary': dict({ 'account_id': '**REDACTED**', diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index a42522592e4b93..69024c2326f2d7 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -1,4 +1,166 @@ # serializer version: 1 +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation Vita', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standby', + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_vita?token=123456789&cache=c7c916a6e18aec3d', + 'friendly_name': 'PlayStation Vita', + 'media_content_id': 'PCSB00074_00', + 'media_content_type': , + 'media_title': "Assassin's Creed® III Liberation", + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation Vita', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform[PS4_idle][media_player.playstation_4-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py index 09fbe4b0de4a82..c1f2691d623cd2 100644 --- a/tests/components/playstation_network/test_init.py +++ b/tests/components/playstation_network/test_init.py @@ -1,7 +1,9 @@ """Tests for PlayStation Network.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from psnawp_api.core import ( PSNAWPAuthenticationError, PSNAWPClientError, @@ -11,10 +13,13 @@ import pytest from homeassistant.components.playstation_network.const import DOMAIN +from homeassistant.components.playstation_network.coordinator import ( + PlaystationNetworkRuntimeData, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -107,3 +112,154 @@ async def test_coordinator_update_auth_failed( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == config_entry.entry_id + + +async def test_trophy_title_coordinator( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator updates when PS Vita is registered.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + +async def test_trophy_title_coordinator_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator starts reauth on authentication error.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = ( + PSNAWPAuthenticationError + ) + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_trophy_title_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator update failed.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = exception + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + runtime_data: PlaystationNetworkRuntimeData = config_entry.runtime_data + assert runtime_data.trophy_titles.last_update_success is False + + +async def test_trophy_title_coordinator_doesnt_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator does not update if no PS Vita is registered.""" + + mock_psnawpapi.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PS5"}, + {"deviceType": "PS3"}, + ] + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + +async def test_trophy_title_coordinator_play_new_game( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we play a new game and get a title image on next trophy titles update.""" + + _tmp = mock_psnawpapi.user.return_value.trophy_titles.return_value + mock_psnawpapi.user.return_value.trophy_titles.return_value = [] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("media_player.playstation_vita")) + assert state.attributes.get("entity_picture") is None + + mock_psnawpapi.user.return_value.trophy_titles.return_value = _tmp + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + assert (state := hass.states.get("media_player.playstation_vita")) + assert ( + state.attributes["entity_picture"] + == "https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG" + ) diff --git a/tests/components/playstation_network/test_media_player.py b/tests/components/playstation_network/test_media_player.py index f503a5ec2976ff..53bf6436c735f4 100644 --- a/tests/components/playstation_network/test_media_player.py +++ b/tests/components/playstation_network/test_media_player.py @@ -114,6 +114,76 @@ async def test_platform( """Test setup of the PlayStation Network media_player platform.""" mock_psnawpapi.user().get_presence.return_value = presence_payload + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + "presence_payload", + [ + { + "profile": { + "presences": [ + { + "onlineStatus": "standby", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + ], +) +@pytest.mark.usefixtures("mock_psnawpapi", "mock_token") +async def test_media_player_psvita( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_psnawpapi: MagicMock, + presence_payload: dict[str, Any], +) -> None: + """Test setup of the PlayStation Network media_player for PlayStation Vita.""" + + mock_psnawpapi.user().get_presence.return_value = { + "basicPresence": { + "availability": "unavailable", + "primaryPlatformInfo": {"onlineStatus": "offline", "platform": ""}, + } + } + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = presence_payload config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 9b410a5fdd63bb..72fabfa3de15d2 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -16,6 +16,7 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from tests.common import MockConfigEntry @@ -39,6 +40,21 @@ } +ADDON_DISCOVERY_INFO = { + "addon": "pyLoad-ng", + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", +} + +ADDON_SERVICE_INFO = HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="pyLoad-ng Addon", + slug="p539df76c_pyload-ng", + uuid="1234", +) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" diff --git a/tests/components/pyload/test_config_flow.py b/tests/components/pyload/test_config_flow.py index 492e4a4b652e59..1eafbd2eb6632f 100644 --- a/tests/components/pyload/test_config_flow.py +++ b/tests/components/pyload/test_config_flow.py @@ -6,11 +6,18 @@ import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT +from .conftest import ( + ADDON_DISCOVERY_INFO, + ADDON_SERVICE_INFO, + NEW_INPUT, + REAUTH_INPUT, + USER_INPUT, +) from tests.common import MockConfigEntry @@ -245,3 +252,183 @@ async def test_reconfigure_errors( assert result["reason"] == "reconfigure_successful" assert config_entry.data == USER_INPUT assert len(hass.config_entries.async_entries()) == 1 + + +async def test_hassio_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = InvalidAuth + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_confirm_only( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery. Abort with confirm only.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_hassio_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_already_configured( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_data_update( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured and we update entry from discovery data.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + unique_id="1234", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_URL] == "http://539df76c-pyload-ng:8000/" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_ignored( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if discovery was ignored.""" + + MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IGNORE, + data={}, + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/shelly/fixtures/pro_3em.json b/tests/components/shelly/fixtures/pro_3em.json index 93351e9bc65ab0..4895766cc498c8 100644 --- a/tests/components/shelly/fixtures/pro_3em.json +++ b/tests/components/shelly/fixtures/pro_3em.json @@ -151,7 +151,7 @@ "c_pf": 0.72, "c_voltage": 230.2, "id": 0, - "n_current": null, + "n_current": 3.124, "total_act_power": 2413.825, "total_aprt_power": 2525.779, "total_current": 11.116, diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 0b8ec71771bd6a..9dcda321057e28 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -4303,6 +4303,62 @@ 'state': '230.2', }) # --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_phase_n_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase N current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-n_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase N current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_n_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.124', + }) +# --- # name: test_shelly_pro_3em[sensor.test_name_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index dd7f2a7bbc3bc1..0f127ba767a302 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,26 +1,18 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator -from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.components.stookwijzer.services import Forecast from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -class Forecast(TypedDict): - """Typed Stookwijzer forecast dict.""" - - datetime: Required[str] - advice: str | None - final: bool | None - - @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/stookwijzer/snapshots/test_services.ambr b/tests/components/stookwijzer/snapshots/test_services.ambr new file mode 100644 index 00000000000000..d5124219d32d79 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_services.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_service_get_forecast + dict({ + 'forecast': tuple( + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ), + }) +# --- diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py new file mode 100644 index 00000000000000..f60730a290d6b5 --- /dev/null +++ b/tests/components/stookwijzer/test_services.py @@ -0,0 +1,72 @@ +"""Tests for the Stookwijzer services.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.stookwijzer.const import ( + ATTR_CONFIG_ENTRY_ID, + DOMAIN, + SERVICE_GET_FORECAST, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_service_get_forecast( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Stookwijzer forecast service.""" + + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_entry_not_loaded( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when entry is not loaded.""" + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_integration_not_found( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when integration not in registry.""" + with pytest.raises( + ServiceValidationError, match='Integration "stookwijzer" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5e29993f0f645a..6971d41750dbe9 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -28,6 +28,7 @@ ATTR_ICON, CONF_ENTITY_ID, CONF_ICON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, ServiceCall @@ -43,11 +44,15 @@ # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" TEST_STATE_ENTITY_ID = "select.test_state" - +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" TEST_STATE_TRIGGER = { "trigger": { "trigger": "state", - "entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID], + "entity_id": [ + _OPTION_INPUT_SELECT, + TEST_STATE_ENTITY_ID, + TEST_AVAILABILITY_ENTITY_ID, + ], }, "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, "action": [ @@ -201,20 +206,6 @@ async def test_multiple_configs(hass: HomeAssistant) -> None: async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" - with assert_setup_component(0, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "select": { - "select_option": {"service": "script.select_option"}, - "options": "{{ ['a', 'b'] }}", - } - } - }, - ) - with assert_setup_component(0, "select"): assert await setup.async_setup_component( hass, @@ -559,3 +550,98 @@ async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: state = hass.states.get(_TEST_SELECT) assert state.state == "a" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_select") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNKNOWN + + # Ensure Trigger template entities update. + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "yes"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + "state": "{{ states('select.test_state') }}", + "availability": "{{ is_state('binary_sensor.test_availability', 'on') }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_select") +async def test_availability(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + hass.states.async_set(TEST_STATE_ENTITY_ID, "test") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, "yes") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 599612ce0b70cd..fed35bc65020c8 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -7,8 +7,8 @@ from homeassistant.components import threshold from homeassistant.components.threshold.config_flow import ConfigFlowHandler from homeassistant.components.threshold.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -174,6 +175,7 @@ def _get_device_config_entries(entry: er.RegistryEntry) -> set[str]: # Set up entities, with backing devices and config entries run1_entry = _create_mock_entity("sensor", "initial") run2_entry = _create_mock_entity("sensor", "changed") + assert run1_entry.device_id != run2_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -186,23 +188,27 @@ def _get_device_config_entries(entry: er.RegistryEntry) -> set[str]: "name": "My threshold", "upper": None, }, - title="My integration", + title="My threshold", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(run1_entry) + assert config_entry.entry_id not in _get_device_config_entries(run1_entry) assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run1_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "entity_id": "sensor.changed"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(run1_entry) - assert config_entry.entry_id in _get_device_config_entries(run2_entry) + assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run2_entry.device_id async def test_device_cleaning( @@ -273,7 +279,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(threshold_config_entry.entry_id) @@ -288,7 +294,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -299,6 +305,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the threshold config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the threshold config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -315,7 +369,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -332,7 +386,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -359,7 +417,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -374,7 +432,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -407,7 +469,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert threshold_config_entry.entry_id not in sensor_device_2.config_entries @@ -424,11 +486,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is moved to the other device + # Check that the entity is linked to the other device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert threshold_config_entry.entry_id in sensor_device_2.config_entries + assert threshold_config_entry.entry_id not in sensor_device_2.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -453,7 +519,7 @@ async def test_async_handle_source_entity_new_entity_id( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -471,12 +537,87 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the threshold config entry is updated with the new entity ID assert threshold_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes threshold config entry from device.""" + + threshold_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": sensor_entity_entry.entity_id, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=1, + minor_version=1, + ) + threshold_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + assert threshold_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + assert threshold_config_entry.version == 1 + assert threshold_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test", + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 4ff6213d082792..22700376b26947 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components.trend.config_flow import ConfigFlowHandler from homeassistant.components.trend.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +200,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(trend_config_entry.entry_id) @@ -214,7 +215,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -225,6 +226,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the trend config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the trend config entry is removed + assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the trend config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -241,7 +289,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -258,7 +306,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -285,7 +336,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -300,7 +351,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the entity is no longer linked to the source device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id is None + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -333,7 +388,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert trend_config_entry.entry_id not in sensor_device_2.config_entries @@ -350,11 +405,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is moved to the other device + # Check that the entity is linked to the other device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_device_2.id + + # Check that the trend config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert trend_config_entry.entry_id in sensor_device_2.config_entries + assert trend_config_entry.entry_id not in sensor_device_2.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -379,7 +438,7 @@ async def test_async_handle_source_entity_new_entity_id( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -397,12 +456,83 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the trend config entry is updated with the new entity ID assert trend_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes trend config entry from device.""" + + trend_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": sensor_entity_entry.entity_id, + "invert": False, + }, + title="My trend", + version=1, + minor_version=1, + ) + trend_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=trend_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + assert trend_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + assert trend_config_entry.version == 1 + assert trend_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": "sensor.test", + "invert": False, + }, + title="My trend", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 22fb10209b0f60..db42da5de0eec6 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1835,7 +1835,7 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: async def bad_data_gen(): yield bytes(0) - with pytest.raises(RuntimeError): + with pytest.raises(HomeAssistantError): # Simulate a bad WAV file async for _chunk in tts._async_convert_audio( hass, "wav", bad_data_gen(), "mp3" diff --git a/tests/components/uptime_kuma/__init__.py b/tests/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000000..ba8ab82dc46bc3 --- /dev/null +++ b/tests/components/uptime_kuma/__init__.py @@ -0,0 +1 @@ +"""Tests for the Uptime Kuma integration.""" diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py new file mode 100644 index 00000000000000..4b7710a48b470d --- /dev/null +++ b/tests/components/uptime_kuma/conftest.py @@ -0,0 +1,101 @@ +"""Common fixtures for the Uptime Kuma tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion +from pythonkuma.models import MonitorStatus + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.uptime_kuma.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Uptime Kuma configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="uptime.example.org", + data={ + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + entry_id="123456789", + ) + + +@pytest.fixture +def mock_pythonkuma() -> Generator[AsyncMock]: + """Mock pythonkuma client.""" + + monitor_1 = UptimeKumaMonitor( + monitor_id=1, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 1", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.HTTP, + monitor_url="https://example.org", + ) + monitor_2 = UptimeKumaMonitor( + monitor_id=2, + monitor_cert_days_remaining=0, + monitor_cert_is_valid=0, + monitor_hostname=None, + monitor_name="Monitor 2", + monitor_port=None, + monitor_response_time=28, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.PORT, + monitor_url=None, + ) + monitor_3 = UptimeKumaMonitor( + monitor_id=3, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 3", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.DOWN, + monitor_type=MonitorType.JSON_QUERY, + monitor_url="https://down.example.org", + ) + + with ( + patch( + "homeassistant.components.uptime_kuma.config_flow.UptimeKuma", autospec=True + ) as mock_client, + patch( + "homeassistant.components.uptime_kuma.coordinator.UptimeKuma", + new=mock_client, + ), + ): + client = mock_client.return_value + + client.metrics.return_value = { + 1: monitor_1, + 2: monitor_2, + 3: monitor_3, + } + client.version = UptimeKumaVersion( + version="2.0.0", major="2", minor="0", patch="0" + ) + + yield client diff --git a/tests/components/uptime_kuma/snapshots/test_sensor.ambr b/tests/components/uptime_kuma/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..49a7d141c475d0 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_sensor.ambr @@ -0,0 +1,968 @@ +# serializer version: 1 +# name: test_setup[sensor.monitor_1_certificate_expiry-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_certificate_expiry', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_1_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'http', + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_1_monitored_url', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 1 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://example.org', + }) +# --- +# name: test_setup[sensor.monitor_1_response_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_1_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'port', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitored_hostname', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored hostname', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_hostname', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored hostname', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_hostname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitored_port', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored port', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_port', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored port', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_port', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_response_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_2_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_2_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 2 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_2_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_setup[sensor.monitor_2_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_2_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_certificate_expiry', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_3_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'json_query', + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_3_monitored_url', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 3 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://down.example.org', + }) +# --- +# name: test_setup[sensor.monitor_3_response_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_3_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'down', + }) +# --- diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py new file mode 100644 index 00000000000000..b70cb9d353c8a6 --- /dev/null +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Uptime Kuma config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaConnectionException + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle errors and recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_pythonkuma.metrics.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py new file mode 100644 index 00000000000000..57390da60d50b9 --- /dev/null +++ b/tests/components/uptime_kuma/test_init.py @@ -0,0 +1,52 @@ +"""Tests for the Uptime Kuma integration.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (UptimeKumaAuthenticationException, ConfigEntryState.SETUP_ERROR), + (UptimeKumaException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready.""" + + mock_pythonkuma.metrics.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is state diff --git a/tests/components/uptime_kuma/test_sensor.py b/tests/components/uptime_kuma/test_sensor.py new file mode 100644 index 00000000000000..25bd76505286e3 --- /dev/null +++ b/tests/components/uptime_kuma/test_sensor.py @@ -0,0 +1,97 @@ +"""Test for Uptime Kuma sensor platform.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pythonkuma import MonitorStatus, UptimeKumaMonitor, UptimeKumaVersion +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma", "entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_migrate_unique_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Snapshot test states of sensor platform.""" + mock_pythonkuma.metrics.return_value = { + "Monitor": UptimeKumaMonitor( + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="1.23.16", major="1", minor="23", patch="16" + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_Monitor_status" + + mock_pythonkuma.metrics.return_value = { + 1: UptimeKumaMonitor( + monitor_id=1, + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="2.0.0-beta.3", major="2", minor="0", patch="0-beta.3" + ) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_1_status" diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index ef235bba99dc88..024fd1aaa7b7a4 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -8,7 +8,7 @@ 'discovery_keys': dict({ }), 'domain': 'utility_meter', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ 'cycle': 'monthly', 'delta_values': False, diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 01fd80acc0e081..0aa73d6d123a23 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -403,11 +403,19 @@ async def test_change_device_source( assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 1 (current) device registry + # Confirm that the configuration entry has not been added to the source entity 1 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_1.device_id # Change configuration options to use source entity 2 (with a linked device) and reload the integration previous_entity_source = source_entity_1 @@ -427,17 +435,25 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 1 (previous) device registry + # Confirm that the configuration entry is not in the source entity 1 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in to the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id # Change configuration options to use source entity 3 (without a device) and reload the integration previous_entity_source = source_entity_2 @@ -457,12 +473,20 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 2 (previous) device registry + # Confirm that the configuration entry has is not in the source entity 2 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries + # Check that the entities are no longer linked to a device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + # Confirm that there is no device with the helper configuration entry assert ( dr.async_entries_for_config_entry( @@ -489,8 +513,16 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ea4af741e19641..ec7fdd1db871c7 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -20,7 +20,7 @@ ) from homeassistant.components.utility_meter.config_flow import ConfigFlowHandler from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -29,7 +29,7 @@ Platform, UnitOfEnergy, ) -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component @@ -108,6 +108,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -601,7 +602,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(utility_meter_config_entry.entry_id) @@ -616,7 +617,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 @pytest.mark.parametrize( @@ -642,6 +643,81 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], +) -> None: + """Test the utility_meter config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], ) -> None: """Test the utility_meter config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -667,7 +743,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor's config entry from the device, this removes the # source sensor @@ -682,7 +758,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -734,7 +818,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor from the device with patch( @@ -747,7 +831,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -805,7 +897,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries @@ -820,11 +912,19 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is moved to the other device + # Check that the entities are linked to the other device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert utility_meter_config_entry.entry_id in sensor_device_2.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -874,7 +974,7 @@ async def test_async_handle_source_entity_new_entity_id( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Change the source entity's entity ID with patch( @@ -890,9 +990,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the utility_meter config entry is updated with the new entity ID assert utility_meter_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -900,3 +1000,108 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events for entity_events in events.values(): assert entity_events == [] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_migration_2_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, + tariffs: list[str], + expected_entities: set[str], +) -> None: + """Test migration from v2.1 removes utility_meter config entry from device.""" + + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": sensor_entity_entry.entity_id, + "tariffs": tariffs, + }, + title="My utility meter", + version=2, + minor_version=1, + ) + utility_meter_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + assert utility_meter_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entities are linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + # Check that the entities are linked to the other device + entities = set() + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + entities.add(utility_meter_entity.entity_id) + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + assert entities == expected_entities + + assert utility_meter_config_entry.version == 2 + assert utility_meter_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test", + "tariffs": [], + }, + title="My utility meter", + version=3, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2de2ee553b3267..f684cdb16a0bbf 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1888,10 +1888,12 @@ async def test_bad_offset(hass: HomeAssistant) -> None: def test_calculate_adjustment_invalid_new_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: """Test that calculate_adjustment method returns None if the new state is invalid.""" mock_sensor = UtilityMeterSensor( + hass, cron_pattern=None, delta_values=False, meter_offset=DEFAULT_OFFSET, diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index ddf4a4f452afaf..946eb032f8ede2 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -572,3 +572,46 @@ async def test_discovered_during_onboarding(hass: HomeAssistant, source, data) - } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_replace_ignored_device(hass: HomeAssistant) -> None: + """Test we can replace an ignored device via discovery.""" + # Add ignored entry to simulate previously ignored device + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_MAC, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + # Patch discovery to find the same ignored device + with _patch_discovery(), _patch_wizlight(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_device" + # Proceed with selecting the device — previously ignored + with ( + _patch_wizlight(), + patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.wiz.async_setup", + return_value=True, + ) as mock_setup, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: FAKE_MAC} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "WiZ Dimmable White ABCABC" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 53cc02eaacf2fa..67c9b24160c72d 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -1,6 +1,19 @@ # serializer version: 1 # name: test_get_tts_audio list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -8,10 +21,29 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_different_formats list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -19,10 +51,29 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_different_formats.1 list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -30,6 +81,12 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_streaming @@ -71,6 +128,23 @@ # --- # name: test_voice_speaker list([ + dict({ + 'data': dict({ + 'voice': dict({ + 'name': 'voice1', + 'speaker': 'speaker1', + }), + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -82,5 +156,11 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 3374328f4119aa..efcf464eebbb4b 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -52,6 +52,7 @@ async def test_get_tts_audio( # Verify audio audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -77,7 +78,10 @@ async def test_get_tts_audio( assert wav_file.getframerate() == 16000 assert wav_file.getsampwidth() == 2 assert wav_file.getnchannels() == 1 - assert wav_file.readframes(wav_file.getnframes()) == audio + + # nframes = 0 due to streaming + assert len(data) == len(audio) + 44 # WAVE header is 44 bytes + assert data[44:] == audio assert mock_client.written == snapshot @@ -88,6 +92,7 @@ async def test_get_tts_audio_different_formats( """Test changing preferred audio format.""" audio = bytes(16000 * 2 * 1) # one second audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -123,6 +128,7 @@ async def test_get_tts_audio_different_formats( # MP3 is the default audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -167,6 +173,7 @@ async def test_get_tts_audio_audio_oserror( """Test get audio and error raising.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -197,6 +204,7 @@ async def test_voice_speaker( """Test using a different voice and speaker.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 465d1b1778b986..c875522b943a67 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4946,6 +4946,37 @@ def single_run_callback(event: Event[EventStateReportedData]) -> None: unsub() +async def test_async_track_state_report_change_event(hass: HomeAssistant) -> None: + """Test listen for both state change and state report events.""" + tracker_called: dict[str, list[str]] = {"light.bowl": [], "light.top": []} + + @ha.callback + def on_state_change(event: Event[EventStateChangedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + @ha.callback + def on_state_report(event: Event[EventStateReportedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + async_track_state_change_event(hass, ["light.bowl", "light.top"], on_state_change) + async_track_state_report_event(hass, ["light.bowl", "light.top"], on_state_report) + entity_ids = ["light.bowl", "light.top"] + state_sequence = ["on", "on", "off", "off"] + for state in state_sequence: + for entity_id in entity_ids: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + # The out-of-order is a result of state change listeners scheduled with + # loop.call_soon, whereas state report listeners are called immediately. + assert tracker_called == { + "light.bowl": ["on", "off", "on", "off"], + "light.top": ["on", "off", "on", "off"], + } + + async def test_async_track_template_no_hass_deprecated( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index ca38f316d89ae4..c87a320e3789bf 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -2,9 +2,6 @@ import pytest -# TODO(abmantis): is this import needed? -# To prevent circular import when running just this file -import homeassistant.components # noqa: F401 from homeassistant.components.group import Group from homeassistant.const import ( ATTR_AREA_ID, @@ -17,17 +14,21 @@ STATE_ON, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + floor_registry as fr, + label_registry as lr, target, ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import ( + MockConfigEntry, RegistryEntryWithDefaults, mock_area_registry, mock_device_registry, @@ -457,3 +458,188 @@ async def test_extract_referenced_entity_ids( ) == expected_selected ) + + +async def test_async_track_target_selector_state_change_event_empty_selector( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_track_target_selector_state_change_event with empty selector.""" + + @callback + def state_change_callback(event): + """Handle state change events.""" + + with pytest.raises(HomeAssistantError) as excinfo: + target.async_track_target_selector_state_change_event( + hass, {}, state_change_callback + ) + assert str(excinfo.value) == ( + "Target selector {} does not have any selectors defined" + ) + + +async def test_async_track_target_selector_state_change_event( + hass: HomeAssistant, +) -> None: + """Test async_track_target_selector_state_change_event with multiple targets.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def state_change_callback(event: Event[EventStateChangedData]): + """Handle state change events.""" + events.append(event) + + last_state = STATE_OFF + + async def set_states_and_check_events( + entities_to_set_state: list[str], entities_to_assert_change: list[str] + ) -> None: + """Toggle the state entities and check for events.""" + nonlocal last_state + last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF + for entity_id in entities_to_set_state: + hass.states.async_set(entity_id, last_state) + await hass.async_block_till_done() + + assert len(events) == len(entities_to_assert_change) + entities_seen = set() + for event in events: + entities_seen.add(event.data["entity_id"]) + assert event.data["new_state"].state == last_state + assert entities_seen == set(entities_to_assert_change) + events.clear() + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device_reg = dr.async_get(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device_1")}, + ) + + untargeted_device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "area_device")}, + ) + + entity_reg = er.async_get(hass) + device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light", + device_id=device_entry.id, + ).entity_id + + untargeted_device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="area_device_light", + device_id=untargeted_device_entry.id, + ).entity_id + + untargeted_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="untargeted_light", + ).entity_id + + targeted_entity = "light.test_light" + + targeted_entities = [targeted_entity, device_entity] + await set_states_and_check_events(targeted_entities, []) + + label = lr.async_get(hass).async_create("Test Label").name + area = ar.async_get(hass).async_create("Test Area").id + floor = fr.async_get(hass).async_create("Test Floor").floor_id + + selector_config = { + ATTR_ENTITY_ID: targeted_entity, + ATTR_DEVICE_ID: device_entry.id, + ATTR_AREA_ID: area, + ATTR_FLOOR_ID: floor, + ATTR_LABEL_ID: label, + } + unsub = target.async_track_target_selector_state_change_event( + hass, selector_config, state_change_callback + ) + + # Test directly targeted entity and device + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Add new entity to the targeted device -> should trigger on state change + device_entity_2 = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light_2", + device_id=device_entry.id, + ).entity_id + + targeted_entities = [targeted_entity, device_entity, device_entity_2] + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Test untargeted entity -> should not trigger + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add label to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, labels={label}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove label from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, labels={}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove area from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted device -> should trigger on state change + device_reg.async_update_device(untargeted_device_entry.id, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], + [*targeted_entities, untargeted_device_entity], + ) + + # Remove area from untargeted device -> should not trigger anymore + device_reg.async_update_device(untargeted_device_entry.id, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], targeted_entities + ) + + # Set the untargeted area on the untargeted entity -> should not trigger + untracked_area = ar.async_get(hass).async_create("Untargeted Area").id + entity_reg.async_update_entity(untargeted_entity, area_id=untracked_area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Set targeted floor on the untargeted area -> should trigger now + ar.async_get(hass).async_update(untracked_area, floor_id=floor) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], + [*targeted_entities, untargeted_entity], + ) + + # Remove untargeted area from targeted floor -> should not trigger anymore + ar.async_get(hass).async_update(untracked_area, floor_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # After unsubscribing, changes should not trigger + unsub() + await set_states_and_check_events(targeted_entities, []) diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index ba473ee0c58afb..406952881bc054 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -2,6 +2,7 @@ import asyncio from functools import partial +import inspect import logging import queue from unittest.mock import patch @@ -102,7 +103,7 @@ def test_catch_log_exception() -> None: async def async_meth(): pass - assert asyncio.iscoroutinefunction( + assert inspect.iscoroutinefunction( logging_util.catch_log_exception(partial(async_meth), lambda: None) ) @@ -120,7 +121,7 @@ def sync_meth(): wrapped = logging_util.catch_log_exception(partial(sync_meth), lambda: None) assert not is_callback(wrapped) - assert not asyncio.iscoroutinefunction(wrapped) + assert not inspect.iscoroutinefunction(wrapped) @pytest.mark.no_fail_on_log_exception