diff --git a/homeassistant/components/agent_dvr/manifest.json b/homeassistant/components/agent_dvr/manifest.json index 9a6c528c33665d..4ec142963637e8 100644 --- a/homeassistant/components/agent_dvr/manifest.json +++ b/homeassistant/components/agent_dvr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/agent_dvr", "iot_class": "local_polling", "loggers": ["agent"], - "requirements": ["agent-py==0.0.23"] + "requirements": ["agent-py==0.0.24"] } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 1d46af2cc4b502..97985a74300c55 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -770,7 +770,7 @@ async def async_volume_down(self) -> None: async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" - volume = int(volume * 100) + volume = int(round(volume * 100)) volume = min(100, volume) volume = max(0, volume) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2c446ac5d70582..8b5c6ef173ff3d 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.4"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"] } diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index f1a01f9d7aac35..d4889c0c5f5bdc 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.2"] + "requirements": ["sense-energy==0.13.3"] } diff --git a/homeassistant/components/ffmpeg/manifest.json b/homeassistant/components/ffmpeg/manifest.json index e5f4f8b93a825c..085db6791b33e8 100644 --- a/homeassistant/components/ffmpeg/manifest.json +++ b/homeassistant/components/ffmpeg/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/ffmpeg", "integration_type": "system", - "requirements": ["ha-ffmpeg==3.2.1"] + "requirements": ["ha-ffmpeg==3.2.2"] } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2df14df4523bd9..4dc5a2b0ae47c6 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20241106.0"] + "requirements": ["home-assistant-frontend==20241106.2"] } diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index a07a62305f2eee..ca4aeeed9384d3 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,5 +1,8 @@ """The go2rtc component.""" +from __future__ import annotations + +from dataclasses import dataclass import logging import shutil @@ -38,7 +41,13 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL +from .const import ( + CONF_DEBUG_UI, + DEBUG_UI_URL_MESSAGE, + DOMAIN, + HA_MANAGED_RTSP_PORT, + HA_MANAGED_URL, +) from .server import Server _LOGGER = logging.getLogger(__name__) @@ -85,13 +94,22 @@ extra=vol.ALLOW_EXTRA, ) -_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) +_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) +@dataclass(frozen=True) +class Go2RtcData: + """Data for go2rtc.""" + + url: str + managed: bool + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up WebRTC.""" url: str | None = None + managed = False if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: await _remove_go2rtc_entries(hass) return True @@ -126,8 +144,9 @@ async def on_stop(event: Event) -> None: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) url = HA_MANAGED_URL + managed = True - hass.data[_DATA_GO2RTC] = url + hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed) discovery_flow.async_create_flow( hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} ) @@ -142,28 +161,32 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up go2rtc from a config entry.""" - url = hass.data[_DATA_GO2RTC] + data = hass.data[_DATA_GO2RTC] # Validate the server URL try: - client = Go2RtcRestClient(async_get_clientsession(hass), url) + client = Go2RtcRestClient(async_get_clientsession(hass), data.url) await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( - f"Could not connect to go2rtc instance on {url}" + f"Could not connect to go2rtc instance on {data.url}" ) from err - _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) + _LOGGER.warning( + "Could not connect to go2rtc instance on %s (%s)", data.url, err + ) return False except Go2RtcVersionError as err: raise ConfigEntryNotReady( f"The go2rtc server version is not supported, {err}" ) from err except Exception as err: # noqa: BLE001 - _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) + _LOGGER.warning( + "Could not connect to go2rtc instance on %s (%s)", data.url, err + ) return False - provider = WebRTCProvider(hass, url) + provider = WebRTCProvider(hass, data) async_register_webrtc_provider(hass, provider) return True @@ -181,12 +204,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None: class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" - def __init__(self, hass: HomeAssistant, url: str) -> None: + def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None: """Initialize the WebRTC provider.""" self._hass = hass - self._url = url + self._data = data self._session = async_get_clientsession(hass) - self._rest_client = Go2RtcRestClient(self._session, url) + self._rest_client = Go2RtcRestClient(self._session, data.url) self._sessions: dict[str, Go2RtcWsClient] = {} @property @@ -208,7 +231,7 @@ async def async_handle_async_webrtc_offer( ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._url, source=camera.entity_id + self._session, self._data.url, source=camera.entity_id ) if not (stream_source := await camera.stream_source()): @@ -219,8 +242,30 @@ async def async_handle_async_webrtc_offer( streams = await self._rest_client.streams.list() - if (stream := streams.get(camera.entity_id)) is None or not any( - stream_source == producer.url for producer in stream.producers + if self._data.managed: + # HA manages the go2rtc instance + stream_org_name = camera.entity_id + "_orginal" + stream_redirect_sources = [ + f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_org_name}", + f"ffmpeg:{stream_org_name}#audio=opus", + ] + + if ( + (stream_org := streams.get(stream_org_name)) is None + or not any( + stream_source == producer.url for producer in stream_org.producers + ) + or (stream_redirect := streams.get(camera.entity_id)) is None + or stream_redirect_sources != [p.url for p in stream_redirect.producers] + ): + await self._rest_client.streams.add(stream_org_name, stream_source) + await self._rest_client.streams.add( + camera.entity_id, stream_redirect_sources + ) + + # go2rtc instance is managed outside HA + elif (stream_org := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream_org.producers ): await self._rest_client.streams.add( camera.entity_id, diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index d33ae3e389759f..3c4dc9a9500968 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,3 +6,4 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" +HA_MANAGED_RTSP_PORT = 18554 diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index ed3b44aadf9a87..91f4433546caf2 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -24,15 +24,16 @@ # Default configuration for HA # - Api is listening only on localhost -# - Disable rtsp listener +# - Enable rtsp for localhost only as ffmpeg needs it # - Clear default ice servers -_GO2RTC_CONFIG_FORMAT = r""" +_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant +# Do not edit it manually + api: listen: "{api_ip}:{api_port}" rtsp: - # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:18554" + listen: "127.0.0.1:{rtsp_port}" webrtc: listen: ":18555/tcp" @@ -67,7 +68,9 @@ def _create_temp_file(api_ip: str) -> str: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: file.write( _GO2RTC_CONFIG_FORMAT.format( - api_ip=api_ip, api_port=HA_MANAGED_API_PORT + api_ip=api_ip, + api_port=HA_MANAGED_API_PORT, + rtsp_port=HA_MANAGED_RTSP_PORT, ).encode() ) return file.name diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 115d46f3d0e46f..7c4a0d9a9736a5 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -104,7 +104,7 @@ "services": { "fetch": { "name": "Fetch message", - "description": "Fetch the email message from the server.", + "description": "Fetch an email message from the server.", "fields": { "entry": { "name": "Entry", diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 1464a2dbc8f4d5..4df997ac939395 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -112,7 +112,7 @@ "services": { "add_all_link": { "name": "Add all link", - "description": "Tells the Insteom Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", + "description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", "fields": { "group": { "name": "Group", diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index aa359dcd16759c..5c65a70c75dfc2 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -114,9 +114,8 @@ async def new_subscriber( implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): raise TypeError(f"Unexpected auth implementation {implementation}") - subscription_name = entry.data.get( - CONF_SUBSCRIPTION_NAME, entry.data[CONF_SUBSCRIBER_ID] - ) + if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: + subscription_name = entry.data[CONF_SUBSCRIBER_ID] auth = AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 30f96f819c1999..2bee54df3dd79c 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -235,7 +235,9 @@ def _stream_expires_at(self) -> datetime.datetime | None: async def _async_refresh_stream(self) -> None: """Refresh stream to extend expiration time.""" now = utcnow() - for webrtc_stream in list(self._webrtc_sessions.values()): + for session_id, webrtc_stream in list(self._webrtc_sessions.items()): + if session_id not in self._webrtc_sessions: + continue if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): _LOGGER.debug( "Stream does not yet expire: %s", webrtc_stream.expires_at @@ -247,7 +249,8 @@ async def _async_refresh_stream(self) -> None: except ApiException as err: _LOGGER.debug("Failed to extend stream: %s", err) else: - self._webrtc_sessions[webrtc_stream.media_session_id] = webrtc_stream + if session_id in self._webrtc_sessions: + self._webrtc_sessions[session_id] = webrtc_stream async def async_camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 976e870cc8396e..581113f0c96ad2 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==6.1.3"] + "requirements": ["google-nest-sdm==6.1.4"] } diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py index 055973e8e37c00..a7ede186d727ec 100644 --- a/homeassistant/components/p1_monitor/config_flow.py +++ b/homeassistant/components/p1_monitor/config_flow.py @@ -57,10 +57,13 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required(CONF_HOST): TextSelector(), - vol.Required(CONF_PORT, default=80): NumberSelector( - NumberSelectorConfig( - mode=NumberSelectorMode.BOX, - ) + vol.Required(CONF_PORT, default=80): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, max=65535, mode=NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), ), } ), diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 79a9bf77578f98..c305e4710fcead 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.6.1", + "python-roborock==2.7.2", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 72d1d045c9a77e..df2317c3a6c9aa 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.2"] + "requirements": ["sense-energy==0.13.3"] } diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 0833bc0a97bc70..54c23e6d6191ae 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -1,8 +1,8 @@ """Services for the seventeentrack integration.""" -from typing import Final +from typing import Any, Final -from pyseventeentrack.package import PACKAGE_STATUS_MAP +from pyseventeentrack.package import PACKAGE_STATUS_MAP, Package import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -81,18 +81,7 @@ async def get_packages(call: ServiceCall) -> ServiceResponse: return { "packages": [ - { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp.isoformat(), - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } + package_to_dict(package) for package in live_packages if slugify(package.status) in package_states or package_states == [] ] @@ -110,6 +99,22 @@ async def archive_package(call: ServiceCall) -> None: await seventeen_coordinator.client.profile.archive_package(tracking_number) + def package_to_dict(package: Package) -> dict[str, Any]: + result = { + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, + ATTR_LOCATION: package.location, + ATTR_STATUS: package.status, + ATTR_INFO_TEXT: package.info_text, + ATTR_FRIENDLY_NAME: package.friendly_name, + } + if timestamp := package.timestamp: + result[ATTR_TIMESTAMP] = timestamp.isoformat() + return result + async def _validate_service(config_entry_id): entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) if not entry: diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 8cf8d735553828..afe352904cebdb 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.5"], + "requirements": ["spotifyaio==0.8.7"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 2dc0e23968c815..b6966fa2933db4 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -38,7 +38,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "You selected a different bridge than the one this config entry was configured with, this is not allowed." }, "error": { "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index 4e49e24b6898a8..d7e1f68ac895a1 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -177,13 +177,7 @@ def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: def _async_update_attrs(self) -> None: """Update the entity attributes.""" - value = self._value - if value == CLOSED: - self._attr_is_closed = True - elif value == OPEN: - self._attr_is_closed = False - else: - self._attr_is_closed = None + self._attr_is_closed = self._value == CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 190f729d99f348..8775da931d598e 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -182,13 +182,7 @@ def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: def _async_update_attrs(self) -> None: """Update the entity attributes.""" - value = self._value - if value == CLOSED: - self._attr_is_closed = True - elif value == OPEN: - self._attr_is_closed = False - else: - self._attr_is_closed = None + self._attr_is_closed = self._value == CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py index dbaef59c2364e7..ed196897c113a1 100644 --- a/homeassistant/components/twitch/config_flow.py +++ b/homeassistant/components/twitch/config_flow.py @@ -78,7 +78,10 @@ async def async_oauth_create_entry( reauth_entry = self._get_reauth_entry() self._abort_if_unique_id_mismatch( reason="wrong_account", - description_placeholders={"title": reauth_entry.title}, + description_placeholders={ + "title": reauth_entry.title, + "username": str(reauth_entry.unique_id), + }, ) new_channels = reauth_entry.options[CONF_CHANNELS] diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9b5ffcf6fad5cd..2781dea529e43a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2147,7 +2147,12 @@ def async_update_entry( if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Deprecated in 2024.11, should fail in 2025.11 if ( - unique_id is not None + # flipr creates duplicates during migration, and asks users to + # remove the duplicate. We don't need warn about it here too. + # We should remove the special case for "flipr" in HA Core 2025.4, + # when the flipr migration period ends + entry.domain != "flipr" + and unique_id is not None and self.async_entry_for_domain_unique_id(entry.domain, unique_id) is not None ): @@ -2425,7 +2430,24 @@ def async_update_issues(self) -> None: issues.add(issue.issue_id) for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 + # flipr creates duplicates during migration, and asks users to + # remove the duplicate. We don't need warn about it here too. + # We should remove the special case for "flipr" in HA Core 2025.4, + # when the flipr migration period ends + if domain == "flipr": + continue for unique_id, entries in unique_ids.items(): + # We might mutate the list of entries, so we need a copy to not mess up + # the index + entries = list(entries) + + # There's no need to raise an issue for ignored entries, we can + # safely remove them once we no longer allow unique id collisions. + # Iterate over a copy of the copy to allow mutating while iterating + for entry in list(entries): + if entry.source == SOURCE_IGNORE: + entries.remove(entry) + if len(entries) < 2: continue issue_id = f"{ISSUE_UNIQUE_ID_COLLISION}_{domain}_{unique_id}" diff --git a/homeassistant/const.py b/homeassistant/const.py index 2988834d3b0df8..210e1cfedfeb70 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b399c64d7e2aa9..793dbfddd9622a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,13 +28,13 @@ dbus-fast==2.24.3 fnv-hash-fast==1.0.2 go2rtc-client==0.1.0 ha-av==10.1.1 -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241106.0 -home-assistant-intents==2024.11.4 +home-assistant-frontend==20241106.2 +home-assistant-intents==2024.11.6 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 @@ -166,7 +166,7 @@ get-mac==1000000000.0.0 charset-normalizer==3.2.0 # dacite: Ensure we have a version that is able to handle type unions for -# Roborock, NAM, Brother, and GIOS. +# NAM, Brother, and GIOS. dacite>=1.7.0 # Musle wheels for pandas 2.2.0 cannot be build for any architecture. diff --git a/pyproject.toml b/pyproject.toml index 6b21d117d9cb82..729235e9766baf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0" +version = "2024.11.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 1e50a44c2dd1f1..6b1c00a5e22b42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -152,7 +152,7 @@ advantage-air==0.4.4 afsapi==0.2.7 # homeassistant.components.agent_dvr -agent-py==0.0.23 +agent-py==0.0.24 # homeassistant.components.geo_json_events aio-geojson-generic-client==0.4 @@ -1011,7 +1011,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.3 +google-nest-sdm==6.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -1069,7 +1069,7 @@ h2==4.1.0 ha-av==10.1.1 # homeassistant.components.ffmpeg -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt ha-iotawattpy==0.1.2 @@ -1124,10 +1124,10 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 # homeassistant.components.home_connect homeconnect==0.8.0 @@ -2393,7 +2393,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.6.1 +python-roborock==2.7.2 # homeassistant.components.smarttub python-smarttub==0.0.36 @@ -2623,7 +2623,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.2 +sense-energy==0.13.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -2707,7 +2707,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.5 +spotifyaio==0.8.7 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a04ce2bf63f7f..698d91f4120e4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ advantage-air==0.4.4 afsapi==0.2.7 # homeassistant.components.agent_dvr -agent-py==0.0.23 +agent-py==0.0.24 # homeassistant.components.geo_json_events aio-geojson-generic-client==0.4 @@ -861,7 +861,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.3 +google-nest-sdm==6.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -907,7 +907,7 @@ h2==4.1.0 ha-av==10.1.1 # homeassistant.components.ffmpeg -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt ha-iotawattpy==0.1.2 @@ -950,10 +950,10 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 # homeassistant.components.home_connect homeconnect==0.8.0 @@ -1914,7 +1914,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.6.1 +python-roborock==2.7.2 # homeassistant.components.smarttub python-smarttub==0.0.36 @@ -2090,7 +2090,7 @@ securetar==2024.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.2 +sense-energy==0.13.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -2159,7 +2159,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.5 +spotifyaio==0.8.7 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 36962ce1fe947d..8730acb386707e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -182,7 +182,7 @@ charset-normalizer==3.2.0 # dacite: Ensure we have a version that is able to handle type unions for -# Roborock, NAM, Brother, and GIOS. +# NAM, Brother, and GIOS. dacite>=1.7.0 # Musle wheels for pandas 2.2.0 cannot be build for any architecture. diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index f54849ee12b9f9..26db1fe5a1a552 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.1 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 894528265e1b22..0bf615de3da879 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -345,3 +345,31 @@ async def test_attr_bluesound_group( ).attributes.get("bluesound_group") assert attr_bluesound_group == ["player-name1111", "player-name2222"] + + +async def test_volume_up_from_6_to_7( + hass: HomeAssistant, + setup_config_entry: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player volume up from 6 to 7. + + This fails if if rounding is not done correctly. See https://github.com/home-assistant/core/issues/129956 for more details. + """ + player_mocks.player_data.status_long_polling_mock.set( + dataclasses.replace( + player_mocks.player_data.status_long_polling_mock.get(), volume=6 + ) + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.player_name1111"}, + blocking=True, + ) + + player_mocks.player_data.player.volume.assert_called_once_with(level=7) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 18a46fdd4d1c2b..ea1971a31d9e86 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator import logging from typing import NamedTuple -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, call, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream @@ -296,7 +296,7 @@ async def test() -> None: ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go_binary( +async def test_setup_managed( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -308,15 +308,131 @@ async def test_setup_go_binary( config: ConfigType, ui_enabled: bool, ) -> None: - """Test the go2rtc config entry with binary.""" + """Test the go2rtc setup with managed go2rtc instance.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry + camera = init_test_integration + + entity_id = camera.entity_id + stream_name_orginal = camera.entity_id + "_orginal" + assert camera.frontend_stream_type == StreamType.HLS + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == ConfigEntryState.LOADED + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) + server_start.assert_called_once() - def after_setup() -> None: - server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) - server_start.assert_called_once() + receive_message_callback = Mock(spec_set=WebRTCSendMessage) - await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + async def test() -> None: + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + ws_client.send.assert_called_once_with( + WebRTCOffer( + OFFER_SDP, + camera.async_get_webrtc_client_configuration().configuration.ice_servers, + ) + ) + ws_client.subscribe.assert_called_once() + + # Simulate the answer from the go2rtc server + callback = ws_client.subscribe.call_args[0][0] + callback(WebRTCAnswer(ANSWER_SDP)) + receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) + + await test() + + stream_added_calls = [ + call(stream_name_orginal, "rtsp://stream"), + call( + entity_id, + [ + f"rtsp://127.0.0.1:18554/{stream_name_orginal}", + f"ffmpeg:{stream_name_orginal}#audio=opus", + ], + ), + ] + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream original missing + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream original source different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://different")]), + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream source different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://stream")]), + entity_id: Stream([Producer("rtsp://different")]), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # If the stream is already added, the stream should not be added again. + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://stream")]), + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_not_called() + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) await hass.async_stop() @@ -332,7 +448,7 @@ def after_setup() -> None: ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go( +async def test_setup_self_hosted( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -342,16 +458,83 @@ async def test_setup_go( mock_is_docker_env: Mock, has_go2rtc_entry: bool, ) -> None: - """Test the go2rtc config entry without binary.""" + """Test the go2rtc with selfhosted go2rtc instance.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}} + camera = init_test_integration + + entity_id = camera.entity_id + assert camera.frontend_stream_type == StreamType.HLS - def after_setup() -> None: - server.assert_not_called() + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == ConfigEntryState.LOADED + server.assert_not_called() + + receive_message_callback = Mock(spec_set=WebRTCSendMessage) - await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + async def test() -> None: + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + ws_client.send.assert_called_once_with( + WebRTCOffer( + OFFER_SDP, + camera.async_get_webrtc_client_configuration().configuration.ice_servers, + ) + ) + ws_client.subscribe.assert_called_once() + + # Simulate the answer from the go2rtc server + callback = ws_client.subscribe.call_args[0][0] + callback(WebRTCAnswer(ANSWER_SDP)) + receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) + + await test() + + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) + + # Stream exists but the source is different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://different")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) + + # If the stream is already added, the stream should not be added again. + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://stream")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_not_called() + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) mock_get_binary.assert_not_called() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index d810dbd88eb93c..e4fe3993f3cddf 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -105,12 +105,13 @@ async def test_server_run_success( # Verify that the config file was written mock_tempfile.write.assert_called_once_with( - f""" + f"""# This file is managed by Home Assistant +# Do not edit it manually + api: listen: "{api_ip}:11984" rtsp: - # ffmpeg needs rtsp for opus audio transcoding listen: "127.0.0.1:18554" webrtc: diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 9c8de0224f0d4a..5d4719918a6702 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -30,6 +30,7 @@ CLIENT_SECRET = "some-client-secret" CLOUD_PROJECT_ID = "cloud-id-9876" SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" +SUBSCRIPTION_NAME = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" @dataclass @@ -86,6 +87,17 @@ class NestTestConfig: }, ) +TEST_CONFIG_NEW_SUBSCRIPTION = NestTestConfig( + config_entry_data={ + "sdm": {}, + "project_id": PROJECT_ID, + "cloud_project_id": CLOUD_PROJECT_ID, + "subscription_name": SUBSCRIPTION_NAME, + "auth_implementation": "imported-cred", + }, + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), +) + class FakeSubscriber(GoogleNestSubscriber): """Fake subscriber that supplies a FakeDeviceManager.""" diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 4c238683130d98..a17803a6cdeddb 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -31,6 +31,7 @@ SUBSCRIBER_ID, TEST_CONFIG_ENTRY_LEGACY, TEST_CONFIG_LEGACY, + TEST_CONFIG_NEW_SUBSCRIPTION, TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, PlatformSetup, @@ -97,6 +98,19 @@ async def test_setup_success( assert entries[0].state is ConfigEntryState.LOADED +@pytest.mark.parametrize("nest_test_config", [(TEST_CONFIG_NEW_SUBSCRIPTION)]) +async def test_setup_success_new_subscription_format( + hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, setup_platform +) -> None: + """Test successful setup.""" + await setup_platform() + assert not error_caplog.records + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + @pytest.mark.parametrize("subscriber_id", [("invalid-subscriber-format")]) async def test_setup_configuration_failure( hass: HomeAssistant, diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py index ea1d12055a0eee..cbd89320074a88 100644 --- a/tests/components/p1_monitor/test_config_flow.py +++ b/tests/components/p1_monitor/test_config_flow.py @@ -36,6 +36,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: assert result2.get("type") is FlowResultType.CREATE_ENTRY assert result2.get("title") == "P1 Monitor" assert result2.get("data") == {CONF_HOST: "example.com", CONF_PORT: 80} + assert isinstance(result2["data"][CONF_PORT], int) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_p1monitor.mock_calls) == 1 diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 805a498041a63b..26ecb729312e0f 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -102,6 +102,7 @@ 'id': '120', 'mode': 'ro', 'name': '错误代码', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -109,6 +110,7 @@ 'id': '121', 'mode': 'ro', 'name': '设备状态', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -116,6 +118,7 @@ 'id': '122', 'mode': 'ro', 'name': '设备电量', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -123,6 +126,7 @@ 'id': '123', 'mode': 'rw', 'name': '清扫模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -130,6 +134,7 @@ 'id': '124', 'mode': 'rw', 'name': '拖地模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -137,6 +142,7 @@ 'id': '125', 'mode': 'rw', 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -144,6 +150,7 @@ 'id': '126', 'mode': 'rw', 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -151,6 +158,7 @@ 'id': '127', 'mode': 'rw', 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -381,6 +389,7 @@ 'id': '120', 'mode': 'ro', 'name': '错误代码', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -388,6 +397,7 @@ 'id': '121', 'mode': 'ro', 'name': '设备状态', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -395,6 +405,7 @@ 'id': '122', 'mode': 'ro', 'name': '设备电量', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -402,6 +413,7 @@ 'id': '123', 'mode': 'rw', 'name': '清扫模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -409,6 +421,7 @@ 'id': '124', 'mode': 'rw', 'name': '拖地模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -416,6 +429,7 @@ 'id': '125', 'mode': 'rw', 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -423,6 +437,7 @@ 'id': '126', 'mode': 'rw', 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -430,6 +445,7 @@ 'id': '127', 'mode': 'rw', 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ diff --git a/tests/components/seventeentrack/snapshots/test_services.ambr b/tests/components/seventeentrack/snapshots/test_services.ambr index 568acea33a5fe5..e172a2de5947dd 100644 --- a/tests/components/seventeentrack/snapshots/test_services.ambr +++ b/tests/components/seventeentrack/snapshots/test_services.ambr @@ -71,3 +71,32 @@ ]), }) # --- +# name: test_packages_with_none_timestamp + dict({ + 'packages': list([ + dict({ + 'destination_country': 'Belgium', + 'friendly_name': 'friendly name 1', + 'info_text': 'info text 1', + 'location': 'location 1', + 'origin_country': 'Belgium', + 'package_type': 'Registered Parcel', + 'status': 'In Transit', + 'tracking_info_language': 'Unknown', + 'tracking_number': '456', + }), + dict({ + 'destination_country': 'Belgium', + 'friendly_name': 'friendly name 2', + 'info_text': 'info text 1', + 'location': 'location 1', + 'origin_country': 'Belgium', + 'package_type': 'Registered Parcel', + 'status': 'Delivered', + 'timestamp': '2020-08-10T10:32:00+00:00', + 'tracking_info_language': 'Unknown', + 'tracking_number': '789', + }), + ]), + }) +# --- diff --git a/tests/components/seventeentrack/test_services.py b/tests/components/seventeentrack/test_services.py index 54c9349c121fdc..bbd5644ad63c7d 100644 --- a/tests/components/seventeentrack/test_services.py +++ b/tests/components/seventeentrack/test_services.py @@ -150,6 +150,28 @@ async def test_archive_package( ) +async def test_packages_with_none_timestamp( + hass: HomeAssistant, + mock_seventeentrack: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure service returns all packages when non provided.""" + await _mock_invalid_packages(mock_seventeentrack) + await init_integration(hass, mock_config_entry) + service_response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PACKAGES, + { + CONFIG_ENTRY_ID_KEY: mock_config_entry.entry_id, + }, + blocking=True, + return_response=True, + ) + + assert service_response == snapshot + + async def _mock_packages(mock_seventeentrack): package1 = get_package(status=10) package2 = get_package( @@ -167,3 +189,19 @@ async def _mock_packages(mock_seventeentrack): package2, package3, ] + + +async def _mock_invalid_packages(mock_seventeentrack): + package1 = get_package( + status=10, + timestamp=None, + ) + package2 = get_package( + tracking_number="789", + friendly_name="friendly name 2", + status=40, + ) + mock_seventeentrack.return_value.profile.packages.return_value = [ + package1, + package2, + ] diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index d3654783bd6db9..2e86286c8da214 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -7,10 +7,11 @@ TedeeDataUpdateException, TedeeLocalAuthException, ) +from pytedee_async.bridge import TedeeBridge import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -134,11 +135,10 @@ async def test_reauth_flow( assert result["reason"] == "reauth_successful" -async def test_reconfigure_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock -) -> None: - """Test that the reconfigure flow works.""" - +async def __do_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" mock_config_entry.add_to_hass(hass) reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) @@ -146,11 +146,19 @@ async def test_reconfigure_flow( assert reconfigure_result["type"] is FlowResultType.FORM assert reconfigure_result["step_id"] == "reconfigure" - result = await hass.config_entries.flow.async_configure( + return await hass.config_entries.flow.async_configure( reconfigure_result["flow_id"], {CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, CONF_HOST: "192.168.1.43"}, ) + +async def test_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Test that the reconfigure flow works.""" + + result = await __do_reconfigure_flow(hass, mock_config_entry) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -162,3 +170,18 @@ async def test_reconfigure_flow( CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, CONF_WEBHOOK_ID: WEBHOOK_ID, } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Ensure reconfigure flow aborts when the bride changes.""" + + mock_tedee.get_local_bridge.return_value = TedeeBridge( + 0, "1111-1111", "Bridge-R2D2" + ) + + result = await __do_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d0a9d5afb4b5df..d530628d27cbdc 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7118,6 +7118,41 @@ async def test_async_update_entry_unique_id_collision( assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) +@pytest.mark.parametrize("domain", ["flipr"]) +async def test_async_update_entry_unique_id_collision_allowed_domain( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, + domain: str, +) -> None: + """Test we warn when async_update_entry creates a unique_id collision. + + This tests we don't warn and don't create issues for domains which have + their own migration path. + """ + assert len(issue_registry.issues) == 0 + + entry1 = MockConfigEntry(domain=domain, unique_id=None) + entry2 = MockConfigEntry(domain=domain, unique_id="not none") + entry3 = MockConfigEntry(domain=domain, unique_id="very unique") + entry4 = MockConfigEntry(domain=domain, unique_id="also very unique") + entry1.add_to_manager(manager) + entry2.add_to_manager(manager) + entry3.add_to_manager(manager) + entry4.add_to_manager(manager) + + manager.async_update_entry(entry2, unique_id=None) + assert len(issue_registry.issues) == 0 + assert len(caplog.record_tuples) == 0 + + manager.async_update_entry(entry4, unique_id="very unique") + assert len(issue_registry.issues) == 0 + assert len(caplog.record_tuples) == 0 + + assert ("already in use") not in caplog.text + + async def test_unique_id_collision_issues( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -7147,6 +7182,12 @@ async def test_unique_id_collision_issues( for _ in range(6): test3.append(MockConfigEntry(domain="test3", unique_id="not_unique")) await manager.async_add(test3[-1]) + # Add an ignored config entry + await manager.async_add( + MockConfigEntry( + domain="test2", unique_id="group_1", source=config_entries.SOURCE_IGNORE + ) + ) # Check we get one issue for domain test2 and one issue for domain test3 assert len(issue_registry.issues) == 2 @@ -7193,7 +7234,7 @@ async def test_unique_id_collision_issues( (HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_2"), } - # Remove the last test2 group2 duplicate, a new issue is created + # Remove the last test2 group2 duplicate, the issue is cleared await manager.async_remove(test2_group_2[1].entry_id) assert not issue_registry.issues