diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 66379303bc7..27add131242 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -2,8 +2,13 @@ from __future__ import annotations -from typing import Any +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from http import HTTPStatus +import logging +from typing import Any, Concatenate +from httpx import HTTPStatusError, RequestError, TimeoutException from pythonxbox.api.provider.catalog.models import Image from pythonxbox.api.provider.smartglass.models import ( PlaybackState, @@ -19,12 +24,16 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .browse_media import build_item_response +from .const import DOMAIN from .coordinator import XboxConfigEntry from .entity import XboxConsoleBaseEntity +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 SUPPORT_XBOX = ( @@ -82,6 +91,35 @@ async def async_setup_entry( add_entities() +def exception_handler[**_P, _R]( + func: Callable[Concatenate[XboxMediaPlayer, _P], Awaitable[_R]], +) -> Callable[Concatenate[XboxMediaPlayer, _P], Coroutine[Any, Any, _R]]: + """Catch Xbox errors.""" + + @wraps(func) + async def wrapper( + self: XboxMediaPlayer, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> _R: + """Catch Xbox errors and raise HomeAssistantError.""" + try: + return await func(self, *args, **kwargs) + except TimeoutException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_exception", + ) from e + except (RequestError, HTTPStatusError) as e: + _LOGGER.debug("Xbox exception:", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="request_exception", + ) from e + + return wrapper + + class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity): """Representation of an Xbox Media Player.""" @@ -143,45 +181,70 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity): url = f"http:{url}" return url + @exception_handler async def async_turn_on(self) -> None: """Turn the media player on.""" - await self.client.smartglass.wake_up(self._console.id) + try: + await self.client.smartglass.wake_up(self._console.id) + except HTTPStatusError as e: + if e.response.status_code == HTTPStatus.NOT_FOUND: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="turn_on_failed", + ) from e + raise + @exception_handler async def async_turn_off(self) -> None: """Turn the media player off.""" await self.client.smartglass.turn_off(self._console.id) + @exception_handler async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" + if mute: await self.client.smartglass.mute(self._console.id) else: await self.client.smartglass.unmute(self._console.id) + self._attr_is_volume_muted = mute self.async_write_ha_state() + @exception_handler async def async_volume_up(self) -> None: """Turn volume up for media player.""" + await self.client.smartglass.volume(self._console.id, VolumeDirection.Up) + @exception_handler async def async_volume_down(self) -> None: """Turn volume down for media player.""" + await self.client.smartglass.volume(self._console.id, VolumeDirection.Down) + @exception_handler async def async_media_play(self) -> None: """Send play command.""" + await self.client.smartglass.play(self._console.id) + @exception_handler async def async_media_pause(self) -> None: """Send pause command.""" + await self.client.smartglass.pause(self._console.id) + @exception_handler async def async_media_previous_track(self) -> None: """Send previous track command.""" + await self.client.smartglass.previous(self._console.id) + @exception_handler async def async_media_next_track(self) -> None: """Send next track command.""" + await self.client.smartglass.next(self._console.id) async def async_browse_media( @@ -198,10 +261,12 @@ class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity): media_content_id, ) + @exception_handler async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Launch an app on the Xbox.""" + if media_id == "Home": await self.client.smartglass.go_home(self._console.id) diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index af78d19d288..db783ed4398 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -174,6 +174,9 @@ "timeout_exception": { "message": "Failed to connect to Xbox Network due to a connection timeout" }, + "turn_on_failed": { + "message": "Turn on failed. Xbox is not connected to the Xbox Network." + }, "xbox_not_configured": { "message": "The Xbox integration is not configured." } diff --git a/tests/components/xbox/test_media_player.py b/tests/components/xbox/test_media_player.py index b9d444ed743..08b678fdf61 100644 --- a/tests/components/xbox/test_media_player.py +++ b/tests/components/xbox/test_media_player.py @@ -1,9 +1,11 @@ """Test the Xbox media_player platform.""" from collections.abc import Generator +from http import HTTPStatus from typing import Any from unittest.mock import patch +from httpx import HTTPStatusError, RequestError, TimeoutException import pytest from pythonxbox.api.provider.smartglass.models import ( SmartglassConsoleStatus, @@ -35,10 +37,12 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import ( AsyncMock, + Mock, MockConfigEntry, async_load_json_object_fixture, snapshot_platform, @@ -202,3 +206,103 @@ async def test_media_player_actions( getattr(xbox_live_client.smartglass, call_method).assert_called_once_with( "HIJKLMN", *call_args ) + + +@pytest.mark.parametrize( + ("service", "service_args", "call_method"), + [ + (SERVICE_TURN_ON, {}, "wake_up"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}, "unmute"), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}, "mute"), + (SERVICE_VOLUME_UP, {}, "volume"), + (SERVICE_VOLUME_DOWN, {}, "volume"), + (SERVICE_MEDIA_PLAY, {}, "play"), + (SERVICE_MEDIA_PAUSE, {}, "pause"), + (SERVICE_MEDIA_PREVIOUS_TRACK, {}, "previous"), + (SERVICE_MEDIA_NEXT_TRACK, {}, "next"), + ( + SERVICE_PLAY_MEDIA, + {ATTR_MEDIA_CONTENT_TYPE: MediaType.APP, ATTR_MEDIA_CONTENT_ID: "Home"}, + "go_home", + ), + ( + SERVICE_PLAY_MEDIA, + { + ATTR_MEDIA_CONTENT_TYPE: MediaType.APP, + ATTR_MEDIA_CONTENT_ID: "327370029", + }, + "launch_app", + ), + ], +) +@pytest.mark.parametrize( + "exception", + [ + TimeoutException(""), + RequestError("", request=Mock()), + HTTPStatusError("", request=Mock(), response=Mock()), + ], +) +async def test_media_player_action_exceptions( + hass: HomeAssistant, + xbox_live_client: AsyncMock, + config_entry: MockConfigEntry, + service: str, + service_args: dict[str, Any], + call_method: str, + exception: Exception, +) -> None: + """Test media player action exceptions.""" + + xbox_live_client.smartglass.get_console_status.return_value = ( + SmartglassConsoleStatus( + **await async_load_json_object_fixture( + hass, "smartglass_console_status_playing.json", DOMAIN + ) # pyright: ignore[reportArgumentType] + ) + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + getattr(xbox_live_client.smartglass, call_method).side_effect = exception + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + target={ATTR_ENTITY_ID: "media_player.xone", **service_args}, + blocking=True, + ) + + +async def test_media_player_turn_on_failed( + hass: HomeAssistant, + xbox_live_client: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test media player turn on failed.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + xbox_live_client.smartglass.wake_up.side_effect = ( + HTTPStatusError( + "", request=Mock(), response=Mock(status_code=HTTPStatus.NOT_FOUND) + ), + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "media_player.xone"}, + blocking=True, + )