diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 2b23928aba87..1ae7da148753 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.3.2"] + "requirements": ["aioairq==0.4.3"] } diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 10fb20bb2ce9..01fde7eb2fbf 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.5"] + "requirements": ["aioairzone==0.9.7"] } diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py index 372ce52e0498..c437f5584db6 100644 --- a/homeassistant/components/apsystems/__init__.py +++ b/homeassistant/components/apsystems/__init__.py @@ -38,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> ip_address=entry.data[CONF_IP_ADDRESS], port=entry.data.get(CONF_PORT, DEFAULT_PORT), timeout=8, + enable_debounce=True, ) coordinator = ApSystemsDataCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index 9376d21ba289..a58530b05e23 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/apsystems", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["apsystems-ez1==2.2.1"] + "requirements": ["apsystems-ez1==2.4.0"] } diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index 93a21ec9f05a..739148454451 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from aiohttp.client_exceptions import ClientConnectionError +from APsystemsEZ1 import InverterReturnedError from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant @@ -40,7 +41,7 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity): """Update switch status and availability.""" try: status = await self._api.get_device_power_status() - except (TimeoutError, ClientConnectionError): + except (TimeoutError, ClientConnectionError, InverterReturnedError): self._attr_available = False else: self._attr_available = True diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index fe16bd73a9e7..e25c077b57fa 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "requirements": [ "bleak==0.22.3", "bleak-retry-connector==3.6.0", - "bluetooth-adapters==0.20.0", + "bluetooth-adapters==0.20.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", "dbus-fast==2.24.3", diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 12f2edeee9a9..9c49813bd830 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -53,7 +53,7 @@ }, "view_path": { "name": "View path", - "description": "The path of the dashboard view to show." + "description": "The URL path of the dashboard view to show." } } } diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index 6318231c281a..bf02d7272800 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -68,7 +68,7 @@ } }, "alarm_arm_home_instant": { - "name": "Alarm are home instant", + "name": "Alarm arm home instant", "description": "Arms the ElkM1 in home instant mode.", "fields": { "code": { diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index c57b707906b3..efa97a9f6b95 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax-api==0.0.5"], + "requirements": ["elmax-api==0.0.6.1"], "zeroconf": [ { "type": "_elmax-ssl._tcp.local." diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index cefe87f49ba5..2dacae52f75a 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -179,6 +179,9 @@ class FFmpegConvertResponse(web.StreamResponse): # Remove metadata and cover art command_args.extend(["-map_metadata", "-1", "-vn"]) + # disable progress stats on stderr + command_args.append("-nostats") + # Output to stdout command_args.append("pipe:") diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index ec7e6f674b3e..a764e9e6fd94 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -118,7 +118,7 @@ }, "service_calls_not_allowed": { "title": "{name} is not permitted to perform Home Assistant actions", - "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perfom Home Assistant action, you can enable this functionality in the options flow." + "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow." } } } diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 2a73e24a3e52..e05cf9a63e57 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import logging from typing import Any import urllib.error @@ -106,7 +107,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): return self.abort_on_import_error(user_input[CONF_URL], "url_error") return self.show_user_form(user_input, {"base": "url_error"}) - feed_title = feed["feed"]["title"] + feed_title = html.unescape(feed["feed"]["title"]) return self.async_create_entry( title=feed_title, diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index 6608c4312fe1..f45b303946aa 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from calendar import timegm from datetime import datetime +import html from logging import getLogger from time import gmtime, struct_time from typing import TYPE_CHECKING @@ -102,7 +103,8 @@ class FeedReaderCoordinator( """Set up the feed manager.""" feed = await self._async_fetch_feed() self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"]) - self.feed_author = feed["feed"].get("author") + if feed_author := feed["feed"].get("author"): + self.feed_author = html.unescape(feed_author) self.feed_version = feedparser.api.SUPPORTED_VERSIONS.get(feed["version"]) self._feed = feed diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index 4b3fb2e25249..ad6aed0fc765 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import logging from feedparser import FeedParserDict @@ -76,15 +77,22 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity): # so we always take the first entry in list, since we only care about the latest entry feed_data: FeedParserDict = data[0] + if description := feed_data.get("description"): + description = html.unescape(description) + + if title := feed_data.get("title"): + title = html.unescape(title) + if content := feed_data.get("content"): if isinstance(content, list) and isinstance(content[0], dict): content = content[0].get("value") + content = html.unescape(content) self._trigger_event( EVENT_FEEDREADER, { - ATTR_DESCRIPTION: feed_data.get("description"), - ATTR_TITLE: feed_data.get("title"), + ATTR_DESCRIPTION: description, + ATTR_TITLE: title, ATTR_LINK: feed_data.get("link"), ATTR_CONTENT: content, }, diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index c787ca702726..0898d1c93184 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -69,37 +69,29 @@ class FibaroCover(FibaroEntity, CoverEntity): # so if it is missing we have a device which supports open / close only return not self.fibaro_device.value.has_value - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. 0 is closed, 100 is open.""" - return self.bound(self.level) + def update(self) -> None: + """Update the state.""" + super().update() - @property - def current_cover_tilt_position(self) -> int | None: - """Return the current tilt position for venetian blinds.""" - return self.bound(self.level2) + self._attr_current_cover_position = self.bound(self.level) + self._attr_current_cover_tilt_position = self.bound(self.level2) - @property - def is_opening(self) -> bool | None: - """Return if the cover is opening or not. + device_state = self.fibaro_device.state - Be aware that this property is only available for some modern devices. - For example the Fibaro Roller Shutter 4 reports this correctly. - """ - if self.fibaro_device.state.has_value: - return self.fibaro_device.state.str_value().lower() == "opening" - return None + # Be aware that opening and closing is only available for some modern + # devices. + # For example the Fibaro Roller Shutter 4 reports this correctly. + if device_state.has_value: + self._attr_is_opening = device_state.str_value().lower() == "opening" + self._attr_is_closing = device_state.str_value().lower() == "closing" - @property - def is_closing(self) -> bool | None: - """Return if the cover is closing or not. - - Be aware that this property is only available for some modern devices. - For example the Fibaro Roller Shutter 4 reports this correctly. - """ - if self.fibaro_device.state.has_value: - return self.fibaro_device.state.str_value().lower() == "closing" - return None + closed: bool | None = None + if self._is_open_close_only(): + if device_state.has_value and device_state.str_value().lower() != "unknown": + closed = device_state.str_value().lower() == "closed" + elif self.current_cover_position is not None: + closed = self.current_cover_position == 0 + self._attr_is_closed = closed def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -109,19 +101,6 @@ class FibaroCover(FibaroEntity, CoverEntity): """Move the cover to a specific position.""" self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION))) - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed.""" - if self._is_open_close_only(): - state = self.fibaro_device.state - if not state.has_value or state.str_value().lower() == "unknown": - return None - return state.str_value().lower() == "closed" - - if self.current_cover_position is None: - return None - return self.current_cover_position == 0 - def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.action("open") diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 09ed45bd5bc2..de42a317cc7d 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -274,7 +274,7 @@ "fields": { "addon": { "name": "Add-on", - "description": "The add-on slug." + "description": "The add-on to start." } } }, @@ -284,17 +284,17 @@ "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to restart." } } }, "addon_stdin": { "name": "Write data to add-on stdin.", - "description": "Writes data to add-on stdin.", + "description": "Writes data to the add-on's standard input.", "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to write to." } } }, @@ -304,7 +304,7 @@ "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to stop." } } }, @@ -314,7 +314,7 @@ "fields": { "addon": { "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", - "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + "description": "The add-on to update." } } }, diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 8c64f492d42b..a3c0a4514d3d 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.60", "babel==2.15.0"] + "requirements": ["holidays==0.61", "babel==2.15.0"] } diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index b3e7eb9a72ad..97af964ffc78 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.1.2"] + "requirements": ["homematicip==1.1.3"] } diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index e94f734069ac..3ed88d0d8233 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio @@ -737,6 +737,16 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) +async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str: + """Get file content from uploaded file.""" + + def _proces_uploaded_file() -> str: + with process_uploaded_file(hass, id) as file_path: + return file_path.read_text(encoding=DEFAULT_ENCODING) + + return await hass.async_add_executor_job(_proces_uploaded_file) + + async def async_get_broker_settings( flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], @@ -795,8 +805,7 @@ async def async_get_broker_settings( return False certificate_id: str | None = user_input.get(CONF_CERTIFICATE) if certificate_id: - with process_uploaded_file(hass, certificate_id) as certificate_file: - certificate = certificate_file.read_text(encoding=DEFAULT_ENCODING) + certificate = await _get_uploaded_file(hass, certificate_id) # Return to form for file upload CA cert or client cert and key if ( @@ -812,15 +821,9 @@ async def async_get_broker_settings( return False if client_certificate_id: - with process_uploaded_file( - hass, client_certificate_id - ) as client_certificate_file: - client_certificate = client_certificate_file.read_text( - encoding=DEFAULT_ENCODING - ) + client_certificate = await _get_uploaded_file(hass, client_certificate_id) if client_key_id: - with process_uploaded_file(hass, client_key_id) as key_file: - client_key = key_file.read_text(encoding=DEFAULT_ENCODING) + client_key = await _get_uploaded_file(hass, client_key_id) certificate_data: dict[str, Any] = {} if certificate: diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 9ec5c355d782..997c6fe54b6a 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or select **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url" + "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or select **Create New Application**.\n1. Set appropriate Application name and Description\n1. Enter `{callback_url}` as Callback URL" }, "config": { "step": { diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 9470b2134d44..505e0a370a03 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -27,6 +27,12 @@ from .const import CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN _LOGGER = logging.getLogger(__name__) +def _clean_user_input(user_input: dict[str, Any]) -> dict[str, Any]: + """Clean up the user input.""" + user_input[CONF_HOST] = user_input[CONF_HOST].strip() + return user_input + + class PingConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ping.""" @@ -46,6 +52,7 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN): ), ) + user_input = _clean_user_input(user_input) if not is_ip_address(user_input[CONF_HOST]): self.async_abort(reason="invalid_ip_address") @@ -81,7 +88,7 @@ class OptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(title="", data=_clean_user_input(user_input)) return self.async_show_form( step_id="init", diff --git a/homeassistant/components/rachio/coordinator.py b/homeassistant/components/rachio/coordinator.py index 25c40bd6656d..62d42f2afdae 100644 --- a/homeassistant/components/rachio/coordinator.py +++ b/homeassistant/components/rachio/coordinator.py @@ -8,6 +8,7 @@ from typing import Any from rachiopy import Rachio from requests.exceptions import Timeout +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -38,6 +39,7 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, rachio: Rachio, + config_entry: ConfigEntry, base_station, base_count: int, ) -> None: @@ -48,6 +50,7 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN} update coordinator", # To avoid exceeding the rate limit, increase polling interval for # each additional base station on the account @@ -76,6 +79,7 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]] self, hass: HomeAssistant, rachio: Rachio, + config_entry: ConfigEntry, base_station, ) -> None: """Initialize a Rachio schedule coordinator.""" @@ -85,6 +89,7 @@ class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]] super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN} schedule update coordinator", update_interval=timedelta(minutes=30), ) diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index f06910cd5051..179e5f5ec0d8 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -189,8 +189,10 @@ class RachioPerson: RachioBaseStation( rachio, base, - RachioUpdateCoordinator(hass, rachio, base, base_count), - RachioScheduleUpdateCoordinator(hass, rachio, base), + RachioUpdateCoordinator( + hass, rachio, self.config_entry, base, base_count + ), + RachioScheduleUpdateCoordinator(hass, rachio, self.config_entry, base), ) for base in base_stations ) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index d28e7e2a5475..329f48e5455c 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -110,7 +110,7 @@ def purge_old_data( _LOGGER.debug("Purging hasn't fully completed yet") return False - if apply_filter and _purge_filtered_data(instance, session) is False: + if apply_filter and not _purge_filtered_data(instance, session): _LOGGER.debug("Cleanup filtered data hasn't fully completed yet") return False @@ -631,7 +631,10 @@ def _purge_old_entity_ids(instance: Recorder, session: Session) -> None: def _purge_filtered_data(instance: Recorder, session: Session) -> bool: - """Remove filtered states and events that shouldn't be in the database.""" + """Remove filtered states and events that shouldn't be in the database. + + Returns true if all states and events are purged. + """ _LOGGER.debug("Cleanup filtered data") database_engine = instance.database_engine assert database_engine is not None @@ -639,7 +642,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: # Check if excluded entity_ids are in database entity_filter = instance.entity_filter - has_more_states_to_purge = False + has_more_to_purge = False excluded_metadata_ids: list[str] = [ metadata_id for (metadata_id, entity_id) in session.query( @@ -648,12 +651,11 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if entity_filter and not entity_filter(entity_id) ] if excluded_metadata_ids: - has_more_states_to_purge = _purge_filtered_states( + has_more_to_purge |= not _purge_filtered_states( instance, session, excluded_metadata_ids, database_engine, now_timestamp ) # Check if excluded event_types are in database - has_more_events_to_purge = False if ( event_type_to_event_type_ids := instance.event_type_manager.get_many( instance.exclude_event_types, session @@ -665,12 +667,12 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if event_type_id is not None ] ): - has_more_events_to_purge = _purge_filtered_events( + has_more_to_purge |= not _purge_filtered_events( instance, session, excluded_event_type_ids, now_timestamp ) # Purge has completed if there are not more state or events to purge - return not (has_more_states_to_purge or has_more_events_to_purge) + return not has_more_to_purge def _purge_filtered_states( diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 7a36991201a8..ae0badb3d84f 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -326,7 +326,19 @@ def migrate_entity_ids( else: new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" new_identifiers = {(DOMAIN, new_device_id)} - device_reg.async_update_device(device.id, new_identifiers=new_identifiers) + existing_device = device_reg.async_get_device(identifiers=new_identifiers) + if existing_device is None: + device_reg.async_update_device( + device.id, new_identifiers=new_identifiers + ) + else: + _LOGGER.warning( + "Reolink device with uid %s already exists, " + "removing device with uid %s", + new_device_id, + device_uid, + ) + device_reg.async_remove_device(device.id) entity_reg = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_reg, config_entry_id) @@ -352,4 +364,18 @@ def migrate_entity_ids( id_parts = entity.unique_id.split("_", 2) if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch): new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}" - entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) + existing_entity = entity_reg.async_get_entity_id( + entity.domain, entity.platform, new_id + ) + if existing_entity is None: + entity_reg.async_update_entity( + entity.entity_id, new_unique_id=new_id + ) + else: + _LOGGER.warning( + "Reolink entity with unique_id %s already exists, " + "removing device with unique_id %s", + new_id, + entity.unique_id, + ) + entity_reg.async_remove(entity.entity_id) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 336876d4c4fc..68a44bf0aae2 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -110,6 +110,7 @@ class ReolinkHost: self._cancel_onvif_check: CALLBACK_TYPE | None = None self._cancel_long_poll_check: CALLBACK_TYPE | None = None self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) + self._fast_poll_error: bool = False self._long_poll_task: asyncio.Task | None = None self._lost_subscription: bool = False @@ -699,14 +700,20 @@ class ReolinkHost: return try: - await self._api.get_motion_state_all_ch() + if self._api.session_active: + await self._api.get_motion_state_all_ch() except ReolinkError as err: - _LOGGER.error( - "Reolink error while polling motion state for host %s:%s: %s", - self._api.host, - self._api.port, - err, - ) + if not self._fast_poll_error: + _LOGGER.error( + "Reolink error while polling motion state for host %s:%s: %s", + self._api.host, + self._api.port, + err, + ) + self._fast_poll_error = True + else: + if self._api.session_active: + self._fast_poll_error = False finally: # schedule next poll if not self._hass.is_stopping: diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 7921bdb6ed58..0e2c918acc93 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.11.1"] + "requirements": ["reolink-aio==0.11.2"] } diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index a6a70ea6c493..b395f8eb1eeb 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -178,6 +178,7 @@ AIRQ_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( value_fn=lambda data: data.co2, extra_fn=None, ), + *DEVICE_SENSOR_TYPES, ) ELEMENT_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index aef70aa6a101..8ba4f3b760e7 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==2.0.1"] + "requirements": ["twentemilieu==2.1.0"] } diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index ca4375d12328..c9f3a2df1050 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -1,5 +1,7 @@ """Support the UPB PIM.""" +import logging + import upb_lib from homeassistant.config_entries import ConfigEntry @@ -14,6 +16,7 @@ from .const import ( EVENT_UPB_SCENE_CHANGED, ) +_LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.LIGHT, Platform.SCENE] @@ -63,3 +66,21 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> upb.disconnect() hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + # 1 -> 2: Unique ID from integer to string + if entry.minor_version == 1: + minor_version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), minor_version=minor_version + ) + + _LOGGER.debug("Migration successful") + + return True diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index d9f111049fd9..788a0336d730 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -78,6 +78,7 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for UPB PIM.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -98,7 +99,7 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if "base" not in errors: - await self.async_set_unique_id(network_id) + await self.async_set_unique_id(str(network_id)) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index b02db7347296..ea08bfe17176 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.60"] + "requirements": ["holidays==0.61"] } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index f3b966e28ea5..e74dc0160d9c 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -86,18 +86,19 @@ "options": { "armed_forces": "Armed forces", "bank": "Bank", + "catholic": "Catholic", + "chinese": "Chinese", + "christian": "Christian", "government": "Government", "half_day": "Half day", + "hebrew": "Hebrew", + "hindu": "Hindu", + "islamic": "Islamic", "optional": "Optional", "public": "Public", "school": "School", "unofficial": "Unofficial", - "workday": "Workday", - "chinese": "Chinese", - "christian": "Christian", - "hebrew": "Hebrew", - "hindu": "Hindu", - "islamic": "Islamic" + "workday": "Workday" } }, "days": { diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index d0505bf2460a..d21cd1c5042b 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -297,7 +297,7 @@ }, "reconfigure_device": { "name": "Reconfigure device", - "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this service.", + "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this action.", "fields": { "ieee": { "name": "[%key:component::zha::services::permit::fields::ieee::name%]", diff --git a/homeassistant/const.py b/homeassistant/const.py index a2068f71ecf6..fd2a55c0a647 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index caa30fa24d16..1f52c4c8b18b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ awesomeversion==24.6.0 bcrypt==4.2.0 bleak-retry-connector==3.6.0 bleak==0.22.3 -bluetooth-adapters==0.20.0 +bluetooth-adapters==0.20.2 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.20.0 cached-ipaddress==0.8.0 diff --git a/pyproject.toml b/pyproject.toml index e13d9ce2739f..f25bdbefdf32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.2" +version = "2024.11.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 9164882565f1..128f8b5e62fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,13 +173,13 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.airq -aioairq==0.3.2 +aioairq==0.4.3 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.5 +aioairzone==0.9.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -474,7 +474,7 @@ apprise==1.9.0 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.2.1 +apsystems-ez1==2.4.0 # homeassistant.components.aqualogic aqualogic==2.6 @@ -607,7 +607,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.0 +bluetooth-adapters==0.20.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -814,7 +814,7 @@ eliqonline==1.2.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.5 +elmax-api==0.0.6.1 # homeassistant.components.elvia elvia==0.1.0 @@ -1121,7 +1121,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.60 +holidays==0.61 # homeassistant.components.frontend home-assistant-frontend==20241106.2 @@ -1133,7 +1133,7 @@ home-assistant-intents==2024.11.6 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.2 +homematicip==1.1.3 # homeassistant.components.horizon horimote==0.4.1 @@ -2547,7 +2547,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.1 +reolink-aio==0.11.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2870,7 +2870,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu -twentemilieu==2.0.1 +twentemilieu==2.1.0 # homeassistant.components.twilio twilio==6.32.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2540db8e09cb..b44385bbd0bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,13 +161,13 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.airq -aioairq==0.3.2 +aioairq==0.4.3 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.5 +aioairzone==0.9.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -447,7 +447,7 @@ apprise==1.9.0 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.2.1 +apsystems-ez1==2.4.0 # homeassistant.components.aranet aranet4==2.4.0 @@ -531,7 +531,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.0 +bluetooth-adapters==0.20.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 @@ -689,7 +689,7 @@ elgato==5.1.2 elkm1-lib==2.2.10 # homeassistant.components.elmax -elmax-api==0.0.5 +elmax-api==0.0.6.1 # homeassistant.components.elvia elvia==0.1.0 @@ -947,7 +947,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.60 +holidays==0.61 # homeassistant.components.frontend home-assistant-frontend==20241106.2 @@ -959,7 +959,7 @@ home-assistant-intents==2024.11.6 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.2 +homematicip==1.1.3 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -2038,7 +2038,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.1 +reolink-aio==0.11.2 # homeassistant.components.rflink rflink==0.0.66 @@ -2283,7 +2283,7 @@ ttn_client==1.2.0 tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu -twentemilieu==2.0.1 +twentemilieu==2.1.0 # homeassistant.components.twilio twilio==6.32.0 diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py index 8eeb89e00cda..1e7d50c3835c 100644 --- a/tests/components/feedreader/conftest.py +++ b/tests/components/feedreader/conftest.py @@ -64,6 +64,18 @@ def fixture_feed_only_summary(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader8.xml") +@pytest.fixture(name="feed_htmlentities") +def fixture_feed_htmlentities(hass: HomeAssistant) -> bytes: + """Load test feed data with HTML Entities.""" + return load_fixture_bytes("feedreader9.xml") + + +@pytest.fixture(name="feed_atom_htmlentities") +def fixture_feed_atom_htmlentities(hass: HomeAssistant) -> bytes: + """Load test ATOM feed data with HTML Entities.""" + return load_fixture_bytes("feedreader10.xml") + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" diff --git a/tests/components/feedreader/fixtures/feedreader10.xml b/tests/components/feedreader/fixtures/feedreader10.xml new file mode 100644 index 000000000000..17ec8069ae1a --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader10.xml @@ -0,0 +1,19 @@ + + + <![CDATA[ATOM RSS en español]]> + + 2024-11-18T14:00:00Z + + + + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + + <![CDATA[Título]]> + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2024-11-18T14:00:00Z + + + + diff --git a/tests/components/feedreader/fixtures/feedreader9.xml b/tests/components/feedreader/fixtures/feedreader9.xml new file mode 100644 index 000000000000..580a42cbd3f6 --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader9.xml @@ -0,0 +1,21 @@ + + + + <![CDATA[RSS en español]]> + + http://www.example.com/main.html + Mon, 18 Nov 2024 15:00:00 +1000 + Mon, 18 Nov 2024 15:00:00 +1000 + 1800 + + + <![CDATA[Título 1]]> + + http://www.example.com/link/1 + GUID 1 + Mon, 18 Nov 2024 15:00:00 +1000 + + + + + diff --git a/tests/components/feedreader/snapshots/test_event.ambr b/tests/components/feedreader/snapshots/test_event.ambr new file mode 100644 index 000000000000..9cce035ea87d --- /dev/null +++ b/tests/components/feedreader/snapshots/test_event.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_event_htmlentities[feed_atom_htmlentities] + ReadOnlyDict({ + 'content': 'Contenido en español', + 'description': 'Resumen en español', + 'event_type': 'feedreader', + 'event_types': list([ + 'feedreader', + ]), + 'friendly_name': 'Mock Title', + 'link': 'http://example.org/2003/12/13/atom03', + 'title': 'Título', + }) +# --- +# name: test_event_htmlentities[feed_htmlentities] + ReadOnlyDict({ + 'content': 'Contenido 1 en español', + 'description': 'Descripción 1', + 'event_type': 'feedreader', + 'event_types': list([ + 'feedreader', + ]), + 'friendly_name': 'Mock Title', + 'link': 'http://www.example.com/link/1', + 'title': 'Título 1', + }) +# --- diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py index 2a434306c0f6..e801227293c3 100644 --- a/tests/components/feedreader/test_config_flow.py +++ b/tests/components/feedreader/test_config_flow.py @@ -246,3 +246,38 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == { CONF_MAX_ENTRIES: 10, } + + +@pytest.mark.parametrize( + ("fixture_name", "expected_title"), + [ + ("feed_htmlentities", "RSS en español"), + ("feed_atom_htmlentities", "ATOM RSS en español"), + ], +) +async def test_feed_htmlentities( + hass: HomeAssistant, + feedparser, + setup_entry, + fixture_name, + expected_title, + request: pytest.FixtureRequest, +) -> None: + """Test starting a flow by user from a feed with HTML Entities in the title.""" + with patch( + "homeassistant.components.feedreader.config_flow.feedparser.http.get", + side_effect=[request.getfixturevalue(fixture_name)], + ): + # init user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: URL} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 491c7e38d022..32f8ecb8080f 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -3,6 +3,9 @@ from datetime import timedelta from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.feedreader.event import ( ATTR_CONTENT, ATTR_DESCRIPTION, @@ -59,3 +62,31 @@ async def test_event_entity( assert state.attributes[ATTR_LINK] == "http://www.example.com/link/1" assert state.attributes[ATTR_CONTENT] == "This is a summary" assert state.attributes[ATTR_DESCRIPTION] == "Description 1" + + +@pytest.mark.parametrize( + ("fixture_name"), + [ + ("feed_htmlentities"), + ("feed_atom_htmlentities"), + ], +) +async def test_event_htmlentities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + fixture_name, + request: pytest.FixtureRequest, +) -> None: + """Test feed event entity with HTML Entities.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=[request.getfixturevalue(fixture_name)], + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("event.mock_title") + assert state + assert state.attributes == snapshot diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index d7700d79e3b2..bc7a66dc86ea 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -12,6 +12,7 @@ import pytest from homeassistant.components.feedreader.const import DOMAIN from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util from . import async_setup_config_entry, create_mock_entry @@ -357,3 +358,23 @@ async def test_feed_errors( freezer.tick(timedelta(hours=1, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_feed_atom_htmlentities( + hass: HomeAssistant, feed_atom_htmlentities, device_registry: dr.DeviceRegistry +) -> None: + """Test ATOM feed author with HTML Entities.""" + + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=[feed_atom_htmlentities], + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + assert device_entry.manufacturer == "Juan Pérez" diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py index 8204a000f29b..bc13030647eb 100644 --- a/tests/components/ping/test_config_flow.py +++ b/tests/components/ping/test_config_flow.py @@ -13,11 +13,15 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("host", "expected_title"), - [("192.618.178.1", "192.618.178.1")], + ("host", "expected"), + [ + ("192.618.178.1", "192.618.178.1"), + (" 192.618.178.1 ", "192.618.178.1"), + (" demo.host ", "demo.host"), + ], ) @pytest.mark.usefixtures("patch_setup") -async def test_form(hass: HomeAssistant, host, expected_title) -> None: +async def test_form(hass: HomeAssistant, host, expected) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -35,21 +39,25 @@ async def test_form(hass: HomeAssistant, host, expected_title) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == expected_title + assert result["title"] == expected assert result["data"] == {} assert result["options"] == { "count": 5, - "host": host, + "host": expected, "consider_home": 180, } @pytest.mark.parametrize( - ("host", "count", "expected_title"), - [("192.618.178.1", 10, "192.618.178.1")], + ("host", "expected_host"), + [ + ("192.618.178.1", "192.618.178.1"), + (" 192.618.178.1 ", "192.618.178.1"), + (" demo.host ", "demo.host"), + ], ) @pytest.mark.usefixtures("patch_setup") -async def test_options(hass: HomeAssistant, host, count, expected_title) -> None: +async def test_options(hass: HomeAssistant, host: str, expected_host: str) -> None: """Test options flow.""" config_entry = MockConfigEntry( @@ -57,8 +65,8 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None source=config_entries.SOURCE_USER, data={}, domain=DOMAIN, - options={"count": count, "host": host, "consider_home": 180}, - title=expected_title, + options={"count": 1, "host": "192.168.1.1", "consider_home": 180}, + title="192.168.1.1", ) config_entry.add_to_hass(hass) @@ -72,15 +80,15 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None result = await hass.config_entries.options.async_configure( result["flow_id"], { - "host": "10.10.10.1", - "count": count, + "host": host, + "count": 10, }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "count": count, - "host": "10.10.10.1", + "count": 10, + "host": expected_host, "consider_home": 180, } diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 245acf4603de..e0b3f7ca8a86 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -964,6 +964,171 @@ async def test_purge_filtered_states( assert session.query(StateAttributes).count() == 0 +@pytest.mark.parametrize( + "recorder_config", [{"exclude": {"entities": ["sensor.excluded"]}}] +) +async def test_purge_filtered_states_multiple_rounds( + hass: HomeAssistant, + recorder_mock: Recorder, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test filtered states are purged when there are multiple rounds to purge.""" + assert recorder_mock.entity_filter("sensor.excluded") is False + + def _add_db_entries(hass: HomeAssistant) -> None: + with session_scope(hass=hass) as session: + # Add states and state_changed events that should be purged + for days in range(1, 4): + timestamp = dt_util.utcnow() - timedelta(days=days) + for event_id in range(1000, 1020): + _add_state_with_state_attributes( + session, + "sensor.excluded", + "purgeme", + timestamp, + event_id * days, + ) + # Add state **without** state_changed event that should be purged + timestamp = dt_util.utcnow() - timedelta(days=1) + session.add( + States( + entity_id="sensor.excluded", + state="purgeme", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + # Add states and state_changed events that should be keeped + timestamp = dt_util.utcnow() - timedelta(days=2) + for event_id in range(200, 210): + _add_state_with_state_attributes( + session, + "sensor.keep", + "keep", + timestamp, + event_id, + ) + # Add states with linked old_state_ids that need to be handled + timestamp = dt_util.utcnow() - timedelta(days=0) + state_attrs = StateAttributes( + hash=0, + shared_attrs=json.dumps( + {"sensor.linked_old_state_id": "sensor.linked_old_state_id"} + ), + ) + state_1 = States( + entity_id="sensor.linked_old_state_id", + state="keep", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + old_state_id=1, + state_attributes=state_attrs, + ) + timestamp = dt_util.utcnow() - timedelta(days=4) + state_2 = States( + entity_id="sensor.linked_old_state_id", + state="keep", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + old_state_id=2, + state_attributes=state_attrs, + ) + state_3 = States( + entity_id="sensor.linked_old_state_id", + state="keep", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + old_state_id=62, # keep + state_attributes=state_attrs, + ) + session.add_all((state_attrs, state_1, state_2, state_3)) + # Add event that should be keeped + session.add( + Events( + event_id=100, + event_type="EVENT_KEEP", + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + convert_pending_states_to_meta(recorder_mock, session) + convert_pending_events_to_event_types(recorder_mock, session) + + service_data = {"keep_days": 10, "apply_filter": True} + _add_db_entries(hass) + + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 74 + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) + assert events_keep.count() == 1 + + await hass.services.async_call( + RECORDER_DOMAIN, SERVICE_PURGE, service_data, blocking=True + ) + + for _ in range(2): + # Make sure the second round of purging runs + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + assert "Cleanup filtered data hasn't fully completed yet" in caplog.text + caplog.clear() + + with session_scope(hass=hass) as session: + states = session.query(States) + assert states.count() == 13 + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) + assert events_keep.count() == 1 + + states_sensor_excluded = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.excluded") + ) + assert states_sensor_excluded.count() == 0 + query = session.query(States) + + assert query.filter(States.state_id == 72).first().old_state_id is None + assert query.filter(States.state_id == 72).first().attributes_id == 71 + assert query.filter(States.state_id == 73).first().old_state_id is None + assert query.filter(States.state_id == 73).first().attributes_id == 71 + + final_keep_state = session.query(States).filter(States.state_id == 74).first() + assert final_keep_state.old_state_id == 62 # should have been kept + assert final_keep_state.attributes_id == 71 + + assert session.query(StateAttributes).count() == 11 + + # Do it again to make sure nothing changes + await hass.services.async_call(RECORDER_DOMAIN, SERVICE_PURGE, service_data) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + with session_scope(hass=hass) as session: + final_keep_state = session.query(States).filter(States.state_id == 74).first() + assert final_keep_state.old_state_id == 62 # should have been kept + assert final_keep_state.attributes_id == 71 + + assert session.query(StateAttributes).count() == 11 + + for _ in range(2): + # Make sure the second round of purging runs + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + assert "Cleanup filtered data hasn't fully completed yet" not in caplog.text + + @pytest.mark.parametrize("use_sqlite", [True, False], indirect=True) @pytest.mark.parametrize( "recorder_config", [{"exclude": {"entities": ["sensor.excluded"]}}] diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 67ac2db82627..f851e13c91dd 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -469,6 +469,116 @@ async def test_migrate_entity_ids( assert device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) +async def test_migrate_with_already_existing_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device ids that need to be migrated while the new ids already exist.""" + original_dev_id = f"{TEST_MAC}_ch0" + new_dev_id = f"{TEST_UID}_{TEST_UID_CAM}" + domain = Platform.SWITCH + + def mock_supported(ch, capability): + if capability == "UID" and ch is None: + return True + if capability == "UID": + return True + return True + + reolink_connect.channels = [0] + reolink_connect.supported = mock_supported + + device_registry.async_get_or_create( + identifiers={(DOMAIN, new_dev_id)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + device_registry.async_get_or_create( + identifiers={(DOMAIN, original_dev_id)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + assert device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) + is None + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) + + +async def test_migrate_with_already_existing_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test entity ids that need to be migrated while the new ids already exist.""" + original_id = f"{TEST_UID}_0_record_audio" + new_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" + dev_id = f"{TEST_UID}_{TEST_UID_CAM}" + domain = Platform.SWITCH + + def mock_supported(ch, capability): + if capability == "UID" and ch is None: + return True + if capability == "UID": + return True + return True + + reolink_connect.channels = [0] + reolink_connect.supported = mock_supported + + dev_entry = device_registry.async_get_or_create( + identifiers={(DOMAIN, dev_id)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=new_id, + config_entry=config_entry, + suggested_object_id=new_id, + disabled_by=None, + device_id=dev_entry.id, + ) + + entity_registry.async_get_or_create( + domain=domain, + platform=DOMAIN, + unique_id=original_id, + config_entry=config_entry, + suggested_object_id=original_id, + disabled_by=None, + device_id=dev_entry.id, + ) + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: diff --git a/tests/components/upb/test_init.py b/tests/components/upb/test_init.py new file mode 100644 index 000000000000..a7621ce65fe5 --- /dev/null +++ b/tests/components/upb/test_init.py @@ -0,0 +1,25 @@ +"""The init tests for the UPB platform.""" + +from unittest.mock import patch + +from homeassistant.components.upb.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: + """Test migrating a 1.1 config entry to 1.2.""" + with patch("homeassistant.components.upb.async_setup_entry", return_value=True): + entry = MockConfigEntry( + domain=DOMAIN, + data={"protocol": "TCP", "address": "1.2.3.4", "file_path": "upb.upe"}, + version=1, + minor_version=1, + unique_id=123456, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == "123456"