2020-07-05 17:45:45 +02:00
#!/usr/bin/env python
from PIL import Image , ImageDraw , ImageFont
import asyncio
from mpd import MPDClient
2020-07-19 18:39:01 +02:00
import spidev
2020-08-21 11:38:38 +02:00
import math
import traceback
2020-10-30 12:53:24 +01:00
from aiohttp import web
import datetime
2020-07-05 17:45:45 +02:00
2020-08-08 16:49:31 +02:00
IO_DELAY = ( 1.0 / 18.0 )
2020-07-05 20:06:20 +02:00
MPD_DELAY = 0.5
2020-08-08 16:49:31 +02:00
SPI_SPEED = 500000
2020-08-21 11:38:38 +02:00
IO_SPEED = 50000
LIVE_SELECT = 160
LIVE_VOLUME = 40
2020-07-05 17:45:45 +02:00
2020-10-30 12:53:24 +01:00
LIKE_FILE = " /var/db/wr_likes "
2020-07-05 17:45:45 +02:00
class display ( object ) :
def __init__ ( self ) :
self . fg = 1
self . width = 128
self . height = 32
self . font_title = ImageFont . truetype ( ' /usr/share/fonts/noto-cjk/NotoSansCJK-Light.ttc ' , 18 )
self . font_info = ImageFont . truetype ( ' /usr/share/fonts/TTF/DejaVuSans.ttf ' , 10 )
self . img = Image . new ( ' 1 ' , ( self . width , self . height ) , color = not self . fg )
self . d = ImageDraw . Draw ( self . img )
def render ( self ) :
print ( " render NYI " )
def animate ( self , ticks ) :
print ( " animate NYI " )
def get_image ( self ) :
2020-07-19 18:39:01 +02:00
temp = self . img
return temp . rotate ( - 90 , expand = True ) . tobytes ( )
2020-07-05 17:45:45 +02:00
class display_playback ( display ) :
def __init__ ( self ) :
super ( ) . __init__ ( )
self . title = " "
self . title_scroll = 0
self . title_scroll_max = - 1
self . info = " "
self . syms = [ ] ;
2020-07-05 19:31:07 +02:00
self . title_img = Image . new ( ' 1 ' , ( 0 , 0 ) , color = not self . fg )
2020-08-08 18:16:47 +02:00
self . bar_redraw = True
2020-07-05 17:45:45 +02:00
2020-08-08 18:17:10 +02:00
def update_text ( self , title , center = False ) :
2020-07-05 17:45:45 +02:00
self . title = title
2020-08-08 18:17:10 +02:00
font_shift = 5
2020-07-05 17:45:45 +02:00
w , h = self . d . textsize ( self . title , font = self . font_title )
2020-08-21 11:38:38 +02:00
h = 20 # Force standard height to avoid artifacting
2020-07-13 18:10:48 +02:00
if ( w > self . width ) : # Check the text is short enough to be static
self . title + = " . "
w , h = self . d . textsize ( self . title , font = self . font_title )
2020-08-21 11:38:38 +02:00
h = 26 # Force standard height to avoid artifacting
2020-07-13 18:10:48 +02:00
w + = 10 # Spacing between repetitions
self . title_scroll_max = w
2020-07-05 17:45:45 +02:00
self . title_scroll = self . title_scroll_max - 10
2020-07-05 19:31:07 +02:00
# Prerender
2020-07-13 18:10:48 +02:00
fw = w + self . width
self . title_img = Image . new ( ' 1 ' , ( fw , h ) , color = not self . fg )
2020-07-05 19:31:07 +02:00
ImageDraw . Draw ( self . title_img ) . text ( ( 0 , - font_shift ) , self . title , font = self . font_title , fill = self . fg )
2020-07-13 18:10:48 +02:00
self . title_img . paste ( self . title_img , ( w , 0 ) )
self . title_img = self . title_img . crop ( ( 0 , 0 , fw , h - font_shift ) )
2020-07-05 17:45:45 +02:00
else :
2020-08-08 18:17:10 +02:00
if ( center ) :
x_shift = ( self . width - w ) / 2
else :
x_shift = 0 ;
w = self . width
self . title_img = Image . new ( ' 1 ' , ( w , h ) , color = not self . fg )
ImageDraw . Draw ( self . title_img ) . text ( ( x_shift , - font_shift ) , self . title , font = self . font_title , fill = self . fg )
2020-07-05 17:45:45 +02:00
self . title_scroll = 0
self . title_scroll_max = - 1
def update_info ( self , info ) :
self . info = info
2020-07-05 19:31:07 +02:00
self . bar_redraw = True
2020-07-05 17:45:45 +02:00
def update_syms ( self , syms ) :
2020-07-05 19:31:07 +02:00
self . syms = syms
self . bar_redraw = True
2020-07-05 17:45:45 +02:00
def scroll_title ( self , amount = 1 ) :
2020-07-05 19:31:07 +02:00
if ( self . title_scroll > = 0 ) :
2020-07-05 17:45:45 +02:00
self . title_scroll = ( self . title_scroll + amount ) % self . title_scroll_max
def animate ( self , ticks ) :
self . scroll_title ( ticks )
2020-07-05 19:31:07 +02:00
def _render_bar ( self ) :
2020-07-05 17:45:45 +02:00
# Clear framebuffer
2020-07-05 19:31:07 +02:00
self . d . rectangle ( ( 0 , self . height - 1 - 10 , self . width , self . height ) , fill = self . fg )
2020-07-05 17:45:45 +02:00
# Playback info
w , h = self . d . textsize ( self . info , font = self . font_info )
2020-07-05 19:42:30 +02:00
self . d . text ( ( self . width - w - 2 , self . height - 1 - 10 - 1 ) , self . info , font = self . font_info , fill = not self . fg )
2020-07-05 17:45:45 +02:00
# Status icons
2020-07-05 19:31:07 +02:00
status = " " . join ( [ " " + icon + " " for icon in self . syms ] )
2020-07-05 19:42:30 +02:00
self . d . text ( ( - 2 , self . height - 1 - 10 - 1 ) , status , font = self . font_info , fill = not self . fg )
2020-07-05 19:31:07 +02:00
def render ( self ) :
# Title (prerendered bitmap to shift)
self . img . paste ( self . title_img , ( - self . title_scroll , 0 ) )
# Other things were pre-rendered to the framebuffer directly
if ( self . bar_redraw ) :
self . bar_redraw = False
self . _render_bar ( )
2020-07-05 17:45:45 +02:00
# TODO: Maybe a progress bar?
#self.d.line((0,15, 127,15), fill=self.fg)
2020-08-21 11:38:38 +02:00
class display_selection ( display ) :
def __init__ ( self ) :
super ( ) . __init__ ( )
self . entries = [ ]
self . page = 0
def animate ( self , ticks ) :
pass
def render ( self ) :
#self.img.paste(self.img_pre, (0,0))
self . img = self . img_pre
2020-08-23 14:10:28 +02:00
def set_entries ( self , entries , left_only = False ) :
self . left_only = left_only
2020-08-21 11:38:38 +02:00
self . entries = entries
self . set_page ( 0 )
def set_page ( self , page ) :
self . page = page
font_shift = 0
x = 0
y = 0
self . img_pre = Image . new ( ' 1 ' , ( self . width , self . height ) , color = not self . fg )
2020-08-23 14:10:28 +02:00
mult = 3 if self . left_only else 6
for entry in self . entries [ page * mult : ] :
2020-08-21 11:38:38 +02:00
ImageDraw . Draw ( self . img_pre ) . text ( ( x , y - font_shift ) , entry , font = self . font_info , fill = self . fg )
y + = self . height / 3
if ( y > = self . height ) :
y = 0
x + = self . width / 2
2020-08-23 14:10:28 +02:00
if ( x > = self . width ) or ( self . left_only ) :
2020-08-21 11:38:38 +02:00
break
def set_page_relative ( self , delta ) :
self . page + = delta
max_page = math . ceil ( len ( self . entries ) / 6 ) - 1
if ( self . page < 0 ) :
self . page = 0
elif ( self . page > max_page ) :
self . page = max_page
self . set_page ( self . page )
def get_entry ( self , offset , page = - 1 ) :
if ( page == - 1 ) :
page = self . page
try :
2020-08-23 14:10:28 +02:00
mult = 3 if self . left_only else 6
return self . entries [ page * mult + offset ]
2020-08-21 11:38:38 +02:00
except :
return " "
2020-07-05 17:45:45 +02:00
class display_manager ( object ) :
def __init__ ( self ) :
self . tick = 0
self . current_display = None
2020-08-21 11:38:38 +02:00
def set_display ( self , disp , live = - 1 ) :
2020-07-05 17:45:45 +02:00
self . current_display = disp
2020-08-08 18:18:22 +02:00
self . current_display . live = live
2020-08-21 11:38:38 +02:00
def set_live ( self , live = - 1 ) :
self . current_display . live = live
def kill_display ( self ) :
self . current_display . live = 0
2020-08-08 18:18:22 +02:00
def display_is_dead ( self ) :
if ( self . current_display ) :
return ( self . current_display . live == 0 )
return 0
2020-07-05 17:45:45 +02:00
def render ( self ) :
if ( self . current_display ) :
self . current_display . render ( )
def get_image ( self ) :
if ( self . current_display ) :
2020-07-05 20:09:31 +02:00
return self . current_display . get_image ( )
2020-07-05 17:45:45 +02:00
else :
2020-07-05 20:09:31 +02:00
return [ ]
2020-07-05 17:45:45 +02:00
def animate ( self , ticks ) :
old_tick = self . tick
self . tick + = ticks
if ( self . current_display ) :
if ( not hasattr ( self . current_display , ' ltick ' ) ) :
self . current_display . ltick = old_tick
self . current_display . animate ( self . tick - self . current_display . ltick )
self . current_display . ltick = self . tick
2020-08-08 18:18:22 +02:00
if ( self . current_display . live > 0 ) :
self . current_display . live - = ticks
2020-07-05 17:45:45 +02:00
# TODO: Consider also animating inactive screens... Might resolve some glitches when updating text while a screen is in the background (or pass `tick` to update funcs)
class radioserv :
2020-10-30 12:53:24 +01:00
def load_liked ( self ) :
try :
f = open ( LIKE_FILE , " r " )
text = f . read ( )
if text != " " :
self . liked = text . split ( " \n " ) ;
else :
self . liked = [ ]
f . close ( ) ;
except :
self . liked = [ ]
def like_song ( self ) :
self . liked . append ( " [ {} ] {} " . format ( datetime . datetime . now ( ) , self . get_formatted_title ( ) ) )
self . disp_info . update_text ( " ♥ " , True )
self . disp_info . update_info ( " Liked song. " )
self . disp_mgr . set_display ( self . disp_info , LIVE_VOLUME )
self . sync_liked ( )
def unlike_song ( self , id ) :
self . liked . pop ( id )
self . sync_liked ( )
def sync_liked ( self ) :
f = open ( LIKE_FILE , " w " )
f . write ( " \n " . join ( self . liked ) )
f . close ( )
2020-08-21 11:38:38 +02:00
def handle_volume ( self , rotary_delta , bat ) :
2020-08-08 17:27:17 +02:00
if ( rotary_delta != 0 ) :
2020-08-21 11:38:38 +02:00
nvol = int ( self . status [ ' volume ' ] ) + ( rotary_delta * 4 )
2020-08-08 17:27:17 +02:00
if ( nvol < 0 ) :
nvol = 0 ;
elif ( nvol > 100 ) :
nvol = 100
2020-08-08 18:18:22 +02:00
self . disp_info . update_text ( " {} % " . format ( nvol ) , True )
2020-08-21 11:38:38 +02:00
self . disp_info . update_info ( " Battery: {} % " . format ( bat ) )
self . disp_mgr . set_display ( self . disp_info , LIVE_VOLUME )
2020-08-08 17:27:17 +02:00
self . mpd . setvol ( nvol )
2020-08-21 11:38:38 +02:00
def open_playlist_screen ( self ) :
playlists = self . mpd . listplaylists ( )
entries = [ ]
for entry in playlists :
entries . append ( entry [ ' playlist ' ] )
2020-08-23 14:10:28 +02:00
self . disp_select . mode = " playlist "
2020-08-21 11:38:38 +02:00
self . disp_select . set_entries ( entries )
2020-08-23 14:10:28 +02:00
self . disp_mgr . set_display ( self . disp_select , LIVE_SELECT )
def open_directory_screen ( self ) :
dirs = self . mpd . listfiles ( " " )
entries = [ ]
for entry in dirs :
try :
entries . append ( entry [ ' directory ' ] )
except :
pass
self . disp_select . mode = " directory "
self . disp_select . set_entries ( entries , True )
2020-08-21 11:38:38 +02:00
self . disp_mgr . set_display ( self . disp_select , LIVE_SELECT )
2020-10-30 12:53:24 +01:00
def get_formatted_title ( self ) :
try :
title = self . currentsong [ ' title ' ]
try :
title + = " by " + self . currentsong [ ' artist ' ]
except :
pass
except :
try :
title = self . currentsong [ ' file ' ]
except :
title = " "
return title
2020-08-21 11:38:38 +02:00
def handle_rx ( self , rdata ) :
# Handle battery voltage
bat = int ( ( int ( rdata [ 2 ] ) - 175 ) / 45 * 100 )
if ( bat < 0 ) :
bat = 0
elif ( bat > 100 ) :
bat = 100
if ( bat == 0 ) :
print ( " Battery depleted. Shutting down. " )
self . kill_event . set ( )
# Handle key presses
key_id = rdata [ 0 ] & 0x0F
key_press_short = ( ( rdata [ 0 ] & 0xC0 ) == 128 )
2020-08-23 14:10:28 +02:00
key_press_long = ( ( rdata [ 0 ] & 0xC0 ) == 192 )
if key_press_short :
print ( " Press key: {} " . format ( key_id ) )
elif key_press_long :
print ( " Long press key: {} " . format ( key_id ) )
2020-08-21 11:38:38 +02:00
# Handle rotary input
rotary_delta = int ( rdata [ 1 ] & 0x1F ) - 16
if ( self . disp_mgr . current_display == self . disp_playback ) :
if ( key_press_short ) :
if ( key_id == 0 ) : # Tune-
2020-10-30 12:53:24 +01:00
self . like_song ( )
2020-08-21 11:38:38 +02:00
elif ( key_id == 1 ) : # Tune+
self . mpd . previous ( )
elif ( key_id == 2 ) : # Power
self . kill_event . set ( )
elif ( key_id == 4 ) : # Wheel
if ( self . status [ ' state ' ] == " stop " ) :
self . mpd . play ( 0 )
else :
self . mpd . pause ( )
elif ( key_id == 5 ) : # Source
self . open_playlist_screen ( )
elif ( key_id == 6 ) : # Info / Menu
self . mpd . next ( )
elif ( key_id == 7 ) : # PRESET
self . mpd . random ( int ( not int ( self . status [ ' random ' ] ) ) )
2020-08-23 14:10:28 +02:00
if ( key_press_long ) :
if ( key_id == 5 ) : # Source
self . open_directory_screen ( )
2020-08-21 11:38:38 +02:00
self . handle_volume ( rotary_delta , bat )
elif ( self . disp_mgr . current_display == self . disp_info ) :
self . handle_volume ( rotary_delta , bat )
if ( key_press_short ) :
self . disp_mgr . kill_display ( )
elif ( self . disp_mgr . current_display == self . disp_select ) :
2020-08-23 14:15:10 +02:00
if ( key_press_short ) or ( key_press_long ) :
2020-08-21 11:38:38 +02:00
sel = - 1
if ( key_id == 0 ) : # Tune-
sel = 2
elif ( key_id == 1 ) : # Tune+
sel = 1
elif ( key_id == 2 ) : # Power
sel = 0
elif ( key_id == 4 ) : # Wheel
sel = 100
elif ( key_id == 5 ) : # Source
sel = 3
elif ( key_id == 6 ) : # Info / Menu
sel = 4
elif ( key_id == 7 ) : # PRESET
sel = 5
if ( sel == - 1 ) :
pass
elif ( sel == 100 ) :
self . disp_mgr . kill_display ( )
else :
2020-08-23 14:10:28 +02:00
print ( " Load {} {} . " . format ( self . disp_select . mode , self . disp_select . get_entry ( sel ) ) )
2020-08-23 14:15:10 +02:00
if key_press_short :
self . mpd . clear ( )
2020-08-23 14:10:28 +02:00
if ( self . disp_select . mode == " playlist " ) :
self . mpd . load ( self . disp_select . get_entry ( sel ) )
else :
self . mpd . add ( self . disp_select . get_entry ( sel ) )
2020-08-23 14:15:10 +02:00
if key_press_short :
self . mpd . play ( 0 )
2020-08-21 11:38:38 +02:00
self . disp_mgr . kill_display ( )
if ( rotary_delta != 0 ) :
self . disp_mgr . set_live ( LIVE_SELECT )
self . disp_select . set_page_relative ( rotary_delta )
2020-08-08 16:52:57 +02:00
2020-07-05 17:45:45 +02:00
async def io_main ( self ) :
while True :
2020-08-21 11:38:38 +02:00
try :
if self . disp_mgr . display_is_dead ( ) :
self . disp_mgr . set_display ( self . disp_playback )
self . disp_mgr . animate ( 1 )
self . disp_mgr . render ( )
rdata = self . spi . xfer ( [ 100 , 101 , 102 ] , IO_SPEED , 1000 )
self . handle_rx ( rdata )
data = list ( self . disp_mgr . get_image ( ) )
rdata = self . spi . xfer ( data , SPI_SPEED , 10000 )
await self . io_event . wait ( )
self . io_event . clear ( )
except :
print ( " Error in io_main " )
traceback . print_exc ( )
2020-07-05 17:45:45 +02:00
async def io_timer_main ( self ) :
while True :
await asyncio . sleep ( IO_DELAY )
self . io_event . set ( )
async def mpd_main ( self ) :
2020-08-08 19:33:03 +02:00
last_title = " Loading... "
2020-07-05 17:45:45 +02:00
last_state = " "
last_info = " "
2020-08-21 11:38:38 +02:00
last_random = - 1
2020-07-05 17:45:45 +02:00
play_icon_map = { " play " : " ▶️ " , " pause " : " ll " , " stop " : " □ " }
while True :
# Query MPD
status = self . mpd . status ( )
currentsong = self . mpd . currentsong ( )
2020-08-21 11:38:38 +02:00
self . status = status
self . currentsong = currentsong
2020-07-05 17:45:45 +02:00
# Song title
2020-10-30 12:53:24 +01:00
title = self . get_formatted_title ( )
2020-07-05 17:45:45 +02:00
if ( title != last_title ) :
self . disp_playback . update_text ( title )
last_title = title
# Playback state (icons)
2020-08-21 11:38:38 +02:00
if ( status [ ' state ' ] != last_state or status [ ' random ' ] != last_random ) :
2020-07-05 17:45:45 +02:00
icons = [ ]
icons . append ( play_icon_map . get ( status [ " state " ] , " ? " ) )
2020-08-21 11:38:38 +02:00
if ( status [ ' random ' ] == ' 1 ' ) :
icons . append ( " Z " )
2020-07-05 17:45:45 +02:00
self . disp_playback . update_syms ( icons )
last_state = status [ " state " ]
2020-08-21 11:38:38 +02:00
last_random = status [ " random " ]
2020-07-05 17:45:45 +02:00
# Playback info
try :
cm , cs = divmod ( round ( float ( status [ ' elapsed ' ] ) ) , 60 )
2020-07-19 18:38:06 +02:00
if " duration " in status :
dm , ds = divmod ( round ( float ( status [ ' duration ' ] ) ) , 60 )
info = " # %d / %d %d : %02d / %d : %02d " % ( int ( status [ ' song ' ] ) + 1 , int ( status [ ' playlistlength ' ] ) , cm , cs , dm , ds )
else :
info = " Radio %02d : %02d " % ( cm , cs )
2020-07-05 17:45:45 +02:00
except :
info = " Stopped "
if ( info != last_info ) :
self . disp_playback . update_info ( info )
# Sadly I can't use mpd.idle() here
await asyncio . sleep ( MPD_DELAY )
2020-10-30 12:53:24 +01:00
async def webserver_main ( self ) :
print ( " ws main " )
routes = web . RouteTableDef ( )
@routes.get ( ' / ' )
@routes.post ( ' / ' )
async def hello ( request ) :
data = await request . post ( )
if ( len ( data ) > 0 ) :
for key in data :
print ( key [ 4 : ] )
self . unlike_song ( int ( key [ 4 : ] ) )
slist = " "
i = 0
for entry in self . liked :
slist + = ' <li> {} <input name= " del_ {} " type= " submit " value= " X " ></input></li> ' . format ( entry , i )
i + = 1
if slist == " " :
slist = " There are no liked songs. Push the like-button to add them to the list. "
text = """
< html >
< head >
< title > Web Radio : Liked Songs < / title >
< / head >
< body >
< h1 > Web Radio : Liked Songs < / h1 >
< p >
< form action = " / " method = " post " >
{ }
< / form >
< / p >
< p >
< a href = " / " > Refresh < / a >
< / p >
< / body >
< / html >
""" .format(slist);
return web . Response ( text = text , headers = ( { ' CONTENT-TYPE ' : " text/html " } ) )
app = web . Application ( )
app . add_routes ( routes )
await web . _run_app ( app , host = " * " , port = 80 )
2020-07-05 17:45:45 +02:00
async def main ( self ) :
2020-07-19 18:39:01 +02:00
# Connect to SPI
self . spi = spidev . SpiDev ( )
self . spi . open ( 0 , 0 )
2020-07-28 21:55:33 +02:00
self . spi . max_speed_hz = SPI_SPEED
2020-07-19 18:39:01 +02:00
self . spi . mode = 0b00
2020-07-05 17:45:45 +02:00
# Connect to MPD
self . mpd = MPDClient ( )
2020-08-21 11:38:38 +02:00
retry = 15
while retry > 0 :
retry - = 1
try :
self . mpd . connect ( " localhost " , 6600 )
break
except :
print ( " Error connecting to MPD. Retrying in 1s. " )
await asyncio . sleep ( 1 )
try :
self . mpd . status ( )
except :
print ( " Could not connect to server. Exiting. " )
return - 1
2020-07-05 17:45:45 +02:00
# Set up displays
self . disp_playback = display_playback ( )
self . disp_playback . update_text ( " Loading... " )
2020-08-08 18:18:22 +02:00
self . disp_info = display_playback ( )
self . disp_info . update_text ( " --- " ) ;
2020-08-21 11:38:38 +02:00
self . disp_select = display_selection ( )
2020-07-05 17:45:45 +02:00
self . disp_mgr = display_manager ( )
self . disp_mgr . set_display ( self . disp_playback )
2020-08-21 11:38:38 +02:00
# Flush I/O messages
rdata = self . spi . xfer ( [ 0 , 0 , 0 ] , IO_SPEED , 1000 )
2020-07-05 17:45:45 +02:00
# Create main tasks
self . io_event = asyncio . Event ( )
2020-08-08 16:52:57 +02:00
self . kill_event = asyncio . Event ( )
2020-07-05 17:45:45 +02:00
io_task = asyncio . create_task ( self . io_main ( ) )
io_timer_task = asyncio . create_task ( self . io_timer_main ( ) )
mpd_task = asyncio . create_task ( self . mpd_main ( ) )
2020-10-30 12:53:24 +01:00
ws_task = asyncio . create_task ( self . webserver_main ( ) )
2020-07-05 17:45:45 +02:00
2020-08-08 16:52:57 +02:00
await self . kill_event . wait ( )
self . kill_event . clear ( )
2020-07-05 17:45:45 +02:00
2020-08-08 19:33:03 +02:00
# Show shutdown screen
self . disp_info . update_text ( " Shutting down " , True ) ;
self . disp_info . update_info ( " " )
self . disp_mgr . set_display ( self . disp_info )
await asyncio . sleep ( 0.1 )
2020-07-05 17:45:45 +02:00
io_timer_task . cancel ( )
io_task . cancel ( )
mpd_task . cancel ( )
self . mpd . close ( )
self . mpd . disconnect ( )
def __init__ ( self ) :
2020-10-30 12:53:24 +01:00
self . loop = asyncio . new_event_loop ( )
asyncio . set_event_loop ( self . loop )
self . load_liked ( )
2020-07-05 17:45:45 +02:00
try :
2020-10-30 12:53:24 +01:00
self . loop . run_until_complete ( self . main ( ) )
2020-07-05 17:45:45 +02:00
finally :
2020-10-30 12:53:24 +01:00
self . loop . run_until_complete ( self . loop . shutdown_asyncgens ( ) )
self . loop . close ( )
2020-07-05 17:45:45 +02:00
r = radioserv ( )