Compare commits

...

10 Commits

5 changed files with 419 additions and 19 deletions

BIN
DejaVuSansMono.ttf Normal file

Binary file not shown.

97
mpdlisten.py Normal file → Executable file
View File

@ -8,14 +8,31 @@ from math import floor
from os import environ
import signal
import sys
from time import sleep
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
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
@ -40,6 +57,8 @@ 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)
@ -50,10 +69,10 @@ 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_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]
@ -189,24 +208,24 @@ def update_display(device, currentsong:dict, status:dict, mode:str, cursor_pos:i
# Draw dynamic UI
ui = static_ui.copy()
draw = ImageDraw.Draw(ui)
draw.text((ui_vol_x, ui_vol_y), status['volume'], fill="white")
if mode == 'playback':
if len(currentsong):
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")
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")
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")
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")
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")
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")
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)
@ -257,10 +276,15 @@ def send_mpd_cmd(client, cmd:str, ui_state:dict):
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':
@ -327,6 +351,15 @@ def send_mpd_cmd(client, cmd:str, ui_state:dict):
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
@ -360,11 +393,29 @@ def main(args):
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:
@ -399,12 +450,26 @@ def main(args):
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'])
update_display(device, mpd_client_currentsong, mpd_client_status, current_mode, ui_state['cursor_pos'])
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

View File

@ -1,12 +1,13 @@
[Unit]
Description=Mpd listen python script
After=network.target
After=mpd.service
[Service]
WorkingDirectory=/home/music/mpdlisten
ExecStart=/home/music/mpdlisten/mpdlisten.py
WorkingDirectory=/home/%u/mpdlisten
ExecStart=/home/%u/mpdlisten/mpdlisten.py
KillSignal=SIGINT
Restart=always
[Install]
WantedBy=default.target
WantedBy=default.target

195
pot_cap.py Executable file
View File

