From 89d5bb29bcde696a6b38e44dc1b900c2a0960832 Mon Sep 17 00:00:00 2001 From: ccostan Date: Mon, 24 Sep 2018 15:21:02 -0400 Subject: [PATCH] Let's give this a shot #421. Perfect timing since my Google ChromeCast Audios got jacked thanks to my new crappy router. --- config/configuration.yaml | 14 +- .../custom_components/media_player/alexa.py | 1077 +++++++++++++++++ config/group/media_players.yaml | 8 +- config/packages/alexa_tts.yaml | 82 ++ config/script/speech_engine.yaml | 5 +- config/script/speech_processing.yaml | 10 +- config/travis_secrets.yaml | 4 +- 7 files changed, 1183 insertions(+), 17 deletions(-) create mode 100755 config/custom_components/media_player/alexa.py create mode 100755 config/packages/alexa_tts.yaml diff --git a/config/configuration.yaml b/config/configuration.yaml index fd3d29e0..5579c9e0 100755 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -233,13 +233,13 @@ climate: # name: Living Room TV # scan_interval: 180 -tts: - - platform: amazon_polly - aws_access_key_id: !secret aws_access_key_ID - aws_secret_access_key: !secret aws_secret_access_key - region_name: 'us-east-1' - text_type: ssml - cache: True +# tts: +# - platform: amazon_polly +# aws_access_key_id: !secret aws_access_key_ID +# aws_secret_access_key: !secret aws_secret_access_key +# region_name: 'us-east-1' +# text_type: ssml +# cache: True # cache_dir: /data/tts wink: diff --git a/config/custom_components/media_player/alexa.py b/config/custom_components/media_player/alexa.py new file mode 100755 index 00000000..02e82beb --- /dev/null +++ b/config/custom_components/media_player/alexa.py @@ -0,0 +1,1077 @@ +""" +Support to interface with Alexa Devices. + +For more details about this platform, please refer to the documentation at +https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 +VERSION 0.9.5 +""" +import logging + +from datetime import timedelta + +import requests +import voluptuous as vol + +from homeassistant import util +from homeassistant.components.media_player import ( + MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, + SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, + SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_SET, + MediaPlayerDevice, DOMAIN, MEDIA_PLAYER_SCHEMA, + SUPPORT_SELECT_SOURCE) +from homeassistant.const import ( + CONF_EMAIL, CONF_PASSWORD, CONF_URL, + STATE_IDLE, STATE_STANDBY, STATE_PAUSED, + STATE_PLAYING) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.event import track_utc_time_change +# from homeassistant.util.json import load_json, save_json +# from homeassistant.util import dt as dt_util + +SUPPORT_ALEXA = (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | + SUPPORT_NEXT_TRACK | SUPPORT_STOP | + SUPPORT_VOLUME_SET | SUPPORT_PLAY | + SUPPORT_PLAY_MEDIA | SUPPORT_TURN_OFF | + SUPPORT_VOLUME_MUTE | SUPPORT_PAUSE | + SUPPORT_SELECT_SOURCE) +_CONFIGURING = [] +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['beautifulsoup4==4.6.0', 'simplejson==3.16.0'] + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=15) +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) + +ALEXA_DATA = "alexa_media" + +SERVICE_ALEXA_TTS = 'alexa_tts' + +ATTR_MESSAGE = 'message' +ALEXA_TTS_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_MESSAGE): cv.string, +}) + +CONF_DEBUG = 'debug' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_DEBUG, default=False): cv.boolean, +}) + + +def request_configuration(hass, config, setup_platform_callback, + status=None): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + async def configuration_callback(callback_data): + """Handle the submitted configuration.""" + hass.async_add_job(setup_platform_callback, callback_data) + + # Get Captcha + if (status and 'captcha_image_url' in status and + status['captcha_image_url'] is not None): + config_id = configurator.request_config( + "Alexa Media Player - Captcha", configuration_callback, + description=('Please enter the text for the captcha.' + ' Please enter anything if the image is missing.' + ), + description_image=status['captcha_image_url'], + submit_caption="Confirm", + fields=[{'id': 'captcha', 'name': 'Captcha'}] + ) + elif (status and 'securitycode_required' in status and + status['securitycode_required']): # Get 2FA code + config_id = configurator.request_config( + "Alexa Media Player - 2FA", configuration_callback, + description=('Please enter your Two-Factor Security code.'), + submit_caption="Confirm", + fields=[{'id': 'securitycode', 'name': 'Security Code'}] + ) + elif (status and 'claimspicker_required' in status and + status['claimspicker_required']): # Get picker method + options = status['claimspicker_message'] + config_id = configurator.request_config( + "Alexa Media Player - Verification Method", configuration_callback, + description=('Please select the verification method. ' + '(e.g., sms or email).
{}').format( + options + ), + submit_caption="Confirm", + fields=[{'id': 'claimsoption', 'name': 'Option'}] + ) + elif (status and 'verificationcode_required' in status and + status['verificationcode_required']): # Get picker method + config_id = configurator.request_config( + "Alexa Media Player - Verification Code", configuration_callback, + description=('Please enter received verification code.'), + submit_caption="Confirm", + fields=[{'id': 'verificationcode', 'name': 'Verification Code'}] + ) + else: # Check login + config_id = configurator.request_config( + "Alexa Media Player - Begin", configuration_callback, + description=('Please hit confirm to begin login attempt.'), + submit_caption="Confirm", + fields=[] + ) + _CONFIGURING.append(config_id) + if (len(_CONFIGURING) > 0 and 'error_message' in status + and status['error_message']): + configurator.notify_errors( # use sync to delay next pop + _CONFIGURING[len(_CONFIGURING)-1], status['error_message']) + if (len(_CONFIGURING) > 1): + configurator.async_request_done(_CONFIGURING.pop(0)) + + +def setup_platform(hass, config, add_devices_callback, + discovery_info=None): + """Set up the Alexa platform.""" + if ALEXA_DATA not in hass.data: + hass.data[ALEXA_DATA] = {} + + email = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + url = config.get(CONF_URL) + + login = AlexaLogin(url, email, password, hass.config.path, + config.get(CONF_DEBUG)) + + async def setup_platform_callback(callback_data): + _LOGGER.debug(("Status: {} got captcha: {} securitycode: {}" + " Claimsoption: {} VerificationCode: {}").format( + login.status, + callback_data.get('captcha'), + callback_data.get('securitycode'), + callback_data.get('claimsoption'), + callback_data.get('verificationcode'))) + login.login(captcha=callback_data.get('captcha'), + securitycode=callback_data.get('securitycode'), + claimsoption=callback_data.get('claimsoption'), + verificationcode=callback_data.get('verificationcode')) + testLoginStatus(hass, config, add_devices_callback, login, + setup_platform_callback) + + testLoginStatus(hass, config, add_devices_callback, login, + setup_platform_callback) + + +def testLoginStatus(hass, config, add_devices_callback, login, + setup_platform_callback): + """Test the login status.""" + if 'login_successful' in login.status and login.status['login_successful']: + _LOGGER.debug("Setting up Alexa devices") + hass.async_add_job(setup_alexa, hass, config, + add_devices_callback, login) + return + elif ('captcha_required' in login.status and + login.status['captcha_required']): + _LOGGER.debug("Creating configurator to request captcha") + elif ('securitycode_required' in login.status and + login.status['securitycode_required']): + _LOGGER.debug("Creating configurator to request 2FA") + elif ('claimspicker_required' in login.status and + login.status['claimspicker_required']): + _LOGGER.debug("Creating configurator to select verification option") + elif ('verificationcode_required' in login.status and + login.status['verificationcode_required']): + _LOGGER.debug("Creating configurator to enter verification code") + elif ('login_failed' in login.status and + login.status['login_failed']): + _LOGGER.debug("Creating configurator to start new login attempt") + hass.async_add_job(request_configuration, hass, config, + setup_platform_callback, + login.status) + + +def setup_alexa(hass, config, add_devices_callback, login_obj): + """Set up a alexa api based on host parameter.""" + alexa_clients = hass.data[ALEXA_DATA] + # alexa_sessions = {} + track_utc_time_change(hass, lambda now: update_devices(), second=30) + + url = config.get(CONF_URL) + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update_devices(): + """Update the devices objects.""" + devices = AlexaAPI.get_devices(url, login_obj._session) + bluetooth = AlexaAPI.get_bluetooth(url, login_obj._session) + + if ((devices is None or bluetooth is None) + and len(_CONFIGURING) == 0): + _LOGGER.debug("Alexa API disconnected; attempting to relogin") + login_obj.login_with_cookie() + + new_alexa_clients = [] + available_client_ids = [] + for device in devices: + + for b_state in bluetooth['bluetoothStates']: + if device['serialNumber'] == b_state['deviceSerialNumber']: + device['bluetooth_state'] = b_state + + available_client_ids.append(device['serialNumber']) + + if device['serialNumber'] not in alexa_clients: + new_client = AlexaClient(config, login_obj._session, device, + update_devices, url) + alexa_clients[device['serialNumber']] = new_client + new_alexa_clients.append(new_client) + elif device['online']: + alexa_clients[device['serialNumber']].refresh(device) + + if new_alexa_clients: + def tts_handler(call): + for alexa in service_to_entities(call): + if call.service == SERVICE_ALEXA_TTS: + message = call.data.get(ATTR_MESSAGE) + alexa.send_tts(message) + + def service_to_entities(call): + """Return the known devices that a service call mentions.""" + entity_ids = extract_entity_ids(hass, call) + if entity_ids: + entities = [entity for entity in new_alexa_clients + if entity.entity_id in entity_ids] + else: + entities = None + + return entities + + hass.services.register(DOMAIN, SERVICE_ALEXA_TTS, tts_handler, + schema=ALEXA_TTS_SCHEMA) + add_devices_callback(new_alexa_clients) + + update_devices() + # Clear configurator. We delay till here to avoid leaving a modal orphan + global _CONFIGURING + for config_id in _CONFIGURING: + configurator = hass.components.configurator + configurator.async_request_done(config_id) + _CONFIGURING = [] + + +class AlexaClient(MediaPlayerDevice): + """Representation of a Alexa device.""" + + def __init__(self, config, session, device, update_devices, url): + """Initialize the Alexa device.""" + # Class info + self.alexa_api = AlexaAPI(self, session, url) + + self.update_devices = update_devices + # Device info + self._device = None + self._device_name = None + self._device_serial_number = None + self._device_type = None + self._device_family = None + self._device_owner_customer_id = None + self._software_version = None + self._available = None + self._capabilities = [] + # Media + self._session = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_pos = None + self._media_album_name = None + self._media_artist = None + self._player_state = None + self._media_is_muted = None + self._media_vol_level = None + self._previous_volume = None + self._source = None + self._source_list = [] + self.refresh(device) + + def _clear_media_details(self): + """Set all Media Items to None.""" + # General + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_pos = None + self._media_album_name = None + self._media_artist = None + self._media_player_state = None + self._media_is_muted = None + self._media_vol_level = None + + def refresh(self, device): + """Refresh key device data.""" + self._device = device + self._device_name = device['accountName'] + self._device_family = device['deviceFamily'] + self._device_type = device['deviceType'] + self._device_serial_number = device['serialNumber'] + self._device_owner_customer_id = device['deviceOwnerCustomerId'] + self._software_version = device['softwareVersion'] + self._available = device['online'] + self._capabilities = device['capabilities'] + self._bluetooth_state = device['bluetooth_state'] + self._source = self._get_source() + self._source_list = self._get_source_list() + session = self.alexa_api.get_state() + + self._clear_media_details() + # update the session if it exists; not doing relogin here + if session is not None: + self._session = session + if 'playerInfo' in self._session: + self._session = self._session['playerInfo'] + if self._session['state'] is not None: + self._media_player_state = self._session['state'] + self._media_pos = (self._session['progress']['mediaProgress'] + if (self._session['progress'] is not None + and 'mediaProgress' in + self._session['progress']) + else None) + self._media_is_muted = (self._session['volume']['muted'] + if (self._session['volume'] is not None + and 'muted' in + self._session['volume']) + else None) + self._media_vol_level = (self._session['volume'] + ['volume'] / 100 + if(self._session['volume'] is not None + and 'volume' in + self._session['volume']) + else None) + self._media_title = (self._session['infoText']['title'] + if (self._session['infoText'] is not None + and 'title' in + self._session['infoText']) + else None) + self._media_artist = (self._session['infoText']['subText1'] + if (self._session['infoText'] is not None + and 'subText1' in + self._session['infoText']) + else None) + self._media_album_name = (self._session['infoText']['subText2'] + if (self._session['infoText'] is not + None and 'subText2' in + self._session['infoText']) + else None) + self._media_image_url = (self._session['mainArt']['url'] + if (self._session['mainArt'] is not + None and 'url' in + self._session['mainArt']) + else None) + self._media_duration = (self._session['progress'] + ['mediaLength'] + if (self._session['progress'] is not + None and 'mediaLength' in + self._session['progress']) + else None) + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + def select_source(self, source): + """Select input source.""" + if source == 'Local Speaker': + self.alexa_api.disconnect_bluetooth() + self._source = 'Local Speaker' + elif self._bluetooth_state['pairedDeviceList'] is not None: + for devices in self._bluetooth_state['pairedDeviceList']: + if devices['friendlyName'] == source: + self.alexa_api.set_bluetooth(devices['address']) + self._source = source + + def _get_source(self): + source = 'Local Speaker' + if self._bluetooth_state['pairedDeviceList'] is not None: + for device in self._bluetooth_state['pairedDeviceList']: + if device['connected'] is True: + return device['friendlyName'] + return source + + def _get_source_list(self): + sources = [] + if self._bluetooth_state['pairedDeviceList'] is not None: + for devices in self._bluetooth_state['pairedDeviceList']: + sources.append(devices['friendlyName']) + return ['Local Speaker'] + sources + + @property + def available(self): + """Return the availability of the client.""" + return self._available + + @property + def unique_id(self): + """Return the id of this Alexa client.""" + return self.device_serial_number + + @property + def name(self): + """Return the name of the device.""" + return self._device_name + + @property + def device_serial_number(self): + """Return the machine identifier of the device.""" + return self._device_serial_number + + @property + def device(self): + """Return the device, if any.""" + return self._device + + @property + def session(self): + """Return the session, if any.""" + return self._session + + @property + def state(self): + """Return the state of the device.""" + if self._media_player_state == 'PLAYING': + return STATE_PLAYING + elif self._media_player_state == 'PAUSED': + return STATE_PAUSED + elif self._media_player_state == 'IDLE': + return STATE_IDLE + return STATE_STANDBY + + def update(self): + """Get the latest details.""" + self.update_devices(no_throttle=True) + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + if self.state in [STATE_PLAYING, STATE_PAUSED]: + return MEDIA_TYPE_MUSIC + return STATE_STANDBY + + @property + def media_artist(self): + """Return the artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_album_name(self): + """Return the album name of current playing media, music track only.""" + return self._media_album_name + + @property + def media_duration(self): + """Return the duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_image_url(self): + """Return the image URL of current playing media.""" + return self._media_image_url + + @property + def media_title(self): + """Return the title of current playing media.""" + return self._media_title + + @property + def device_family(self): + """Return the make of the device (ex. Echo, Other).""" + return self._device_family + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ALEXA + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + if not (self.state in [STATE_PLAYING, STATE_PAUSED] + and self.available): + return + self.alexa_api.set_volume(volume) + self._media_vol_level = volume + + @property + def volume_level(self): + """Return the volume level of the client (0..1).""" + return self._media_vol_level + + @property + def is_volume_muted(self): + """Return boolean if volume is currently muted.""" + if self.volume_level == 0: + return True + return False + + def mute_volume(self, mute): + """Mute the volume. + + Since we can't actually mute, we'll: + - On mute, store volume and set volume to 0 + - On unmute, set volume to previously stored volume + """ + if not (self.state == STATE_PLAYING and self.available): + return + + self._media_is_muted = mute + if mute: + self._previous_volume = self.volume_level + self.alexa_api.set_volume(0) + else: + if self._previous_volume is not None: + self.alexa_api.set_volume(self._previous_volume) + else: + self.alexa_api.set_volume(50) + + def media_play(self): + """Send play command.""" + if not (self.state in [STATE_PLAYING, STATE_PAUSED] + and self.available): + return + self.alexa_api.play() + + def media_pause(self): + """Send pause command.""" + if not (self.state in [STATE_PLAYING, STATE_PAUSED] + and self.available): + return + self.alexa_api.pause() + + def turn_off(self): + """Turn the client off.""" + # Fake it since we can't turn the client off + self.media_pause() + + def media_next_track(self): + """Send next track command.""" + if not (self.state in [STATE_PLAYING, STATE_PAUSED] + and self.available): + return + self.alexa_api.next() + + def media_previous_track(self): + """Send previous track command.""" + if not (self.state in [STATE_PLAYING, STATE_PAUSED] + and self.available): + return + self.alexa_api.previous() + + def send_tts(self, message): + """Send TTS to Device NOTE: Does not work on WHA Groups.""" + self.alexa_api.send_tts(message) + + def play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the media player.""" + if media_type == "music": + self.alexa_api.send_tts("Sorry, text to speech can only be called " + " with the media player alexa tts service") + else: + self.alexa_api.play_music(media_type, media_id) + + @property + def device_state_attributes(self): + """Return the scene state attributes.""" + attr = { + 'available': self._available, + } + return attr + + +class AlexaLogin(): + """Class to handle login connection to Alexa.""" + + def __init__(self, url, email, password, configpath, debug=False): + """Set up initial connection and log in.""" + self._url = url + self._email = email + self._password = password + self._session = None + self._data = None + self.status = {} + self._cookiefile = configpath("{}.pickle".format(ALEXA_DATA)) + self._debugpost = configpath("{}post.html".format(ALEXA_DATA)) + self._debugget = configpath("{}get.html".format(ALEXA_DATA)) + self._lastreq = None + self._debug = debug + + self.login_with_cookie() + + def login_with_cookie(self): + """Attempt to login after loading cookie.""" + import pickle + cookies = None + + if (self._cookiefile): + try: + _LOGGER.debug( + "Trying cookie from file {}".format( + self._cookiefile)) + with open(self._cookiefile, 'rb') as myfile: + cookies = pickle.load(myfile) + _LOGGER.debug("cookie loaded: {}".format(cookies)) + except Exception as ex: + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.debug( + "Error loading pickled cookie from {}: {}".format( + self._cookiefile, message)) + + self.login(cookies=cookies) + + def reset_login(self): + """Remove data related to existing login.""" + self._session = None + self._data = None + self._lastreq = None + self.status = {} + + def get_inputs(self, soup, searchfield={'name': 'signIn'}): + """Parse soup for form with searchfield.""" + data = {} + form = soup.find('form', searchfield) + for field in form.find_all('input'): + try: + data[field['name']] = "" + data[field['name']] = field['value'] + except: # noqa: E722 pylint: disable=bare-except + pass + return data + + def test_loggedin(self, cookies=None): + """Function that will test the connection is logged in. + + Attempts to get device list, and if unsuccessful login failed + """ + if self._session is None: + '''initiate session''' + + self._session = requests.Session() + + '''define session headers''' + self._session.headers = { + 'User-Agent': ('Mozilla/5.0 (Windows NT 6.3; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/68.0.3440.106 Safari/537.36'), + 'Accept': ('text/html,application/xhtml+xml, ' + 'application/xml;q=0.9,*/*;q=0.8'), + 'Accept-Language': '*' + } + self._session.cookies = cookies + + get_resp = self._session.get('https://alexa.' + self._url + + '/api/devices-v2/device') + # with open(self._debugget, mode='wb') as localfile: + # localfile.write(get_resp.content) + + try: + from json.decoder import JSONDecodeError + from simplejson import JSONDecodeError as SimpleJSONDecodeError + # Need to catch both as Python 3.5 appears to use simplejson + except ImportError: + JSONDecodeError = ValueError + try: + get_resp.json() + except (JSONDecodeError, SimpleJSONDecodeError) as ex: + # ValueError is necessary for Python 3.5 for some reason + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.debug("Not logged in: {}".format(message)) + return False + _LOGGER.debug("Logged in.") + return True + + def login(self, cookies=None, captcha=None, securitycode=None, + claimsoption=None, verificationcode=None): + """Login to Amazon.""" + from bs4 import BeautifulSoup + import pickle + + if (cookies is not None and self.test_loggedin(cookies)): + _LOGGER.debug("Using cookies to log in") + self.status = {} + self.status['login_successful'] = True + _LOGGER.debug("Log in successful with cookies") + return + else: + _LOGGER.debug("No valid cookies for log in; using credentials") + # site = 'https://www.' + self._url + '/gp/sign-in.html' + # use alexa site instead + site = 'https://alexa.' + self._url + '/api/devices-v2/device' + if self._session is None: + '''initiate session''' + + self._session = requests.Session() + + '''define session headers''' + self._session.headers = { + 'User-Agent': ('Mozilla/5.0 (Windows NT 6.3; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/68.0.3440.106 Safari/537.36'), + 'Accept': ('text/html,application/xhtml+xml, ' + 'application/xml;q=0.9,*/*;q=0.8'), + 'Accept-Language': '*' + } + + if self._lastreq is not None: + site = self._lastreq.url + _LOGGER.debug("Loaded last request to {} ".format(site)) + html = self._lastreq.text + '''get BeautifulSoup object of the html of the login page''' + if self._debug: + with open(self._debugget, mode='wb') as localfile: + localfile.write(self._lastreq.content) + + soup = BeautifulSoup(html, 'html.parser') + site = soup.find('form').get('action') + if site is None: + site = self._lastreq.url + elif site == 'verify': + import re + site = re.search(r'(.+)/(.*)', + self._lastreq.url).groups()[0] + "/verify" + + if self._data is None: + resp = self._session.get(site) + self._lastreq = resp + if resp.history: + _LOGGER.debug("Get to {} was redirected to {}".format( + site, + resp.url)) + self._session.headers['Referer'] = resp.url + else: + _LOGGER.debug("Get to {} was not redirected".format(site)) + self._session.headers['Referer'] = site + + html = resp.text + '''get BeautifulSoup object of the html of the login page''' + if self._debug: + with open(self._debugget, mode='wb') as localfile: + localfile.write(resp.content) + + soup = BeautifulSoup(html, 'html.parser') + '''scrape login page to get all the inputs required for login''' + self._data = self.get_inputs(soup) + site = soup.find('form', {'name': 'signIn'}).get('action') + + # _LOGGER.debug("Init Form Data: {}".format(self._data)) + + '''add username and password to the data for post request''' + '''check if there is an input field''' + if "email" in self._data: + self._data['email'] = self._email.encode('utf-8') + if "password" in self._data: + self._data['password'] = self._password.encode('utf-8') + if "rememberMe" in self._data: + self._data['rememberMe'] = "true".encode('utf-8') + + status = {} + _LOGGER.debug(("Preparing post to {} Captcha: {}" + " SecurityCode: {} Claimsoption: {} " + "VerificationCode: {}").format( + site, + captcha, + securitycode, + claimsoption, + verificationcode + )) + if (captcha is not None and 'guess' in self._data): + self._data['guess'] = captcha.encode('utf-8') + if (securitycode is not None and 'otpCode' in self._data): + self._data['otpCode'] = securitycode.encode('utf-8') + self._data['rememberDevice'] = "" + if (claimsoption is not None and 'option' in self._data): + self._data['option'] = claimsoption.encode('utf-8') + if (verificationcode is not None and 'code' in self._data): + self._data['code'] = verificationcode.encode('utf-8') + self._session.headers['Content-Type'] = ("application/x-www-form-" + "urlencoded; charset=utf-8") + self._data.pop('', None) + + if self._debug: + _LOGGER.debug("Cookies: {}".format(self._session.cookies)) + _LOGGER.debug("Submit Form Data: {}".format(self._data)) + _LOGGER.debug("Header: {}".format(self._session.headers)) + + '''submit post request with username/password and other needed info''' + post_resp = self._session.post(site, data=self._data) + self._session.headers['Referer'] = site + + self._lastreq = post_resp + if self._debug: + with open(self._debugpost, mode='wb') as localfile: + localfile.write(post_resp.content) + + post_soup = BeautifulSoup(post_resp.content, 'html.parser') + + login_tag = post_soup.find('form', {'name': 'signIn'}) + captcha_tag = post_soup.find(id="auth-captcha-image") + + '''another login required and no captcha request? try once more. + This is a necessary hack as the first attempt always fails. + TODO: Figure out how to remove this hack + ''' + if (login_tag is not None and captcha_tag is None): + login_url = login_tag.get("action") + _LOGGER.debug("Performing second login to: {}".format( + login_url)) + post_resp = self._session.post(login_url, + data=self._data) + if self._debug: + with open(self._debugpost, mode='wb') as localfile: + localfile.write(post_resp.content) + post_soup = BeautifulSoup(post_resp.content, 'html.parser') + login_tag = post_soup.find('form', {'name': 'signIn'}) + captcha_tag = post_soup.find(id="auth-captcha-image") + + securitycode_tag = post_soup.find(id="auth-mfa-otpcode") + errorbox = (post_soup.find(id="auth-error-message-box") + if post_soup.find(id="auth-error-message-box") else + post_soup.find(id="auth-warning-message-box")) + claimspicker_tag = post_soup.find('form', {'name': 'claimspicker'}) + verificationcode_tag = post_soup.find('form', {'action': 'verify'}) + + '''pull out Amazon error message''' + + if errorbox: + error_message = errorbox.find('h4').string + for li in errorbox.findAll('li'): + error_message += li.find('span').string + _LOGGER.debug("Error message: {}".format(error_message)) + status['error_message'] = error_message + + if captcha_tag is not None: + _LOGGER.debug("Captcha requested") + status['captcha_required'] = True + status['captcha_image_url'] = captcha_tag.get('src') + self._data = self.get_inputs(post_soup) + + elif securitycode_tag is not None: + _LOGGER.debug("2FA requested") + status['securitycode_required'] = True + self._data = self.get_inputs(post_soup, {'id': 'auth-mfa-form'}) + + elif claimspicker_tag is not None: + claims_message = "" + options_message = "" + for div in claimspicker_tag.findAll('div', 'a-row'): + claims_message += "{}\n".format(div.string) + for label in claimspicker_tag.findAll('label'): + value = (label.find('input')['value']) if label.find( + 'input') else "" + message = (label.find('span').string) if label.find( + 'span') else "" + valuemessage = ("Option: {} = `{}`.\n".format( + value, message)) if value != "" else "" + options_message += valuemessage + _LOGGER.debug("Verification method requested: {}".format( + claims_message, options_message)) + status['claimspicker_required'] = True + status['claimspicker_message'] = options_message + self._data = self.get_inputs(post_soup, {'name': 'claimspicker'}) + elif verificationcode_tag is not None: + _LOGGER.debug("Verification code requested:") + status['verificationcode_required'] = True + self._data = self.get_inputs(post_soup, {'action': 'verify'}) + elif login_tag is not None: + login_url = login_tag.get("action") + _LOGGER.debug("Another login requested to: {}".format( + login_url)) + status['login_failed'] = True + + else: + _LOGGER.debug("Captcha/2FA not requested; confirming login.") + if self.test_loggedin(): + _LOGGER.debug("Login confirmed; saving cookie to {}".format( + self._cookiefile)) + status['login_successful'] = True + with open(self._cookiefile, 'wb') as myfile: + try: + pickle.dump(self._session.cookies, myfile) + except Exception as ex: + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.debug( + "Error saving pickled cookie to {}: {}".format( + self._cookiefile, + message)) + else: + _LOGGER.debug("Login failed; check credentials") + status['login_failed'] = True + + self.status = status + + +class AlexaAPI(): + """Class for accessing Alexa.""" + + def __init__(self, device, session, url): + """Initialize Alexa device.""" + self._device = device + self._session = session + self._url = 'https://alexa.' + url + + csrf = self._session.cookies.get_dict()['csrf'] + self._session.headers['csrf'] = csrf + + def _post_request(self, uri, data): + try: + self._session.post(self._url + uri, json=data) + except Exception as ex: + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.error("An error occured accessing the API: {}".format( + message)) + + def _get_request(self, uri, data=None): + try: + return self._session.get(self._url + uri, json=data) + except Exception as ex: + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.error("An error occured accessing the API: {}".format( + message)) + return None + + def play_music(self, provider_id, search_phrase): + """Play Music based on search.""" + data = { + "behaviorId": "PREVIEW", + "sequenceJson": "{\"@type\": \ + \"com.amazon.alexa.behaviors.model.Sequence\", \ + \"startNode\":{\"@type\": \ + \"com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode\", \ + \"type\":\"Alexa.Music.PlaySearchPhrase\",\"operationPayload\": \ + {\"deviceType\":\"" + self._device._device_type + "\", \ + \"deviceSerialNumber\":\"" + self._device.unique_id + + "\",\"locale\":\"en-US\", \ + \"customerId\":\"" + self._device._device_owner_customer_id + + "\", \"searchPhrase\": \"" + search_phrase + "\", \ + \"sanitizedSearchPhrase\": \"" + search_phrase + "\", \ + \"musicProviderId\": \"" + provider_id + "\"}}}", + "status": "ENABLED" + } + self._post_request('/api/behaviors/preview', + data=data) + + def send_tts(self, message): + """Send message for TTS at speaker.""" + data = { + "behaviorId": "PREVIEW", + "sequenceJson": "{\"@type\": \ + \"com.amazon.alexa.behaviors.model.Sequence\", \ + \"startNode\":{\"@type\": \ + \"com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode\", \ + \"type\":\"Alexa.Speak\",\"operationPayload\": \ + {\"deviceType\":\"" + self._device._device_type + "\", \ + \"deviceSerialNumber\":\"" + self._device.unique_id + + "\",\"locale\":\"en-US\", \ + \"customerId\":\"" + self._device._device_owner_customer_id + + "\", \"textToSpeak\": \"" + message + "\"}}}", + "status": "ENABLED" + } + self._post_request('/api/behaviors/preview', + data=data) + + def set_media(self, data): + """Select the media player.""" + self._post_request('/api/np/command?deviceSerialNumber=' + + self._device.unique_id + '&deviceType=' + + self._device._device_type, data=data) + + def previous(self): + """Play previous.""" + self.set_media({"type": "PreviousCommand"}) + + def next(self): + """Play next.""" + self.set_media({"type": "NextCommand"}) + + def pause(self): + """Pause.""" + self.set_media({"type": "PauseCommand"}) + + def play(self): + """Play.""" + self.set_media({"type": "PlayCommand"}) + + def set_volume(self, volume): + """Set volume.""" + self.set_media({"type": "VolumeLevelCommand", + "volumeLevel": volume*100}) + + def get_state(self): + """Get state.""" + try: + response = self._get_request('/api/np/player?deviceSerialNumber=' + + self._device.unique_id + + '&deviceType=' + + self._device._device_type + + '&screenWidth=2560') + return response.json() + except Exception as ex: + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.error("An error occured accessing the API: {}".format( + message)) + return None + + @staticmethod + def get_bluetooth(url, session): + """Get paired bluetooth devices.""" + try: + + response = session.get('https://alexa.' + url + + '/api/bluetooth?cached=false') + return response.json() + except Exception as ex: + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.error("An error occured accessing the API: {}".format( + message)) + return None + + def set_bluetooth(self, mac): + """Pair with bluetooth device with mac address.""" + self._post_request('/api/bluetooth/pair-sink/' + + self._device._device_type + '/' + + self._device.unique_id, + data={"bluetoothDeviceAddress": mac}) + + def disconnect_bluetooth(self): + """Disconnect all bluetooth devices.""" + self._post_request('/api/bluetooth/disconnect-sink/' + + self._device._device_type + '/' + + self._device.unique_id, data=None) + + @staticmethod + def get_devices(url, session): + """Identify all Alexa devices.""" + try: + response = session.get('https://alexa.' + url + + '/api/devices-v2/device') + return response.json()['devices'] + except Exception as ex: + template = ("An exception of type {0} occurred." + " Arguments:\n{1!r}") + message = template.format(type(ex).__name__, ex.args) + _LOGGER.error("An error occured accessing the API: {}".format( + message)) + return None \ No newline at end of file diff --git a/config/group/media_players.yaml b/config/group/media_players.yaml index 506f3872..c73355b1 100755 --- a/config/group/media_players.yaml +++ b/config/group/media_players.yaml @@ -1,9 +1,13 @@ media_players: entities: - media_player.livingroomcc - - media_player.whole_house - media_player.living_room_tv - media_player.living_room_ultra - media_player.upstairs_living_room - - media_player.alarm_clock - media_player.bedroom_alarm_panel + - media_player.living_room + - media_player.office + - media_player.kitchen + - media_player.justin_room + - media_player.tap + - media_player.upstairs diff --git a/config/packages/alexa_tts.yaml b/config/packages/alexa_tts.yaml new file mode 100755 index 00000000..2d8e823b --- /dev/null +++ b/config/packages/alexa_tts.yaml @@ -0,0 +1,82 @@ +##################################################################### +# @package : alexa_tts +# @description : alexa_tts settings +##################################################################### +homeassistant: + customize: + ################################################ + ## Node Anchors + ################################################ + package.node_anchors: + customize: &customize + package: 'alexa_tts' + + hidden: &hidden + <<: *customize + hidden: true + +################################################################### +##media_player +################################################################### +media_player: + - platform: alexa + email: !secret alexa_email + password: !secret alexa_password + url: amazon.com +################################################################# +##group +################################################################# +group: +# + all_echoes: + view: yes + control: hidden + name: 'Alexa' + entities: + - media_player.living_room + - media_player.office + - media_player.kitchen + - media_player.justin_room + - media_player.tap + - media_player.upstairs +# + +################################################################## +#automation +################################################################## +# automation: +# ## Announce what is typed as input +# - alias: Alexa TTS +# trigger: +# platform: state +# entity_id: input_select.alexa +# action: +# - service: media_player.volume_set +# data_template: +# entity_id: > +# {% if is_state('input_select.alexa', 'Living Room') %} +# media_player.livingroom +# {% elif is_state('input_select.alexa', 'This device') %} +# media_player.this_device +# {% elif is_state('input_select.alexa', 'None') %} +# false +# {% endif %} +# volume_level: '{{ states.input_number.alexa_volume.state | float /10 }}' +# - service: media_player.alexa_tts +# data_template: +# entity_id: > +# {% if is_state('input_select.alexa', 'Living Room') %} +# media_player.livingroom +# {% elif is_state('input_select.alexa', 'This device') %} +# media_player.this_device +# {% elif is_state('input_select.alexa', 'None') %} +# false +# {% endif %} +# message: "{{ states.input_text.alexa_tts.state }}" +# +# - delay: '00:00:02' +# +# - service: input_select.select_option +# data: +# entity_id: input_select.alexa +# option: None diff --git a/config/script/speech_engine.yaml b/config/script/speech_engine.yaml index c97778ee..c3982735 100755 --- a/config/script/speech_engine.yaml +++ b/config/script/speech_engine.yaml @@ -31,8 +31,9 @@ speech_engine: media_player: >- {% if media_player | length == 0 %} {% set media_player = [ - 'media_player.livingroomcc', - 'media_player.bedroom_alarm_panel' + 'media_player.living_room', + 'media_player.bedroom_alarm_panel', + 'media_player.office' ] %} {% endif %} diff --git a/config/script/speech_processing.yaml b/config/script/speech_processing.yaml index 1b2a052d..36d1a555 100755 --- a/config/script/speech_processing.yaml +++ b/config/script/speech_processing.yaml @@ -58,19 +58,19 @@ speech_processing: 0.3 {% endif %} - - service: tts.amazon_polly_say + - service: media_player.alexa_tts data_template: entity_id: > {% if states.group.bed.state == 'off' %} - media_player.livingroomCC + media_player.living_room {% else %} media_player.alarm_clock, media_player.bedroom_alarm_panel {% endif %} message: >- - + # {{ speech_message }} - - cache: true + # + # cache: true - service: input_boolean.turn_off data: diff --git a/config/travis_secrets.yaml b/config/travis_secrets.yaml index 330e7ebc..aa54c8f4 100755 --- a/config/travis_secrets.yaml +++ b/config/travis_secrets.yaml @@ -9,7 +9,9 @@ MQTT_username: MQTT_username MQTT_password: password ssl_certificate: fake_key.pem ssl_key: fake_key.pem -http_base_url: your.website.com +http_base_url: vCloudInfo.com +alexa_email: carlo@vCloudInfo.COM +alexa_password: YOUTUBE_vCloudInfo ifttt_key: iftttKEYPassphrase camera1_url: http://192.168.10.21:88/cgi-bin/CGIProxy.fcgi?cmd=getDevState&usr=admin&pwd=password camera2_url: http://192.168.10.22:88/cgi-bin/CGIProxy.fcgi?cmd=getDevState&usr=admin&pwd=password