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 aioesphomeapi import APIClient
from homeassistant.components import ffmpeg, zeroconf from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, 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.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN from . import dashboard, ffmpeg_proxy
from .dashboard import async_setup as async_setup_dashboard from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the esphome component.""" """Set up the esphome component."""
proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData() ffmpeg_proxy.async_setup(hass)
await dashboard.async_setup(hass)
await async_setup_dashboard(hass)
hass.http.register_view(
FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data)
)
return True return True

View File

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

View File

@ -22,5 +22,3 @@ PROJECT_URLS = {
# ESPHome always uses .0 for the changelog URL # ESPHome always uses .0 for the changelog URL
STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" 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" 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 import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
# Import config flow so that it's added to the registry # Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper 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) return await func(self, *args, **kwargs)
except APIConnectionError as error: except APIConnectionError as error:
raise HomeAssistantError( 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 ) from error
return handler return handler
@ -194,6 +201,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
_static_info: _InfoT _static_info: _InfoT
_state: _StateT _state: _StateT
_has_state: bool _has_state: bool
device_entry: dr.DeviceEntry
def __init__( def __init__(
self, self,

View File

@ -11,17 +11,20 @@ from typing import Final
from aiohttp import web from aiohttp import web
from aiohttp.abc import AbstractStreamWriter, BaseRequest from aiohttp.abc import AbstractStreamWriter, BaseRequest
from homeassistant.components import ffmpeg
from homeassistant.components.ffmpeg import FFmpegManager from homeassistant.components.ffmpeg import FFmpegManager
from homeassistant.components.http import HomeAssistantView 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__) _LOGGER = logging.getLogger(__name__)
_MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2 _MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2
@callback
def async_create_proxy_url( def async_create_proxy_url(
hass: HomeAssistant, hass: HomeAssistant,
device_id: str, device_id: str,
@ -32,7 +35,7 @@ def async_create_proxy_url(
width: int | None = None, width: int | None = None,
) -> str: ) -> str:
"""Create a use proxy URL that automatically converts the media.""" """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( return data.async_create_proxy_url(
device_id, media_url, media_format, rate, channels, width device_id, media_url, media_format, rate, channels, width
) )
@ -313,3 +316,16 @@ class FFmpegProxyView(HomeAssistantView):
assert writer is not None assert writer is not None
await resp.transcode(request, writer) await resp.transcode(request, writer)
return resp 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, announcement: bool,
) -> str | None: ) -> str | None:
"""Get URL for ffmpeg proxy.""" """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 # Choose the first default or announcement supported format
format_to_use: MediaPlayerSupportedFormat | None = None format_to_use: MediaPlayerSupportedFormat | None = None
for supported_format in supported_formats: for supported_format in supported_formats:

View File

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

View File

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

View File

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

View File

@ -181,6 +181,35 @@ browse_media:
selector: selector:
text: 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: select_source:
target: target:
entity: 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": { "select_source": {
"name": "Select source", "name": "Select source",
"description": "Sends the media player the command to change input 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,
URI_SCHEME_REGEX, URI_SCHEME_REGEX,
) )
from .error import MediaSourceError, Unresolvable from .error import MediaSourceError, UnknownMediaSource, Unresolvable
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
__all__ = [ __all__ = [
@ -113,7 +113,11 @@ def _get_media_item(
return MediaSourceItem(hass, domain, "", target_media_player) return MediaSourceItem(hass, domain, "", target_media_player)
if item.domain is not None and item.domain not in hass.data[DOMAIN]: 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 return item
@ -132,7 +136,14 @@ async def async_browse_media(
try: try:
item = await _get_media_item(hass, media_content_id, None).async_browse() item = await _get_media_item(hass, media_content_id, None).async_browse()
except ValueError as err: 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: if content_filter is None or item.children is None:
return item return item
@ -165,7 +176,14 @@ async def async_resolve_media(
try: try:
item = _get_media_item(hass, media_content_id, target_media_player) item = _get_media_item(hass, media_content_id, target_media_player)
except ValueError as err: 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() return await item.async_resolve()

View File

@ -9,3 +9,7 @@ class MediaSourceError(HomeAssistantError):
class Unresolvable(MediaSourceError): class Unresolvable(MediaSourceError):
"""When media ID is not resolvable.""" """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" 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( async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
hass: HomeAssistant, hass: HomeAssistant,
hass_storage: dict[str, Any], hass_storage: dict[str, Any],
@ -105,9 +106,7 @@ async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
return_value={}, return_value={},
), ),
): ):
await async_setup_component(hass, "hassio", {}) assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done() await hass.async_block_till_done()
assert "test-slug is no longer installed" in caplog.text assert "test-slug is no longer installed" in caplog.text
assert not mock_dashboard_api.called assert not mock_dashboard_api.called

View File

@ -3,17 +3,16 @@
from collections.abc import Generator from collections.abc import Generator
import os import os
import re import re
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, patch
from aiohasupervisor.models import AddonsStats, AddonState from aiohasupervisor.models import AddonsStats, AddonState
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
import pytest import pytest
from homeassistant.auth.models import RefreshToken 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.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.setup import async_setup_component
from . import SUPERVISOR_TOKEN from . import SUPERVISOR_TOKEN
@ -31,55 +30,6 @@ def disable_security_filter() -> Generator[None]:
yield 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 @pytest.fixture
async def hassio_client( async def hassio_client(
hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator 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 # Test invalid base
with pytest.raises(BrowseError) as excinfo: with pytest.raises(BrowseError) as excinfo:
await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/") 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 # Test successful listing
media = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/events") media = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/events")

View File

@ -119,8 +119,10 @@ from .typing import (
if TYPE_CHECKING: if TYPE_CHECKING:
# Local import to avoid processing recorder and SQLite modules when running a # Local import to avoid processing recorder and SQLite modules when running a
# testcase which does not use the recorder. # testcase which does not use the recorder.
from homeassistant.auth.models import RefreshToken
from homeassistant.components import recorder from homeassistant.components import recorder
pytest.register_assert_rewrite("tests.common") pytest.register_assert_rewrite("tests.common")
from .common import ( # noqa: E402, isort:skip from .common import ( # noqa: E402, isort:skip
@ -1894,6 +1896,67 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]:
yield mock_bleak_scanner_start 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 @pytest.fixture
def integration_frame_path() -> str: def integration_frame_path() -> str:
"""Return the path to the integration frame. """Return the path to the integration frame.