@ -0,0 +1,195 @@
#!/usr/bin/env python
# pot_cap.py
# 2016-09-26
# Public Domain
import time
import pigpio
class reader:
"""
A class to measure the time taken to charge a capacitor
through a resistance. The time taken will be propotional
to the voltage, resistance, and capacitance. If two values
are fixed the third can be estimated.
The following circuit should be used.
3V3 ----- Resistor --+-- Capacitor ----- Ground
|
+-- GPIO
"""
def __init__(self, pi, gpio, drain_ms=1.0, timeout_s=1.0):
"""
Instantiate with the Pi and GPIO of the resistor/capacitor
system to monitor.
Optionally the time taken to fully drain the capacitor
may be give as drain_ms. The value defaults to
1 millisecond.
Optionally a timeout may be specified as timeout_s. The
value defaults to 1.0 seconds.
If the readings appear to vary too much in static
conditions or there are many False results with massive
readings perhaps the capacitor isn't fully discharging.
Try increasing draim_ms.
If False is always returned perhaps the capacitor needs
more time to charge. Try increasing timeout_s.
"""
self.pi = pi
self.gpio = gpio
self.drain_ms = drain_ms
self.timeout_s = timeout_s
self._timeout_us = timeout_s * 1000000.0
"""
Use a script on the daemon to do the time critical bit.
It saves the tick in v0, changes the mode of p0 to an input,
gets the tick and subtracts the first tick, divides by 2 to
get the range, adds the range to the first tick to get the
estimated start tick. Returns the estimated start tick in p2
and range in p3. Finally p1 is incremented to indicate the
script has completed.
"""
self._sid = pi.store_script(
b't sta v0 m p0 r t sub v0 div 2 sta p3 add v0 sta p2 inr p1 ')
s = pigpio.PI_SCRIPT_INITING
while s == pigpio.PI_SCRIPT_INITING:
s, p = self.pi.script_status(self._sid)
time.sleep(0.001)
self._cb = pi.callback(gpio, pigpio.EITHER_EDGE, self._cbf)
def _cbf(self, g, l, t):
"""
Record the tick when the GPIO becomes high.
"""
if l == 1:
self._end = t
def read(self):
"""
Triggers and returns a reading.
A tuple of the reading status (True for a good reading,
False for a timeout or outlier), the reading, and the
range are returned.
The reading is the number of microseconds taken for the
capacitor to charge. The range is a measure of how
accurately the start of recharge was measured as +/-
microseconds.
"""
timeout = time.time() + self.timeout_s
self.pi.write(self.gpio, 0)
time.sleep(self.drain_ms/1000.0)
while (self.pi.read(self.gpio) != 0) and (time.time() < timeout):
time.sleep(0.001)
self._end = None
self.pi.run_script(self._sid, [self.gpio, 0])
while time.time() < timeout:
s, p = self.pi.script_status(self._sid)
if p[1]:
break
time.sleep(0.001)
# p[2] is start charge tick from script
# p[3] is +/- range from script
if time.time() < timeout:
_start = p[2]
if _start < 0:
_start += (1<<32)
while self._end is None and time.time() < timeout:
time.sleep(0.001)
if self._end is not None:
diff = pigpio.tickDiff(_start, self._end)
# Discard obvious outliers
if (diff < self._timeout_us) and (p[3] < 6):
return True, diff, p[3]
else:
return False, diff, p[3]
return False, 0, 0
def cancel(self):
"""
Cancels the reader and releases resources.
"""
self.pi.delete_script(self._sid)
self._cb.cancel()
if __name__ == "__main__":
import sys
import time
import pigpio
import pot_cap
RUN_TIME = 30
POT_CAP_GPIO = 23
DRAIN_MS = 1.0
TIMEOUT_S = 1.0
# ./pot_cap.py [run time [gpio [drain ms [timeout s]]]]
if len(sys.argv) > 1:
run_time = float(sys.argv[1])
else:
run_time = RUN_TIME
if len(sys.argv) > 2:
pot_cap_gpio = int(sys.argv[2])
else:
pot_cap_gpio = POT_CAP_GPIO
if len(sys.argv) > 3:
drain_ms = float(sys.argv[3])
else:
drain_ms = DRAIN_MS
if len(sys.argv) > 4:
timeout_s = float(sys.argv[4])
else:
timeout_s = TIMEOUT_S
pi = pigpio.pi() # Connect to Pi.
print("# rt={:.1f} g={} drain={:.1f} timeout={:.1f}".format(
run_time, pot_cap_gpio, drain_ms, timeout_s))
# Instantiate Pot/Cap reader.
pc = pot_cap.reader(pi, pot_cap_gpio, drain_ms, timeout_s)
start = time.time()
while (time.time()-start) < run_time:
s, v, r = pc.read()
if s and r < 4:
print("{} {} {}".format(s, v, r))
time.sleep(0.01)
pc.cancel() # Cancel the reader.
pi.stop() # Disconnect from Pi.

139
readme.md Normal file
View File

