diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d27706a9c17929..45a67bf4dc3546 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: category: "/language:python" diff --git a/homeassistant/components/gatus/sensor.py b/homeassistant/components/gatus/sensor.py index dfa5e418d32db8..86fea0e5462f9e 100644 --- a/homeassistant/components/gatus/sensor.py +++ b/homeassistant/components/gatus/sensor.py @@ -12,7 +12,7 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfTime +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -42,6 +42,12 @@ class GatusSensorEntityDescription(SensorEntityDescription): else None ), ), + GatusSensorEntityDescription( + key="status_code", + translation_key="status_code", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda result: result.status, + ), ) diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json index d0fc3eaabc64fc..8223fd3c0b4b74 100644 --- a/homeassistant/components/gatus/strings.json +++ b/homeassistant/components/gatus/strings.json @@ -33,6 +33,9 @@ "sensor": { "response_time": { "name": "Response time" + }, + "status_code": { + "name": "Status code" } } }, diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 3a3242898ce4bf..e2f385df3528ba 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.0.1"] + "requirements": ["gcal-sync==9.1.0", "oauth2client==4.1.3", "ical==14.1.0"] } diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index 721fe155304d7f..1f9c456e5884d3 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -140,33 +140,33 @@ async def async_browse_media( if item.identifier: can_search = bool(ImmichMediaSourceIdentifier(item.identifier).unique_id) + title, children = await self._async_build_immich(item, entries) + return BrowseMediaSource( domain=DOMAIN, identifier=item.identifier, media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title="Immich", + title=title, can_play=False, can_expand=True, can_search=can_search, search_media_classes=[MediaClass.IMAGE, MediaClass.VIDEO], children_media_class=MediaClass.DIRECTORY, - children=[ - *await self._async_build_immich(item, entries), - ], + children=children, ) async def _async_build_immich( self, item: MediaSourceItem, entries: list[ConfigEntry] - ) -> list[BrowseMediaSource]: - """Handle browsing different immich instances.""" + ) -> tuple[str, list[BrowseMediaSource]]: + """Return the title and the children of the browsed item.""" # -------------------------------------------------------- # root level, render immich instances # -------------------------------------------------------- if not item.identifier: LOGGER.debug("Render all Immich instances") - return [ + return "Immich", [ BrowseMediaSource( domain=DOMAIN, identifier=entry.unique_id, @@ -193,7 +193,7 @@ async def _async_build_immich( if identifier.collection is None: LOGGER.debug("Render all collections for %s", entry.title) - return [ + return entry.title, [ BrowseMediaSource( domain=DOMAIN, identifier=f"{identifier.unique_id}|{collection}", @@ -221,9 +221,9 @@ async def _async_build_immich( translation_placeholders={"msg": str(err)}, ) from err except ImmichError: - return [] + return identifier.collection, [] - return [ + return identifier.collection, [ BrowseMediaSource( domain=DOMAIN, identifier=f"{identifier.unique_id}|albums|{album.album_id}", @@ -248,9 +248,9 @@ async def _async_build_immich( translation_placeholders={"msg": str(err)}, ) from err except ImmichError: - return [] + return identifier.collection, [] - return [ + return identifier.collection, [ BrowseMediaSource( domain=DOMAIN, identifier=f"{identifier.unique_id}|tags|{tag.tag_id}", @@ -274,9 +274,9 @@ async def _async_build_immich( translation_placeholders={"msg": str(err)}, ) from err except ImmichError: - return [] + return identifier.collection, [] - return [ + return identifier.collection, [ BrowseMediaSource( domain=DOMAIN, identifier=f"{identifier.unique_id}|people|{person.person_id}", @@ -295,6 +295,7 @@ async def _async_build_immich( # -------------------------------------------------------- assert identifier.collection_id is not None assets: list[ImmichAsset] = [] + title = identifier.collection if identifier.collection == "albums": LOGGER.debug( "Render all assets of album %s for %s", @@ -302,6 +303,9 @@ async def _async_build_immich( entry.title, ) try: + album = await immich_api.albums.async_get_album_info( + identifier.collection_id + ) assets = await immich_api.search.async_get_all_by_album_ids( [identifier.collection_id] ) @@ -312,7 +316,9 @@ async def _async_build_immich( translation_placeholders={"msg": str(err)}, ) from err except ImmichError: - return [] + return title, [] + + title = album.album_name elif identifier.collection == "tags": LOGGER.debug( @@ -320,6 +326,9 @@ async def _async_build_immich( identifier.collection_id, ) try: + tag = await immich_api.tags.async_get_tag_by_id( + identifier.collection_id + ) assets = await immich_api.search.async_get_all_by_tag_ids( [identifier.collection_id] ) @@ -330,7 +339,9 @@ async def _async_build_immich( translation_placeholders={"msg": str(err)}, ) from err except ImmichError: - return [] + return title, [] + + title = tag.name elif identifier.collection == "people": LOGGER.debug( @@ -338,6 +349,9 @@ async def _async_build_immich( identifier.collection_id, ) try: + person = await immich_api.people.async_get_person_by_id( + identifier.collection_id + ) assets = await immich_api.search.async_get_all_by_person_ids( [identifier.collection_id] ) @@ -348,7 +362,10 @@ async def _async_build_immich( translation_placeholders={"msg": str(err)}, ) from err except ImmichError: - return [] + return title, [] + + title = person.name + elif identifier.collection == "favorites": LOGGER.debug("Render all assets for favorites collection") try: @@ -360,9 +377,9 @@ async def _async_build_immich( translation_placeholders={"msg": str(err)}, ) from err except ImmichError: - return [] + return title, [] - return _parse_assets(assets, identifier) + return title, _parse_assets(assets, identifier) @override async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index f3f456e6966b1d..a0596dbe6759f3 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -48,14 +48,18 @@ EVICT_EXPIRED_HOUR = 3 # Interval at which buffered telegram writes are flushed to the database. -# Websocket queries flush on demand (``flush_first=True``), so the only telegrams -# at risk from a longer interval are those buffered during an ungraceful shutdown. -FLUSH_INTERVAL_SECONDS = 600 +# Postgres flushes far more often than sqlite: push-based consumers, unlike +# on-demand-flushed (``flush_first=True``) queries, only see a telegram once +# it is actually written. +FLUSH_INTERVAL_SECONDS_SQLITE = 600 +FLUSH_INTERVAL_SECONDS_POSTGRES = 1 # The buffer drops the oldest telegrams when full. Size it to cover a full -# flush interval at ~50 telegrams/s, the maximum rate of a KNX TP line, so -# nothing is dropped while the database is healthy. -MAX_BUFFER_TELEGRAMS = FLUSH_INTERVAL_SECONDS * 50 +# flush interval at ~50 telegrams/s, the maximum rate of a single KNX TP line, +# times 4 for headroom - routing can record multiple TP lines concurrently - +# so nothing is dropped while the database is healthy. +MAX_BUFFER_TELEGRAMS_SQLITE = FLUSH_INTERVAL_SECONDS_SQLITE * 50 * 4 +MAX_BUFFER_TELEGRAMS_POSTGRES = FLUSH_INTERVAL_SECONDS_POSTGRES * 50 * 4 # Timeout for the migration probe and store initialization, so an unreachable # database cannot block KNX setup until the driver/OS connection timeout expires. @@ -127,8 +131,8 @@ def __init__( self._uninitialized_store = BufferedPostgresStore( self.dsn, retention_days=self.retention_days, - flush_interval=FLUSH_INTERVAL_SECONDS, - max_buffer_size=MAX_BUFFER_TELEGRAMS, + flush_interval=FLUSH_INTERVAL_SECONDS_POSTGRES, + max_buffer_size=MAX_BUFFER_TELEGRAMS_POSTGRES, ) else: full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) @@ -136,8 +140,8 @@ def __init__( self._uninitialized_store = BufferedSqliteStore( full_path, retention_days=self.retention_days, - flush_interval=FLUSH_INTERVAL_SECONDS, - max_buffer_size=MAX_BUFFER_TELEGRAMS, + flush_interval=FLUSH_INTERVAL_SECONDS_SQLITE, + max_buffer_size=MAX_BUFFER_TELEGRAMS_SQLITE, ) self._xknx_telegram_cb_handle = ( diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 1fd9aae523a0c4..8b9f52a9dafc48 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -104,6 +104,7 @@ async def register_panel(hass: HomeAssistant) -> None: module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}", embed_iframe=True, require_admin=True, + handle_safe_area=True, ) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index dbb058974a86ef..29482a85008207 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==14.0.1"] + "requirements": ["ical==14.1.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 59bbc47444f632..44384b1a8e7793 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==14.0.1"] + "requirements": ["ical==14.1.0"] } diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index 17aeffed046c95..a76490ccb01ac2 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -23,9 +23,11 @@ CONF_TRUST_EXTERNAL_SCRIPT = "trust_external_script" CONF_URL_EXCLUSIVE_GROUP = "url_exclusive_group" CONF_REQUIRE_ADMIN = "require_admin" +CONF_HANDLE_SAFE_AREA = "handle_safe_area" DEFAULT_EMBED_IFRAME = False DEFAULT_TRUST_EXTERNAL = False +DEFAULT_HANDLE_SAFE_AREA = False DEFAULT_ICON = "mdi:bookmark" LEGACY_URL = "/api/panel_custom/{}" @@ -59,6 +61,9 @@ default=DEFAULT_TRUST_EXTERNAL, ): cv.boolean, vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, + vol.Optional( + CONF_HANDLE_SAFE_AREA, default=DEFAULT_HANDLE_SAFE_AREA + ): cv.boolean, } ), ], @@ -92,6 +97,9 @@ async def async_register_panel( # If your panel is used to configure an integration, # needs the domain of the integration config_panel_domain: str | None = None, + # If your panel handles the safe area insets itself, opting out of the + # padding Home Assistant would otherwise add around it + handle_safe_area: bool = DEFAULT_HANDLE_SAFE_AREA, ) -> None: """Register a new custom panel.""" if js_url is None and module_url is None: @@ -103,6 +111,7 @@ async def async_register_panel( "name": webcomponent_name, "embed_iframe": embed_iframe, "trust_external": trust_external, + "handle_safe_area": handle_safe_area, } if js_url is not None: @@ -148,6 +157,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "trust_external": panel[CONF_TRUST_EXTERNAL_SCRIPT], "embed_iframe": panel[CONF_EMBED_IFRAME], "require_admin": panel[CONF_REQUIRE_ADMIN], + "handle_safe_area": panel[CONF_HANDLE_SAFE_AREA], } if CONF_JS_URL in panel: diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 3feafc86e6f6df..1dde06a669baba 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==14.0.1"] + "requirements": ["ical==14.1.0"] } diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index f4b04bc9ce99f9..9ff83ece9a74f4 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -6,7 +6,9 @@ from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, Platform, ) from homeassistant.core import HomeAssistant @@ -16,7 +18,13 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey -from .const import DEFAULT_TIMEOUT, DOMAIN, VIZIO_DEVICE_CLASSES +from .const import ( + CONF_APPS, + CONF_VOLUME_STEP, + DEFAULT_TIMEOUT, + DOMAIN, + VIZIO_DEVICE_CLASSES, +) from .coordinator import ( VizioAppsDataUpdateCoordinator, VizioConfigEntry, @@ -37,6 +45,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: VizioConfigEntry) -> bool: + """Migrate old config entries.""" + if entry.version == 1 and entry.minor_version == 1: + # Settings imported from YAML were stored in data; they belong in options + data = dict(entry.data) + options = dict(entry.options) + if (volume_step := data.pop(CONF_VOLUME_STEP, None)) is not None: + options.setdefault(CONF_VOLUME_STEP, volume_step) + if apps := dict(data.pop(CONF_APPS, {})): + include_or_exclude = { + key: apps.pop(key) + for key in (CONF_INCLUDE, CONF_EXCLUDE) + if key in apps + } + if include_or_exclude: + options.setdefault(CONF_APPS, include_or_exclude) + if apps: + data[CONF_APPS] = apps + hass.config_entries.async_update_entry( + entry, data=data, options=options, minor_version=2 + ) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: VizioConfigEntry) -> bool: """Load the saved entities.""" host = entry.data[CONF_HOST] diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index bb256ac0ffff69..122c599cdc78fd 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -211,6 +211,7 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Vizio config flow.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -227,18 +228,6 @@ def __init__(self) -> None: self._must_show_form: bool | None = None self._pair_challenge: PairChallenge | None = None self._data: dict[str, Any] | None = None - self._apps: dict[str, list] = {} - - async def _create_entry(self, input_dict: dict[str, Any]) -> ConfigFlowResult: - """Create vizio config entry.""" - # Remove extra keys that will not be used by entry setup - input_dict.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE, None) - input_dict.pop(CONF_INCLUDE_OR_EXCLUDE, None) - - if self._apps: - input_dict[CONF_APPS] = self._apps - - return self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict) @override async def async_step_user( @@ -292,7 +281,9 @@ async def async_step_user( errors["base"] = "cannot_connect" if not errors: - return await self._create_entry(user_input) + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) else: self._data = copy.deepcopy(user_input) return await self.async_step_pair_tv() @@ -389,33 +380,13 @@ async def async_step_pair_tv( errors=errors, ) - async def _pairing_complete(self, step_id: str) -> ConfigFlowResult: - """Handle config flow completion.""" - assert self._data - if not self._must_show_form: - return await self._create_entry(self._data) - - self._must_show_form = False - return self.async_show_form( - step_id=step_id, - description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]}, - ) - async def async_step_pairing_complete( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Complete non-import sourced config flow. - - Display final message to user confirming pairing. - """ - return await self._pairing_complete("pairing_complete") - - async def async_step_pairing_complete_import( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Complete import sourced config flow. + """Display final message to user confirming pairing.""" + assert self._data + if not self._must_show_form: + return self.async_create_entry(title=self._data[CONF_NAME], data=self._data) - Display final message to user confirming pairing and displaying - access token. - """ - return await self._pairing_complete("pairing_complete_import") + self._must_show_form = False + return self.async_show_form(step_id="pairing_complete") diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index d05cb527546d75..f9c28e6cab72b6 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -55,42 +55,6 @@ async def async_setup_entry( """Set up a Vizio media player entry.""" device_class = config_entry.data[CONF_DEVICE_CLASS] - # If config entry options not set up, set them up, - # otherwise assign values managed in options - volume_step = config_entry.options.get( - CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP) - ) - - params = {} - if not config_entry.options: - params["options"] = {CONF_VOLUME_STEP: volume_step} - - include_or_exclude_key = next( - ( - key - for key in config_entry.data.get(CONF_APPS, {}) - if key in (CONF_INCLUDE, CONF_EXCLUDE) - ), - None, - ) - if include_or_exclude_key: - params["options"][CONF_APPS] = { - include_or_exclude_key: config_entry.data[CONF_APPS][ - include_or_exclude_key - ].copy() - } - - if not config_entry.data.get(CONF_VOLUME_STEP): - new_data = config_entry.data.copy() - new_data.update({CONF_VOLUME_STEP: volume_step}) - params["data"] = new_data - - if params: - hass.config_entries.async_update_entry( - config_entry, - **params, # type: ignore[arg-type] - ) - entity = VizioDevice( config_entry, device_class, @@ -148,7 +112,9 @@ def __init__( @property def _volume_step(self) -> int: """Return the configured volume step.""" - return cast(int, self._config_entry.options[CONF_VOLUME_STEP]) + return cast( + int, self._config_entry.options.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP) + ) @property def _conf_apps(self) -> dict[str, Any]: diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index f2092e440fa11c..0e067b1d73e8b9 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -2,8 +2,7 @@ "config": { "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "updated_entry": "This entry has already been set up but the name, apps, and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly." + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -22,10 +21,6 @@ "description": "Your VIZIO SmartCast device is now connected to Home Assistant.", "title": "Pairing complete" }, - "pairing_complete_import": { - "description": "Your VIZIO SmartCast device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'.", - "title": "[%key:component::vizio::config::step::pairing_complete::title%]" - }, "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]", @@ -33,7 +28,9 @@ "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "host": "Hostname or IP address of your VIZIO SmartCast device." + "access_token": "The access token used to authenticate with your TV. Leave empty to start a pairing process that will provide one.", + "host": "Hostname or IP address of your VIZIO SmartCast device.", + "name": "The name used for the device and its entities in Home Assistant." }, "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process." } @@ -64,6 +61,11 @@ "include_or_exclude": "Include or exclude apps?", "volume_step": "Volume step size" }, + "data_description": { + "apps_to_include_or_exclude": "The apps that should be included in or excluded from the source list.", + "include_or_exclude": "Whether the selected apps should be included in or excluded from the source list.", + "volume_step": "The number of volume levels the volume changes by with each volume up or volume down command." + }, "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.", "title": "Update VIZIO SmartCast device options" } diff --git a/requirements_all.txt b/requirements_all.txt index dc1eae5d178dac..d4f723005a502d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1329,7 +1329,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==14.0.1 +ical==14.1.0 # homeassistant.components.caldav icalendar==6.3.1 diff --git a/tests/components/gatus/snapshots/test_sensor.ambr b/tests/components/gatus/snapshots/test_sensor.ambr index 8bb3bf1c31eea8..deae4b1ccb69d4 100644 --- a/tests/components/gatus/snapshots/test_sensor.ambr +++ b/tests/components/gatus/snapshots/test_sensor.ambr @@ -57,3 +57,53 @@ 'state': '23.12', }) # --- +# name: test_sensor_setup_and_states[sensor.core_backend_service_status_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.core_backend_service_status_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status code', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status code', + 'platform': 'gatus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status_code', + 'unique_id': '1234567890abcdef1234567890abcdef_backend_service_status_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.core_backend_service_status_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Core Backend Service Status code', + }), + 'context': , + 'entity_id': 'sensor.core_backend_service_status_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- diff --git a/tests/components/gatus/test_sensor.py b/tests/components/gatus/test_sensor.py index 0da46742ddf8b4..bba25e68450bed 100644 --- a/tests/components/gatus/test_sensor.py +++ b/tests/components/gatus/test_sensor.py @@ -143,3 +143,25 @@ async def test_sensor_missing_duration( state = hass.states.get("sensor.backend_service_response_time") assert state is not None assert state.state == STATE_UNKNOWN + + +async def test_sensor_missing_status_code( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a result missing status code evaluates to STATE_UNKNOWN for status code sensor.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[Result(success=True, status=None, duration=12500000)], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.backend_service_status_code") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index fc74c697223c3c..72d0e25f3ec497 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -82,6 +82,7 @@ def mock_immich_albums() -> AsyncMock: """Mock the Immich server.""" mock = AsyncMock(spec=ImmichAlbums) mock.async_get_all_albums.return_value = [ALBUM_DATA] + mock.async_get_album_info.return_value = ALBUM_DATA mock.async_add_assets_to_album.return_value = [ ImmichAddAssetsToAlbumResponse.from_dict( {"id": "abcdef-0123456789", "success": True} @@ -151,6 +152,7 @@ def mock_immich_people() -> AsyncMock: } ), ] + mock.async_get_person_by_id.return_value = mock.async_get_all_people.return_value[0] mock.async_get_person_thumbnail.return_value = b"yyyy" return mock @@ -257,6 +259,7 @@ def mock_immich_tags() -> AsyncMock: }, ), ] + mock.async_get_tag_by_id.return_value = mock.async_get_all_tags.return_value[0] return mock diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index f06bf86b7af041..a9c7a8937d348f 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -6,7 +6,7 @@ from aiohttp import web from aioimmich.assets.models import AssetType -from aioimmich.exceptions import ImmichError, ImmichForbiddenError +from aioimmich.exceptions import ImmichError, ImmichForbiddenError, ImmichNotFoundError import pytest from homeassistant.components.immich.const import DOMAIN @@ -130,6 +130,7 @@ async def test_browse_media_get_root( root_media_source = await source.async_browse_media(item) assert root_media_source + assert root_media_source.title == "Immich" assert root_media_source.can_search is False assert len(root_media_source.children) == 1 media_file = root_media_source.children[0] @@ -144,6 +145,7 @@ async def test_browse_media_get_root( root_media_source = await source.async_browse_media(item) assert root_media_source + assert root_media_source.title == "Someone" assert root_media_source.can_search is True assert len(root_media_source.children) == 4 @@ -226,6 +228,7 @@ async def test_browse_media_collections( root_media_source = await source.async_browse_media(item) assert root_media_source + assert root_media_source.title == collection assert root_media_source.can_search is True assert len(root_media_source.children) == len(children) for idx, child in enumerate(children): @@ -363,11 +366,12 @@ async def test_browse_media_collection_items_error( @pytest.mark.parametrize( - ("collection", "collection_id", "children"), + ("collection", "collection_id", "title", "children"), [ ( "albums", "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "My Album", [ { "original_file_name": "filename.jpg", @@ -388,6 +392,7 @@ async def test_browse_media_collection_items_error( ], ), ( + "favorites", "favorites", "favorites", [ @@ -412,6 +417,7 @@ async def test_browse_media_collection_items_error( ( "people", "6176838a-ac5a-4d1f-9a35-91c591d962d8", + "Me", [ { "original_file_name": "20250714_201122.jpg", @@ -433,7 +439,8 @@ async def test_browse_media_collection_items_error( ), ( "tags", - "6176838a-ac5a-4d1f-9a35-91c591d962d8", + "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + "Halloween", [ { "original_file_name": "20110306_025024.jpg", @@ -461,6 +468,7 @@ async def test_browse_media_collection_get_items( mock_config_entry: MockConfigEntry, collection: str, collection_id: str, + title: str, children: list[dict], ) -> None: """Test browse_media returning albums.""" @@ -480,6 +488,7 @@ async def test_browse_media_collection_get_items( root_media_source = await source.async_browse_media(item) assert root_media_source + assert root_media_source.title == title assert len(root_media_source.children) == len(children) for idx, child in enumerate(children): @@ -500,6 +509,52 @@ async def test_browse_media_collection_get_items( ) +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + pytest.param("albums", ("albums", "async_get_album_info"), id="albums"), + pytest.param("people", ("people", "async_get_person_by_id"), id="people"), + pytest.param("tags", ("tags", "async_get_tag_by_id"), id="tags"), + ], +) +async def test_browse_media_title_of_unknown_collection_item( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], +) -> None: + """Test browse_media falls back to the collection name for unknown items.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichNotFoundError( + { + "message": "Not found or no permission", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|unknown-id", + None, + ) + root_media_source = await source.async_browse_media(item) + + assert root_media_source.title == collection + assert len(root_media_source.children) == 0 + + async def test_media_view( hass: HomeAssistant, tmp_path: Path, diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 71e6db9ebf94d1..2a22908cf231c9 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -21,6 +21,8 @@ REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR, ) from homeassistant.components.knx.telegrams import ( + FLUSH_INTERVAL_SECONDS_POSTGRES, + FLUSH_INTERVAL_SECONDS_SQLITE, STORE_INIT_RETRY_BACKOFF, TelegramDict, ) @@ -684,3 +686,43 @@ async def test_postgres_backend_init_error( issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) is not None ) + + +async def test_postgres_backend_flushes_more_often_than_sqlite( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Postgres flushes every second; sqlite keeps the original 10 minute interval. + + Sqlite's long interval is safe because the websocket history API already + flushes on demand. Postgres consumers that only learn about a telegram + once it is written (e.g. a LISTEN/NOTIFY-driven live view) have no such + fallback, so they need a much shorter interval. + """ + assert FLUSH_INTERVAL_SECONDS_POSTGRES < FLUSH_INTERVAL_SECONDS_SQLITE + + dsn = "postgresql://user:secret@db.local:5432/knx" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: dsn, + }, + ) + await knx.setup_integration(add_entry_to_hass=False) + + store = hass.data[KNX_MODULE_KEY].telegrams.store + assert store.flush_interval == FLUSH_INTERVAL_SECONDS_POSTGRES + + +async def test_sqlite_backend_keeps_long_flush_interval( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Sqlite's flush interval is unaffected by the Postgres-specific tuning.""" + await knx.setup_integration() + + store = hass.data[KNX_MODULE_KEY].telegrams.store + assert store.flush_interval == FLUSH_INTERVAL_SECONDS_SQLITE diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py index 7a3545620ac294..5045fdd07a6a4a 100644 --- a/tests/components/panel_custom/test_init.py +++ b/tests/components/panel_custom/test_init.py @@ -61,6 +61,7 @@ async def test_js_webcomponent(hass: HomeAssistant) -> None: "name": "todo-mvc", "embed_iframe": True, "trust_external": True, + "handle_safe_area": False, }, } assert panel.frontend_url_path == "nice_url" @@ -81,6 +82,7 @@ async def test_module_webcomponent(hass: HomeAssistant) -> None: "embed_iframe": True, "trust_external_script": True, "require_admin": True, + "handle_safe_area": True, } } @@ -102,6 +104,7 @@ async def test_module_webcomponent(hass: HomeAssistant) -> None: "name": "todo-mvc", "embed_iframe": True, "trust_external": True, + "handle_safe_area": True, }, } assert panel.frontend_url_path == "nice_url" @@ -136,6 +139,7 @@ async def test_latest_and_es5_build(hass: HomeAssistant) -> None: "module_url": "/local/latest.js", "embed_iframe": False, "trust_external": False, + "handle_safe_area": False, }, } assert panel.frontend_url_path == "nice_url" @@ -169,6 +173,7 @@ async def test_register_config_panel(hass: HomeAssistant) -> None: embed_iframe=True, require_admin=True, config_panel_domain="test", + handle_safe_area=True, ) panels = hass.data.get(frontend.DATA_PANELS, []) @@ -183,6 +188,7 @@ async def test_register_config_panel(hass: HomeAssistant) -> None: "name": "custom-frontend", "embed_iframe": True, "trust_external": False, + "handle_safe_area": True, }, } assert panel.frontend_url_path == "config_panel" diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 7ca882997ca492..180d5ab595eb06 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -23,7 +23,6 @@ from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, - CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_NAME, @@ -120,41 +119,14 @@ def audio_setting( CONF_VOLUME_STEP: VOLUME_STEP, } -MOCK_TV_WITH_INCLUDE_CONFIG = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, - CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]}, -} - -MOCK_TV_WITH_EXCLUDE_CONFIG = { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, - CONF_APPS: {CONF_EXCLUDE: ["Netflix"]}, -} - MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG = { CONF_NAME: NAME, CONF_HOST: HOST, CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_VOLUME_STEP: VOLUME_STEP, CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, } - -MOCK_TV_APPS_WITH_VALID_APPS_CONFIG = { - CONF_HOST: HOST, - CONF_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]}, -} - MOCK_TV_CONFIG_NO_TOKEN = { CONF_NAME: NAME, CONF_HOST: HOST, diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index 269d833c2e788f..5f19bfce810685 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -1,6 +1,7 @@ """Tests for Vizio init.""" from datetime import timedelta +from typing import Any from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -11,11 +12,17 @@ MediaPlayerDeviceClass, ) from homeassistant.components.vizio import DATA_APPS -from homeassistant.components.vizio.const import DOMAIN +from homeassistant.components.vizio.const import ( + CONF_ADDITIONAL_CONFIGS, + CONF_APPS, + CONF_VOLUME_STEP, + DOMAIN, +) from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, CONF_HOST, + CONF_INCLUDE, CONF_NAME, STATE_UNAVAILABLE, ) @@ -23,7 +30,18 @@ from homeassistant.helpers import device_registry as dr from .conftest import setup_integration -from .const import APP_RECORDS, HOST2, MODEL, NAME2, UNIQUE_ID, VERSION +from .const import ( + ADDITIONAL_APP_CONFIG, + APP_RECORDS, + CURRENT_APP, + HOST2, + MOCK_USER_VALID_TV_CONFIG, + MODEL, + NAME2, + UNIQUE_ID, + VERSION, + VOLUME_STEP, +) from tests.common import MockConfigEntry, async_fire_time_changed @@ -172,3 +190,66 @@ async def test_device_registry_without_model_or_version( assert device.model is None assert device.sw_version is None assert device.manufacturer == "VIZIO" + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +@pytest.mark.parametrize( + ("data", "options", "expected_data", "expected_options"), + [ + pytest.param( + { + **MOCK_USER_VALID_TV_CONFIG, + CONF_VOLUME_STEP: VOLUME_STEP, + CONF_APPS: { + CONF_INCLUDE: [CURRENT_APP], + CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG], + }, + }, + {}, + { + **MOCK_USER_VALID_TV_CONFIG, + CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, + }, + { + CONF_VOLUME_STEP: VOLUME_STEP, + CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]}, + }, + id="moves_settings_to_options", + ), + pytest.param( + {**MOCK_USER_VALID_TV_CONFIG, CONF_VOLUME_STEP: VOLUME_STEP}, + {CONF_VOLUME_STEP: VOLUME_STEP + 1}, + MOCK_USER_VALID_TV_CONFIG, + {CONF_VOLUME_STEP: VOLUME_STEP + 1}, + id="existing_options_win", + ), + pytest.param( + MOCK_USER_VALID_TV_CONFIG, + {}, + MOCK_USER_VALID_TV_CONFIG, + {}, + id="nothing_to_migrate", + ), + ], +) +async def test_migrate_entry_to_minor_version_2( + hass: HomeAssistant, + data: dict[str, Any], + options: dict[str, Any], + expected_data: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + """Test migrating a 1.1 entry moves settings from data to options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options=options, + unique_id=UNIQUE_ID, + minor_version=1, + ) + await setup_integration(hass, config_entry) + + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert dict(config_entry.data) == expected_data + assert dict(config_entry.options) == expected_options diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 2ab9c6ef040374..40e23216e9ecb1 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -45,6 +45,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_EXCLUDE, + CONF_INCLUDE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -71,8 +73,7 @@ INPUT_LIST_WITH_APPS, MAX_VOLUME, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, - MOCK_TV_WITH_EXCLUDE_CONFIG, - MOCK_TV_WITH_INCLUDE_CONFIG, + MOCK_USER_VALID_TV_CONFIG, NAME, UNIQUE_ID, UNKNOWN_APP_CONFIG, @@ -463,15 +464,12 @@ async def test_options_update( """Test when config entry update event fires.""" await _test_setup_speaker(hass, mock_speaker_config_entry, True) config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.options - new_options = config_entry.options.copy() - updated_options = {CONF_VOLUME_STEP: VOLUME_STEP} - new_options.update(updated_options) + assert not config_entry.options hass.config_entries.async_update_entry( entry=config_entry, - options=new_options, + options={CONF_VOLUME_STEP: VOLUME_STEP}, ) - assert config_entry.options == updated_options + assert config_entry.options == {CONF_VOLUME_STEP: VOLUME_STEP} await hass.async_block_till_done() await _test_service( hass, MP_DOMAIN, "volume_up", SERVICE_VOLUME_UP, None, steps=VOLUME_STEP @@ -560,9 +558,12 @@ async def test_setup_with_apps_include( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: - """Test device setup with apps and apps["include"] in config.""" + """Test device setup with apps and apps["include"] in options.""" config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_TV_WITH_INCLUDE_CONFIG, unique_id=UNIQUE_ID + domain=DOMAIN, + data=MOCK_USER_VALID_TV_CONFIG, + options={CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]}}, + unique_id=UNIQUE_ID, ) async with _cm_for_test_setup_tv_with_apps( hass, config_entry, CURRENT_APP_CONFIG_OBJ @@ -580,9 +581,12 @@ async def test_setup_with_apps_exclude( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: - """Test device setup with apps and apps["exclude"] in config.""" + """Test device setup with apps and apps["exclude"] in options.""" config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_TV_WITH_EXCLUDE_CONFIG, unique_id=UNIQUE_ID + domain=DOMAIN, + data=MOCK_USER_VALID_TV_CONFIG, + options={CONF_APPS: {CONF_EXCLUDE: ["Netflix"]}}, + unique_id=UNIQUE_ID, ) async with _cm_for_test_setup_tv_with_apps( hass, config_entry, CURRENT_APP_CONFIG_OBJ