474 lines
19 KiB
Python
Executable File
474 lines
19 KiB
Python
Executable File
#!/bin/env python
|
|
# This is adapted from mpdlisten.c : https://gist.github.com/sahib/6718139
|
|
# 2024 abelliqueux <contact@arthus.net>
|
|
#
|
|
# 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
|
|
# 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):
|
|
# 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)
|
|
# ~ print("{} {}".format(v_1, v_2))
|
|
if (abs(volume - v_1) > 1) and (abs(volume - v_2) > 2):
|
|
client.setvol(volume)
|
|
# ~ print("Volume: {}".format(volume))
|
|
if volume < min_val:
|
|
volume = 0
|
|
if volume > 100:
|
|
volume = 100
|
|
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']))
|
|
# 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'])
|
|
update_display(device, mpd_client_currentsong, mpd_client_status, current_mode, ui_state['cursor_pos'])
|
|
# 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:]))
|