From 21cb844419b87b3a7bc10a8961355508ca2186ed Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 24 May 2019 00:19:38 +0200 Subject: [PATCH] working slimproto implementation --- music_assistant/models.py | 2 +- .../modules/musicproviders/qobuz.py | 2 +- .../modules/musicproviders/spotify.py | 2 +- music_assistant/modules/player.py | 9 +- .../modules/playerproviders/pylms.py | 624 ++++++++++++++---- music_assistant/modules/web.py | 18 +- requirements.txt | 3 +- 7 files changed, 503 insertions(+), 157 deletions(-) diff --git a/music_assistant/models.py b/music_assistant/models.py index a033720d..e88b5132 100755 --- a/music_assistant/models.py +++ b/music_assistant/models.py @@ -477,7 +477,7 @@ class MusicPlayer(): self.group_parent = None # set to id of REAL group/parent player self.is_group = False # is this player a group player ? self.settings = {} - self.enabled = False + self.enabled = True class PlayerProvider(): ''' diff --git a/music_assistant/modules/musicproviders/qobuz.py b/music_assistant/modules/musicproviders/qobuz.py index 69a84130..25f93659 100644 --- a/music_assistant/modules/musicproviders/qobuz.py +++ b/music_assistant/modules/musicproviders/qobuz.py @@ -268,7 +268,7 @@ class QobuzProvider(MusicProvider): async with aiohttp.ClientSession(loop=asyncio.get_event_loop(), connector=aiohttp.TCPConnector(verify_ssl=False)) as session: async with session.get(streamdetails['url']) as resp: while True: - chunk = await resp.content.read(512000) + chunk = await resp.content.read(10240000) if not chunk: break yield chunk diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/modules/musicproviders/spotify.py index ef92f9e9..3640e544 100644 --- a/music_assistant/modules/musicproviders/spotify.py +++ b/music_assistant/modules/musicproviders/spotify.py @@ -253,7 +253,7 @@ class SpotifyProvider(MusicProvider): args = ['-n', 'temp', '-u', self._username, '-p', self._password, '--pass-through', '--single-track', track_id] process = await asyncio.create_subprocess_exec(spotty, *args, stdout=asyncio.subprocess.PIPE) while not process.stdout.at_eof(): - chunk = await process.stdout.read(512000) + chunk = await process.stdout.read(10240000) if not chunk: break yield chunk diff --git a/music_assistant/modules/player.py b/music_assistant/modules/player.py index d3161507..a8163685 100755 --- a/music_assistant/modules/player.py +++ b/music_assistant/modules/player.py @@ -290,7 +290,10 @@ class Player(): player_settings = self.mass.config['player_settings'].get(player_id,{}) for key, def_value, desc in self.mass.config['player_settings']['__desc__']: if not key in player_settings: - player_settings[key] = def_value + if (isinstance(def_value, str) and def_value.startswith('<')): + player_settings[key] = None + else: + player_settings[key] = def_value self.mass.config['player_settings'][player_id] = player_settings return player_settings @@ -353,7 +356,7 @@ class Player(): if http_stream: params = {"provider": provider, "track_id": str(item_id), "player_id": str(player_id)} params_str = urllib.parse.urlencode(params) - uri = 'http://%s:8095/stream?%s'% (self.local_ip, params_str) + uri = 'http://%s:%s/stream?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str) elif provider == "spotify": uri = 'spotify://spotify:track:%s' % item_id elif provider == "qobuz": @@ -408,7 +411,7 @@ class Player(): self.__fill_audio_buffer(process.stdin, track_id, provider, input_content_type)) # put chunks from stdout into queue while not process.stdout.at_eof(): - chunk = await process.stdout.read(512000) + chunk = await process.stdout.read(10240000) await audioqueue.put(chunk) if not chunk: break diff --git a/music_assistant/modules/playerproviders/pylms.py b/music_assistant/modules/playerproviders/pylms.py index d7179d7e..8583b107 100644 --- a/music_assistant/modules/playerproviders/pylms.py +++ b/music_assistant/modules/playerproviders/pylms.py @@ -4,13 +4,14 @@ import asyncio import os import struct +from collections import OrderedDict import time import decimal from typing import List import random import sys -from netaddr import EUI -from utils import run_periodic, LOGGER, parse_track_title +import socket +from utils import run_periodic, LOGGER, parse_track_title, try_parse_int, get_ip from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist from constants import CONF_ENABLED @@ -19,18 +20,18 @@ def setup(mass): ''' setup the provider''' enabled = mass.config["playerproviders"]['pylms'].get(CONF_ENABLED) if enabled: - provider = PyLMSProvider(mass) + provider = PyLMSServer(mass) return provider return False def config_entries(): ''' get the config entries for this provider (list with key/value pairs)''' return [ - (CONF_ENABLED, False, CONF_ENABLED) + (CONF_ENABLED, True, CONF_ENABLED) ] -class PyLMSProvider(PlayerProvider): - ''' Python implementation of SlimProto ''' +class PyLMSServer(PlayerProvider): + ''' Python implementation of SlimProto server ''' def __init__(self, mass): self.prov_id = 'pylms' @@ -38,79 +39,225 @@ class PyLMSProvider(PlayerProvider): self.icon = '' self.mass = mass self._players = {} - self._players = {} + self._lmsplayers = {} + self._player_queue = {} + self._player_queue_index = {} self.buffer = b'' self.last_msg_received = 0 self.supported_musicproviders = ['http'] - mass.event_loop.create_task(asyncio.start_server(self.__handle_client, 'localhost', 3483)) + # start slimproto server + mass.event_loop.create_task(asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483)) + # setup discovery + listen = mass.event_loop.create_datagram_endpoint( + DiscoveryProtocol, local_addr=('0.0.0.0', 3483), + family=socket.AF_INET, reuse_address=True, reuse_port=True, + allow_broadcast=True) + mass.event_loop.create_task(listen) + + ### Provider specific implementation ##### + + async def player_command(self, player_id, cmd:str, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' + if cmd == 'play': + if self._players[player_id].state == PlayerState.Stopped: + await self.__queue_play(player_id, None) + else: + self._lmsplayers[player_id].unpause() + elif cmd == 'pause': + self._lmsplayers[player_id].pause() + elif cmd == 'stop': + self._lmsplayers[player_id].stop() + elif cmd == 'next': + self._lmsplayers[player_id].next() + elif cmd == 'previous': + await self.__queue_previous(player_id) + elif cmd == 'power' and cmd_args == 'off': + self._lmsplayers[player_id].power_off() + elif cmd == 'power': + self._lmsplayers[player_id].power_on() + elif cmd == 'volume': + self._lmsplayers[player_id].volume_set(try_parse_int(cmd_args)) + elif cmd == 'mute' and cmd_args == 'off': + self._lmsplayers[player_id].unmute() + elif cmd == 'mute': + self._lmsplayers[player_id].mute() + + async def player_queue(self, player_id, offset=0, limit=50): + ''' return the current items in the player's queue ''' + return self._player_queue[player_id][offset:limit] + + async def play_media(self, player_id, media_items, queue_opt='play'): + ''' + play media on a player + ''' + cur_queue_index = self._player_queue_index.get(player_id, 0) + + if queue_opt == 'replace' or not self._player_queue[player_id]: + # overwrite queue with new items + self._player_queue[player_id] = media_items + await self.__queue_play(player_id, 0) + elif queue_opt == 'play': + # replace current item with new item(s) + self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index] + media_items + self._player_queue[player_id][cur_queue_index+1:] + await self.__queue_play(player_id, cur_queue_index) + elif queue_opt == 'next': + # insert new items at current index +1 + self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index+1] + media_items + self._player_queue[player_id][cur_queue_index+1:] + elif queue_opt == 'add': + # add new items at end of queue + self._player_queue[player_id] = self._player_queue[player_id] + media_items + + ### Provider specific (helper) methods ##### + + async def __queue_play(self, player_id, index): + ''' send play command to player ''' + if not index: + index = self._player_queue_index[player_id] + if len(self._player_queue[player_id]) >= index-1: + track = self._player_queue[player_id][index] + self._lmsplayers[player_id].stop() + self._lmsplayers[player_id].play(track.uri) + self._player_queue_index[player_id] = index + + async def __queue_next(self, player_id): + ''' request next track from queue ''' + if not player_id in self._player_queue or not player_id in self._player_queue: + return + cur_queue_index = self._player_queue_index[player_id] + if len(self._player_queue[player_id]) > cur_queue_index: + new_queue_index = cur_queue_index + 1 + elif self._players[player_id].repeat_enabled: + new_queue_index = 0 + else: + LOGGER.warning("next track requested but no more tracks in queue") + return + return await self.__queue_play(player_id, new_queue_index) + + async def __queue_previous(self, player_id): + ''' request previous track from queue ''' + if not player_id in self._player_queue: + return + cur_queue_index = self._player_queue_index[player_id] + if cur_queue_index == 0 and len(self._player_queue[player_id]) > 1: + new_queue_index = len(self._player_queue[player_id]) -1 + elif cur_queue_index == 0: + new_queue_index = cur_queue_index + else: + new_queue_index -= 1 + self._player_queue_index[player_id] = new_queue_index + return await self.__queue_play(player_id, new_queue_index) + + async def __handle_player_event(self, player_id, event, event_data=None): + ''' handle event from player ''' + if not player_id: + return + LOGGER.debug("Event from player %s: %s - event_data: %s" %(player_id, event, str(event_data))) + lms_player = self._lmsplayers[player_id] + if event == "next_track": + return await self.__queue_next(player_id) + if not player_id in self._players: + player = MusicPlayer() + player.player_id = player_id + player.player_provider = self.prov_id + self._players[player_id] = player + if not player_id in self._player_queue: + self._player_queue[player_id] = [] + if not player_id in self._player_queue_index: + self._player_queue_index[player_id] = 0 + else: + player = self._players[player_id] + # update player properties + player.name = lms_player.player_name + player.volume_level = lms_player.volume_level + player.cur_item_time = lms_player._elapsed_seconds + if event == "disconnected": + player.enabled = False + elif event == "power": + player.powered = event_data + elif event == "state": + player.state = event_data + if self._player_queue[player_id]: + cur_queue_index = self._player_queue_index[player_id] + player.cur_item = self._player_queue[player_id][cur_queue_index] + # update player details + await self.mass.player.update_player(player) + + async def __handle_socket_client(self, reader, writer): + ''' handle a client connection on the socket''' + LOGGER.debug("new socket client connected") + stream_host = get_ip() + stream_port = self.mass.config['base']['web']['http_port'] + lms_player = PyLMSPlayer(stream_host, stream_port) - async def __handle_client(self, reader, writer): - request = None - lms_player = PyLMSPlayer() - def send_frame(command, data): + ''' send command to lms player''' packet = struct.pack('!H', len(data) + 4) + command + data - print("Sending packet %r" % packet) writer.write(packet) + + def handle_event(event, event_data=None): + ''' handle events from player''' + if event == "connected": + self._lmsplayers[lms_player.player_id] = lms_player + asyncio.create_task(self.__handle_player_event(lms_player.player_id, event, event_data)) + lms_player.send_frame = send_frame - asyncio.create_task(self.send_play(lms_player)) + lms_player.send_event = handle_event + heartbeat_task = asyncio.create_task(self.send_heartbeat(lms_player)) - while request != 'quit': - data = await reader.read(100) - if not data: + # keep reading bytes from the socket + while True: + data = await reader.read(64) + if data: + lms_player.dataReceived(data) + else: break - #data = data.decode('latin-1') - print(data) - lms_player.dataReceived(data) - - #response = str(eval(request)) + '\n' - #writer.write(response.encode('utf8')) - LOGGER.info('client disconnected') - - async def send_play(self, lms_player): - await asyncio.sleep(5) - lms_player.play() - lms_player.unpause() - - - - + # disconnect + heartbeat_task.cancel() + asyncio.create_task(self.__handle_player_event(lms_player.player_id, 'disconnected')) + @run_periodic(5) + async def send_heartbeat(self, lms_player): + timestamp = int(time.time()) + data = lms_player.pack_stream(b"t", replayGain=timestamp, flags=0) + lms_player.send_frame(b"strm", data) ### Provider specific implementation ##### class PyLMSPlayer(object): - ''' Python implementation of SlimProto ''' - - # these numbers are also in a dict in Collection. This should obviously be refactored. - typeMap = { - 0: b'o', # ogg - 1: b'm', # mp3 - 2: b'f', # flac - 3: b'p', # pcm (wav etc.) - } + ''' very basic Python implementation of SlimProto ''' - def __init__(self): + def __init__(self, stream_host, stream_port): self.buffer = b'' #self.display = Display() - self.volume = PyLMSVolume() - self.device_type = None - self.mac_address = None self.send_frame = None - - def connectionEstablished(self): - """ Called when a connection has been successfully established with - the player. """ - #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.ESTABLISHED)) - LOGGER.info("Connected to squeezebox") - - - def connectionLost(self, reason): - #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.DISCONNECTED)) - #self.service.players.remove(self) - pass - + self.send_event = None + self.stream_host = stream_host + self.stream_port = stream_port + self.playback_millis = 0 + self._volume = PyLMSVolume() + self._device_type = None + self._mac_address = None + self._player_name = None + self._last_volume = 0 + self._last_heartbeat = 0 + self._elapsed_seconds = 0 + self._elapsed_milliseconds = 0 + + @property + def player_name(self): + if self._player_name: + return self._player_name + return "%s - %s" %(self._device_type, self._mac_address) + + @property + def player_id(self): + return self._mac_address + + @property + def volume_level(self): + return self._volume.volume + def dataReceived(self, data): self.buffer = self.buffer + data if len(self.buffer) > 8: @@ -120,31 +267,29 @@ class PyLMSPlayer(object): if len(self.buffer) >= plen: packet, self.buffer = self.buffer[8:plen], self.buffer[plen:] operation = operation.strip(b"!").strip().decode() - LOGGER.info("operation: %s" % operation) + #LOGGER.info("operation: %s" % operation) handler = getattr(self, "process_%s" % operation, None) if handler is None: raise NotImplementedError handler(packet) - - def send_version(self): - self.send_frame(b'vers', b'7.0') + self.send_frame(b'vers', b'7.8') - def pack_stream(self, command, autostart=b"1", formatbyte = b'o', pcmargs = b'1321', threshold = 255, + def pack_stream(self, command, autostart=b"1", formatbyte = b'o', pcmargs = (b'?',b'?',b'?',b'?'), threshold = 200, spdif = b'0', transDuration = 0, transType = b'0', flags = 0x40, outputThreshold = 0, - replayGainHigh = 0, replayGainLow = 0, serverPort = 8095, serverIp = 0): - return struct.pack("!ccc4sBcBcBBBHHHL", - command, autostart, formatbyte, pcmargs, + replayGain=0, serverPort = 8095, serverIp = 0): + return struct.pack("!cccccccBcBcBBBLHL", + command, autostart, formatbyte, *pcmargs, threshold, spdif, transDuration, transType, - flags, outputThreshold, 0, replayGainHigh, replayGainLow, serverPort, serverIp) + flags, outputThreshold, 0, replayGain, serverPort, serverIp) - def stop_streaming(self): + def stop(self): data = self.pack_stream(b"q", autostart=b"0", flags=0) self.send_frame(b"strm", data) def pause(self): - data = self.pack_stream(b"bp", autostart=b"0", flags=0) + data = self.pack_stream(b"p", autostart=b"0", flags=0) self.send_frame(b"strm", data) LOGGER.info("Sending pause request") @@ -153,67 +298,88 @@ class PyLMSPlayer(object): self.send_frame(b"strm", data) LOGGER.info("Sending unpause request") - def stop(self): - self.stop_streaming() + def next(self): + data = self.pack_stream(b"f", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + self.send_event("next_track") + + def previous(self): + data = self.pack_stream(b"f", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + self.send_event("previous_track") - def play(self): + def power_on(self): + self.send_frame(b"aude", struct.pack("2B", 1, 1)) + self.send_event("power", True) + + def power_off(self): + self.stop() + self.send_frame(b"aude", struct.pack("2B", 0, 0)) + self.send_event("power", False) + + def mute_on(self): + self.send_frame(b"aude", struct.pack("2B", 0, 0)) + self.send_event("mute", True) + + def mute_off(self): + self.send_frame(b"aude", struct.pack("2B", 1, 1)) + self.send_event("mute", False) + + def volume_up(self): + self._volume.increment() + self.send_volume() + + def volume_down(self): + self._volume.decrement() + self.send_volume() + + def volume_set(self, new_vol): + self._volume.volume = new_vol + self.send_volume() + + def play(self, uri, crossfade=False): command = b's' - autostart = b'1' - formatbyte = self.typeMap[2] - uri = "/stream?provider=spotify&track_id=56z8UyE4foPVnSrER7lVR5" - data = self.pack_stream(command, autostart=autostart, flags=0x00, formatbyte=formatbyte) - request = "GET %s HTTP/1.0\r\n\r\n" % uri + autostart = b'3' # we use direct stream for now so let the player do the messy work with buffers + transType= b'1' if crossfade else b'0' + transDuration = 10 if crossfade else 0 + formatbyte = b'f' # fixed to flac + uri = '/stream' + uri.split('/stream')[1] + data = self.pack_stream(command, autostart=autostart, flags=0x00, formatbyte=formatbyte, transType=transType, transDuration=transDuration) + headers = "Connection: close\r\nAccept: */*\r\nHost: %s:%s\r\n" %(self.stream_host, self.stream_port) + request = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers) data = data + request.encode("utf-8") self.send_frame(b'strm', data) - LOGGER.info("Requesting play from squeezebox %s" % (id(self),)) - #self.displayTrack(track) - - # def play(self, track): - # command = b's' - # autostart = b'1' - # formatbyte = self.typeMap[track.type] - # data = self.pack_stream(command, autostart=autostart, flags=0x00, formatbyte=formatbyte) - # request = "GET %s HTTP/1.0\r\n\r\n" % (track.player_uri(id(self)),) - # data = data + request.encode("utf-8") - # self.send_frame(b'strm', data) - # LOGGER.info("Requesting play from squeezebox %s" % (id(self),)) - # self.displayTrack(track) + LOGGER.info("Requesting play from squeezebox" ) def displayTrack(self, track): self.render("%s by %s" % (track.title, track.artist)) def process_HELO(self, data): - #(devId, rev, mac, wlan, bytes) = struct.unpack('BB6sHL', data[:16]) (devId, rev, mac) = struct.unpack('BB6s', data[:8]) - (mac,) = struct.unpack(">q", b'00'+mac) - mac = EUI(mac) - self.device_type = devices.get(devId, 'unknown device') - self.mac_address = str(mac) - LOGGER.info("HELO received from %s %s" % (self.mac_address, self.device_type)) + device_mac = ':'.join("%02x" % x for x in mac) + self._device_type = devices.get(devId, 'unknown device') + self._mac_address = str(device_mac).lower() + LOGGER.debug("HELO received from %s %s" % (self._mac_address, self._device_type)) self.init_client() def init_client(self): + ''' initialize a new connected client ''' + self.send_event("connected") self.send_version() - self.stop_streaming() + self.stop() self.setBrightness() #self.set_visualisation(SpectrumAnalyser()) self.send_frame(b"setd", struct.pack("B", 0)) self.send_frame(b"setd", struct.pack("B", 4)) - self.enableAudio() - self.send_volume() - self.send_frame(b"strm", self.pack_stream(b't', autostart=b"1", flags=0, replayGainHigh=0)) - self.connectionEstablished() - - def enableAudio(self): - self.send_frame(b"aude", struct.pack("2B", 1, 1)) - + self.power_on() + self.volume_set(40) # TODO: remember last volume + def send_volume(self): - og = self.volume.old_gain() - ng = self.volume.new_gain() - LOGGER.info("Volume set to %d (%d/%d)" % (self.volume.volume, og, ng)) + og = self._volume.old_gain() + ng = self._volume.new_gain() + LOGGER.info("Volume set to %d (%d/%d)" % (self._volume.volume, og, ng)) d = self.send_frame(b"audg", struct.pack("!LLBBLL", og, og, 1, 255, ng, ng)) - #self.service.evreactor.fireEvent(VolumeChanged(self, self.volume)) - return d + self.send_event("volume", self._volume.volume) def setBrightness(self, level=4): assert 0 <= level <= 4 @@ -233,7 +399,6 @@ class PyLMSPlayer(object): self.send_frame(b"grfe", frame) def process_STAT(self, data): - #print "STAT received: %r" % data ev = data[:4] if ev == b'\x00\x00\x00\x00': LOGGER.info("Presumed informational stat message") @@ -244,61 +409,81 @@ class PyLMSPlayer(object): handler(data[4:]) def stat_aude(self, data): - LOGGER.info("ACK aude") + (spdif_enable, dac_enable) = struct.unpack("2B", data[:4]) + powered = spdif_enable or dac_enable + self.send_event("power", powered) + LOGGER.debug("ACK aude - Received player power: %s" % powered) def stat_audg(self, data): - LOGGER.info("ACK audg") + LOGGER.info("Received volume_level from player %s" % data) + self.send_event("volume", self._volume.volume) def stat_strm(self, data): - LOGGER.info("ACK strm") + LOGGER.debug("ACK strm") + #self.send_frame(b"cont", b"0") def stat_STMc(self, data): - LOGGER.info("Status Message: Connect") + LOGGER.debug("Status Message: Connect") def stat_STMd(self, data): - LOGGER.info("Decoder Ready") - #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.READY)) + LOGGER.debug("Decoder Ready for next track") + self.send_event("next_track") def stat_STMe(self, data): LOGGER.info("Connection established") def stat_STMf(self, data): LOGGER.info("Status Message: Connection closed") + self.send_event("state", PlayerState.Stopped) def stat_STMh(self, data): LOGGER.info("Status Message: End of headers") def stat_STMn(self, data): - LOGGER.info("Decoder does not support file format") + LOGGER.error("Decoder does not support file format") def stat_STMo(self, data): - LOGGER.info("Output Underrun") - + ''' No more decoded (uncompressed) data to play; triggers rebuffering. ''' + LOGGER.debug("Output Underrun") + def stat_STMp(self, data): - LOGGER.info("Pause confirmed") - #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.PAUSED)) + '''Pause confirmed''' + self.send_event("state", PlayerState.Paused) def stat_STMr(self, data): - LOGGER.info("Resume confirmed") - #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.PLAYING)) + '''Resume confirmed''' + self.send_event("state", PlayerState.Playing) def stat_STMs(self, data): - LOGGER.info("Player status message: playback of new track has started") - #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.PLAYING)) + '''Playback of new track has started''' + self.send_event("state", PlayerState.Playing) def stat_STMt(self, data): - """ Timer heartbeat """ - self.last_heartbeat = time.time() + """ heartbeat from client """ + timestamp = time.time() + self._last_heartbeat = timestamp + (num_crlf, mas_initialized, mas_mode, rptr, wptr, + bytes_received_h, bytes_received_l, signal_strength, + jiffies, output_buffer_size, output_buffer_fullness, + elapsed_seconds, voltage, elapsed_milliseconds, + server_timestamp, error_code) = struct.unpack("!BBBLLLLHLLLLHLLH", data) + if elapsed_seconds != self._elapsed_seconds: + self.send_event("progress") + self._elapsed_seconds = elapsed_seconds + self._elapsed_milliseconds = elapsed_milliseconds def stat_STMu(self, data): - LOGGER.info("End of playback") - #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.UNDERRUN)) + '''Normal end of playback''' + LOGGER.info("End of playback - Underrun") + self.send_event("state", PlayerState.Stopped) def process_BYE(self, data): LOGGER.info("BYE received") + self.send_event("disconnected") def process_RESP(self, data): LOGGER.info("RESP received") + self.send_frame(b"cont", b"0") def process_BODY(self, data): LOGGER.info("BODY received") @@ -318,12 +503,13 @@ class PyLMSPlayer(object): this player will be called. This is mostly relevant for volume changes - most other button presses will require some context to operate. """ (time, code) = struct.unpack("!IxxI", data) - command = Remote.codes.get(code, None) - if command is not None: - LOGGER.info("IR received: %r, %r" % (code, command)) - #self.service.evreactor.fireEvent(RemoteButtonPressed(self, command)) - else: - LOGGER.info("Unknown IR received: %r, %r" % (time, code)) + LOGGER.info("IR code %s" % code) + # command = Remote.codes.get(code, None) + # if command is not None: + # LOGGER.info("IR received: %r, %r" % (code, command)) + # #self.service.evreactor.fireEvent(RemoteButtonPressed(self, command)) + # else: + # LOGGER.info("Unknown IR received: %r, %r" % (time, code)) def process_RAWI(self, data): LOGGER.info("RAWI received") @@ -335,21 +521,171 @@ class PyLMSPlayer(object): LOGGER.info("BUTN received") def process_KNOB(self, data): + ''' Transporter only, knob-related ''' LOGGER.info("KNOB received") def process_SETD(self, data): - LOGGER.info("SETD received") + ''' Get/set player firmware settings ''' + LOGGER.debug("SETD received %s" % data) + cmd_id = data[0] + if cmd_id == 0: + # received player name + data = data[1:].decode() + self._player_name = data + self.send_event("name") def process_UREQ(self, data): LOGGER.info("UREQ received") - def process_remote_volumeup(self): - self.volume.increment() - self.send_volume() - def process_remote_volumedown(self): - self.volume.decrement() - self.send_volume() +class Datagram(object): + + @classmethod + def decode(self, data): + if data[0] == 'e': + return TLVDiscoveryRequestDatagram(data) + elif data[0] == 'E': + return TLVDiscoveryResponseDatagram(data) + elif data[0] == 'd': + return ClientDiscoveryDatagram(data) + elif data[0] == 'h': + pass # Hello! + elif data[0] == 'i': + pass # IR + elif data[0] == '2': + pass # i2c? + elif data[0] == 'a': + pass # ack! + +class ClientDiscoveryDatagram(Datagram): + + device = None + firmware = None + client = None + + def __init__(self, data): + s = struct.unpack('!cxBB8x6B', data) + assert s[0] == 'd' + self.device = s[1] + self.firmware = hex(s[2]) + self.client = ":".join(["%02x" % (x,) for x in s[3:]]) + + def __repr__(self): + return "<%s device=%r firmware=%r client=%r>" % (self.__class__.__name__, self.device, self.firmware, self.client) + +class DiscoveryResponseDatagram(Datagram): + + def __init__(self, hostname, port): + hostname = hostname[:16].encode("UTF-8") + hostname += (16 - len(hostname)) * '\x00' + self.packet = struct.pack('!c16s', 'D', hostname) + +class TLVDiscoveryRequestDatagram(Datagram): + + def __init__(self, data): + requestdata = OrderedDict() + assert data[0] == 'e' + idx = 1 + length = len(data)-5 + while idx <= length: + typ, l = struct.unpack_from("4sB", data, idx) + if l: + val = data[idx+5:idx+5+l] + idx += 5+l + else: + val = None + idx += 5 + requestdata[typ] = val + self.data = requestdata + + def __repr__(self): + return "<%s data=%r>" % (self.__class__.__name__, self.data.items()) + +class TLVDiscoveryResponseDatagram(Datagram): + + def __init__(self, responsedata): + parts = ['E'] # new discovery format + for typ, value in responsedata.items(): + if value is None: + value = '' + elif len(value) > 255: + LOGGER.warning("Response %s too long, truncating to 255 bytes" % typ) + value = value[:255] + parts.extend((typ, chr(len(value)), value)) + self.packet = ''.join(parts) + +class DiscoveryProtocol(): + + def connection_made(self, transport): + self.transport = transport + # Allow receiving multicast broadcasts + sock = self.transport.get_extra_info('socket') + group = socket.inet_aton('239.255.255.250') + mreq = struct.pack('4sL', group, socket.INADDR_ANY) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + def build_TLV_response(self, requestdata): + responsedata = OrderedDict() + for typ, value in requestdata.items(): + if typ == 'NAME': + # send full host name - no truncation + value = 'macbook-marcel' # TODO + elif typ == 'IPAD': + # send ipaddress as a string only if it is set + value = '192.168.1.145' # TODO + # :todo: IPv6 + if value == '0.0.0.0': + # do not send back an ip address + typ = None + elif typ == 'JSON': + # send port as a string + json_port = 9000 # todo: web.service.port + value = str(json_port) + elif typ == 'VERS': + # send server version + value = '7.9' + elif typ == 'UUID': + # send server uuid + value = 'test' + # elif typ == 'JVID': + # # not handle, just log the information + # typ = None + # log.msg("Jive: %x:%x:%x:%x:%x:%x:" % struct.unpack('>6B', value), + # logLevel=logging.INFO) + else: + LOGGER.error('Unexpected information request: %r', typ) + typ = None + if typ: + responsedata[typ] = value + return responsedata + + def datagram_received(self, data, addr): + try: + data = data.decode() + LOGGER.info('Received %r from %s' % (data, addr)) + dgram = Datagram.decode(data) + LOGGER.info("Data received from %s: %s" % (addr, dgram)) + if isinstance(dgram, ClientDiscoveryDatagram): + self.sendDiscoveryResponse(addr) + elif isinstance(dgram, TLVDiscoveryRequestDatagram): + resonsedata = self.build_TLV_response(dgram.data) + self.sendTLVDiscoveryResponse(resonsedata, addr) + except Exception as exc: + LOGGER.exception(exc) + + def sendDiscoveryResponse(self, addr): + dgram = DiscoveryResponseDatagram('macbook-marcel', 3483) + LOGGER.info("Sending discovery response %r" % (dgram.packet,)) + self.transport.sendto(dgram.packet.encode(), addr) + + def sendTLVDiscoveryResponse(self, resonsedata, addr): + dgram = TLVDiscoveryResponseDatagram(resonsedata) + LOGGER.info("Sending discovery response %r" % (dgram.packet,)) + self.transport.sendto(dgram.packet.encode(), addr) + + + + diff --git a/music_assistant/modules/web.py b/music_assistant/modules/web.py index e3dbfc24..d7a31d9d 100755 --- a/music_assistant/modules/web.py +++ b/music_assistant/modules/web.py @@ -26,14 +26,18 @@ def setup(mass): else: ssl_key = '' hostname = conf['hostname'] - return Web(mass, ssl_cert, ssl_key, hostname) + http_port = conf['http_port'] + https_port = conf['https_port'] + return Web(mass, http_port, https_port, ssl_cert, ssl_key, hostname) def create_config_entries(config): ''' get the config entries for this module (list with key/value pairs)''' config_entries = [ + ('http_port', 8095, 'web_http_port'), + ('https_port', 8096, 'web_https_port'), ('ssl_certificate', '', 'web_ssl_cert'), ('ssl_key', '', 'web_ssl_key'), - ('hostname', '', 'web_ssl_host') + ('cert_fqdn_host', '', 'cert_fqdn_host') ] if not config['base'].get('web'): config['base']['web'] = {} @@ -45,11 +49,13 @@ def create_config_entries(config): class Web(): ''' webserver and json/websocket api ''' - def __init__(self, mass, ssl_cert, ssl_key, hostname): + def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host): self.mass = mass + self._http_port = http_port + self._https_port = https_port self._ssl_cert = ssl_cert self._ssl_key = ssl_key - self._hostname = hostname + self._cert_fqdn_host = cert_fqdn_host self.http_session = aiohttp.ClientSession() mass.event_loop.create_task(self.setup_web()) @@ -82,12 +88,12 @@ class Web(): self.runner = web.AppRunner(app) await self.runner.setup() - http_site = web.TCPSite(self.runner, '0.0.0.0', 8095) + http_site = web.TCPSite(self.runner, '0.0.0.0', self._http_port) await http_site.start() if self._ssl_cert and self._ssl_key: ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key) - https_site = web.TCPSite(self.runner, '0.0.0.0', 8096, ssl_context=ssl_context) + https_site = web.TCPSite(self.runner, '0.0.0.0', self._https_port, ssl_context=ssl_context) await https_site.start() async def get_items(self, request): diff --git a/requirements.txt b/requirements.txt index ba7b397f..f62f7a92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ asyncio_throttle aiocometd aiosqlite pytaglib -python-slugify \ No newline at end of file +python-slugify +netaddr \ No newline at end of file -- 2.34.1