Skip to main content

 A framework in Python for a simple (battery powered?) controller, with album art display, using the great SoCo Python library.  Uses the Waveshare 1.3inch LCD HAT, which has a joystick and three buttons for control.  These are less than $20 on Amazon.  A case with buttons can be 3D printed using these files from PiSugar, for use with their battery.

I haven’t tried the battery, and don’t have a 3D printer, but it should make for a nice portable controller.  Of course, power optimizations, such as turning off HDMI, Bluetooth, etc, would be required on the Pi Zero W.

Anyhow, here’s the basic script.  Requires the Waveshare installations down to but not including their FBCP driver (under “Guides for Pi” tab), as well as SoCo, the Python Pillow library, and WaveShare’s ST7789.py script (just copy to same directory as the sonos.py script below).

I haven’t programmed the 3 buttons, but the joystick is used for volume up/down, prev/next, and play/pause.  The screen displays the album art or radio station logo.

I have no intention to develop this any further or offer support.  Just keeping the old grey matter from turning to jelly.

#!/usr/bin/python3
import spidev as SPI
import ST7789
import RPi.GPIO as GPIO

import soco
from soco.discovery import by_name
import requests
import textwrap
import io,os,sys,time,datetime
import re

import time
from time import sleep
from threading import Thread
try:
from Queue import Queue
except ImportError:
from queue import Queue

from PIL import Image
from PIL import ImageFont
from PIL import ImageDraw
from PIL import ImageOps

########################################
## Sonos zone to monitor - CHANGE ME! ##
zone = by_name('Office')
########################################

# Raspberry Pi pin configuration:
RST = 27
DC = 25
BL = 24
KEY_UP_PIN = 6
KEY_DOWN_PIN = 19
KEY_LEFT_PIN = 5
KEY_RIGHT_PIN = 26
KEY_PRESS_PIN = 13

KEY1_PIN = 21
KEY2_PIN = 20
KEY3_PIN = 16

bus = 0
device = 0

# 240x240 display with hardware SPI:
disp = ST7789.ST7789(SPI.SpiDev(bus, device),RST, DC, BL)

# Initialize library.
disp.Init()

# Clear display.
disp.clear()

#init GPIO
# for P4:
# sudo vi /boot/config.txt
# gpio=6,19,5,26,13,21,20,16=pu
GPIO.setmode(GPIO.BCM)
GPIO.setup(KEY_UP_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(KEY_DOWN_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(KEY_LEFT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(KEY_RIGHT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(KEY_PRESS_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(KEY1_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(KEY2_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(KEY3_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up

def handle_event(event):
try:
if event == KEY_UP_PIN:
v = min(zone.volume,99)
zone.volume = v + 1
print("Up: ",zone.volume)
elif event == KEY_DOWN_PIN:
v = max(zone.volume,1)
zone.volume = v - 1
print("Down: ",zone.volume)
elif event == KEY_RIGHT_PIN:
zone.next()
print("Next")
elif event == KEY_LEFT_PIN:
zone.previous()
print("Prev")
elif event == KEY_PRESS_PIN:
if playerstatus == 'PLAYING':
zone.pause()
print("Pause")
else:
zone.play()
print("Play")
except Exception as e:
print(e)

def event_thread():
while True:
try:
if not GPIO.input(KEY_UP_PIN):
handle_event(KEY_UP_PIN)

if not GPIO.input(KEY_DOWN_PIN):
handle_event(KEY_DOWN_PIN)

if not GPIO.input(KEY_RIGHT_PIN):
handle_event(KEY_RIGHT_PIN)

if not GPIO.input(KEY_LEFT_PIN):
handle_event(KEY_LEFT_PIN)

if not GPIO.input(KEY_PRESS_PIN):
handle_event(KEY_PRESS_PIN)

sleep(0.1)

except Exception as e:
print(e)

## Red and Blue color channels are reversed from normal RGB on pi framebuffer
def swap_redblue(img):
"Swap red and blue channels in image"
r, g, b, a = img.split()
return Image.merge("RGBA", (b, g, r, a))


## Clear to black
def clearscreen():
img = Image.new('RGBA',size=(240,240),color=(0,0,0,255))
disp.ShowImage(img,0,0)

## Grab the album cover and display
def getcoverart(cover_url):

try:
img = Image.open(requests.get(cover_url, stream=True).raw)
img = img.resize((240,240))
img = img.convert('RGBA')
disp.ShowImage(img,0,0)

except Exception as e:
print(e)

## Handle Sonos AV Events
def parseavevent(event):
global playerstatus

try:
playerstate = event.transport_state
playerstatus = playerstate
except AttributeError:
return

if playerstate == "TRANSITIONING":
return

if playerstate != "PLAYING":
clearscreen()
return

try:
metadata = event.current_track_meta_data
except AttributeError:
return

# This can happen if the the player becomes part of a group
try:
if metadata == "" or not hasattr(metadata, "album_art_uri"):
return
except Exception as e:
print(e)
return
try:
if metadata.album_art_uri.startswith("http"):
albumart = metadata.album_art_uri
else:
albumart = "http://%s:1400%s#.jpg" % (
zone.ip_address,
metadata.album_art_uri)

getcoverart(albumart)

except Exception as e:
print(e)

playerstatus = ""

queue = Queue()

info = zone.avTransport.subscribe(
auto_renew=True, event_queue=queue)

## Start key event handler thread
th = Thread(target=event_thread)
th.start()

## Start monitoring events for zone
while True:
try:
## If any Sonos events, handle them
if not queue.empty():
ev = queue.get()
if ev.service.service_type == "AVTransport":
parseavevent(ev)

except Exception as e:
print("Exception:",e)

sleep(1)