From 2cfc11d4b990912956a6ae17839d5eda94512a62 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 24 Jul 2023 19:58:11 +0200 Subject: [PATCH] Limit AndroidTV screencap calls (#96485) --- .../components/androidtv/media_player.py | 52 ++++++++----- tests/components/androidtv/patchers.py | 4 + .../components/androidtv/test_media_player.py | 75 +++++++++++++++---- 3 files changed, 99 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 8f5f3bdfe56..4f927f242df 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -2,8 +2,9 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime +from datetime import timedelta import functools +import hashlib import logging from typing import Any, Concatenate, ParamSpec, TypeVar @@ -35,6 +36,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import Throttle from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac from .const import ( @@ -65,6 +67,8 @@ ATTR_DEVICE_PATH = "device_path" ATTR_HDMI_INPUT = "hdmi_input" ATTR_LOCAL_PATH = "local_path" +MIN_TIME_BETWEEN_SCREENCAPS = timedelta(seconds=60) + SERVICE_ADB_COMMAND = "adb_command" SERVICE_DOWNLOAD = "download" SERVICE_LEARN_SENDEVENT = "learn_sendevent" @@ -228,6 +232,9 @@ class ADBDevice(MediaPlayerEntity): self._entry_id = entry_id self._entry_data = entry_data + self._media_image: tuple[bytes | None, str | None] = None, None + self._attr_media_image_hash = None + info = aftv.device_properties model = info.get(ATTR_MODEL) self._attr_device_info = DeviceInfo( @@ -304,34 +311,39 @@ class ADBDevice(MediaPlayerEntity): ) ) - @property - def media_image_hash(self) -> str | None: - """Hash value for media image.""" - return f"{datetime.now().timestamp()}" if self._screencap else None - @adb_decorator() async def _adb_screencap(self) -> bytes | None: """Take a screen capture from the device.""" return await self.aftv.adb_screencap() - async def async_get_media_image(self) -> tuple[bytes | None, str | None]: - """Fetch current playing image.""" + async def _async_get_screencap(self, prev_app_id: str | None = None) -> None: + """Take a screen capture from the device when enabled.""" if ( not self._screencap or self.state in {MediaPlayerState.OFF, None} or not self.available ): - return None, None + self._media_image = None, None + self._attr_media_image_hash = None + else: + force: bool = prev_app_id is not None + if force: + force = prev_app_id != self._attr_app_id + await self._adb_get_screencap(no_throttle=force) - media_data = await self._adb_screencap() - if media_data: - return media_data, "image/png" + @Throttle(MIN_TIME_BETWEEN_SCREENCAPS) + async def _adb_get_screencap(self, **kwargs) -> None: + """Take a screen capture from the device every 60 seconds.""" + if media_data := await self._adb_screencap(): + self._media_image = media_data, "image/png" + self._attr_media_image_hash = hashlib.sha256(media_data).hexdigest()[:16] + else: + self._media_image = None, None + self._attr_media_image_hash = None - # If an exception occurred and the device is no longer available, write the state - if not self.available: - self.async_write_ha_state() - - return None, None + async def async_get_media_image(self) -> tuple[bytes | None, str | None]: + """Fetch current playing image.""" + return self._media_image @adb_decorator() async def async_media_play(self) -> None: @@ -485,6 +497,7 @@ class AndroidTVDevice(ADBDevice): if not self.available: return + prev_app_id = self._attr_app_id # Get the updated state and attributes. ( state, @@ -514,6 +527,8 @@ class AndroidTVDevice(ADBDevice): else: self._attr_source_list = None + await self._async_get_screencap(prev_app_id) + @adb_decorator() async def async_media_stop(self) -> None: """Send stop command.""" @@ -575,6 +590,7 @@ class FireTVDevice(ADBDevice): if not self.available: return + prev_app_id = self._attr_app_id # Get the `state`, `current_app`, `running_apps` and `hdmi_input`. ( state, @@ -601,6 +617,8 @@ class FireTVDevice(ADBDevice): else: self._attr_source_list = None + await self._async_get_screencap(prev_app_id) + @adb_decorator() async def async_media_stop(self) -> None: """Send stop (back) command.""" diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index f0fca5aae90..aae99b34438 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -185,6 +185,10 @@ def isfile(filepath): return filepath.endswith("adbkey") +PATCH_SCREENCAP = patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap", + return_value=b"image", +) PATCH_SETUP_ENTRY = patch( "homeassistant.components.androidtv.async_setup_entry", return_value=True, diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index c7083626e15..847bc5c7d2f 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -1,4 +1,5 @@ """The tests for the androidtv platform.""" +from datetime import timedelta import logging from typing import Any from unittest.mock import Mock, patch @@ -70,10 +71,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import slugify +from homeassistant.util.dt import utcnow from . import patchers -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator HOST = "127.0.0.1" @@ -263,7 +265,7 @@ async def test_reconnect( caplog.set_level(logging.DEBUG) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( SHELL_RESPONSE_STANDBY - )[patch_key]: + )[patch_key], patchers.PATCH_SCREENCAP: await async_update_entity(hass, entity_id) state = hass.states.get(entity_id) @@ -751,7 +753,9 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: assert state is not None assert state.state == STATE_OFF - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: await async_update_entity(hass, entity_id) state = hass.states.get(entity_id) assert state is not None @@ -890,8 +894,11 @@ async def test_get_image_http( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell("11")[patch_key]: + with patchers.patch_shell("11")[ + patch_key + ], patchers.PATCH_SCREENCAP as patch_screen_cap: await async_update_entity(hass, entity_id) + patch_screen_cap.assert_called() media_player_name = "media_player." + slugify( CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] @@ -901,21 +908,53 @@ async def test_get_image_http( client = await hass_client_no_auth() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap", return_value=b"image" - ): - resp = await client.get(state.attributes["entity_picture"]) - content = await resp.read() - + resp = await client.get(state.attributes["entity_picture"]) + content = await resp.read() assert content == b"image" - with patch( + next_update = utcnow() + timedelta(seconds=30) + with patchers.patch_shell("11")[ + patch_key + ], patchers.PATCH_SCREENCAP as patch_screen_cap, patch( + "homeassistant.util.utcnow", return_value=next_update + ): + async_fire_time_changed(hass, next_update, True) + await hass.async_block_till_done() + patch_screen_cap.assert_not_called() + + next_update = utcnow() + timedelta(seconds=60) + with patchers.patch_shell("11")[ + patch_key + ], patchers.PATCH_SCREENCAP as patch_screen_cap, patch( + "homeassistant.util.utcnow", return_value=next_update + ): + async_fire_time_changed(hass, next_update, True) + await hass.async_block_till_done() + patch_screen_cap.assert_called() + + +async def test_get_image_http_fail(hass: HomeAssistant) -> None: + """Test taking a screen capture fail.""" + + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) + config_entry.add_to_hass(hass) + + with patchers.patch_connect(True)[patch_key], patchers.patch_shell( + SHELL_RESPONSE_OFF + )[patch_key]: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with patchers.patch_shell("11")[patch_key], patch( "androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap", side_effect=ConnectionResetError, ): - resp = await client.get(state.attributes["entity_picture"]) + await async_update_entity(hass, entity_id) # The device is unavailable, but getting the media image did not cause an exception + media_player_name = "media_player." + slugify( + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] + ) state = hass.states.get(media_player_name) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -986,7 +1025,9 @@ async def test_services_androidtv(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: await _test_service( hass, entity_id, SERVICE_MEDIA_NEXT_TRACK, "media_next_track" ) @@ -1034,7 +1075,9 @@ async def test_services_firetv(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: await _test_service(hass, entity_id, SERVICE_MEDIA_STOP, "back") await _test_service(hass, entity_id, SERVICE_TURN_OFF, "adb_shell") await _test_service(hass, entity_id, SERVICE_TURN_ON, "adb_shell") @@ -1050,7 +1093,9 @@ async def test_volume_mute(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: service_data = {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True} with patch( "androidtv.androidtv.androidtv_async.AndroidTVAsync.mute_volume",