From 72b6e5fabe87b46cc08abf33d6e5307ec4e59fa3 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:27:31 +0100 Subject: [PATCH] Add remote action exceptions to Xbox (#162347) --- homeassistant/components/xbox/remote.py | 54 +++++++++- tests/components/xbox/test_remote.py | 127 +++++++++++++++++++++++- 2 files changed, 177 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index 2f979e11c30..5efa8f24a8f 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -3,9 +3,13 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable -from typing import Any +from collections.abc import Awaitable, Callable, Coroutine, Iterable +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.smartglass import SmartglassProvider from pythonxbox.api.provider.smartglass.models import InputKeyType, PowerState @@ -16,11 +20,15 @@ from homeassistant.components.remote import ( RemoteEntity, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import XboxConfigEntry from .entity import XboxConsoleBaseEntity +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 MAP_COMMAND: dict[str, Callable[[SmartglassProvider], Callable]] = { @@ -72,6 +80,35 @@ async def async_setup_entry( add_entities() +def exception_handler[**_P, _R]( + func: Callable[Concatenate[XboxRemote, _P], Awaitable[_R]], +) -> Callable[Concatenate[XboxRemote, _P], Coroutine[Any, Any, _R]]: + """Catch Xbox errors.""" + + @wraps(func) + async def wrapper( + self: XboxRemote, + *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 XboxRemote(XboxConsoleBaseEntity, RemoteEntity): """Representation of an Xbox remote.""" @@ -80,14 +117,25 @@ class XboxRemote(XboxConsoleBaseEntity, RemoteEntity): """Return True if device is on.""" return self.data.status.power_state == PowerState.On + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the Xbox 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, **kwargs: Any) -> None: """Turn the Xbox off.""" await self.client.smartglass.turn_off(self._console.id) + @exception_handler async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send controller or text input to the Xbox.""" num_repeats = kwargs[ATTR_NUM_REPEATS] diff --git a/tests/components/xbox/test_remote.py b/tests/components/xbox/test_remote.py index 43ac23ed728..99221b8f883 100644 --- a/tests/components/xbox/test_remote.py +++ b/tests/components/xbox/test_remote.py @@ -1,8 +1,10 @@ """Test the Xbox remote platform.""" from collections.abc import Generator +from http import HTTPStatus from unittest.mock import AsyncMock, patch +from httpx import HTTPStatusError, RequestError, TimeoutException import pytest from pythonxbox.api.provider.smartglass.models import InputKeyType from syrupy.assertion import SnapshotAssertion @@ -21,9 +23,10 @@ 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 MockConfigEntry, snapshot_platform +from tests.common import Mock, MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) @@ -209,3 +212,125 @@ async def test_turn_off( ) xbox_live_client.smartglass.turn_off.assert_called_once_with("HIJKLMN") + + +@pytest.mark.parametrize( + ("command", "call_method"), + [ + ("Play", "play"), + ("Nexus", "press_button"), + ("Hello world", "insert_text"), + ], +) +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + (TimeoutException(""), "timeout_exception"), + (RequestError("", request=Mock()), "request_exception"), + (HTTPStatusError("", request=Mock(), response=Mock()), "request_exception"), + ], +) +async def test_send_command_exceptions( + hass: HomeAssistant, + xbox_live_client: AsyncMock, + config_entry: MockConfigEntry, + command: str, + call_method: str, + exception: Exception, + translation_key: str, +) -> None: + """Test remote send command exceptions.""" + + 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, check=lambda e: e.translation_key == translation_key + ): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_COMMAND: command, ATTR_DELAY_SECS: 0}, + target={ATTR_ENTITY_ID: "remote.xone"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + (TimeoutException(""), "timeout_exception"), + (RequestError("", request=Mock()), "request_exception"), + (HTTPStatusError("", request=Mock(), response=Mock()), "request_exception"), + ( + HTTPStatusError( + "", request=Mock(), response=Mock(status_code=HTTPStatus.NOT_FOUND) + ), + "turn_on_failed", + ), + ], +) +async def test_turn_on_exceptions( + hass: HomeAssistant, + xbox_live_client: AsyncMock, + config_entry: MockConfigEntry, + exception: Exception, + translation_key: str, +) -> None: + """Test remote turn on exceptions.""" + + 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 = exception + with pytest.raises( + HomeAssistantError, check=lambda e: e.translation_key == translation_key + ): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "remote.xone"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + (TimeoutException(""), "timeout_exception"), + (RequestError("", request=Mock()), "request_exception"), + (HTTPStatusError("", request=Mock(), response=Mock()), "request_exception"), + ], +) +async def test_turn_off_exceptions( + hass: HomeAssistant, + xbox_live_client: AsyncMock, + config_entry: MockConfigEntry, + exception: Exception, + translation_key: str, +) -> None: + """Test remote turn off exceptions.""" + + 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.turn_off.side_effect = exception + with pytest.raises( + HomeAssistantError, check=lambda e: e.translation_key == translation_key + ): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + target={ATTR_ENTITY_ID: "remote.xone"}, + blocking=True, + )