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-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
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-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 )
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-
print ( self . status ) # TODO: Only for testing purposes. This key is still free!
print ( self . currentsong )
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
try :
2020-07-19 18:37:48 +02:00
title = currentsong [ ' title ' ]
try :
title + = " by " + currentsong [ ' artist ' ]
except :
pass
2020-07-05 17:45:45 +02:00
except :
try :
title = currentsong [ ' file ' ] ;
except :
2020-08-08 19:33:03 +02:00
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 )
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-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 ) :
loop = asyncio . new_event_loop ( )
asyncio . set_event_loop ( loop )
try :
loop . run_until_complete ( self . main ( ) )
finally :
loop . run_until_complete ( loop . shutdown_asyncgens ( ) )
loop . close ( )
r = radioserv ( )