mpdlistenpy/mpdlisten.py

308 lines
11 KiB
Python

#!/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
# 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
# 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
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 = 6
ui_vol_y = 4
ui_vol_x = device.width - ui_vol_width - 8
ui_vol_icon_coords = (ui_vol_x - 10, 4)
ui_vol_icon_polygon = [0,3,3,3,8,0,8,8,3,5,0,5]
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,1, 8,4, 6,6, 2,4, 0,6]
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=[0,0,8,0,8,8,0,8], XY=(((btn_width+1)*1.5), (menu_bar_y + 2))),
BTN_3=dict(FUNCTION='toggle', ICON=[0,0,8,4,0,8], XY=(((btn_width+1)*2.5), (menu_bar_y + 2))),
BTN_4=dict(FUNCTION='next', ICON=[0,0,3,3,3,0,8,4,3,8,3,5,0,8], XY=(((btn_width+1)*3.5), (menu_bar_y + 2))),
BTN_5=dict(FUNCTION='menu', ICON=[0,0, 5,0, 4,2, 8,5, 10,10, 7,7, 2,4, 0,6], XY=(((btn_width+1)*4.5), (menu_bar_y + 2))),
),
'browse' : dict(BTN_1=dict(FUNCTION='none', ICON=[0,0,8,0,8,8,0,8], XY=(((btn_width+1)*0.5), (menu_bar_y + 2)) ),
BTN_2=dict(FUNCTION='none', ICON=[0,0,8,0,8,8,0,8], XY=(((btn_width+1)*1.5), (menu_bar_y + 2))),
BTN_3=dict(FUNCTION='none', ICON=[0,0,8,0,8,8,0,8], XY=(((btn_width+1)*2.5), (menu_bar_y + 2))),
BTN_4=dict(FUNCTION='none', ICON=[0,0,8,0,8,8,0,8], XY=(((btn_width+1)*3.5), (menu_bar_y + 2))),
BTN_5=dict(FUNCTION='menu', ICON=[0,0, 5,0, 4,2, 8,5, 10,10, 7,7, 2,4, 0,6], 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):
i=0
while i < len(polygon):
polygon[i] += offset[0]
polygon[i+1] += offset[1]
i+=2
return polygon
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, status):
# 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)
draw.text((ui_vol_x, ui_vol_y), status['volume'], fill="white")
if current_mode == 'playback':
draw.text((ui_text_x, 2), currentsong['artist'], fill="white")
draw.text((ui_text_x, 14), currentsong['title'], fill="white")
draw.text((ui_text_x, 26), currentsong['album'], fill="white")
if 'elapsed' in status:
draw.text((ui_text_x, 38), "{}/{}".format(sectomin(status['elapsed']), sectomin(status['duration'])), fill="white")
device.contrast(0)
device.display(ui)
def send_mpd_cmd(client, cmd:str):
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]
global static_ui
static_ui = generate_static_ui(current_mode)
if cmd == 'prev':
if client.status()['state'] != 'stop':
client.previous()
elif cmd == 'next':
if client.status()['state'] != 'stop':
client.next()
elif cmd == 'toggle':
if client.status()['state'] in idle_states:
client.play()
else:
client.pause()
elif cmd == 'stop':
client.stop()
else:
return 0
return 1
def main(args):
previous_sond_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
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:
# MPD
mpd_status = client.status()
if len(mpd_status):
mpd_client_status = mpd_status
mpd_client_currentsong = client.currentsong()
play_state = mpd_client_status['state']
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_sond_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_sond_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)
update_display(device, mpd_client_currentsong, mpd_client_status)
# 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']):
send_mpd_cmd(client, MODES[current_mode][BTN]['FUNCTION'])
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'])
device.cleanup()
client.disconnect()
return 0
def signal_handler(sig, frame):
global ctrlc_pressed
print('You pressed Ctrl+C!')
ctrlc_pressed = True
# ~ sys.exit(0)
if __name__ == '__main__':
signal.signal(signal.SIGINT, signal_handler)
sys.exit(main(sys.argv[1:]))