#!/usr/bin/env python3 # vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab ######################################################################### # Copyright 2013 Marcus Popp marcus@popp.mx # 2017 Nino Coric mail2n.coric@gmail.com # 2020,2022 Bernd Meiners Bernd.Meiners@mail.de ######################################################################### # This file is part of SmartHomeNG. # https://www.smarthomeNG.de # https://knx-user-forum.de/forum/supportforen/smarthome-py # # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # SmartHomeNG is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with SmartHomeNG. If not, see . ######################################################################### import logging import threading import re import datetime from lib.model.smartplugin import SmartPlugin from lib.item import Items from lib.network import Tcp_client from .webif import WebInterface class MPD(SmartPlugin): PLUGIN_VERSION = "1.6.1" STATUS = 'mpd_status' SONGINFO = 'mpd_songinfo' STATISTIC = 'mpd_statistic' COMMAND = 'mpd_command' URL = 'mpd_url' LOCALPLAYLIST = 'mpd_localplaylist' RAWCOMMAND = 'mpd_rawcommand' DATABASE = 'mpd_database' # use e.g. as MPD.STATUS to keep in namespace def __init__(self, sh): """ Initalizes the plugin. If the sh object is needed at all, the method self.get_sh() should be used to get it. There should be almost no need for a reference to the sh object any more. Plugins have to use the new way of getting parameter values: use the SmartPlugin method get_parameter_value(parameter_name). Anywhere within the Plugin you can get the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It returns the value in the datatype that is defined in the metadata. """ # Call init code of parent class (SmartPlugin) super().__init__() from bin.smarthome import VERSION if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': self.logger = logging.getLogger(__name__) self.logger.debug(f"init {__name__}") self._init_complete = False # get the parameters for the plugin (as defined in metadata plugin.yaml): self.instance = self.get_instance_name() self.host = self.get_parameter_value('host') self.port = self.get_parameter_value('port') self._cycle = self.get_parameter_value('cycle') name = 'plugins.' + self.get_fullname() self._client = Tcp_client(name=name, host=self.host, port=self.port, binary=False, autoreconnect=True, connect_cycle=5, retry_cycle=30, timeout=20) self._client.set_callbacks(connected=self.handle_connect, data_received=self.parse_reply) self._cmd_lock = threading.Lock() self._reply_lock = threading.Condition() self._reply = {} self._status_items = {} self._currentsong_items = {} self._statistic_items = {} self.orphanItems = [] self.lastWarnTime = None self.warnInterval = 3600 # warn once per hour for orphaned items if some exist self._internal_Items = {'isPlaying': False, 'isPaused': False, 'isStopped': False, 'isMuted': False, 'lastVolume': 20, 'currentName': ''} self._mpd_statusRequests = ['volume', 'repeat', 'random', 'single', 'consume', 'playlist', 'playlistlength', 'mixrampdb', 'state', 'song', 'songid', 'time', 'elapsed', 'bitrate', 'audio', 'nextsong', 'nextsongid', 'duration', 'xfade', 'mixrampdelay', 'updating_db', 'error', 'playpause', 'mute'] self._mpd_currentsongRequests = ['file', 'Last-Modified', 'Artist', 'Album', 'Title', 'Name', 'Track', 'Time', 'Pos', 'Id'] self._mpd_statisticRequests = ['artists', 'albums', 'songs', 'uptime', 'db_playtime', 'db_update', 'playtime'] # _mpd_playbackCommands and _mpd_playbackOptions are both handled as 'mpd_command'! self._mpd_playbackCommands = ['next', 'pause', 'play', 'playid', 'previous', 'seek', 'seekid', 'seekcur', 'stop', 'playpause', 'mute'] self._mpd_playbackOptions = ['consume', 'crossfade', 'mixrampdb', 'mixrampdelay', 'random', 'repeat', 'setvol', 'single', 'replay_gain_mode'] self._mpd_rawCommand = ['rawcommand'] self._mpd_databaseCommands = ['update', 'rescan'] self.init_webinterface(WebInterface) self.logger.debug("init done") self._init_complete = True def run(self): """ Run method for the plugin """ self.logger.debug("Run method called") if self._client.connect(): self.logger.debug(f'successfully connected to {self.host}:{self.port} within run method') else: self.logger.error(f'Connection to {self.host}:{self.port} not possible. Plugin deactivated.') return self.alive = True self.scheduler_add('update_status', self.update_status, cycle=self._cycle) # if you need to create child threads, do not make them daemon = True! # They will not shutdown properly. (It's a python bug) def stop(self): self.alive = False # added to effect better cleanup on stop if self.scheduler_get('update_status'): self.scheduler_remove('update_status') try: self._client.close() except: pass def handle_connect(self, client): self.logger.debug("handle_connect") ### self.found_terminator = self.parse_reply def parse_reply(self, client, data): """ called when data is received from mpd :param data: contains raw data :type data: string """ # According to https://mpd.readthedocs.io/en/latest/protocol.html all data is encoded in UTF-8 # but it could be that there is binary data included, prepended by "binary: \n" data = data.decode() self.logger.debug(f"parse_reply => {data}") if data.startswith('OK'): self._reply_lock.acquire() self._reply_lock.notify() self._reply_lock.release() elif data.startswith('ACK'): self.logger.error(data) else: lines = data.splitlines() for line in lines: key, sep, value = line.partition(': ') self._reply[key] = value def parse_item(self, item): """ Called upon plugin initialization :param item: The item to process. """ # all status-related items here if self.get_iattr_value(item.conf, MPD.STATUS) in self._mpd_statusRequests: key = (self.get_iattr_value(item.conf, MPD.STATUS)) self._status_items[key] = item if self.get_iattr_value(item.conf, MPD.SONGINFO) in self._mpd_currentsongRequests: key = (self.get_iattr_value(item.conf, MPD.SONGINFO)) self._currentsong_items[key] = item if self.get_iattr_value(item.conf, MPD.STATISTIC) in self._mpd_statisticRequests: key = (self.get_iattr_value(item.conf, MPD.STATISTIC)) self._statistic_items[key] = item # do not return after status-related items => they can be combined with command-related items # all command-related items here if self.get_iattr_value(item.conf, MPD.COMMAND) in self._mpd_playbackCommands \ or self.get_iattr_value(item.conf, MPD.COMMAND) in self._mpd_playbackOptions \ or self.get_iattr_value(item.conf, MPD.URL) is not None \ or self.get_iattr_value(item.conf, MPD.LOCALPLAYLIST) is not None \ or self.get_iattr_value(item.conf, MPD.RAWCOMMAND) in self._mpd_rawCommand \ or self.get_iattr_value(item.conf, MPD.DATABASE) in self._mpd_databaseCommands: self.logger.debug(f"callback assigned for item {item}") return self.update_item def update_status(self): # refresh all subscribed items warn = self.canWarnNow() self.update_statusitems(warn) self.update_currentsong(warn) self.update_statistic(warn) if warn: self.lastWarnTime = datetime.datetime.now() for warn in self.orphanItems: self.logger.warning(warn) self.orphanItems = [] def update_statusitems(self, warn): if not self._client.connected(): if warn: self.logger.error("update_status while not connected") return if (len(self._status_items) <= 0): if warn: self.logger.warning("status: no items to refresh") return self.logger.debug("requesting status") status = self._send('status') self.refreshItems(self._status_items, status, warn) def update_currentsong(self, warn): if not self._client.connected(): if warn: self.logger.error("update_currentsong while not connected") return if (len(self._currentsong_items) <= 0): if warn: self.logger.warning("currentsong: no items to refresh") return self.logger.debug("requesting currentsong") currentsong = self._send('currentsong') self.refreshItems(self._currentsong_items, currentsong, warn) def update_statistic(self, warn): if not self._client.connected(): if warn: self.logger.error("update_statistic while not connected") return if (len(self._statistic_items) <= 0): if warn: self.logger.warning("statistic: no items to refresh") return self.logger.debug("requesting statistic") stats = self._send('stats') self.refreshItems(self._statistic_items, stats, warn) def refreshItems(self, subscribedItems, response, warn): if not self.alive: return # 1. check response for the internal items and refresh them if 'state' in response: val = response['state'] if val == 'play': self._internal_Items['isPlaying'] = True self._internal_Items['isPaused'] = False self._internal_Items['isStopped'] = False elif val == 'pause': self._internal_Items['isPlaying'] = False self._internal_Items['isPaused'] = True self._internal_Items['isStopped'] = False elif val == 'stop': self._internal_Items['isPlaying'] = False self._internal_Items['isPaused'] = False self._internal_Items['isStopped'] = True else: self.logger.error(f"unknown state: {val}") if 'volume' in response: val = float(response['volume']) if val <= 0: self._internal_Items['isMuted'] = True else: self._internal_Items['isMuted'] = False self._internal_Items['lastVolume'] = val if 'Name' in response: val = response['Name'] if val: self._internal_Items['currentName'] = val # 2. check response for subscribed items and refresh them for key in subscribedItems: # update subscribed items (if value has changed) which exist directly in the response from MPD if key in response: val = response[key] item = subscribedItems[key] if item.type() == 'num': try: val = float(val) except: self.logger.error(f"can't parse {val} to float") continue elif item.type() == 'bool': if val == '0': val = False elif val == '1': val = True else: self.logger.error("can't parse {val} to bool") continue if item() != val: self.logger.debug(f"update item {item}, old value: {item()} type:{item.type()}, new value: {val} type: {type(val)}") self.setItemValue(item, val) # update subscribed items which do not exist in the response from MPD elif key == 'playpause': item = subscribedItems[key] val = self._internal_Items['isPlaying'] if item() != val: self.setItemValue(item, val) elif key == 'mute': item = subscribedItems[key] val = self._internal_Items['isMuted'] if item() != val: self.setItemValue(item, val) elif key == 'Artist': item = subscribedItems[key] val = self._internal_Items['currentName'] if item() != val: self.setItemValue(item, val) # do not reset these items when the tags are missing in the response from MPD # that happens while MPD switches the current track! elif key in ('volume', 'repeat', 'random'): return else: if warn: self.orphanItems.append(f'subscribed item "{key}" not in response from MPD => consider unsubscribing this item') # reset orphaned items because MPD does not send some items when they are disabled e.g. mixrampdb, error,... # to keep these items consistent in SHNG set them to "" or 0. # Whenever MPD resends values the items will be refreshed in SHNG item = subscribedItems[key] self.setItemValue(item, None) def setItemValue(self, item, value): """ Sets an Items value according to its type :param item: Item :param value: a new value to set, will be automatically casted for an appropriate type """ if item.type() == 'str': if value is None or not value: value = '' item(str(value), self.get_shortname()) elif item.type() == 'num': if value is None: value = 0 item(float(value), self.get_shortname()) else: item(value, self.get_shortname()) def update_item(self, item, caller=None, source=None, dest=None): """ Called when an Item changes within SmartHomeNG :param item: item to be updated towards the plugin :param caller: if given it represents the callers name :param source: if given it represents the source :param dest: if given it represents the dest """ if not self.alive: return False if self.alive and caller != self.get_shortname(): self.logger.debug(f"update_item called for item {item}") # only investigate on a command if self.has_iattr( item.conf, MPD.COMMAND): # playbackCommands if self.get_iattr_value(item.conf, MPD.COMMAND) == 'next': self._send('next') return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'play': self._send("play {}".format(item())) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'pause': self._send("pause {}".format(item())) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'stop': self._send('stop') return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'playpause': if self._internal_Items['isPlaying']: self._send("pause 1") elif self._internal_Items['isPaused']: self._send("pause 0") elif self._internal_Items['isStopped']: self._send("play") if self.get_iattr_value(item.conf, MPD.COMMAND) == 'playid': self._send("playid {}".format(item())) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'previous': self._send("previous") return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'seek': val = item() if val: pattern = re.compile('^\d+[ ]\d+$') if pattern.match(val): self._send("seek {}".format(val)) return self.logger.warning("ignoring invalid seek value") return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'seekid': val = item() if val: pattern = re.compile('^\d+[ ]\d+$') if pattern.match(val): self._send("seekid {}".format(val)) return self.logger.warning("ignoring invalid seekid value") return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'seekcur': val = item() if val: pattern = re.compile('^[+-]?\d+$') if pattern.match(val): self._send("seekcur {}".format(val)) return self.logger.warning("ignoring invalid seekcur value") return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'mute': # own-defined item if self._internal_Items['lastVolume'] < 0: # can be -1 if MPD can't detect the current volume self._internal_Items['lastVolume'] = 20 self._send("setvol {}".format(int(self._internal_Items['lastVolume']) if self._internal_Items['isMuted'] else 0)) return # playbackoptions if self.get_iattr_value(item.conf, MPD.COMMAND) == 'consume': self._send("consume {}".format(1 if item() else 0)) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'crossfade': self._send("crossfade {}".format(item())) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'mixrampdb': self._send("mixrampdb {}".format(item())) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'mixrampdelay': self._send("mixrampdelay {}".format(item())) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'random': self._send("random {}".format(1 if item() else 0)) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'repeat': self._send("repeat {}".format(1 if item() else 0)) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'setvol': val = item() if val > 100: self.logger.warning("invalid volume => value > 100 => set 100") val = 100 elif val < 0: self.logger.warning("invalid volume => value < 0 => set 0") val = 0 self._send("setvol {}".format(val)) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'single': self._send("single {}".format(1 if item() else 0)) return if self.get_iattr_value(item.conf, MPD.COMMAND) == 'replay_gain_mode': val = item() if val in ['off', 'track', 'album', 'auto']: self._send("replay_gain_mode {}".format(1 if item() else 0)) else: self.logger.warning(f"ignoring invalid value ({val}) for replay_gain_mode") pass return # url if self.has_iattr(item.conf, MPD.URL): self.play_url(item) return # localplaylist if self.has_iattr(item.conf, MPD.LOCALPLAYLIST): self.play_localplaylist(item) return # rawcommand if self.get_iattr_value(item.conf, MPD.RAWCOMMAND) == 'rawcommand': self._send(item()) return # database if self.get_iattr_value(item.conf, MPD.DATABASE) == 'update': command = 'update' if item(): command = "{} {}".format(command, item()) self._send(command) return if self.get_iattr_value(item.conf, MPD.DATABASE) == 'rescan': command = 'rescan' if item(): command = "{} {}".format(command, item()) self._send(command) return def canWarnNow(self): if self.lastWarnTime is None: return True if self.lastWarnTime + datetime.timedelta(seconds=self.warnInterval) \ <= datetime.datetime.now(): return True return False def _parse_url(self, url): name, sep, ext = url.rpartition('.') ext = ext.lower() play = [] if ext in ('m3u', 'pls'): content = self.get_sh().tools.fetch_url(url, timeout=4) if content is False: return play content = content.decode() if ext == 'pls': for line in content.splitlines(): if line.startswith('File'): num, tmp, url = line.partition('=') play.append(url) else: for line in content.splitlines(): if line.startswith('http://'): play.append(line) else: play.append(url) return play def play_url(self, item): url = self.get_iattr_value(item.conf, MPD.URL) play = self._parse_url(url) if play == []: self.logger.warning("no url to add") return self._send('clear', False) for url in play: self._send("add {}".format(url), False) self._send('play', False) def play_localplaylist(self, item): file = self.get_iattr_value(item.conf, MPD.LOCALPLAYLIST) if file: self._send('clear', False) self._send('load {}'.format(file), False) self._send('play', False) else: self.logger.warning("no playlistname to send") def _send(self, command, wait=True): if not self.alive: self.logger.error('Trying to send data but plugin is not running') return None with self._cmd_lock: self._reply = {} with self._reply_lock: self.logger.debug(f"send {command} to MPD") self._client.send((command + '\n').encode()) if wait: self.logger.debug(f"waiting for reply") self._reply_lock.wait(1) self.logger.debug(f"waiting for reply done") reply = self._reply self._reply = {} return reply