diff --git a/.coveragerc b/.coveragerc index aabeda5ad818b54242d3fc4a86d24b906c77cbf9..d7792e56d8a1b9c548a572f8fcef3a502e67d540 100644 --- a/.coveragerc +++ b/.coveragerc @@ -129,6 +129,7 @@ omit = homeassistant/components/media_player/mpd.py homeassistant/components/media_player/onkyo.py homeassistant/components/media_player/panasonic_viera.py + homeassistant/components/media_player/pandora.py homeassistant/components/media_player/pioneer.py homeassistant/components/media_player/plex.py homeassistant/components/media_player/roku.py diff --git a/homeassistant/components/media_player/pandora.py b/homeassistant/components/media_player/pandora.py new file mode 100644 index 0000000000000000000000000000000000000000..629a2ed2ba99c5317f23816d4dae153ad13bd6a8 --- /dev/null +++ b/homeassistant/components/media_player/pandora.py @@ -0,0 +1,337 @@ +""" +Component for controlling Pandora stations through the pianobar client. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/media_player.pandora/ +""" + +import logging +import re +import os +import signal +from datetime import timedelta +import shutil + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_SELECT_SOURCE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, + MediaPlayerDevice) +from homeassistant.const import (STATE_OFF, STATE_PAUSED, STATE_PLAYING, + STATE_IDLE) +from homeassistant import util + +REQUIREMENTS = ['pexpect==4.0.1'] +_LOGGER = logging.getLogger(__name__) + +# SUPPORT_VOLUME_SET is close to available but we need volume up/down +# controls in the GUI. +PANDORA_SUPPORT = \ + SUPPORT_PAUSE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | \ + SUPPORT_SELECT_SOURCE + +CMD_MAP = {SERVICE_MEDIA_NEXT_TRACK: 'n', + SERVICE_MEDIA_PLAY_PAUSE: 'p', + SERVICE_MEDIA_PLAY: 'p', + SERVICE_VOLUME_UP: ')', + SERVICE_VOLUME_DOWN: '('} +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2) +CURRENT_SONG_PATTERN = re.compile(r'"(.*?)"\s+by\s+"(.*?)"\son\s+"(.*?)"', + re.MULTILINE) +STATION_PATTERN = re.compile(r'Station\s"(.+?)"', re.MULTILINE) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the media player pandora platform.""" + if not _pianobar_exists(): + return False + pandora = PandoraMediaPlayer('Pandora') + + # make sure we end the pandora subprocess on exit in case user doesn't + # power it down. + def _stop_pianobar(_event): + pandora.turn_off() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar) + add_devices([pandora]) + + +# pylint: disable=too-many-instance-attributes +class PandoraMediaPlayer(MediaPlayerDevice): + """A media player that uses the Pianobar interface to Pandora.""" + + # pylint: disable=abstract-method + def __init__(self, name): + """Initialize the demo device.""" + MediaPlayerDevice.__init__(self) + self._name = name + self._player_state = STATE_OFF + self._station = '' + self._media_title = '' + self._media_artist = '' + self._media_album = '' + self._stations = [] + self._time_remaining = 0 + self._media_duration = 0 + self._pianobar = None + + @property + def should_poll(self): + """Should be polled for current state.""" + return True + + @property + def name(self): + """Return the name of the media player.""" + return self._name + + @property + def state(self): + """Return the state of the player.""" + return self._player_state + + def turn_on(self): + """Turn the media player on.""" + import pexpect + self._pianobar = pexpect.spawn('pianobar') + _LOGGER.info('Started pianobar subprocess') + mode = self._pianobar.expect(['Receiving new playlist', + 'Select station:']) + if mode == 1: + # station list was presented. dismiss it. + self._pianobar.sendcontrol('m') + + self._update_stations() + self.update_playing_status() + + self._player_state = STATE_IDLE + self.update_ha_state() + + def turn_off(self): + """Turn the media player off.""" + import pexpect + if self._pianobar is None: + _LOGGER.info('Pianobar subprocess already stopped') + return + self._pianobar.send('q') + try: + _LOGGER.info('Stopped Pianobar subprocess') + except pexpect.exceptions.TIMEOUT: + # kill the process group + os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM) + _LOGGER.info('Killed Pianobar subprocess') + self._pianobar = None + self._player_state = STATE_OFF + self.update_ha_state() + + def media_play(self): + """Send play command.""" + self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + self._player_state = STATE_PLAYING + self.update_ha_state() + + def media_pause(self): + """Send pause command.""" + self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE) + self._player_state = STATE_PAUSED + self.update_ha_state() + + def media_next_track(self): + """Go to next track.""" + self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK) + self.update_ha_state() + + @property + def supported_media_commands(self): + """Show what this supports.""" + return PANDORA_SUPPORT + + @property + def source(self): + """Name of the current input source.""" + return self._station + + @property + def source_list(self): + """List of available input sources.""" + return self._stations + + @property + def media_title(self): + """Title of current playing media.""" + self.update_playing_status() + return self._media_title + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + return self._media_album + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + def select_source(self, source): + """Choose a different Pandora station and play it.""" + try: + station_index = self._stations.index(source) + except ValueError: + _LOGGER.warning('Station `%s` is not in list', source) + return + _LOGGER.info('Setting station %s, %d', source, station_index) + self._pianobar.send('s') + self._pianobar.expect('Select station') + self._pianobar.sendline('{}'.format(station_index)) + self._pianobar.expect('\r\n') + self._player_state = STATE_PLAYING + + def update_playing_status(self): + """Query pianobar for info about current media_title, station.""" + response = self._query_for_playing_status() + if not response: + return + self._update_current_station(response) + self._update_current_song(response) + self._update_song_position() + + def _query_for_playing_status(self): + """Query system for info about current track.""" + import pexpect + self._clear_buffer() + self._pianobar.send('i') + try: + match_idx = self._pianobar.expect([br'(\d\d):(\d\d)/(\d\d):(\d\d)', + 'No song playing', + 'Select station', + 'Receiving new playlist']) + except pexpect.exceptions.EOF: + _LOGGER.info('Pianobar process already exited.') + return None + + self._log_match() + if match_idx == 1: + # idle. + response = None + elif match_idx == 2: + # stuck on a station selection dialog. Clear it. + _LOGGER.warning('On unexpected station list page.') + self._pianobar.sendcontrol('m') # press enter + self._pianobar.sendcontrol('m') # do it again b/c an 'i' got in + response = self.update_playing_status() + elif match_idx == 3: + _LOGGER.debug('Received new playlist list.') + response = self.update_playing_status() + else: + response = self._pianobar.before.decode('utf-8') + return response + + def _update_current_station(self, response): + """Update current station.""" + station_match = re.search(STATION_PATTERN, response) + if station_match: + self._station = station_match.group(1) + _LOGGER.debug('Got station as: %s', self._station) + else: + _LOGGER.warning('No station match. ') + + def _update_current_song(self, response): + """Update info about current song.""" + song_match = re.search(CURRENT_SONG_PATTERN, response) + if song_match: + (self._media_title, self._media_artist, + self._media_album) = song_match.groups() + _LOGGER.debug('Got song as: %s', self._media_title) + else: + _LOGGER.warning('No song match.') + + @util.Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_song_position(self): + """ + Get the song position and duration. + + It's hard to predict whether or not the music will start during init + so we have to detect state by checking the ticker. + + """ + (cur_minutes, cur_seconds, + total_minutes, total_seconds) = self._pianobar.match.groups() + time_remaining = int(cur_minutes) * 60 + int(cur_seconds) + self._media_duration = int(total_minutes) * 60 + int(total_seconds) + + if time_remaining != self._time_remaining: + _LOGGER.debug('PLAYING') + self._player_state = STATE_PLAYING + elif self._player_state == STATE_PLAYING: + _LOGGER.debug('PAUSED') + self._player_state = STATE_PAUSED + self._time_remaining = time_remaining + + def _log_match(self): + """Log grabbed values from console.""" + _LOGGER.debug('Before: %s\nMatch: %s\nAfter: %s', + repr(self._pianobar.before), + repr(self._pianobar.match), + repr(self._pianobar.after)) + + def _send_pianobar_command(self, service_cmd): + """Send a command to Pianobar.""" + command = CMD_MAP.get(service_cmd) + if command is None: + _LOGGER.info('Command %s not supported yet', service_cmd) + self._pianobar.sendline(command) + + def _update_stations(self): + """List defined Pandora stations.""" + self._pianobar.send('s') + self._pianobar.expect('Select station:') + station_lines = self._pianobar.before.decode('utf-8') + _LOGGER.debug('Getting stations: %s', station_lines) + self._stations = [] + for line in station_lines.split('\r\n'): + match = re.search(r'\d+\).....(.+)', line) + if match: + station = match.group(1).strip() + _LOGGER.debug('Found station %s', station) + self._stations.append(station) + else: + _LOGGER.debug('No station match on `%s`', line) + self._pianobar.sendcontrol('m') # press enter with blank line + self._pianobar.sendcontrol('m') # do it twice in case an 'i' got in + + def _clear_buffer(self): + """ + Clear buffer from pexpect. + + This is necessary because there are a bunch of 00:00 in the buffer + + """ + import pexpect + try: + while not self._pianobar.expect('.+', timeout=0.1): + pass + except pexpect.exceptions.TIMEOUT: + pass + + +def _pianobar_exists(): + """Verify that Pianobar is properly installed.""" + pianobar_exe = shutil.which('pianobar') + if pianobar_exe: + return True + else: + _LOGGER.warning('The Pandora component depends on the Pianobar ' + 'client, which cannot be found. Please install ' + 'using instructions at' + 'https://home-assistant.io' + '/components/media_player.pandora/') + return False diff --git a/requirements_all.txt b/requirements_all.txt index a2aec136dbb405ef878148fee738d05d1e6f1ad9..d112e3944b60e23e90143a379d15aedfa0897379 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -195,6 +195,7 @@ panasonic_viera==0.2 # homeassistant.components.device_tracker.aruba # homeassistant.components.device_tracker.asuswrt +# homeassistant.components.media_player.pandora pexpect==4.0.1 # homeassistant.components.light.hue