diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index dac8c13a8edda6..6da3f760b46b7f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,13 +342,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -521,7 +521,7 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 718fee7893c5b9..e4361b6d9d393d 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -61,6 +61,10 @@ def entity_selector_compatible( if current else None ) + if unit_of_measurement is None: + return selector.EntitySelector( + selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS) + ) entities = [ ent.entity_id diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py index 2ebd56213690e5..f06633f16cdd24 100644 --- a/homeassistant/components/lyngdorf/config_flow.py +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Lyngdorf integration.""" +import logging from typing import Any, override from urllib.parse import urlparse @@ -23,6 +24,8 @@ from .const import CONF_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN +_LOGGER = logging.getLogger(__name__) + class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Lyngdorf config flow.""" @@ -158,6 +161,11 @@ async def _async_set_info_from_discovery( device_model_name = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" if not (model := lookup_receiver_model(device_model_name)): + _LOGGER.warning( + "SSDP discovered device with unrecognized model name %r at %s", + device_model_name, + self._host, + ) raise AbortFlow("unsupported_model") self._device_model = model.model_name self._device_serial_number = ( diff --git a/homeassistant/components/mqtt/infrared.py b/homeassistant/components/mqtt/infrared.py index dd620bf2d3a44d..f686f63b10c939 100644 --- a/homeassistant/components/mqtt/infrared.py +++ b/homeassistant/components/mqtt/infrared.py @@ -220,7 +220,7 @@ def _handle_state_message_received(self, msg: ReceiveMessage) -> None: _LOGGER.debug("Ignoring retained infrared signal on topic %s", msg.topic) return payload = self._value_template(msg.payload) - if not payload or payload in (PAYLOAD_NONE, "null"): + if not payload or payload in (PAYLOAD_NONE, "null", '""'): _LOGGER.debug( "Ignoring payload for %s on topic %s, with template %s", self.entity_id, diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index 94b43b2a716e95..65df5a443e2eac 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -76,6 +76,39 @@ LIBRARY_AUDIOBOOKS: MASSMediaType.AUDIOBOOK, } +MUSIC_MASS_MEDIA_TYPES = [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, +] + +MEDIA_CLASS_MASS_MEDIA_TYPE_MAP = { + MediaClass.ARTIST: [MASSMediaType.ARTIST], + MediaClass.ALBUM: [MASSMediaType.ALBUM], + MediaClass.TRACK: [MASSMediaType.TRACK], + MediaClass.PLAYLIST: [MASSMediaType.PLAYLIST], + # music is the class a voice assistant picks for a plain "play something" + # request, so it has to mean music rather than the radio stations we + # happen to hand back to HA under the same class + MediaClass.MUSIC: MUSIC_MASS_MEDIA_TYPES, + MediaClass.DIRECTORY: [MASSMediaType.AUDIOBOOK], + MediaClass.PODCAST: [MASSMediaType.PODCAST], +} + +SEARCHABLE_MASS_MEDIA_TYPES = [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, + MASSMediaType.RADIO, + MASSMediaType.AUDIOBOOK, + MASSMediaType.PODCAST, +] + +# an artist holds nothing else we can search or browse +ARTIST_MASS_MEDIA_TYPES = [MASSMediaType.ALBUM, MASSMediaType.TRACK] + MEDIA_CONTENT_TYPE_FLAC = "audio/flac" THUMB_SIZE = 200 SORT_NAME = "sort_name" @@ -490,22 +523,48 @@ async def _search_within_playlist( async def _search_within_artist( - mass: MusicAssistantClient, artist_uri: str, search_query: str, limit: int + mass: MusicAssistantClient, + artist_uri: str, + search_query: str, + limit: int, + media_types: list[MASSMediaType], ) -> SearchResults: """Search for content within an artist's catalog.""" artist = await mass.music.get_item_by_uri(artist_uri) search_query = f"{artist.name} - {search_query}" return await mass.music.search( search_query, - media_types=[MASSMediaType.ALBUM, MASSMediaType.TRACK], + media_types=media_types, limit=limit, ) def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: - """Map query to Music Assistant media types.""" + """Map query to Music Assistant media types. + + Returns nothing when the query rules out everything we could look for. + """ media_types: list[MASSMediaType] = [] + # searching inside an artist can never turn up more than their own + # albums and tracks, whatever the rest of the query asks for + allowed = ( + ARTIST_MASS_MEDIA_TYPES + if "artist/" in (query.media_content_id or "") + else SEARCHABLE_MASS_MEDIA_TYPES + ) + + # an explicit filter is the only thing the user picked themselves, so it + # wins from the media type that merely surrounds the search, and asking + # for something unsearchable leaves nothing rather than everything + if query.media_filter_classes: + requested = { + media_type + for cls in query.media_filter_classes + for media_type in MEDIA_CLASS_MASS_MEDIA_TYPE_MAP.get(cls, ()) + } + return [media_type for media_type in allowed if media_type in requested] + match query.media_content_type: case MediaType.ARTIST: media_types = [MASSMediaType.ARTIST] @@ -523,21 +582,7 @@ def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: media_types = [MASSMediaType.PODCAST] case _: # No specific type selected - if query.media_filter_classes: - # Map MediaClass to search types - mapping = { - MediaClass.ARTIST: MASSMediaType.ARTIST, - MediaClass.ALBUM: MASSMediaType.ALBUM, - MediaClass.TRACK: MASSMediaType.TRACK, - MediaClass.PLAYLIST: MASSMediaType.PLAYLIST, - MediaClass.MUSIC: MASSMediaType.RADIO, - MediaClass.DIRECTORY: MASSMediaType.AUDIOBOOK, - MediaClass.PODCAST: MASSMediaType.PODCAST, - } - media_types = [ - mapping[cls] for cls in query.media_filter_classes if cls in mapping - ] - elif library_media_type := LIBRARY_MASS_MEDIA_TYPE_MAP.get( + if library_media_type := LIBRARY_MASS_MEDIA_TYPE_MAP.get( query.media_content_id or "" ): # Searching from a library listing scopes to that library, @@ -545,19 +590,10 @@ def _get_media_types_from_query(query: SearchMediaQuery) -> list[MASSMediaType]: # rather than as a concrete media type. media_types = [library_media_type] - # Default to all types if none specified - if not media_types: - media_types = [ - MASSMediaType.ARTIST, - MASSMediaType.ALBUM, - MASSMediaType.TRACK, - MASSMediaType.PLAYLIST, - MASSMediaType.RADIO, - MASSMediaType.AUDIOBOOK, - MASSMediaType.PODCAST, - ] - - return media_types + # Default to everything we are allowed to look for if none specified + return [ + media_type for media_type in media_types if media_type in allowed + ] or allowed def _process_search_results( @@ -645,6 +681,12 @@ async def async_search_media( limit = 5 # Default limit per media type search_results: SearchResults | None = None + # Determine which media types to search + media_types = _get_media_types_from_query(query) + if not media_types: + # the query ruled out everything we could have looked for + return SearchMedia(result=[]) + # Handle media_content_id if provided (for contextual searches) if query.media_content_id: if "album/" in query.media_content_id: @@ -658,12 +700,9 @@ async def async_search_media( if "artist/" in query.media_content_id: # For artists, we already run a search, so save the results search_results = await _search_within_artist( - mass, query.media_content_id, search_query, limit + mass, query.media_content_id, search_query, limit, media_types ) - # Determine which media types to search - media_types = _get_media_types_from_query(query) - # Execute search using the Music Assistant API if we haven't already done so if search_results is None: search_results = await mass.music.search( diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 395fe0b964133b..ea80800d7c4554 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pyportainer"], "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.42"] + "requirements": ["pyportainer==1.0.43"] } diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index 5cc49287d71669..e7658cb394ba6d 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -94,7 +94,7 @@ def native_value(self): @override def native_unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return UnitOfDensity + return UnitOfDensity.MICROGRAMS_PER_CUBIC_METER def update(self) -> None: """Read from sensor and update the state.""" diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index e64af7b6e0787a..f0d1f671ae3791 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -139,6 +139,16 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + TeslemetryBinarySensorEntityDescription( + key="climate_state_is_rear_defroster_on", + polling=True, + streaming_listener=lambda vehicle, callback: vehicle.listen_RearDefrostEnabled( + callback + ), + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dashcam_state", polling=True, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 84957296f254d7..974171c5b47a3d 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -104,6 +104,9 @@ "climate_state_is_preconditioning": { "name": "Preconditioning" }, + "climate_state_is_rear_defroster_on": { + "name": "Rear defroster" + }, "components_grid_services_enabled": { "name": "Grid services enabled" }, diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index bf05be04a6f80e..bb256ac0ffff69 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -35,7 +35,6 @@ CONF_APPS_TO_INCLUDE_OR_EXCLUDE, CONF_INCLUDE_OR_EXCLUDE, CONF_VOLUME_STEP, - DEFAULT_DEVICE_CLASS, DEFAULT_NAME, DEFAULT_VOLUME_STEP, DEVICE_ID, @@ -64,14 +63,6 @@ def _get_config_schema(input_dict: dict[str, Any] | None = None) -> vol.Schema: CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) ): str, vol.Required(CONF_HOST, default=input_dict.get(CONF_HOST)): str, - vol.Required( - CONF_DEVICE_CLASS, - default=input_dict.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS), - ): vol.All( - str, - vol.Lower, - vol.In([MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.SPEAKER]), - ), vol.Optional( CONF_ACCESS_TOKEN, default=input_dict.get(CONF_ACCESS_TOKEN, "") ): str, @@ -109,6 +100,17 @@ def _get_device( ) +async def _async_detect_device_class( + hass: HomeAssistant, host: str +) -> MediaPlayerDeviceClass: + """Detect whether the device at host is a TV or a speaker.""" + return ( + MediaPlayerDeviceClass.TV + if await async_is_tv(host, session=async_get_clientsession(hass, False)) + else MediaPlayerDeviceClass.SPEAKER + ) + + async def _async_get_unique_id( hass: HomeAssistant, host: str, device_class: str ) -> str | None: @@ -248,6 +250,11 @@ async def async_step_user( if user_input is not None: # Store current values in case setup fails and user needs to edit self._user_schema = _get_config_schema(user_input) + # Zeroconf discovery provides the device class; detect it otherwise + if CONF_DEVICE_CLASS not in user_input: + user_input[CONF_DEVICE_CLASS] = await _async_detect_device_class( + self.hass, user_input[CONF_HOST] + ) if self.unique_id is None: unique_id = await _async_get_unique_id( self.hass, user_input[CONF_HOST], user_input[CONF_DEVICE_CLASS] @@ -308,11 +315,7 @@ async def async_step_zeroconf( num_chars_to_strip = len(discovery_info.type) + 1 name = discovery_info.name[:-num_chars_to_strip] - device_class = ( - MediaPlayerDeviceClass.TV - if await async_is_tv(host) - else MediaPlayerDeviceClass.SPEAKER - ) + device_class = await _async_detect_device_class(self.hass, host) # Set unique ID early for discovery flow so we can abort if needed unique_id = await _async_get_unique_id(self.hass, host, device_class) diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 101d6e6d919543..06838a8a04a9aa 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -17,7 +17,6 @@ CONF_MESSAGE = "MESSAGE" CONF_VOLUME_STEP = "volume_step" -DEFAULT_DEVICE_CLASS = MediaPlayerDeviceClass.TV DEFAULT_NAME = "Vizio SmartCast" DEFAULT_TIMEOUT = 8 DEFAULT_VOLUME_STEP = 1 diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 585123809a678c..05f6403aef64a6 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -29,7 +29,6 @@ "user": { "data": { "access_token": "[%key:common::config_flow::data::access_token%]", - "device_class": "Device type", "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" }, diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 25ca808b702e8a..57cf169382a32b 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -160,11 +160,37 @@ def _update_callback(self) -> None: def _update_states(self) -> None: """Update entity state attributes.""" tv_state = self._client.tv_state - self._update_sources() - self._attr_state = ( - MediaPlayerState.ON if tv_state.is_on else MediaPlayerState.OFF + self._attr_extra_state_attributes = {} + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cast(str, self.unique_id))}, + manufacturer="LG", + name=self._device_name, ) + + if tv_state.is_on or not self._supported_features: + supported = SUPPORT_WEBOSTV + if tv_state.sound_output == "external_speaker": + supported = supported | SUPPORT_WEBOSTV_VOLUME + elif tv_state.sound_output != "lineout": + supported = ( + supported + | SUPPORT_WEBOSTV_VOLUME + | MediaPlayerEntityFeature.VOLUME_SET + ) + + self._supported_features = supported + + if not tv_state.is_on: + self._attr_state = MediaPlayerState.OFF + self._attr_assumed_state = False + return + + self._attr_state = MediaPlayerState.ON + + self._update_sources() + self._attr_is_volume_muted = cast(bool, tv_state.muted) self._attr_volume_level = None @@ -193,27 +219,8 @@ def _update_states(self) -> None: icon = tv_state.apps[tv_state.current_app_id]["icon"] self._attr_media_image_url = icon - if self.state != MediaPlayerState.OFF or not self._supported_features: - supported = SUPPORT_WEBOSTV - if tv_state.sound_output == "external_speaker": - supported = supported | SUPPORT_WEBOSTV_VOLUME - elif tv_state.sound_output != "lineout": - supported = ( - supported - | SUPPORT_WEBOSTV_VOLUME - | MediaPlayerEntityFeature.VOLUME_SET - ) - - self._supported_features = supported - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, cast(str, self.unique_id))}, - manufacturer="LG", - name=self._device_name, - ) - self._attr_assumed_state = True - if tv_state.is_on and tv_state.media_state: + if tv_state.media_state: self._attr_assumed_state = False for entry in tv_state.media_state: if entry.get("playState") == "playing": @@ -224,20 +231,18 @@ def _update_states(self) -> None: self._attr_state = MediaPlayerState.IDLE tv_info = self._client.tv_info - if self.state != MediaPlayerState.OFF: - maj_v = tv_info.software.get("major_ver") - min_v = tv_info.software.get("minor_ver") - if maj_v and min_v: - self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" + maj_v = tv_info.software.get("major_ver") + min_v = tv_info.software.get("minor_ver") + if maj_v and min_v: + self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" - if model := tv_info.system.get("modelName"): - self._attr_device_info["model"] = model + if model := tv_info.system.get("modelName"): + self._attr_device_info["model"] = model - if serial_number := tv_info.system.get("serialNumber"): - self._attr_device_info["serial_number"] = serial_number + if serial_number := tv_info.system.get("serialNumber"): + self._attr_device_info["serial_number"] = serial_number - self._attr_extra_state_attributes = {} - if tv_state.sound_output is not None or self.state != MediaPlayerState.OFF: + if tv_state.sound_output is not None: self._attr_extra_state_attributes = { ATTR_SOUND_OUTPUT: tv_state.sound_output } diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 5bc658490e6586..fe87a4e9a6bc28 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -86,6 +86,7 @@ async_get_setup_timings, async_wait_component, ) +from homeassistant.util import slugify from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages @@ -126,6 +127,7 @@ def async_register_commands( async_reg(hass, handle_manifest_list) async_reg(hass, handle_ping) async_reg(hass, handle_render_template) + async_reg(hass, handle_slugify) async_reg(hass, handle_subscribe_bootstrap_integrations) async_reg(hass, handle_subscribe_condition) async_reg(hass, handle_subscribe_condition_platforms) @@ -726,6 +728,17 @@ def handle_ping( connection.send_message(pong_message(msg["id"])) +@callback +@decorators.websocket_command( + {vol.Required("type"): "slugify", vol.Required("text"): str} +) +def handle_slugify( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle slugify command.""" + connection.send_result(msg["id"], {"slug": slugify(msg["text"])}) + + @lru_cache def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: """Return a cached template.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index abc05e3f5324dc..2f1c6d097b16e0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.31 +uv==0.11.32 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 2c7bf1895401be..f74df5070bcc96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.31", + "uv==0.11.32", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index 247e7ea7e510ca..5c0c6fd46d0785 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.31 +uv==0.11.32 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/requirements_all.txt b/requirements_all.txt index 79241796359145..678c92732b80aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2488,7 +2488,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.42 +pyportainer==1.0.43 # homeassistant.components.probe_plus pyprobeplus==1.1.2 diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index 37b0760dc03917..d8757ac25c6706 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -6,6 +6,7 @@ from homeassistant import config_entries from homeassistant.components.integration.const import DOMAIN +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import selector @@ -151,3 +152,39 @@ async def test_options(hass: HomeAssistant, platform) -> None: state = hass.states.get(f"{platform}.my_integration") assert state.state != "unknown" assert state.attributes["unit_of_measurement"] == "kdogmin" + + +async def test_options_source_selector_with_missing_source_unit( + hass: HomeAssistant, +) -> None: + """Test reconfiguring when the current source unit is missing.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "left", + "name": "My integration", + "round": 1.0, + "source": "sensor.input", + "unit_prefix": "k", + "unit_time": "min", + }, + title="My integration", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hass.states.async_set("sensor.input", "unavailable") + hass.states.async_set( + "sensor.valid_power", 10, {"unit_of_measurement": UnitOfPower.WATT} + ) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + source = result["data_schema"].schema["source"] + assert isinstance(source, selector.EntitySelector) + assert source.config["domain"] == ["counter", "input_number", "sensor"] + assert "include_entities" not in source.config diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py index e60a79970344e0..ac001aa7d6e6ca 100644 --- a/tests/components/lyngdorf/test_config_flow.py +++ b/tests/components/lyngdorf/test_config_flow.py @@ -260,7 +260,9 @@ async def test_ssdp_discovery_no_serial(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_determine_id" -async def test_ssdp_discovery_unsupported_model(hass: HomeAssistant) -> None: +async def test_ssdp_discovery_unsupported_model( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test SSDP discovery aborts when model is not supported.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -278,6 +280,7 @@ async def test_ssdp_discovery_unsupported_model(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_model" + assert "UNKNOWN-MODEL" in caplog.text async def test_ssdp_discovery_missing_model(hass: HomeAssistant) -> None: diff --git a/tests/components/mqtt/test_infrared.py b/tests/components/mqtt/test_infrared.py index 3b98f78d3493ef..7e51fe38022539 100644 --- a/tests/components/mqtt/test_infrared.py +++ b/tests/components/mqtt/test_infrared.py @@ -183,6 +183,12 @@ def _handle_received_signal(signal: infrared.InfraredReceivedSignal) -> None: logging.DEBUG, False, ), + ( + '""', + "Ignoring payload for infrared.test on topic test-topic, with template None", + logging.DEBUG, + False, + ), ( "None", "Ignoring payload for infrared.test on topic test-topic, with template None", diff --git a/tests/components/music_assistant/test_media_browser.py b/tests/components/music_assistant/test_media_browser.py index 69539cb05ca3df..90ddac684a9296 100644 --- a/tests/components/music_assistant/test_media_browser.py +++ b/tests/components/music_assistant/test_media_browser.py @@ -437,6 +437,213 @@ async def test_browse_artist_search_media_classes( assert browse_item.search_media_classes == [MediaClass.ALBUM, MediaClass.TRACK] +@pytest.mark.parametrize( + "media_content_type", + [MediaType.MUSIC, MediaType.ARTIST, None], +) +async def test_search_within_artist_ignores_surrounding_media_type( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_type: str | None, +) -> None: + """Test that an artist search returns its albums and tracks either way. + + An artist holds no artists, so a surrounding artist media type must not be + taken as the thing to look for, or the whole response gets discarded. + """ + await setup_integration_from_fixtures(hass, music_assistant_client) + + artist = MagicMock() + artist.name = "Test Artist" + mock = MockSearchResults(["album", "track"]) + + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=artist + ), + patch.object( + music_assistant_client.music, + "search", + return_value=SearchResults(albums=mock.albums, tracks=mock.tracks), + ) as mock_search, + ): + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="test", + media_content_type=media_content_type, + media_content_id="library://artist/127", + ), + ) + + assert mock_search.call_args.kwargs["media_types"] == [ + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + ] + assert {item.media_class for item in search_results.result} == { + MediaClass.ALBUM, + MediaClass.TRACK, + } + + +@pytest.mark.parametrize( + "media_content_type", + [MediaType.MUSIC, MediaType.ARTIST], +) +@pytest.mark.parametrize( + ("media_filter_classes", "expected_media_types", "expected_classes"), + [ + ( + None, + [MASSMediaType.ALBUM, MASSMediaType.TRACK], + {MediaClass.ALBUM, MediaClass.TRACK}, + ), + ({MediaClass.ALBUM}, [MASSMediaType.ALBUM], {MediaClass.ALBUM}), + ({MediaClass.TRACK}, [MASSMediaType.TRACK], {MediaClass.TRACK}), + ], +) +async def test_search_within_artist_with_filter_classes( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_type: str, + media_filter_classes: set[MediaClass] | None, + expected_media_types: list[MASSMediaType], + expected_classes: set[MediaClass], +) -> None: + """Test that the filters offered on an artist listing narrow its results. + + A filter is picked by the user, so it has to win from whatever media type + happens to surround the search. + """ + await setup_integration_from_fixtures(hass, music_assistant_client) + + artist = MagicMock() + artist.name = "Test Artist" + mock = MockSearchResults(["album", "track"]) + + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=artist + ), + patch.object( + music_assistant_client.music, + "search", + return_value=SearchResults(albums=mock.albums, tracks=mock.tracks), + ) as mock_search, + ): + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="test", + media_content_type=media_content_type, + media_content_id="library://artist/127", + media_filter_classes=media_filter_classes, + ), + ) + + # the artist name scopes the query that is sent to the search api + assert mock_search.call_args.args[0] == "Test Artist - test" + # a filter narrows what we ask for, instead of asking for everything + # an artist can hold and dropping most of the response again + assert mock_search.call_args.kwargs["media_types"] == expected_media_types + assert {item.media_class for item in search_results.result} == expected_classes + + +@pytest.mark.parametrize( + ("media_content_id", "expected_media_types"), + [ + ( + None, + [ + MASSMediaType.ARTIST, + MASSMediaType.ALBUM, + MASSMediaType.TRACK, + MASSMediaType.PLAYLIST, + ], + ), + # inside an artist there is no more music to be had than their own + ("library://artist/127", [MASSMediaType.ALBUM, MASSMediaType.TRACK]), + ], +) +async def test_search_media_music_class_searches_music( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_id: str | None, + expected_media_types: list[MASSMediaType], +) -> None: + """Test that asking for music searches music instead of radio. + + A voice assistant sends this class for a plain "play something" request, + and we hand radio stations back to HA under the same class, which is why + it used to end up searching radio only. + """ + await setup_integration_from_fixtures(hass, music_assistant_client) + + artist = MagicMock() + artist.name = "Test Artist" + mock = MockSearchResults(["artist", "album", "track", "playlist"]) + + with ( + patch.object( + music_assistant_client.music, "get_item_by_uri", return_value=artist + ), + patch.object( + music_assistant_client.music, + "search", + return_value=SearchResults( + artists=mock.artists, + albums=mock.albums, + tracks=mock.tracks, + playlists=mock.playlists, + ), + ) as mock_search, + ): + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="some artist", + media_content_id=media_content_id, + media_filter_classes={MediaClass.MUSIC}, + ), + ) + + assert mock_search.call_args.kwargs["media_types"] == expected_media_types + assert search_results.result + + +@pytest.mark.parametrize( + ("media_content_id", "media_filter_classes"), + [ + # an artist holds no playlists, so there is nothing to find + ("library://artist/127", {MediaClass.PLAYLIST}), + # nothing we can search for is an image + ("library://artist/127", {MediaClass.IMAGE}), + (None, {MediaClass.IMAGE}), + ], +) +async def test_search_media_with_unsearchable_filter( + hass: HomeAssistant, + music_assistant_client: MagicMock, + media_content_id: str | None, + media_filter_classes: set[MediaClass], +) -> None: + """Test that a filter we cannot honour returns nothing, not everything.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + + with patch.object(music_assistant_client.music, "search") as mock_search: + search_results = await async_search_media( + music_assistant_client, + SearchMediaQuery( + search_query="test", + media_content_id=media_content_id, + media_filter_classes=media_filter_classes, + ), + ) + + mock_search.assert_not_called() + assert search_results.result == [] + + async def test_search_media_results_are_browsable( hass: HomeAssistant, music_assistant_client: MagicMock, diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 0644a9faa89130..8da87f02e20f51 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -173,6 +173,7 @@ async def test_thinking_content( ollama.CONF_THINK: True, }, ) + await hass.async_block_till_done() conversation_id = "conversation_id_1234" @@ -717,6 +718,7 @@ def stream(*args, **kwargs) -> AsyncGenerator[dict]: subentry, data={**subentry.data, ollama.CONF_MAX_HISTORY: 0}, ) + await hass.async_block_till_done() for i in range(100): result = await conversation.async_converse( hass, @@ -894,6 +896,7 @@ async def test_reasoning_filter( ollama.CONF_THINK: think, }, ) + await hass.async_block_till_done() with patch( "ollama.AsyncClient.chat", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index f96e11d555b654..70d4447cff51a8 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -808,6 +808,57 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_rear_defroster-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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_defroster', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rear defroster', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear defroster', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_is_rear_defroster_on', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_is_rear_defroster_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_defroster-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'heat', + : 'Test Rear defroster', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_defroster', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1635,6 +1686,20 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_defroster-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'heat', + : 'Test Rear defroster', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_defroster', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index bf7bea16d93559..1f13871be3a738 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -86,6 +86,7 @@ async def test_binary_sensors_streaming( } }, Signal.DRIVER_SEAT_BELT: None, + Signal.REAR_DEFROST_ENABLED: True, }, "createdAt": "2024-10-04T10:45:17.537Z", } @@ -104,6 +105,7 @@ async def test_binary_sensors_streaming( assert hass.states.get("binary_sensor.test_front_driver_door").state == "off" assert hass.states.get("binary_sensor.test_front_passenger_door").state == "off" assert hass.states.get("binary_sensor.test_driver_seat_belt").state == "off" + assert hass.states.get("binary_sensor.test_rear_defroster").state == "on" async def test_binary_sensors_connectivity( diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index d783ed922e5fa5..56fd48c43f0cb4 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -238,6 +238,16 @@ def vizio_guess_device_type_fixture() -> Generator[None]: yield +@pytest.fixture(name="vizio_detect_tv") +def vizio_detect_tv_fixture() -> Generator[None]: + """Mock vizio device type probe to report a TV.""" + with patch( + "homeassistant.components.vizio.config_flow.async_is_tv", + return_value=True, + ): + yield + + @pytest.fixture(name="vizio_cant_connect") def vizio_cant_connect_fixture() -> Generator[None]: """Mock vizio device can't connect with valid auth.""" diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 2201aeb5d6e9e6..6c147513bd33af 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -45,7 +45,9 @@ from tests.common import MockConfigEntry -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") +@pytest.mark.usefixtures( + "vizio_connect", "vizio_bypass_setup", "vizio_guess_device_type" +) async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: """Test user config flow with minimum fields.""" # test form shows @@ -66,7 +68,7 @@ async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: assert result["data"][CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.SPEAKER -@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup") +@pytest.mark.usefixtures("vizio_connect", "vizio_bypass_setup", "vizio_detect_tv") async def test_user_flow_all_fields(hass: HomeAssistant) -> None: """Test user config flow with all fields.""" # test form shows diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 68602b7f2a5ac4..9217b7e6f00b90 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -62,6 +62,7 @@ SERVICE_VOLUME_UP, STATE_OFF, STATE_UNAVAILABLE, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -911,23 +912,41 @@ async def test_reauth_reconnect( async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" + client.tv_state.media_state = [] await setup_webostv(hass) + # on but no media state, assumed state is set + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.ON + assert state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + + # playing state, assumed state is not set client.tv_state.media_state = [{"playState": "playing"}] await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.PLAYING + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + # paused state, assumed state is not set client.tv_state.media_state = [{"playState": "paused"}] await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.PAUSED + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + # unloaded state, assumed state is not set client.tv_state.media_state = [{"playState": "unloaded"}] await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.IDLE + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) + # off state, assumed state is not set client.tv_state.is_on = False await client.mock_state_update() - assert hass.states.get(ENTITY_ID).state == STATE_OFF + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == MediaPlayerState.OFF + assert not state.attributes.get(EntityStateAttribute.ASSUMED_STATE) async def test_availability( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index f87f7634c51576..5aeb3f78bcd1d8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1247,6 +1247,17 @@ async def test_ping(websocket_client: MockHAClientWebSocket) -> None: assert msg["type"] == "pong" +async def test_slugify(websocket_client: MockHAClientWebSocket) -> None: + """Test slugify command.""" + await websocket_client.send_json_auto_id( + {"type": "slugify", "text": "Living room Thermostat Temperature"} + ) + + msg = await websocket_client.receive_json() + assert msg["success"] is True + assert msg["result"] == {"slug": "living_room_thermostat_temperature"} + + async def test_call_service_context_with_user( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator,