WIP- slimproto implementation
authormarcelveldt <marcelvanderveldt@MacBook-Pro.local>
Wed, 22 May 2019 12:00:56 +0000 (14:00 +0200)
committermarcelveldt <marcelvanderveldt@MacBook-Pro.local>
Wed, 22 May 2019 12:00:56 +0000 (14:00 +0200)
music_assistant/modules/playerproviders/lms.py
music_assistant/modules/playerproviders/pylms.py [new file with mode: 0644]

index 47ff77e8079b025f007a623d45f7cc3875707898..9aa94263be505111f7924c601720f4f8af8a876f 100644 (file)
@@ -145,7 +145,10 @@ class LMSProvider(PlayerProvider):
         player.shuffle_enabled = player_details.get('playlist shuffle',0) != 0
         player.repeat_enabled = player_details.get('playlist repeat',0) != 0
         # player state
-        player.powered = player_details['power'] == 1
+        if 'power' in player_details:
+            player.powered = player_details['power'] == 1
+        else:
+            print(player_details) # DEBUG
         if player_details['mode'] == 'play':
             player.state = PlayerState.Playing
         elif player_details['mode'] == 'pause':
diff --git a/music_assistant/modules/playerproviders/pylms.py b/music_assistant/modules/playerproviders/pylms.py
new file mode 100644 (file)
index 0000000..d7179d7
--- /dev/null
@@ -0,0 +1,451 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+import struct
+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
+from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
+from constants import CONF_ENABLED
+
+
+def setup(mass):
+    ''' setup the provider'''
+    enabled = mass.config["playerproviders"]['pylms'].get(CONF_ENABLED)
+    if enabled:
+        provider = PyLMSProvider(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)
+        ]
+
+class PyLMSProvider(PlayerProvider):
+    ''' Python implementation of SlimProto '''
+
+    def __init__(self, mass):
+        self.prov_id = 'pylms'
+        self.name = 'Logitech Media Server Emulation'
+        self.icon = ''
+        self.mass = mass
+        self._players = {}
+        self._players = {}
+        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))       
+
+
+    async def __handle_client(self, reader, writer):
+        request = None
+        lms_player = PyLMSPlayer()
+        
+        def send_frame(command, data):
+            packet = struct.pack('!H', len(data) + 4) + command + data
+            print("Sending packet %r" % packet)
+            writer.write(packet)
+        lms_player.send_frame = send_frame
+        asyncio.create_task(self.send_play(lms_player))
+        
+        while request != 'quit':
+            data = await reader.read(100)
+            if not data:
+                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()
+
+
+
+
+
+
+    ### 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.)
+    }
+
+    def __init__(self):
+        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
+
+    def dataReceived(self, data):
+        self.buffer = self.buffer + data
+        if len(self.buffer) > 8:
+            operation, length = self.buffer[:4], self.buffer[4:8]
+            length = struct.unpack('!I', length)[0]
+            plen = length + 8
+            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)
+                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')
+
+    def pack_stream(self, command, autostart=b"1", formatbyte = b'o', pcmargs = b'1321', threshold = 255,
+                    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,
+                           threshold, spdif, transDuration, transType,
+                           flags, outputThreshold, 0, replayGainHigh, replayGainLow, serverPort, serverIp)
+
+    def stop_streaming(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)
+        self.send_frame(b"strm", data)
+        LOGGER.info("Sending pause request")
+
+    def unpause(self):
+        data = self.pack_stream(b"u", autostart=b"0", flags=0)
+        self.send_frame(b"strm", data)
+        LOGGER.info("Sending unpause request")
+
+    def stop(self):
+        self.stop_streaming()
+
+    def play(self):
+        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
+        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)
+
+    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))
+        self.init_client()
+
+    def init_client(self):
+        self.send_version()
+        self.stop_streaming()
+        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))
+
+    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))
+        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
+
+    def setBrightness(self, level=4):
+        assert 0 <= level <= 4
+        self.send_frame(b"grfb", struct.pack("!H", level))
+
+    def set_visualisation(self, visualisation):
+        self.send_frame(b"visu", visualisation.pack())
+
+    def render(self, text):
+        #self.display.clear()
+        #self.display.renderText(text, "DejaVu-Sans", 16, (0,0))
+        #self.updateDisplay(self.display.frame())
+        pass
+
+    def updateDisplay(self, bitmap, transition = 'c', offset=0, param=0):
+        frame = struct.pack("!Hcb", offset, transition, param) + bitmap
+        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")
+        else:
+            handler = getattr(self, 'stat_%s' % ev.decode(), None)
+            if handler is None:
+                raise NotImplementedError("Stat message %r not known" % ev)
+            handler(data[4:])
+
+    def stat_aude(self, data):
+        LOGGER.info("ACK aude")
+
+    def stat_audg(self, data):
+        LOGGER.info("ACK audg")
+
+    def stat_strm(self, data):
+        LOGGER.info("ACK strm")
+
+    def stat_STMc(self, data):
+        LOGGER.info("Status Message: Connect")
+
+    def stat_STMd(self, data):
+        LOGGER.info("Decoder Ready")
+        #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.READY))
+
+    def stat_STMe(self, data):
+        LOGGER.info("Connection established")
+
+    def stat_STMf(self, data):
+        LOGGER.info("Status Message: Connection closed")
+
+    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")
+
+    def stat_STMo(self, data):
+        LOGGER.info("Output Underrun")
+
+    def stat_STMp(self, data):
+        LOGGER.info("Pause confirmed")
+        #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.PAUSED))
+
+    def stat_STMr(self, data):
+        LOGGER.info("Resume confirmed")
+        #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.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))
+
+    def stat_STMt(self, data):
+        """ Timer heartbeat """
+        self.last_heartbeat = time.time()
+
+    def stat_STMu(self, data):
+        LOGGER.info("End of playback")
+        #self.service.evreactor.fireEvent(StateChanged(self, StateChanged.State.UNDERRUN))
+
+    def process_BYE(self, data):
+        LOGGER.info("BYE received")
+
+    def process_RESP(self, data):
+        LOGGER.info("RESP received")
+
+    def process_BODY(self, data):
+        LOGGER.info("BODY received")
+
+    def process_META(self, data):
+        LOGGER.info("META received")
+
+    def process_DSCO(self, data):
+        LOGGER.info("Data Stream Disconnected")
+
+    def process_DBUG(self, data):
+        LOGGER.info("DBUG received")
+
+    def process_IR(self, data):
+        """ Slightly involved codepath here. This raises an event, which may
+        be picked up by the service and then the process_remote_* function in
+        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))
+
+    def process_RAWI(self, data):
+        LOGGER.info("RAWI received")
+
+    def process_ANIC(self, data):
+        LOGGER.info("ANIC received")
+
+    def process_BUTN(self, data):
+        LOGGER.info("BUTN received")
+
+    def process_KNOB(self, data):
+        LOGGER.info("KNOB received")
+
+    def process_SETD(self, data):
+        LOGGER.info("SETD received")
+
+    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()
+
+
+
+# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO
+devices = {
+    2: 'squeezebox',
+    3: 'softsqueeze',
+    4: 'squeezebox2',
+    5: 'transporter',
+    6: 'softsqueeze3',
+    7: 'receiver',
+    8: 'squeezeslave',
+    9: 'controller',
+    10: 'boom',
+    11: 'softboom',
+    12: 'squeezeplay',
+    }
+
+
+class PyLMSVolume(object):
+
+    """ Represents a sound volume. This is an awful lot more complex than it
+    sounds. """
+
+    minimum = 0
+    maximum = 100
+    step = 1
+
+    # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source
+    # i don't know how much magic it contains, or any way I can test it
+    old_map = [
+        0, 1, 1, 1, 2, 2, 2, 3,  3,  4,
+        5, 5, 6, 6, 7, 8, 9, 9, 10, 11,
+        12, 13, 14, 15, 16, 16, 17, 18, 19, 20,
+        22, 23, 24, 25, 26, 27, 28, 29, 30, 32,
+        33, 34, 35, 37, 38, 39, 40, 42, 43, 44,
+        46, 47, 48, 50, 51, 53, 54, 56, 57, 59,
+        60, 61, 63, 65, 66, 68, 69, 71, 72, 74,
+        75, 77, 79, 80, 82, 84, 85, 87, 89, 90,
+        92, 94, 96, 97, 99, 101, 103, 104, 106, 108, 110,
+        112, 113, 115, 117, 119, 121, 123, 125, 127, 128
+        ];
+
+    # new gain parameters, from the same place
+    total_volume_range = -50 # dB
+    step_point = -1           # Number of steps, up from the bottom, where a 2nd volume ramp kicks in.
+    step_fraction = 1         # fraction of totalVolumeRange where alternate volume ramp kicks in.
+
+    def __init__(self):
+        self.volume = 50
+
+    def increment(self):
+        """ Increment the volume """
+        self.volume += self.step
+        if self.volume > self.maximum:
+            self.volume = self.maximum
+
+    def decrement(self):
+        """ Decrement the volume """
+        self.volume -= self.step
+        if self.volume < self.minimum:
+            self.volume = self.minimum
+
+    def old_gain(self):
+        """ Return the "Old" gain value as required by the squeezebox """
+        return self.old_map[self.volume]
+
+    def decibels(self):
+        """ Return the "new" gain value. """
+
+        step_db = self.total_volume_range * self.step_fraction
+        max_volume_db = 0 # different on the boom?
+
+        # Equation for a line:
+        # y = mx+b
+        # y1 = mx1+b, y2 = mx2+b.
+        # y2-y1 = m(x2 - x1)
+        # y2 = m(x2 - x1) + y1
+        slope_high = max_volume_db - step_db / (100.0 - self.step_point)
+        slope_low = step_db - self.total_volume_range / (self.step_point - 0.0)
+        x2 = self.volume
+        if (x2 > self.step_point):
+            m = slope_high
+            x1 = 100
+            y1 = max_volume_db
+        else:
+            m = slope_low
+            x1 = 0
+            y1 = self.total_volume_range
+        return m * (x2 - x1) + y1
+
+    def new_gain(self):
+        db = self.decibels()
+        floatmult = 10 ** (db/20.0)
+        # avoid rounding errors somehow
+        if -30 <= db <= 0:
+            return int(floatmult * (1 << 8) + 0.5) * (1<<8)
+        else:
+            return int((floatmult * (1<<16)) + 0.5)
\ No newline at end of file