384 lines
12 KiB
Python
384 lines
12 KiB
Python
"""Component for controlling Pandora stations through the pianobar client."""
|
|
from datetime import timedelta
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
|
|
import pexpect
|
|
|
|
from homeassistant import util
|
|
from homeassistant.components.media_player import MediaPlayerEntity
|
|
from homeassistant.components.media_player.const import (
|
|
MEDIA_TYPE_MUSIC,
|
|
SUPPORT_NEXT_TRACK,
|
|
SUPPORT_PAUSE,
|
|
SUPPORT_PLAY,
|
|
SUPPORT_SELECT_SOURCE,
|
|
SUPPORT_TURN_OFF,
|
|
SUPPORT_TURN_ON,
|
|
)
|
|
from homeassistant.const import (
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
SERVICE_MEDIA_NEXT_TRACK,
|
|
SERVICE_MEDIA_PLAY,
|
|
SERVICE_MEDIA_PLAY_PAUSE,
|
|
SERVICE_VOLUME_DOWN,
|
|
SERVICE_VOLUME_UP,
|
|
STATE_IDLE,
|
|
STATE_OFF,
|
|
STATE_PAUSED,
|
|
STATE_PLAYING,
|
|
)
|
|
|
|
_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
|
|
| SUPPORT_PLAY
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
"""Set up the Pandora media player 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_entities([pandora])
|
|
|
|
|
|
class PandoraMediaPlayer(MediaPlayerEntity):
|
|
"""A media player that uses the Pianobar interface to Pandora."""
|
|
|
|
def __init__(self, name):
|
|
"""Initialize the Pandora device."""
|
|
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 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."""
|
|
if self._player_state != STATE_OFF:
|
|
return
|
|
self._pianobar = pexpect.spawn("pianobar")
|
|
_LOGGER.info("Started pianobar subprocess")
|
|
mode = self._pianobar.expect(
|
|
["Receiving new playlist", "Select station:", "Email:"]
|
|
)
|
|
if mode == 1:
|
|
# station list was presented. dismiss it.
|
|
self._pianobar.sendcontrol("m")
|
|
elif mode == 2:
|
|
_LOGGER.warning(
|
|
"The pianobar client is not configured to log in. "
|
|
"Please create a configuration file for it as described at "
|
|
"https://www.home-assistant.io/integrations/pandora/"
|
|
)
|
|
# pass through the email/password prompts to quit cleanly
|
|
self._pianobar.sendcontrol("m")
|
|
self._pianobar.sendcontrol("m")
|
|
self._pianobar.terminate()
|
|
self._pianobar = None
|
|
return
|
|
self._update_stations()
|
|
self.update_playing_status()
|
|
|
|
self._player_state = STATE_IDLE
|
|
self.schedule_update_ha_state()
|
|
|
|
def turn_off(self):
|
|
"""Turn the media player off."""
|
|
if self._pianobar is None:
|
|
_LOGGER.info("Pianobar subprocess already stopped")
|
|
return
|
|
self._pianobar.send("q")
|
|
try:
|
|
_LOGGER.debug("Stopped Pianobar subprocess")
|
|
self._pianobar.terminate()
|
|
except pexpect.exceptions.TIMEOUT:
|
|
# kill the process group
|
|
os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM)
|
|
_LOGGER.debug("Killed Pianobar subprocess")
|
|
self._pianobar = None
|
|
self._player_state = STATE_OFF
|
|
self.schedule_update_ha_state()
|
|
|
|
def media_play(self):
|
|
"""Send play command."""
|
|
self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
|
|
self._player_state = STATE_PLAYING
|
|
self.schedule_update_ha_state()
|
|
|
|
def media_pause(self):
|
|
"""Send pause command."""
|
|
self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
|
|
self._player_state = STATE_PAUSED
|
|
self.schedule_update_ha_state()
|
|
|
|
def media_next_track(self):
|
|
"""Go to next track."""
|
|
self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK)
|
|
self.schedule_update_ha_state()
|
|
|
|
@property
|
|
def supported_features(self):
|
|
"""Flag media player features that are supported."""
|
|
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_content_type(self):
|
|
"""Content type of current playing media."""
|
|
return MEDIA_TYPE_MUSIC
|
|
|
|
@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.debug("Setting station %s, %d", source, station_index)
|
|
self._send_station_list_command()
|
|
self._pianobar.sendline(f"{station_index}")
|
|
self._pianobar.expect("\r\n")
|
|
self._player_state = STATE_PLAYING
|
|
|
|
def _send_station_list_command(self):
|
|
"""Send a station list command."""
|
|
self._pianobar.send("s")
|
|
try:
|
|
self._pianobar.expect("Select station:", timeout=1)
|
|
except pexpect.exceptions.TIMEOUT:
|
|
# try again. Buffer was contaminated.
|
|
self._clear_buffer()
|
|
self._pianobar.send("s")
|
|
self._pianobar.expect("Select station:")
|
|
|
|
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."""
|
|
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
|
|
# pylint: disable=assignment-from-none
|
|
response = self.update_playing_status()
|
|
elif match_idx == 3:
|
|
_LOGGER.debug("Received new playlist list")
|
|
# pylint: disable=assignment-from-none
|
|
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 not in (self._time_remaining, self._media_duration):
|
|
self._player_state = STATE_PLAYING
|
|
elif self._player_state == STATE_PLAYING:
|
|
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)
|
|
_LOGGER.debug("Sending pinaobar command %s for %s", command, service_cmd)
|
|
if command is None:
|
|
_LOGGER.info("Command %s not supported yet", service_cmd)
|
|
self._clear_buffer()
|
|
self._pianobar.sendline(command)
|
|
|
|
def _update_stations(self):
|
|
"""List defined Pandora stations."""
|
|
self._send_station_list_command()
|
|
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
|
|
|
|
"""
|
|
try:
|
|
while not self._pianobar.expect(".+", timeout=0.1):
|
|
pass
|
|
except pexpect.exceptions.TIMEOUT:
|
|
pass
|
|
except pexpect.exceptions.EOF:
|
|
pass
|
|
|
|
|
|
def _pianobar_exists():
|
|
"""Verify that Pianobar is properly installed."""
|
|
pianobar_exe = shutil.which("pianobar")
|
|
if pianobar_exe:
|
|
return True
|
|
|
|
_LOGGER.warning(
|
|
"The Pandora integration depends on the Pianobar client, which "
|
|
"cannot be found. Please install using instructions at "
|
|
"https://www.home-assistant.io/integrations/media_player.pandora/"
|
|
)
|
|
return False
|