diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 2b23928aba8..1ae7da14875 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 10fb20bb2ce..01fde7eb2fb 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 372ce52e049..c437f5584db 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 9376d21ba28..a58530b05e2 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 93a21ec9f05..73914845445 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 fe16bd73a9e..e25c077b57f 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 12f2edeee9a..9c49813bd83 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 6318231c281..bf02d727280 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 c57b707906b..efa97a9f6b9 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 cefe87f49ba..2dacae52f75 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 ec7e6f674b3..a764e9e6fd9 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 2a73e24a3e5..e05cf9a63e5 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 6608c4312fe..f45b303946a 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 4b3fb2e2524..ad6aed0fc76 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 c787ca70272..0898d1c9318 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 09ed45bd5bc..de42a317cc7 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 8c64f492d42..a3c0a4514d3 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 b3e7eb9a72a..97af964ffc7 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 e94f734069a..3ed88d0d823 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 9ec5c355d78..997c6fe54b6 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 9470b2134d4..505e0a370a0 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 25c40bd6656..62d42f2afda 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 f06910cd505..179e5f5ec0d 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 d28e7e2a547..329f48e5455 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 7a36991201a..ae0badb3d84 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 336876d4c4f..68a44bf0aae 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 7921bdb6ed5..0e2c918acc9 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 a6a70ea6c49..b395f8eb1ee 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 aef70aa6a10..8ba4f3b760e 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 ca4375d1232..c9f3a2df105 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 d9f111049fd..788a0336d73 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 b02db734729..ea08bfe1717 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 f3b966e28ea..e74dc0160d9 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 d0505bf2460..d21cd1c5042 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 a2068f71ecf..fd2a55c0a64 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 caa30fa24d1..1f52c4c8b18 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 e13d9ce2739..f25bdbefdf3 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 9164882565f..128f8b5e62f 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 2540db8e09c..b44385bbd0b 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 8eeb89e00cd..1e7d50c3835 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 00000000000..17ec8069ae1 --- /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 00000000000..580a42cbd3f --- /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 00000000000..9cce035ea87 --- /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 2a434306c0f..e801227293c 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 491c7e38d02..32f8ecb8080 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 d7700d79e3b..bc7a66dc86e 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 8204a000f29..bc13030647e 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 245acf4603d..e0b3f7ca8a8 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 67ac2db8262..f851e13c91d 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 00000000000..a7621ce65fe --- /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"