Compare commits

...

8 Commits

Author SHA1 Message Date
Josef Zweck
c3037bae39
Add service definition for user facing action to media player search (#143177)
* Add service definition for user facing action to media player search

* add filter

* Reorder and update fields in services.yaml
2025-04-18 15:07:46 +02:00
J. Nick Koston
9b1ab34352
Fix hassio mocking in ESPHome dashboard tests (#143212) 2025-04-18 14:11:36 +02:00
J. Nick Koston
221a8597da
Make unknown media source exception translatable (#143208) 2025-04-18 01:17:56 -10:00
J. Nick Koston
45022752a0
Make remaining ESPHome exceptions translatable (#143184) 2025-04-17 22:22:08 -10:00
J. Nick Koston
aa342eb476
Add additional config entry typing to ESPHome (#143126) 2025-04-17 22:03:52 -10:00
J. Nick Koston
32b26b8270
Add icons for ESPHome entities (#143202) 2025-04-17 21:56:11 -10:00
J. Nick Koston
e07c29caad
Small improvements to ESPHome setup (#143204) 2025-04-17 21:51:16 -10:00
J. Nick Koston
b487c12ab1
Remove unreachable code in ESPHome media_players (#143203) 2025-04-17 21:51:03 -10:00
20 changed files with 246 additions and 95 deletions

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from aioesphomeapi import APIClient
from homeassistant.components import ffmpeg, zeroconf
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.const import (
CONF_HOST,
@ -17,13 +17,10 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN
from .dashboard import async_setup as async_setup_dashboard
from . import dashboard, ffmpeg_proxy
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -33,12 +30,8 @@ CLIENT_INFO = f"Home Assistant {ha_version}"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the esphome component."""
proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData()
await async_setup_dashboard(hass)
hass.http.register_view(
FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data)
)
ffmpeg_proxy.async_setup(hass)
await dashboard.async_setup(hass)
return True

View File

@ -47,6 +47,7 @@ from .const import (
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
from .entry_data import ESPHomeConfigEntry
from .manager import async_replace_device
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
@ -608,7 +609,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: ESPHomeConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()

View File

@ -22,5 +22,3 @@ PROJECT_URLS = {
# ESPHome always uses .0 for the changelog URL
STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0"
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy"

View File

@ -28,6 +28,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
@ -167,7 +169,12 @@ def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]](
return await func(self, *args, **kwargs)
except APIConnectionError as error:
raise HomeAssistantError(
f"Error communicating with device: {error}"
translation_domain=DOMAIN,
translation_key="error_communicating_with_device",
translation_placeholders={
"device_name": self._device_info.name,
"error": str(error),
},
) from error
return handler
@ -194,6 +201,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
_static_info: _InfoT
_state: _StateT
_has_state: bool
device_entry: dr.DeviceEntry
def __init__(
self,

View File

@ -11,17 +11,20 @@ from typing import Final
from aiohttp import web
from aiohttp.abc import AbstractStreamWriter, BaseRequest
from homeassistant.components import ffmpeg
from homeassistant.components.ffmpeg import FFmpegManager
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.hass_dict import HassKey
from .const import DATA_FFMPEG_PROXY
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2
@callback
def async_create_proxy_url(
hass: HomeAssistant,
device_id: str,
@ -32,7 +35,7 @@ def async_create_proxy_url(
width: int | None = None,
) -> str:
"""Create a use proxy URL that automatically converts the media."""
data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY]
data = hass.data[DATA_FFMPEG_PROXY]
return data.async_create_proxy_url(
device_id, media_url, media_format, rate, channels, width
)
@ -313,3 +316,16 @@ class FFmpegProxyView(HomeAssistantView):
assert writer is not None
await resp.transcode(request, writer)
return resp
DATA_FFMPEG_PROXY: HassKey[FFmpegProxyData] = HassKey(f"{DOMAIN}.ffmpeg_proxy")
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the ffmpeg proxy."""
proxy_data = FFmpegProxyData()
hass.data[DATA_FFMPEG_PROXY] = proxy_data
hass.http.register_view(
FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data)
)

View File

@ -0,0 +1,20 @@
{
"entity": {
"binary_sensor": {
"assist_in_progress": {
"default": "mdi:timer-sand"
}
},
"select": {
"pipeline": {
"default": "mdi:filter-outline"
},
"vad_sensitivity": {
"default": "mdi:volume-high"
},
"wake_word": {
"default": "mdi:microphone"
}
}
}
}

View File

@ -148,10 +148,6 @@ class EsphomeMediaPlayer(
announcement: bool,
) -> str | None:
"""Get URL for ffmpeg proxy."""
if self.device_entry is None:
# Device id is required
return None
# Choose the first default or announcement supported format
format_to_use: MediaPlayerSupportedFormat | None = None
for supported_format in supported_formats:

View File

@ -20,13 +20,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from .entity import EsphomeEntity, platform_async_setup_entry
from .entry_data import ESPHomeConfigEntry
from .enum_mapper import EsphomeEnumMapper
PARALLEL_UPDATES = 0
@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up esphome sensors based on a config entry."""

View File

@ -184,6 +184,15 @@
"exceptions": {
"action_call_failed": {
"message": "Failed to execute the action call {call_name} on {device_name}: {error}"
},
"error_communicating_with_device": {
"message": "Error communicating with the device {device_name}: {error}"
},
"error_compiling": {
"message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information."
},
"error_uploading": {
"message": "Error during OTA of {configuration}; Try again in ESPHome dashboard for more information."
}
}
}

View File

@ -18,7 +18,6 @@ from homeassistant.components.update import (
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
@ -27,6 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN
from .coordinator import ESPHomeDashboardCoordinator
from .dashboard import async_get_dashboard
from .domain_data import DomainData
@ -36,7 +36,7 @@ from .entity import (
esphome_state_property,
platform_async_setup_entry,
)
from .entry_data import RuntimeEntryData
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
PARALLEL_UPDATES = 0
@ -47,7 +47,7 @@ NO_FEATURES = UpdateEntityFeature(0)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ESPHome update based on a config entry."""
@ -202,16 +202,23 @@ class ESPHomeDashboardUpdateEntity(
api = coordinator.api
device = coordinator.data.get(self._device_info.name)
assert device is not None
configuration = device["configuration"]
try:
if not await api.compile(device["configuration"]):
if not await api.compile(configuration):
raise HomeAssistantError(
f"Error compiling {device['configuration']}; "
"Try again in ESPHome dashboard for more information."
translation_domain=DOMAIN,
translation_key="error_compiling",
translation_placeholders={
"configuration": configuration,
},
)
if not await api.upload(device["configuration"], "OTA"):
if not await api.upload(configuration, "OTA"):
raise HomeAssistantError(
f"Error updating {device['configuration']} via OTA; "
"Try again in ESPHome dashboard for more information."
translation_domain=DOMAIN,
translation_key="error_uploading",
translation_placeholders={
"configuration": configuration,
},
)
finally:
await self.coordinator.async_request_refresh()

View File

@ -68,6 +68,9 @@
"repeat_set": {
"service": "mdi:repeat"
},
"search_media": {
"service": "mdi:text-search"
},
"select_sound_mode": {
"service": "mdi:surround-sound"
},

View File

@ -181,6 +181,35 @@ browse_media:
selector:
text:
search_media:
target:
entity:
domain: media_player
supported_features:
- media_player.MediaPlayerEntityFeature.SEARCH_MEDIA
fields:
search_query:
required: true
example: "Beatles"
selector:
text:
media_content_type:
required: false
example: "music"
selector:
text:
media_content_id:
required: false
example: "A:ALBUMARTIST/Beatles"
selector:
text:
media_filter_classes:
required: false
example: ["album", "artist"]
selector:
text:
multiple: true
select_source:
target:
entity:

View File

@ -274,6 +274,28 @@
}
}
},
"search_media": {
"name": "Search media",
"description": "Searches the available media.",
"fields": {
"media_content_id": {
"name": "[%key:component::media_player::services::browse_media::fields::media_content_id::name%]",
"description": "[%key:component::media_player::services::browse_media::fields::media_content_id::description%]"
},
"media_content_type": {
"name": "[%key:component::media_player::services::browse_media::fields::media_content_type::name%]",
"description": "[%key:component::media_player::services::browse_media::fields::media_content_type::description%]"
},
"search_query": {
"name": "Search query",
"description": "The term to search for."
},
"media_filter_classes": {
"name": "Media filter classes",
"description": "List of media classes to filter the search results by."
}
}
},
"select_source": {
"name": "Select source",
"description": "Sends the media player the command to change input source.",

View File

@ -33,7 +33,7 @@ from .const import (
URI_SCHEME,
URI_SCHEME_REGEX,
)
from .error import MediaSourceError, Unresolvable
from .error import MediaSourceError, UnknownMediaSource, Unresolvable
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
__all__ = [
@ -113,7 +113,11 @@ def _get_media_item(
return MediaSourceItem(hass, domain, "", target_media_player)
if item.domain is not None and item.domain not in hass.data[DOMAIN]:
raise ValueError("Unknown media source")
raise UnknownMediaSource(
translation_domain=DOMAIN,
translation_key="unknown_media_source",
translation_placeholders={"domain": item.domain},
)
return item
@ -132,7 +136,14 @@ async def async_browse_media(
try:
item = await _get_media_item(hass, media_content_id, None).async_browse()
except ValueError as err:
raise BrowseError(str(err)) from err
raise BrowseError(
translation_domain=DOMAIN,
translation_key="browse_media_failed",
translation_placeholders={
"media_content_id": str(media_content_id),
"error": str(err),
},
) from err
if content_filter is None or item.children is None:
return item
@ -165,7 +176,14 @@ async def async_resolve_media(
try:
item = _get_media_item(hass, media_content_id, target_media_player)
except ValueError as err:
raise Unresolvable(str(err)) from err
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="resolve_media_failed",
translation_placeholders={
"media_content_id": str(media_content_id),
"error": str(err),
},
) from err
return await item.async_resolve()

View File

@ -9,3 +9,7 @@ class MediaSourceError(HomeAssistantError):
class Unresolvable(MediaSourceError):
"""When media ID is not resolvable."""
class UnknownMediaSource(MediaSourceError, ValueError):
"""When media source is unknown."""

View File

@ -0,0 +1,13 @@
{
"exceptions": {
"browse_media_failed": {
"message": "Failed to browse media with content id {media_content_id}: {error}"
},
"resolve_media_failed": {
"message": "Failed to resolve media with content id {media_content_id}: {error}"
},
"unknown_media_source": {
"message": "Unknown media source: {domain}"
}
}
}

View File

@ -81,6 +81,7 @@ async def test_restore_dashboard_storage_end_to_end(
assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052"
@pytest.mark.usefixtures("hassio_stubs")
async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
hass: HomeAssistant,
hass_storage: dict[str, Any],
@ -105,9 +106,7 @@ async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
return_value={},
),
):
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
await async_setup_component(hass, DOMAIN, {})
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert "test-slug is no longer installed" in caplog.text
assert not mock_dashboard_api.called

View File

@ -3,17 +3,16 @@
from collections.abc import Generator
import os
import re
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, patch
from aiohasupervisor.models import AddonsStats, AddonState
from aiohttp.test_utils import TestClient
import pytest
from homeassistant.auth.models import RefreshToken
from homeassistant.components.hassio.handler import HassIO, HassioAPIError
from homeassistant.components.hassio.handler import HassIO
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.setup import async_setup_component
from . import SUPERVISOR_TOKEN
@ -31,55 +30,6 @@ def disable_security_filter() -> Generator[None]:
yield
@pytest.fixture
def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]:
"""Fixture to inject hassio env."""
with (
patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}),
patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}),
patch(
"homeassistant.components.hassio.HassIO.get_info",
Mock(side_effect=HassioAPIError()),
),
):
yield
@pytest.fixture
async def hassio_stubs(
hassio_env: None,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
supervisor_client: AsyncMock,
) -> RefreshToken:
"""Create mock hassio http client."""
with (
patch(
"homeassistant.components.hassio.HassIO.update_hass_api",
return_value={"result": "ok"},
) as hass_api,
patch(
"homeassistant.components.hassio.HassIO.update_hass_timezone",
return_value={"result": "ok"},
),
patch(
"homeassistant.components.hassio.HassIO.get_info",
side_effect=HassioAPIError(),
),
patch(
"homeassistant.components.hassio.HassIO.get_ingress_panels",
return_value={"panels": []},
),
patch(
"homeassistant.components.hassio.issues.SupervisorIssues.setup",
),
):
await async_setup_component(hass, "hassio", {})
return hass_api.call_args[0][1]
@pytest.fixture
async def hassio_client(
hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator

View File

@ -57,8 +57,10 @@ async def test_async_browse_media(hass: HomeAssistant) -> None:
# Test invalid base
with pytest.raises(BrowseError) as excinfo:
await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/")
assert str(excinfo.value) == "Invalid media source URI"
assert str(excinfo.value) == (
"Failed to browse media with content id media-source://netatmo/: "
"Invalid media source URI"
)
# Test successful listing
media = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/events")

View File

@ -119,8 +119,10 @@ from .typing import (
if TYPE_CHECKING:
# Local import to avoid processing recorder and SQLite modules when running a
# testcase which does not use the recorder.
from homeassistant.auth.models import RefreshToken
from homeassistant.components import recorder
pytest.register_assert_rewrite("tests.common")
from .common import ( # noqa: E402, isort:skip
@ -1894,6 +1896,67 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]:
yield mock_bleak_scanner_start
@pytest.fixture
def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]:
"""Fixture to inject hassio env."""
from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel
HassioAPIError,
)
from .components.hassio import ( # pylint: disable=import-outside-toplevel
SUPERVISOR_TOKEN,
)
with (
patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}),
patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}),
patch(
"homeassistant.components.hassio.HassIO.get_info",
Mock(side_effect=HassioAPIError()),
),
):
yield
@pytest.fixture
async def hassio_stubs(
hassio_env: None,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
supervisor_client: AsyncMock,
) -> RefreshToken:
"""Create mock hassio http client."""
from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel
HassioAPIError,
)
with (
patch(
"homeassistant.components.hassio.HassIO.update_hass_api",
return_value={"result": "ok"},
) as hass_api,
patch(
"homeassistant.components.hassio.HassIO.update_hass_timezone",
return_value={"result": "ok"},
),
patch(
"homeassistant.components.hassio.HassIO.get_info",
side_effect=HassioAPIError(),
),
patch(
"homeassistant.components.hassio.HassIO.get_ingress_panels",
return_value={"panels": []},
),
patch(
"homeassistant.components.hassio.issues.SupervisorIssues.setup",
),
):
await async_setup_component(hass, "hassio", {})
return hass_api.call_args[0][1]
@pytest.fixture
def integration_frame_path() -> str:
"""Return the path to the integration frame.