working slimproto implementation
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 23 May 2019 22:19:38 +0000 (00:19 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 23 May 2019 22:19:38 +0000 (00:19 +0200)
music_assistant/models.py
music_assistant/modules/musicproviders/qobuz.py
music_assistant/modules/musicproviders/spotify.py
music_assistant/modules/player.py
music_assistant/modules/playerproviders/pylms.py
music_assistant/modules/web.py
requirements.txt

index a033720df8bc5968d30171f3e8c45e70940dcebd..e88b513230514d19b5d12b28ae6e8baf42470eab 100755 (executable)
@@ -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():
     ''' 
index 69a8413022f7b01e6f11d9675e5e8b56c3ed9b41..25f93659a9145f44e94cc1eea69b79a397ad9a25 100644 (file)
@@ -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
index ef92f9e92153b3e9db681195292b76c7087a77d1..3640e544ee51538689dc0b3e8d19329a48c2afd5 100644 (file)
@@ -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
index d3161507eef608bf7c9b38384f50bae67d7e251a..a8163685469868d44534ee487bb4667d1b3e2fbe 100755 (executable)
@@ -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
index d7179d7e861fcd7e319ddea562b04263e80bd437..8583b1074daa7857f2232f432ba1020e4289faa9 100644 (file)
@@ -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)
+
+
+
+
 
 
 
index e3dbfc24b5d4994d86ba5ef4a30f682a5c940549..d7a31d9dd31e87e88e330ad3e76849f3265e2034 100755 (executable)
@@ -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):
index ba7b397fb80e83578e7fd47f40203fb5781f78cb..f62f7a92edf10600e8f57bb2f54e8a1acb8ad7d9 100644 (file)
@@ -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