Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/integration/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/lyngdorf/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Config flow for Lyngdorf integration."""

import logging
from typing import Any, override
from urllib.parse import urlparse

Expand All @@ -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."""
Expand Down Expand Up @@ -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 = (
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/mqtt/infrared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 74 additions & 35 deletions homeassistant/components/music_assistant/media_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand All @@ -523,41 +582,18 @@ 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,
# because the browse tree reports those pages as our own domain
# 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(
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/portainer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["pyportainer"],
"quality_scale": "platinum",
"requirements": ["pyportainer==1.0.42"]
"requirements": ["pyportainer==1.0.43"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/serial_pm/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
10 changes: 10 additions & 0 deletions homeassistant/components/teslemetry/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/teslemetry/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
31 changes: 17 additions & 14 deletions homeassistant/components/vizio/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/vizio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/vizio/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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%]"
},
Expand Down
Loading
Loading