@ -0,0 +1,139 @@
# mpdlistenpy
This python script is supposed to run on a rpi and does the following :
* Relay trigger : Watches a MPD instance for play/pause/stop states and activates a relay via GPIOs accordingly ; the usecase for this is powering on/off a pair of speakers only when something is running.
* OLED display : Drives a 0.96" OLED display (ssd1306) to display MPD status (volume, current artist, album, title...)
* Button input : watches GPIOs for button input ; I'm using 5 push buttons for controlling playback (previous, next, toggle playback, stop, menu) or browse the database and add stuff to the playlist.
* Potentiometer input : Watches a GPIO for estimating the resistance of a rotary potentiometer and set volume accordingly.
## Setup
### Software
1. Install the latest **light** version of Raspberry Pi OS and run it on the RPI model of your choice.
2. Install the following dependencies (these are for mpd, the python script and filesystem handling):
```
sudo apt install --no-install-recommends --no-install-suggests mpd gvfs-fuse gvfs-backends gvfs libglib2.0-bin python3-musicpd python3-luma.oled i2c-tools python3-pil libjpeg-dev zlib1g-dev libfreetype6-dev liblcms2-dev libopenjp2-7 libtiff5-dev exfat-fuse
```
3. Configure '~/.config/mpd/mpd.conf' as you please and enable mpd systemd unit :
```
systemctl --user --full enable mpd
```
4. See [Installing the systemd service](#installing-the-systemd-service) for installing the mpdlistenpy systemd service and start/enable it.
5. See [Automounting Samba shares on startup](#automounting-samba-shares-on-startup) for setting up an automount service and start/enable it.
### Hardware
#### Overall GPIOs usage
| FUNCTION | GPIO # |
| --- | --- |
| OLED | GND, 3.3V, 2(SDA), 3(SCL) |
| 5 BUTTONS | GND, 7, 8, 9, 11, 25 |
| POTENTIOMETER | GND, 3.3V, 23 |
| RELAY | GND, 5V, 17 |
You can check these here : [https://pinout.xyz/](https://pinout.xyz/)
#### Oled screen
Using the SSD1306 OLED screen involves a few steps described here :
[https://luma-oled.readthedocs.io/en/latest/hardware.html](https://luma-oled.readthedocs.io/en/latest/hardware.html)
and here :
[https://luma-oled.readthedocs.io/en/latest/software.html](https://luma-oled.readthedocs.io/en/latest/software.html)
Once you've gone through these steps, you should be good to go.
#### Relay circuit
We're using the circuit described on projects-raspberry.com :
##### Components
* 5V DC coil relay
* BC337 NPN transistor
* 1N4002 [diode](https://en.wikipedia.org/wiki/1N400x_general-purpose_diodes)
* 1KΩ resistor
##### Diagram
![https://web.archive.org/web/20240217011938/https://projects-raspberry.com/raspberry-pi-driving-a-relay-using-gpio/](https://web.archive.org/web/20240217011938im_/https://projects-raspberry.com/wp-content/uploads/2015/04/Raspberry-Pi-%E2%80%93-Driving-a-Relay-using-GPIO2.jpg)
*source: [https://projects-raspberry.com/raspberry-pi-driving-a-relay-using-gpio/](https://web.archive.org/web/20240217011938/https://projects-raspberry.com/raspberry-pi-driving-a-relay-using-gpio/)*
#### Buttons circuit
Dead simple way described here, using a common GND, and 1 GPIO/button :
[http://razzpisampler.oreilly.com/ch07.html](https://web.archive.org/web/20240121074741/http://razzpisampler.oreilly.com/ch07.html)
The only difference is the addition of a resistor on the GND rail.
If you're short on GPIOs, there is a way to use a single GPIO for multiple buttons based on resistance described here :
[https://www.instructables.com/RaspberryPi-Multiple-Buttons-On-One-Digital-Pin/](https://www.instructables.com/RaspberryPi-Multiple-Buttons-On-One-Digital-Pin/)
#### Potentiometer circuit
Using the 'step response' method described [here](http://razzpisampler.oreilly.com/ch08.html#Discussion) (It's the same principle as in the link above),
we can use an analog potentiometer to control volume.
The solution described in the previous link is not very accurate though, so we'll be using an alternative solution offered by abyz.me.uk :
[https://abyz.me.uk/rpi/pigpio/examples.html#Python_pot_cap_py](https://abyz.me.uk/rpi/pigpio/examples.html#Python_pot_cap_py)
##### Components
* 100nf Ceramic capacitor
* 5KΩ Potentiometer
##### Diagram
```
3V3 ----- Potentiometer --+-- Capacitor ----- Ground
|
+-- GPIO 23
```
Instructions for use are in the source file. The library ('pot_cap.py') is included in this project for convenience.
You might have to install the 'pigpio' library though :
```
sudo apt install python3-pigpio
```
You can find out more about installing 'pigpio' here :
[https://abyz.me.uk/rpi/pigpio/download.html](https://abyz.me.uk/rpi/pigpio/download.html)
## Optional steps
### Installing the systemd service
* Copy the provided service file 'mpdlistenpy.service' to '~/.config/systemd/user/mpdlistenpy.service'.
* Update, enable and start the service as a user :
```
# If you need to edit the path to the script (should be in ~/mpdlisten by default)
# systemctl --user --full edit mpdlistenpy.service
systemctl --user daemon-reload
systemctl --user enable mpdlistenpy.service
systemctl --user start mpdlistenpy.service
```
### Automounting Samba shares on startup
If you want to setup a systemd service that mounts smb shares on startup, follow these instructions :
[Automount SMB shares on startup](https://forge.chapril.org/ABelliqueux/smbautomount)
*source :* [https://root.nix.dk/en/utility-scripts/mount-samba-share-as-user](https://root.nix.dk/en/utility-scripts/mount-samba-share-as-user)
### Automounting USB
[https://gist.github.com/zebrajaeger/168341df88abb6caaea5a029a2117925](https://gist.github.com/zebrajaeger/168341df88abb6caaea5a029a2117925)