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 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."""

View file

@ -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,

View file

@ -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",