From ab8c127a4a466f2c137239c0eb06c8143adea106 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Sun, 18 Nov 2018 22:05:58 -0800 Subject: [PATCH] Enable native support + ADB authentication for Fire TV (#17767) * Enable native support + ADB authentication for Fire TV * Remove unnecessary underscore assignments * Bump firetv to 1.0.5.3 * Change requirements to 'firetv>=1.0.6' * Change requirement from 'firetv>=1.0.6' to 'firetv==1.0.6' * Address pylint errors * Ran 'python script/gen_requirements_all.py' * Address some minor reviewer comments * Run 'python script/gen_requirements_all.py' * Just use the 'requirements_all.txt' and 'requirements_test_all.txt' from the 'dev' branch... * Edit the 'requirements_all.txt' file manually * Pass flake8 tests * Pass pylint tests, add extended description for 'select_source' * More precise exception catching * More Pythonic returns * Import exceptions inside '__init__' * Remove 'time.sleep' command * Sort the imports * Use 'config[key]' instead of 'config.get(key)' * Remove accessing of hidden attributes; bump firetv version to 1.0.7 * Bump firetv to 1.0.7 in 'requirements_all.txt' * Don't access 'self.firetv._adb', use 'self.available' instead * Remove '_host' and '_adbkey' attributes * Create the 'FireTV' object in 'setup_platform' and check the connection before instantiating the entity * Fixed config validation for 'adbkey' * add_devices -> add_entities * Remove 'pylint: disable=no-name-in-module' * Don't assume the device is available after attempting to connect * Update the state after reconnecting * Modifications to 'adb_decorator' * Modifications to 'setup_platform' * Don't update the state if the ADB reconnect attempt was unsuccessful * 'return None' -> 'return' * Use 'threading.Lock()' instead of a boolean for 'adb_lock' * Use a non-blocking 'threading.Lock' --- .../components/media_player/firetv.py | 353 ++++++++++-------- requirements_all.txt | 3 + 2 files changed, 210 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 3914d2381b2..0c1984b3bce 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -4,166 +4,147 @@ Support for functionality to interact with FireTV devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.firetv/ """ +import functools import logging - -import requests +import threading import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, MediaPlayerDevice) + MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, ) from homeassistant.const import ( - CONF_DEVICE, CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, - STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_UNKNOWN) + CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, + STATE_PLAYING, STATE_STANDBY) import homeassistant.helpers.config_validation as cv +REQUIREMENTS = ['firetv==1.0.7'] + _LOGGER = logging.getLogger(__name__) SUPPORT_FIRETV = SUPPORT_PAUSE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET | \ - SUPPORT_PLAY + SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ + SUPPORT_VOLUME_SET | SUPPORT_PLAY + +CONF_ADBKEY = 'adbkey' +CONF_GET_SOURCE = 'get_source' +CONF_GET_SOURCES = 'get_sources' -DEFAULT_SSL = False -DEFAULT_DEVICE = 'default' -DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Amazon Fire TV' -DEFAULT_PORT = 5556 -DEVICE_ACTION_URL = '{0}://{1}:{2}/devices/action/{3}/{4}' -DEVICE_LIST_URL = '{0}://{1}:{2}/devices/list' -DEVICE_STATE_URL = '{0}://{1}:{2}/devices/state/{3}' -DEVICE_APPS_URL = '{0}://{1}:{2}/devices/{3}/apps/{4}' +DEFAULT_PORT = 5555 +DEFAULT_GET_SOURCE = True +DEFAULT_GET_SOURCES = True + + +def has_adb_files(value): + """Check that ADB key files exist.""" + priv_key = value + pub_key = '{}.pub'.format(value) + cv.isfile(pub_key) + return cv.isfile(priv_key) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_ADBKEY): has_adb_files, + vol.Optional(CONF_GET_SOURCE, default=DEFAULT_GET_SOURCE): cv.boolean, + vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean }) +PACKAGE_LAUNCHER = "com.amazon.tv.launcher" +PACKAGE_SETTINGS = "com.amazon.tv.settings" + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the FireTV platform.""" - name = config.get(CONF_NAME) - ssl = config.get(CONF_SSL) - proto = 'https' if ssl else 'http' - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - device_id = config.get(CONF_DEVICE) + from firetv import FireTV - try: - response = requests.get( - DEVICE_LIST_URL.format(proto, host, port)).json() - if device_id in response[CONF_DEVICES].keys(): - add_entities([FireTVDevice(proto, host, port, device_id, name)]) - _LOGGER.info("Device %s accessible and ready for control", - device_id) - else: - _LOGGER.warning("Device %s is not registered with firetv-server", - device_id) - except requests.exceptions.RequestException: - _LOGGER.error("Could not connect to firetv-server at %s", host) + host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT]) + + if CONF_ADBKEY in config: + ftv = FireTV(host, config[CONF_ADBKEY]) + adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY]) + else: + ftv = FireTV(host) + adb_log = "" + + if not ftv.available: + _LOGGER.warning("Could not connect to Fire TV at %s%s", host, adb_log) + return + + name = config[CONF_NAME] + get_source = config[CONF_GET_SOURCE] + get_sources = config[CONF_GET_SOURCES] + + device = FireTVDevice(ftv, name, get_source, get_sources) + add_entities([device]) + _LOGGER.info("Setup Fire TV at %s%s", host, adb_log) -class FireTV: - """The firetv-server client. +def adb_decorator(override_available=False): + """Send an ADB command if the device is available and not locked.""" + def adb_wrapper(func): + """Wait if previous ADB commands haven't finished.""" + @functools.wraps(func) + def _adb_wrapper(self, *args, **kwargs): + # If the device is unavailable, don't do anything + if not self.available and not override_available: + return None - Should a native Python 3 ADB module become available, python-firetv can - support Python 3, it can be added as a dependency, and this class can be - dispensed of. + # If an ADB command is already running, skip this command + if not self.adb_lock.acquire(blocking=False): + _LOGGER.info('Skipping an ADB command because a previous ' + 'command is still running') + return None - For now, it acts as a client to the firetv-server HTTP server (which must - be running via Python 2). - """ + # Additional ADB commands will be prevented while trying this one + try: + returns = func(self, *args, **kwargs) + except self.exceptions: + _LOGGER.error('Failed to execute an ADB command; will attempt ' + 'to re-establish the ADB connection in the next ' + 'update') + returns = None + self._available = False # pylint: disable=protected-access + finally: + self.adb_lock.release() - def __init__(self, proto, host, port, device_id): - """Initialize the FireTV server.""" - self.proto = proto - self.host = host - self.port = port - self.device_id = device_id + return returns - @property - def state(self): - """Get the device state. An exception means UNKNOWN state.""" - try: - response = requests.get( - DEVICE_STATE_URL.format( - self.proto, self.host, self.port, self.device_id - ), timeout=10).json() - return response.get('state', STATE_UNKNOWN) - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve device state for %s", self.device_id) - return STATE_UNKNOWN + return _adb_wrapper - @property - def current_app(self): - """Return the current app.""" - try: - response = requests.get( - DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, 'current' - ), timeout=10).json() - _current_app = response.get('current_app') - if _current_app: - return _current_app.get('package') - - return None - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve current app for %s", self.device_id) - return None - - @property - def running_apps(self): - """Return a list of running apps.""" - try: - response = requests.get( - DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, 'running' - ), timeout=10).json() - return response.get('running_apps') - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve running apps for %s", self.device_id) - return None - - def action(self, action_id): - """Perform an action on the device.""" - try: - requests.get(DEVICE_ACTION_URL.format( - self.proto, self.host, self.port, self.device_id, action_id - ), timeout=10) - except requests.exceptions.RequestException: - _LOGGER.error( - "Action request for %s was not accepted for device %s", - action_id, self.device_id) - - def start_app(self, app_name): - """Start an app.""" - try: - requests.get(DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, - app_name + '/start'), timeout=10) - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not start %s on %s", app_name, self.device_id) + return adb_wrapper class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" - def __init__(self, proto, host, port, device, name): + def __init__(self, ftv, name, get_source, get_sources): """Initialize the FireTV device.""" - self._firetv = FireTV(proto, host, port, device) + from adb.adb_protocol import ( + InvalidCommandError, InvalidResponseError, InvalidChecksumError) + + self.firetv = ftv + self._name = name - self._state = STATE_UNKNOWN - self._running_apps = None + self._get_source = get_source + self._get_sources = get_sources + + # whether or not the ADB connection is currently in use + self.adb_lock = threading.Lock() + + # ADB exceptions to catch + self.exceptions = (TypeError, ValueError, AttributeError, + InvalidCommandError, InvalidResponseError, + InvalidChecksumError) + + self._state = None + self._available = self.firetv.available self._current_app = None + self._running_apps = None @property def name(self): @@ -185,6 +166,11 @@ class FireTVDevice(MediaPlayerDevice): """Return the state of the player.""" return self._state + @property + def available(self): + """Return whether or not the ADB connection is valid.""" + return self._available + @property def source(self): """Return the current app.""" @@ -195,60 +181,135 @@ class FireTVDevice(MediaPlayerDevice): """Return a list of running apps.""" return self._running_apps + @adb_decorator(override_available=True) def update(self): """Get the latest date and update device state.""" - self._state = { - 'idle': STATE_IDLE, - 'off': STATE_OFF, - 'play': STATE_PLAYING, - 'pause': STATE_PAUSED, - 'standby': STATE_STANDBY, - 'disconnected': STATE_UNKNOWN, - }.get(self._firetv.state, STATE_UNKNOWN) - - if self._state not in [STATE_OFF, STATE_UNKNOWN]: - self._running_apps = self._firetv.running_apps - self._current_app = self._firetv.current_app - else: + # Check if device is disconnected. + if not self._available: self._running_apps = None self._current_app = None + # Try to connect + self.firetv.connect() + self._available = self.firetv.available + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Check if device is off. + if not self.firetv.screen_on: + self._state = STATE_OFF + self._running_apps = None + self._current_app = None + + # Check if screen saver is on. + elif not self.firetv.awake: + self._state = STATE_IDLE + self._running_apps = None + self._current_app = None + + else: + # Get the running apps. + if self._get_sources: + self._running_apps = self.firetv.running_apps + + # Get the current app. + if self._get_source: + current_app = self.firetv.current_app + if isinstance(current_app, dict)\ + and 'package' in current_app: + self._current_app = current_app['package'] + else: + self._current_app = current_app + + # Show the current app as the only running app. + if not self._get_sources: + if self._current_app: + self._running_apps = [self._current_app] + else: + self._running_apps = None + + # Check if the launcher is active. + if self._current_app in [PACKAGE_LAUNCHER, + PACKAGE_SETTINGS]: + self._state = STATE_STANDBY + + # Check for a wake lock (device is playing). + elif self.firetv.wake_lock: + self._state = STATE_PLAYING + + # Otherwise, device is paused. + else: + self._state = STATE_PAUSED + + # Don't get the current app. + elif self.firetv.wake_lock: + # Check for a wake lock (device is playing). + self._state = STATE_PLAYING + else: + # Assume the devices is on standby. + self._state = STATE_STANDBY + + @adb_decorator() def turn_on(self): """Turn on the device.""" - self._firetv.action('turn_on') + self.firetv.turn_on() + @adb_decorator() def turn_off(self): """Turn off the device.""" - self._firetv.action('turn_off') + self.firetv.turn_off() + @adb_decorator() def media_play(self): """Send play command.""" - self._firetv.action('media_play') + self.firetv.media_play() + @adb_decorator() def media_pause(self): """Send pause command.""" - self._firetv.action('media_pause') + self.firetv.media_pause() + @adb_decorator() def media_play_pause(self): """Send play/pause command.""" - self._firetv.action('media_play_pause') + self.firetv.media_play_pause() + @adb_decorator() + def media_stop(self): + """Send stop (back) command.""" + self.firetv.back() + + @adb_decorator() def volume_up(self): """Send volume up command.""" - self._firetv.action('volume_up') + self.firetv.volume_up() + @adb_decorator() def volume_down(self): """Send volume down command.""" - self._firetv.action('volume_down') + self.firetv.volume_down() + @adb_decorator() def media_previous_track(self): """Send previous track command (results in rewind).""" - self._firetv.action('media_previous') + self.firetv.media_previous() + @adb_decorator() def media_next_track(self): """Send next track command (results in fast-forward).""" - self._firetv.action('media_next') + self.firetv.media_next() + @adb_decorator() def select_source(self, source): - """Select input source.""" - self._firetv.start_app(source) + """Select input source. + + If the source starts with a '!', then it will close the app instead of + opening it. + """ + if isinstance(source, str): + if not source.startswith('!'): + self.firetv.launch_app(source) + else: + self.firetv.stop_app(source[1:].lstrip()) diff --git a/requirements_all.txt b/requirements_all.txt index 67b0bebdd97..098f8d25c19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -379,6 +379,9 @@ fiblary3==0.1.7 # homeassistant.components.sensor.fints fints==1.0.1 +# homeassistant.components.media_player.firetv +firetv==1.0.7 + # homeassistant.components.sensor.fitbit fitbit==0.3.0