#!/bin/env python # This is adapted from mpdlisten.c : https://gist.github.com/sahib/6718139 # 2024 abelliqueux # # MPD client import musicpd from math import floor from os import environ import signal import sys from time import sleep, time # Relay import RPi.GPIO as GPIO # OLED SSD1306 from luma.core.interface.serial import i2c from luma.core.render import canvas from luma.oled.device import ssd1306 from PIL import Image, ImageDraw, ImageFont # Pot_cap import pigpio import pot_cap min_val = 8 max_val = 298 vol_mult = 100/(max_val-min_val) volume = 0 v_1 = 0 v_2 = 0 ctrlc_pressed = False pot_cap_gpio = 23 drain_ms = 0.8 timeout_s = 1.0 jfont = ImageFont.truetype('DejaVuSansMono.ttf', 10) # MPD config off_delay = 3 mpd_host=None mpd_port=None mpd_passwd = None mpd_states = ['play', 'pause', 'stop', 'unknown'] # Relay GPIO setup GPIO.setmode(GPIO.BCM) RELAIS_1_GPIO = 17 GPIO.setup(RELAIS_1_GPIO, GPIO.OUT) # GPIO Assign mode # Buttons GPIO BTNS = { 'BTN_1' : dict(GPIO=7, state=1), 'BTN_2' : dict(GPIO=8, state=1), 'BTN_3' : dict(GPIO=11, state=1), 'BTN_4' : dict(GPIO=25, state=1), 'BTN_5' : dict(GPIO=9, state=1), } for BTN in BTNS: GPIO.setup(BTNS[BTN]['GPIO'], GPIO.IN, pull_up_down=GPIO.PUD_UP) # SSD1306 setup - bouding box is (0, 0, 127, 63), 128x64 # TODO : Set an idle_display timer and run device.hide()/device.show() according to value # GPIOS 2, 3 serial = i2c(port=1, address=0x3C) device = ssd1306(serial) # GUI config - top left is 0,0, bottom right is 127,63 number_of_btns = 5 btn_width = floor((device.width-4) / number_of_btns ) btn_height = 10 menu_line_width = 1 menu_bar_y = device.height - btn_height - menu_line_width - 1 ui_text_x = 4 ui_vol_width = 18 ui_vol_y = menu_bar_y - 14 ui_vol_x = device.width - ui_vol_width ui_vol_icon_coords = (ui_vol_x - 10, ui_vol_y+2) ui_vol_icon_polygon = [0,3,3,3,8,0,8,8,3,5,0,5] play_icon = [0,0,8,4,0,8] # ~ play_icon_ = [0,0,8,4,0,8] pause_icon = [0,0,3,0,3,8,5,8,5,0,8,0,8,8,0,8] stop_icon = [0,0,8,0,8,8,0,8] next_icon = [0,0,3,3,3,0,8,4,3,8,3,5,0,8] prev_icon = [0,4,3,0,3,3,8,0,8,8,3,5,3,8] menu_icon = [0,0, 5,0, 4,2, 8,5, 10,10, 7,7, 2,4, 0,6] menu_icon_ = [0,0, 5,0, 4,2, 8,5, 10,10, 7,7, 2,4, 0,6] down_icon = [0,0, 8,0, 4,8] up_icon = [0,8, 4,0, 8,8] add_icon = [0,3, 3,3, 3,0, 6,0, 6,3, 8,3, 8,6, 6,6, 6,8, 3,8, 3,6, 0,6] ui_state = {'cursor_pos': 0, 'prev_cursor_pos': 0, 'current_selection': [], 'current_selection_level': 0, 'current_artist': None, 'current_album': None } MODES_ORDER = ['playback', 'browse'] MODES = { 'playback' : dict(BTN_1=dict(FUNCTION='prev', ICON=prev_icon, XY=(((btn_width+1)*0.5), (menu_bar_y + 2)) ), BTN_2=dict(FUNCTION='stop', ICON=stop_icon, XY=(((btn_width+1)*1.5), (menu_bar_y + 2))), BTN_3=dict(FUNCTION='toggle', ICON=play_icon, XY=(((btn_width+1)*2.5), (menu_bar_y + 2))), BTN_4=dict(FUNCTION='next', ICON=next_icon, XY=(((btn_width+1)*3.5), (menu_bar_y + 2))), BTN_5=dict(FUNCTION='menu', ICON=menu_icon, XY=(((btn_width+1)*4.5), (menu_bar_y + 2))), ), 'browse' : dict(BTN_1=dict(FUNCTION='down', ICON=down_icon, XY=(((btn_width+1)*0.5), (menu_bar_y + 2)) ), BTN_2=dict(FUNCTION='up', ICON=up_icon, XY=(((btn_width+1)*1.5), (menu_bar_y + 2))), BTN_3=dict(FUNCTION='select', ICON=play_icon, XY=(((btn_width+1)*2.5), (menu_bar_y + 2))), BTN_4=dict(FUNCTION='add', ICON=add_icon, XY=(((btn_width+1)*3.5), (menu_bar_y + 2))), BTN_5=dict(FUNCTION='menu', ICON=menu_icon, XY=(((btn_width+1)*4.5), (menu_bar_y + 2))), ), } # Becomes true when receiving SIGINT ctrlc_pressed = False mpd_client_status = {'volume': 'N/A', 'repeat': 'N/A', 'random': 'N/A', 'single': 'N/A', 'consume': 'N/A', 'partition': 'N/A', 'playlist': 'N/A', 'playlistlength': 'N/A', 'mixrampdb': 'N/A', 'state': 'N/A', 'song': 'N/A', 'songid': 'N/A', 'time': '0', 'elapsed': '0', 'bitrate': 'N/A', 'duration': '0', 'audio': 'N/A', 'nextsong': 'N/A', 'nextsongid': 'N/A' } mpd_client_currentsong = {'file': 'N/A', 'last-modified': 'N/A', 'format': 'N/A', 'artist': 'N/A', 'albumartist': 'N/A', 'title': 'N/A', 'album': 'N/A', 'track': 'N/A', 'date': 'N/A', 'genre': 'N/A', 'time': 'N/A', 'duration': 'N/A', 'pos': 'N/A', 'id': 'N/A'} # Source host, port from env variables if 'MPD_HOST' in environ: mpd_host = environ['MPD_HOST'] # Extract password if provided if len(mpd_host.split('@')) > 1: mpd_passwd = mpd_host.split('@')[0] mpd_host = mpd_host.split('@')[1] if 'MPD_PORT' in environ: mpd_port = environ['MPD_PORT'] static_ui = None current_mode = "playback" def sectomin(sec:str): minute = 0 minute = floor(float(sec)/60) second = round(float(sec)-minute*60) # Format time as 00:00 return "{:02d}:{:02d}".format(minute, second) def apply_xy_offset(polygon:list, offset:tuple): polygon_copy = [] i=0 while i < len(polygon): polygon_copy.append(polygon[i] + offset[0]) polygon_copy.append(polygon[i+1] + offset[1]) i+=2 return polygon_copy def offset_polygons(modes:dict): for mode in modes: for btn in modes[mode]: modes[mode][btn]['ICON'] = apply_xy_offset(modes[mode][btn]['ICON'], modes[mode][btn]['XY']) def generate_static_ui(mode:str): # ~ if mode == "playback": im = Image.new(mode='1', size=(device.width, device.height)) draw = ImageDraw.Draw(im) # ~ draw.rectangle(device.bounding_box, outline="white", fill="black") draw.line([(0, menu_bar_y - 2),(device.width, menu_bar_y - 2)], width=menu_line_width, fill="white") i = 1 while i < number_of_btns: draw.line([((btn_width+1)*i, menu_bar_y),((btn_width+1)*i, device.height)], width=menu_line_width, fill="white") i+=1 # Draw icons according to current mode # ~ for mode in MODES: for btn in MODES[current_mode]: draw.polygon(MODES[mode][btn]['ICON'], fill="white") draw.polygon(ui_vol_icon_polygon, fill="white") return im def update_display(device, currentsong:dict, status:dict, mode:str, cursor_pos:int): # We want to display 4 buttons at the bottom of the screen btn_width = floor((device.width-3) / 4) btn_height = 8 menu_line_width = 1 menu_bar_y = device.height - btn_height - menu_line_width # Draw dynamic UI ui = static_ui.copy() draw = ImageDraw.Draw(ui) if mode == 'playback': if len(currentsong): draw.text((ui_text_x, 2), currentsong['artist'], fill="white", font=jfont) draw.text((ui_text_x, 14), currentsong['title'], fill="white", font=jfont) draw.text((ui_text_x, 26), currentsong['album'], fill="white", font=jfont) if 'elapsed' in status: draw.text((ui_text_x, 38), "{}/{}".format(sectomin(status['elapsed']), sectomin(status['duration'])), fill="white", font=jfont) elif mode == 'browse': draw.regular_polygon(bounding_circle=(ui_text_x + 2, 6, 4), n_sides=3, rotation=270, outline="white", fill="black") if (type(ui_state['current_selection']) is list) and (len(ui_state['current_selection'])): draw.text((ui_text_x + 10, 1), ui_state['current_selection'][cursor_pos], fill="white", font=jfont) if (len(ui_state['current_selection']) > 1) and (cursor_pos < len(ui_state['current_selection'])-1): draw.text((ui_text_x, 14), ui_state['current_selection'][cursor_pos+1], fill="white", font=jfont) if len(ui_state['current_selection']) > 2 and (cursor_pos < len(ui_state['current_selection'])-2): draw.text((ui_text_x, 26), ui_state['current_selection'][cursor_pos+2], fill="white", font=jfont) else: draw.text((ui_text_x + 10, 1), ui_state['current_selection'], fill="white", font=jfont) draw.text((ui_vol_x, ui_vol_y), "{:02d}".format(int(status['volume'])), fill="white", font=jfont) device.contrast(0) device.display(ui) def populate_empty_tags(current_selection:list, selection_level:int): if '' in current_selection: if selection_level == 0: # Empty artist current_selection[current_selection.index('')] = '(No artist)' if selection_level == 1: # Empty album current_selection[current_selection.index('')] = '(No album)' if selection_level == 2: # Empty title current_selection[current_selection.index('')] = '(No title)' def send_mpd_cmd(client, cmd:str, ui_state:dict): cursor_pos = ui_state['cursor_pos'] prev_cursor_pos = ui_state['prev_cursor_pos'] current_selection = ui_state['current_selection'] current_artist = ui_state['current_artist'] current_album = ui_state['current_album'] current_selection_level = ui_state['current_selection_level'] idle_states = ['stop', 'pause'] if cmd == 'menu': global current_mode current_mode_index = MODES_ORDER.index(current_mode) # Avoid out of range if current_mode_index >= len(MODES_ORDER)-1: current_mode_index = 0 else: current_mode_index += 1 current_mode = MODES_ORDER[current_mode_index] # TODO : use menu button as return # prev_cursor_pos global static_ui static_ui = generate_static_ui(current_mode) current_selection = client.list('artist') current_selection_level = 0 populate_empty_tags(current_selection, current_selection_level) cursor_pos = 0 elif cmd == 'prev': if client.status()['state'] != 'stop': client.previous() elif cmd == 'next': if client.status()['state'] != 'stop': client.next() elif cmd == 'toggle': # ~ global MODES if client.status()['state'] in idle_states: client.play() MODES['playback']['BTN_3']['ICON'] = apply_xy_offset(play_icon, MODES['playback']['BTN_3']['XY']) static_ui = generate_static_ui('playback') else: client.pause() MODES['playback']['BTN_3']['ICON'] = apply_xy_offset(pause_icon, MODES['playback']['BTN_3']['XY']) static_ui = generate_static_ui('playback') elif cmd == 'stop': client.stop() elif cmd == 'down': if cursor_pos in range(0, len(current_selection)-1): cursor_pos += 1 else: cursor_pos = 0 elif cmd == 'up': if cursor_pos in range(1, len(current_selection)): cursor_pos -= 1 else: cursor_pos = len(current_selection)-1 elif cmd == 'select': if current_selection_level == 0: # Select artist # ~ current_selection = current_selection[cursor_pos] # ~ current_selection = client.list('artist') if len(current_selection): current_artist = current_selection[cursor_pos] current_selection = client.list('album', 'artist', current_selection[cursor_pos]) print("Artist:") print(current_artist) current_selection_level += 1 populate_empty_tags(current_selection, current_selection_level) prev_cursor_pos = cursor_pos cursor_pos = 0 elif current_selection_level == 1: if len(current_selection): current_album = current_selection[cursor_pos] # List albums by selected artist # ~ current_selection = client.list('album', 'artist', current_selection[cursor_pos]) current_selection = client.list('title', 'album', current_selection[cursor_pos]) print("Album:") print(current_album) current_selection_level += 1 populate_empty_tags(current_selection, current_selection_level) cursor_pos = 0 elif current_selection_level == 2: # List songs by selected album # ~ current_selection = client.list('title', 'album', current_selection[cursor_pos]) current_selection = client.list('artist') cursor_pos = prev_cursor_pos print("Return to menu") print(current_selection) # TODO : change icon to a X and return to artist list if used current_selection_level = 0 elif cmd == 'add': if current_selection_level == 0: print(current_selection[cursor_pos]) selected_song_files = client.find('artist', current_selection[cursor_pos]) if current_selection_level == 1: print(current_artist) selected_song_files = client.find('artist', current_artist, 'album', current_selection[cursor_pos]) if current_selection_level == 2: print(current_album) selected_song_files = client.find('title', current_selection[cursor_pos], 'artist', current_artist,'album', current_album ) # ~ selected_song_file = client.find('title', current_selection[cursor_pos]) # ~ selected_song_files = selected_song_file[0] for file in selected_song_files: client.add(file['file']) state = {'cursor_pos': cursor_pos, 'prev_cursor_pos': prev_cursor_pos, 'current_selection': current_selection, 'current_selection_level': current_selection_level, 'current_artist': current_artist, 'current_album':current_album} return state def main(args): # Idle timer ui_idle_for = 0 # Pot_cap # Connect to Pi. pi = pigpio.pi() # Instantiate Pot/Cap reader. pc = pot_cap.reader(pi, pot_cap_gpio, drain_ms, timeout_s) start = time() previous_song_id = None previous_state = None paused_since_seconds = 0 # MPDclient setup client = musicpd.MPDClient() if mpd_passwd is not None: client.pwd = mpd_passwd if mpd_host is not None: client.host = mpd_host if mpd_port is not None: client.port = mpd_port client.mpd_timeout = 5 try: client.connect() except musicpd.ConnectionError as errorMessage: print(repr(errorMessage)) print("Check host and port are correct.") # ~ print(client.status()) # duration, elapsed, volume, repeat, random, single # ~ print(client.currentsong()) # artist, title, album # Cache MPD's artist list global ui_state ui_state['current_selection'] = client.list('artist') # List albums by artist # ~ album_by = client.list('album', 'artist', artist_list[x]) # List song by album # ~ songs_from_album = client.list('title', 'album', album_by_x[x]) offset_polygons(MODES) global ui_vol_icon_polygon ui_vol_icon_polygon = apply_xy_offset(ui_vol_icon_polygon, ui_vol_icon_coords) global static_ui static_ui = generate_static_ui(current_mode) while ctrlc_pressed is False: # pot_cap global v_1 global v_2 global volume s, v, r = pc.read() if s and r < 4: volume = round(v*vol_mult) if (abs(volume - v_1) > 1) and (abs(volume - v_2) > 2): print("Volume: {}".format(volume)) if volume < min_val: volume = 0 if volume > 100: volume = 100 client.setvol(100-volume) ui_idle_for = 0 v_2 = v_1 v_1 = volume # MPD mpd_status = client.status() if len(mpd_status): mpd_client_status = mpd_status mpd_client_currentsong = client.currentsong() #print(mpd_client_status['volume']) if 'state' in mpd_client_status: play_state = mpd_client_status['state'] if 'songid' in mpd_client_status: current_song_id = mpd_client_status['songid'] if play_state in mpd_states: if play_state == 'play': paused_since_seconds = 0 if (current_song_id != previous_song_id) and (previous_state != play_state): print("Play") # Relay on GPIO.output(RELAIS_1_GPIO, GPIO.HIGH) if play_state == 'pause': if paused_since_seconds < off_delay: paused_since_seconds += 0.2 print("Paused for {:.1f}".format(paused_since_seconds)) if (paused_since_seconds >= off_delay) and GPIO.input(RELAIS_1_GPIO): print("Off") # Relay off GPIO.output(RELAIS_1_GPIO, GPIO.LOW) if play_state == 'stop' or play_state == 'unknown': previous_song_id = None paused_since_seconds = 0 if previous_state != play_state: print("Stopped") # Relay off GPIO.output(RELAIS_1_GPIO, GPIO.LOW) previous_state = play_state sleep(.2) # Handle buttons for BTN in BTNS: # Avoid double trigger by saving the previous state of the button and commpare it to current state if (GPIO.input(BTNS[BTN]['GPIO']) == 0) and (GPIO.input(BTNS[BTN]['GPIO']) != BTNS[BTN]['state']): ui_state = send_mpd_cmd(client, MODES[current_mode][BTN]['FUNCTION'], ui_state) print("{} pressed".format(MODES[current_mode][BTN]['FUNCTION'])) # Reset idle timer ui_idle_for = 0 # Save previous state BTNS[BTN]['state'] = GPIO.input(BTNS[BTN]['GPIO']) if (GPIO.input(BTNS[BTN]['GPIO']) == 1) and (GPIO.input(BTNS[BTN]['GPIO']) != BTNS[BTN]['state']): # Save previous state BTNS[BTN]['state'] = GPIO.input(BTNS[BTN]['GPIO']) if int(ui_idle_for) < 10: ui_idle_for += .2 device.show() update_display(device, mpd_client_currentsong, mpd_client_status, current_mode, ui_state['cursor_pos']) elif int(ui_idle_for) == 10: print("Ui idle for 10 seconds, suspending display...") # Avoid further execution ui_idle_for = 11 device.hide() # pot_cap pc.cancel() # Cancel the reader. pi.stop() # Disconnect from Pi. device.cleanup() client.disconnect() return 0 def signal_handler(sig, frame): global ctrlc_pressed print('You pressed Ctrl+C!') ctrlc_pressed = True if __name__ == '__main__': signal.signal(signal.SIGINT, signal_handler) sys.exit(main(sys.argv[1:]))