Limit AndroidTV screencap calls (#96485)

This commit is contained in:
ollo69 2023-07-24 19:58:11 +02:00 committed by GitHub
parent 345df715d6
commit 2cfc11d4b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 99 additions and 32 deletions

View File

@ -2,8 +2,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from datetime import datetime from datetime import timedelta
import functools import functools
import hashlib
import logging import logging
from typing import Any, Concatenate, ParamSpec, TypeVar 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle
from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac
from .const import ( from .const import (
@ -65,6 +67,8 @@ ATTR_DEVICE_PATH = "device_path"
ATTR_HDMI_INPUT = "hdmi_input" ATTR_HDMI_INPUT = "hdmi_input"
ATTR_LOCAL_PATH = "local_path" ATTR_LOCAL_PATH = "local_path"
MIN_TIME_BETWEEN_SCREENCAPS = timedelta(seconds=60)
SERVICE_ADB_COMMAND = "adb_command" SERVICE_ADB_COMMAND = "adb_command"
SERVICE_DOWNLOAD = "download" SERVICE_DOWNLOAD = "download"
SERVICE_LEARN_SENDEVENT = "learn_sendevent" SERVICE_LEARN_SENDEVENT = "learn_sendevent"
@ -228,6 +232,9 @@ class ADBDevice(MediaPlayerEntity):
self._entry_id = entry_id self._entry_id = entry_id
self._entry_data = entry_data 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 info = aftv.device_properties
model = info.get(ATTR_MODEL) model = info.get(ATTR_MODEL)
self._attr_device_info = DeviceInfo( 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() @adb_decorator()
async def _adb_screencap(self) -> bytes | None: async def _adb_screencap(self) -> bytes | None:
"""Take a screen capture from the device.""" """Take a screen capture from the device."""
return await self.aftv.adb_screencap() return await self.aftv.adb_screencap()
async def async_get_media_image(self) -> tuple[bytes | None, str | None]: async def _async_get_screencap(self, prev_app_id: str | None = None) -> None:
"""Fetch current playing image.""" """Take a screen capture from the device when enabled."""
if ( if (
not self._screencap not self._screencap
or self.state in {MediaPlayerState.OFF, None} or self.state in {MediaPlayerState.OFF, None}
or not self.available 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() @Throttle(MIN_TIME_BETWEEN_SCREENCAPS)
if media_data: async def _adb_get_screencap(self, **kwargs) -> None:
return media_data, "image/png" """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 async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
if not self.available: """Fetch current playing image."""
self.async_write_ha_state() return self._media_image
return None, None
@adb_decorator() @adb_decorator()
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
@ -485,6 +497,7 @@ class AndroidTVDevice(ADBDevice):
if not self.available: if not self.available:
return return
prev_app_id = self._attr_app_id
# Get the updated state and attributes. # Get the updated state and attributes.
( (
state, state,
@ -514,6 +527,8 @@ class AndroidTVDevice(ADBDevice):
else: else:
self._attr_source_list = None self._attr_source_list = None
await self._async_get_screencap(prev_app_id)
@adb_decorator() @adb_decorator()
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Send stop command.""" """Send stop command."""
@ -575,6 +590,7 @@ class FireTVDevice(ADBDevice):
if not self.available: if not self.available:
return return
prev_app_id = self._attr_app_id
# Get the `state`, `current_app`, `running_apps` and `hdmi_input`. # Get the `state`, `current_app`, `running_apps` and `hdmi_input`.
( (
state, state,
@ -601,6 +617,8 @@ class FireTVDevice(ADBDevice):
else: else:
self._attr_source_list = None self._attr_source_list = None
await self._async_get_screencap(prev_app_id)
@adb_decorator() @adb_decorator()
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Send stop (back) command.""" """Send stop (back) command."""

View File

@ -185,6 +185,10 @@ def isfile(filepath):
return filepath.endswith("adbkey") return filepath.endswith("adbkey")
PATCH_SCREENCAP = patch(
"androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap",
return_value=b"image",
)
PATCH_SETUP_ENTRY = patch( PATCH_SETUP_ENTRY = patch(
"homeassistant.components.androidtv.async_setup_entry", "homeassistant.components.androidtv.async_setup_entry",
return_value=True, return_value=True,

View File

@ -1,4 +1,5 @@
"""The tests for the androidtv platform.""" """The tests for the androidtv platform."""
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
@ -70,10 +71,11 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.util import slugify from homeassistant.util import slugify
from homeassistant.util.dt import utcnow
from . import patchers from . import patchers
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
HOST = "127.0.0.1" HOST = "127.0.0.1"
@ -263,7 +265,7 @@ async def test_reconnect(
caplog.set_level(logging.DEBUG) caplog.set_level(logging.DEBUG)
with patchers.patch_connect(True)[patch_key], patchers.patch_shell( with patchers.patch_connect(True)[patch_key], patchers.patch_shell(
SHELL_RESPONSE_STANDBY SHELL_RESPONSE_STANDBY
)[patch_key]: )[patch_key], patchers.PATCH_SCREENCAP:
await async_update_entity(hass, entity_id) await async_update_entity(hass, entity_id)
state = hass.states.get(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 is not None
assert state.state == STATE_OFF 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) await async_update_entity(hass, entity_id)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state is not None 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) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() 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) await async_update_entity(hass, entity_id)
patch_screen_cap.assert_called()
media_player_name = "media_player." + slugify( media_player_name = "media_player." + slugify(
CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME]
@ -901,21 +908,53 @@ async def test_get_image_http(
client = await hass_client_no_auth() client = await hass_client_no_auth()
with patch( resp = await client.get(state.attributes["entity_picture"])
"androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap", return_value=b"image" content = await resp.read()
):
resp = await client.get(state.attributes["entity_picture"])
content = await resp.read()
assert content == b"image" 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", "androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap",
side_effect=ConnectionResetError, 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 # 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) state = hass.states.get(media_player_name)
assert state is not None assert state is not None
assert state.state == STATE_UNAVAILABLE 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) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() 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( await _test_service(
hass, entity_id, SERVICE_MEDIA_NEXT_TRACK, "media_next_track" 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) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() 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_MEDIA_STOP, "back")
await _test_service(hass, entity_id, SERVICE_TURN_OFF, "adb_shell") await _test_service(hass, entity_id, SERVICE_TURN_OFF, "adb_shell")
await _test_service(hass, entity_id, SERVICE_TURN_ON, "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) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() 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} service_data = {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True}
with patch( with patch(
"androidtv.androidtv.androidtv_async.AndroidTVAsync.mute_volume", "androidtv.androidtv.androidtv_async.AndroidTVAsync.mute_volume",