From 79e9eb1b94f0d1b79a57eaa1d6aee4d4dc54ef41 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 3 Mar 2022 23:08:29 -0600 Subject: [PATCH] Suppress roku power off timeout errors (#67414) --- homeassistant/components/roku/__init__.py | 32 --------------- homeassistant/components/roku/helpers.py | 40 +++++++++++++++++++ homeassistant/components/roku/manifest.json | 2 +- homeassistant/components/roku/media_player.py | 29 +++++++------- homeassistant/components/roku/remote.py | 8 ++-- homeassistant/components/roku/select.py | 5 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roku/test_media_player.py | 9 ++++- 9 files changed, 70 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index e6e31f08713..f24d08909b8 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -1,14 +1,6 @@ """Support for Roku.""" from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine -from functools import wraps -import logging -from typing import Any, TypeVar - -from rokuecp import RokuConnectionError, RokuError -from typing_extensions import Concatenate, ParamSpec - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant @@ -16,7 +8,6 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN from .coordinator import RokuDataUpdateCoordinator -from .entity import RokuEntity CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -27,10 +18,6 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, ] -_LOGGER = logging.getLogger(__name__) - -_T = TypeVar("_T", bound="RokuEntity") -_P = ParamSpec("_P") async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -53,22 +40,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -def roku_exception_handler( - func: Callable[Concatenate[_T, _P], Awaitable[None]] # type: ignore[misc] -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc] - """Decorate Roku calls to handle Roku exceptions.""" - - @wraps(func) - async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: - try: - await func(self, *args, **kwargs) - except RokuConnectionError as error: - if self.available: - _LOGGER.error("Error communicating with API: %s", error) - except RokuError as error: - if self.available: - _LOGGER.error("Invalid response from API: %s", error) - - return wrapper diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index 7f507a9fe52..26fdb53c935 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -1,6 +1,21 @@ """Helpers for Roku.""" from __future__ import annotations +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +import logging +from typing import Any, TypeVar + +from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError +from typing_extensions import Concatenate, ParamSpec + +from .entity import RokuEntity + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T", bound=RokuEntity) +_P = ParamSpec("_P") + def format_channel_name(channel_number: str, channel_name: str | None = None) -> str: """Format a Roku Channel name.""" @@ -8,3 +23,28 @@ def format_channel_name(channel_number: str, channel_name: str | None = None) -> return f"{channel_name} ({channel_number})" return channel_number + + +def roku_exception_handler(ignore_timeout: bool = False) -> Callable[..., Callable]: + """Decorate Roku calls to handle Roku exceptions.""" + + def decorator( + func: Callable[Concatenate[_T, _P], Awaitable[None]], # type: ignore[misc] + ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc] + @wraps(func) + async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except RokuConnectionTimeoutError as error: + if not ignore_timeout and self.available: + _LOGGER.error("Error communicating with API: %s", error) + except RokuConnectionError as error: + if self.available: + _LOGGER.error("Error communicating with API: %s", error) + except RokuError as error: + if self.available: + _LOGGER.error("Invalid response from API: %s", error) + + return wrapper + + return decorator diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 4918e7742be..433ce6b29d1 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -2,7 +2,7 @@ "domain": "roku", "name": "Roku", "documentation": "https://www.home-assistant.io/integrations/roku", - "requirements": ["rokuecp==0.14.1"], + "requirements": ["rokuecp==0.15.0"], "homekit": { "models": ["3810X", "4660X", "7820X", "C105X", "C135X"] }, diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 9cf17d890a4..8dd76f0b9cb 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -51,7 +51,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import roku_exception_handler from .browse_media import async_browse_media from .const import ( ATTR_ARTIST_NAME, @@ -65,7 +64,7 @@ from .const import ( ) from .coordinator import RokuDataUpdateCoordinator from .entity import RokuEntity -from .helpers import format_channel_name +from .helpers import format_channel_name, roku_exception_handler _LOGGER = logging.getLogger(__name__) @@ -289,7 +288,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): app.name for app in self.coordinator.data.apps if app.name is not None ) - @roku_exception_handler + @roku_exception_handler() async def search(self, keyword: str) -> None: """Emulate opening the search screen and entering the search keyword.""" await self.coordinator.roku.search(keyword) @@ -321,68 +320,68 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): media_content_type, ) - @roku_exception_handler + @roku_exception_handler() async def async_turn_on(self) -> None: """Turn on the Roku.""" await self.coordinator.roku.remote("poweron") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler(ignore_timeout=True) async def async_turn_off(self) -> None: """Turn off the Roku.""" await self.coordinator.roku.remote("poweroff") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_media_pause(self) -> None: """Send pause command.""" if self.state not in (STATE_STANDBY, STATE_PAUSED): await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_media_play(self) -> None: """Send play command.""" if self.state not in (STATE_STANDBY, STATE_PLAYING): await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_media_play_pause(self) -> None: """Send play/pause command.""" if self.state != STATE_STANDBY: await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_media_previous_track(self) -> None: """Send previous track command.""" await self.coordinator.roku.remote("reverse") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_media_next_track(self) -> None: """Send next track command.""" await self.coordinator.roku.remote("forward") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" await self.coordinator.roku.remote("volume_mute") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_volume_up(self) -> None: """Volume up media player.""" await self.coordinator.roku.remote("volume_up") - @roku_exception_handler + @roku_exception_handler() async def async_volume_down(self) -> None: """Volume down media player.""" await self.coordinator.roku.remote("volume_down") - @roku_exception_handler + @roku_exception_handler() async def async_play_media( self, media_type: str, media_id: str, **kwargs: Any ) -> None: @@ -487,7 +486,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_select_source(self, source: str) -> None: """Select input source.""" if source == "Home": diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index 9a0cd6f51e3..6d1312c0b03 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -9,10 +9,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import roku_exception_handler from .const import DOMAIN from .coordinator import RokuDataUpdateCoordinator from .entity import RokuEntity +from .helpers import roku_exception_handler async def async_setup_entry( @@ -44,19 +44,19 @@ class RokuRemote(RokuEntity, RemoteEntity): """Return true if device is on.""" return not self.coordinator.data.state.standby - @roku_exception_handler + @roku_exception_handler() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self.coordinator.roku.remote("poweron") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler(ignore_timeout=True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self.coordinator.roku.remote("poweroff") await self.coordinator.async_request_refresh() - @roku_exception_handler + @roku_exception_handler() async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to one device.""" num_repeats = kwargs[ATTR_NUM_REPEATS] diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 9120a4fe9ce..e11748114d1 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -12,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import roku_exception_handler from .const import DOMAIN from .coordinator import RokuDataUpdateCoordinator from .entity import RokuEntity -from .helpers import format_channel_name +from .helpers import format_channel_name, roku_exception_handler @dataclass @@ -163,7 +162,7 @@ class RokuSelectEntity(RokuEntity, SelectEntity): """Return a set of selectable options.""" return self.entity_description.options_fn(self.coordinator.data) - @roku_exception_handler + @roku_exception_handler() async def async_select_option(self, option: str) -> None: """Set the option.""" await self.entity_description.set_fn( diff --git a/requirements_all.txt b/requirements_all.txt index 3707317d306..a5b88f0e02b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2063,7 +2063,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.14.1 +rokuecp==0.15.0 # homeassistant.components.roomba roombapy==1.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e53bed06e4d..c216e921e61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1309,7 +1309,7 @@ rflink==0.0.62 ring_doorbell==0.7.2 # homeassistant.components.roku -rokuecp==0.14.1 +rokuecp==0.15.0 # homeassistant.components.roomba roombapy==1.6.5 diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 050814e3817..21fd2e861b6 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch import pytest -from rokuecp import RokuError +from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.media_player.const import ( @@ -164,10 +164,15 @@ async def test_tv_setup( assert device_entry.suggested_area == "Living room" +@pytest.mark.parametrize( + "error", + [RokuConnectionTimeoutError, RokuConnectionError, RokuError], +) async def test_availability( hass: HomeAssistant, mock_roku: MagicMock, mock_config_entry: MockConfigEntry, + error: RokuError, ) -> None: """Test entity availability.""" now = dt_util.utcnow() @@ -179,7 +184,7 @@ async def test_availability( await hass.async_block_till_done() with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = RokuError + mock_roku.update.side_effect = error async_fire_time_changed(hass, future) await hass.async_block_till_done() assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE