diff --git a/Python-WiFi-Radio/PiPhi.py b/Python-WiFi-Radio/PiPhi.py new file mode 100755 index 00000000..6b921bc6 --- /dev/null +++ b/Python-WiFi-Radio/PiPhi.py @@ -0,0 +1,527 @@ +#! /usr/bin/python + +# UI wrapper for 'pianobar' client for Pandora, using Adafruit 16x2 LCD +# Pi Plate for Raspberry Pi. +# Written by Adafruit Industries. MIT license. +# +# Required hardware includes any internet-connected Raspberry Pi +# system, any of the Adafruit 16x2 LCD w/Keypad Pi Plate varieties +# and either headphones or amplified speakers. +# Required software includes the Adafruit Raspberry Pi Python Code +# repository, pexpect library and pianobar. A Pandora account is +# also necessary. +# +# Resources: +# http://www.adafruit.com/products/1109 RGB Positive 16x2 LCD + Keypad +# http://www.adafruit.com/products/1110 RGB Negative 16x2 LCD + Keypad +# http://www.adafruit.com/products/1115 Blue & White 16x2 LCD + Keypad + +import atexit, pexpect, pickle, socket, time, subprocess +from Adafruit_I2C import Adafruit_I2C +from Adafruit_MCP230xx import Adafruit_MCP230XX +from Adafruit_CharLCDPlate import Adafruit_CharLCDPlate + + +# Constants: +RGB_LCD = False # Set to 'True' if using color backlit LCD +HALT_ON_EXIT = False # Set to 'True' to shut down system when exiting +MAX_FPS = 6 if RGB_LCD else 4 # Limit screen refresh rate for legibility +VOL_MIN = -20 +VOL_MAX = 15 +VOL_DEFAULT = 0 +HOLD_TIME = 3.0 # Time (seconds) to hold select button for shut down +PICKLEFILE = '/home/pi/.config/pianobar/state.p' + +# Global state: +volCur = VOL_MIN # Current volume +volNew = VOL_DEFAULT # 'Next' volume after interactions +volSpeed = 1.0 # Speed of volume change (accelerates w/hold) +volSet = False # True if currently setting volume +paused = False # True if music is paused +staSel = False # True if selecting station +volTime = 0 # Time of last volume button interaction +playMsgTime = 0 # Time of last 'Playing' message display +staBtnTime = 0 # Time of last button press on station menu +xTitle = 16 # X position of song title (scrolling) +xInfo = 16 # X position of artist/album (scrolling) +xStation = 0 # X position of station (scrolling) +xTitleWrap = 0 +xInfoWrap = 0 +xStationWrap = 0 +songTitle = '' +songInfo = '' +stationNum = 0 # Station currently playing +stationNew = 0 # Station currently highlighted in menu +stationList = [''] +stationIDs = [''] + +# Char 7 gets reloaded for different modes. These are the bitmaps: +charSevenBitmaps = [ + [0b10000, # Play (also selected station) + 0b11000, + 0b11100, + 0b11110, + 0b11100, + 0b11000, + 0b10000, + 0b00000], + [0b11011, # Pause + 0b11011, + 0b11011, + 0b11011, + 0b11011, + 0b11011, + 0b11011, + 0b00000], + [0b00000, # Next Track + 0b10100, + 0b11010, + 0b11101, + 0b11010, + 0b10100, + 0b00000, + 0b00000]] + + +# -------------------------------------------------------------------------- + + +# Exit handler tries to leave LCD in a nice state. +def cleanExit(): + if lcd is not None: + time.sleep(0.5) + lcd.backlight(lcd.OFF) + lcd.clear() + lcd.stop() + if pianobar is not None: + pianobar.kill(0) + + +def shutdown(): + lcd.clear() + if HALT_ON_EXIT: + if RGB_LCD: lcd.backlight(lcd.YELLOW) + lcd.message('Wait 30 seconds\nto unplug...') + # Ramp down volume over 5 seconds while 'wait' message shows + steps = int((volCur - VOL_MIN) + 0.5) + 1 + pause = 5.0 / steps + for i in range(steps): + pianobar.send('(') + time.sleep(pause) + subprocess.call("sync") + cleanExit() + subprocess.call(["shutdown", "-h", "now"]) + else: + exit(0) + + +# Draws song title or artist/album marquee at given position. +# Returns new position to avoid global uglies. +def marquee(s, x, y, xWrap): + lcd.setCursor(0, y) + if x > 0: # Initially scrolls in from right edge + lcd.message(' ' * x + s[0:16-x]) + else: # Then scrolls w/wrap indefinitely + lcd.message(s[-x:16-x]) + if x < xWrap: return 0 + return x - 1 + + +def drawPlaying(): + lcd.createChar(7, charSevenBitmaps[0]) + lcd.setCursor(0, 1) + lcd.message('\x07 Playing ') + return time.time() + + +def drawPaused(): + lcd.createChar(7, charSevenBitmaps[1]) + lcd.setCursor(0, 1) + lcd.message('\x07 Paused ') + + +def drawNextTrack(): + lcd.createChar(7, charSevenBitmaps[2]) + lcd.setCursor(0, 1) + lcd.message('\x07 Next track... ') + + +# Draw station menu (overwrites fulls screen to facilitate scrolling) +def drawStations(stationNew, listTop, xStation, staBtnTime): + last = len(stationList) + if last > 2: last = 2 # Limit stations displayed + ret = 0 # Default return value (for station scrolling) + line = 0 # Line counter + msg = '' # Clear output string to start + for s in stationList[listTop:listTop+2]: # For each station... + sLen = len(s) # Length of station name + if (listTop + line) == stationNew: # Selected station? + msg += chr(7) # Show selection cursor + if sLen > 15: # Is station name longer than line? + if (time.time() - staBtnTime) < 0.5: + # Just show start of line for half a sec + s2 = s[0:15] + else: + # After that, scrollinate + s2 = s + ' ' + s[0:15] + xStationWrap = -(sLen + 2) + s2 = s2[-xStation:15-xStation] + if xStation > xStationWrap: + ret = xStation - 1 + else: # Short station name - pad w/spaces if needed + s2 = s[0:15] + if sLen < 15: s2 += ' ' * (15 - sLen) + else: # Not currently-selected station + msg += ' ' # No cursor + s2 = s[0:15] # Clip or pad name to 15 chars + if sLen < 15: s2 += ' ' * (15 - sLen) + msg += s2 # Add station name to output message + line += 1 + if line == last: break + msg += '\n' # Not last line - add newline + lcd.setCursor(0, 0) + lcd.message(msg) + return ret + + +def getStations(): + lcd.clear() + lcd.message('Retrieving\nstation list...') + pianobar.expect('Select station: ', timeout=20) + # 'before' is now string of stations I believe + # break up into separate lines + a = pianobar.before.splitlines() + names = [] + ids = [] + # Parse each line + for b in a[:-1]: # Skip last line (station select prompt) + # Occasionally a queued up 'TIME: -XX:XX/XX:XX' string or + # 'new playlist...' appears in the output. Station list + # entries have a known format, so it's straightforward to + # skip these bogus lines. +# print '\"{}\"'.format(b) + if (b.find('playlist...') >= 0) or (b.find('Autostart') >= 0): + continue +# if b[0:5].find(':') >= 0: continue +# if (b.find(':') >= 0) or (len(b) < 13): continue + # Alternate strategy: must contain either 'QuickMix' or 'Radio': + # Somehow the 'playlist' case would get through this check. Buh? + if b.find('Radio') or b.find('QuickMix'): + id = b[5:7].strip() + name = b[13:].strip() + # If 'QuickMix' found, always put at head of list + if name == 'QuickMix': + ids.insert(0, id) + names.insert(0, name) + else: + ids.append(id) + names.append(name) + return names, ids + + +# -------------------------------------------------------------------------- +# Initialization + +atexit.register(cleanExit) + +lcd = Adafruit_CharLCDPlate() +lcd.begin(16, 2) +lcd.clear() + +# Create volume bargraph custom characters (chars 0-5): +for i in range(6): + bitmap = [] + bits = (255 << (5 - i)) & 0x1f + for j in range(8): bitmap.append(bits) + lcd.createChar(i, bitmap) + +# Create up/down icon (char 6) +lcd.createChar(6, + [0b00100, + 0b01110, + 0b11111, + 0b00000, + 0b00000, + 0b11111, + 0b01110, + 0b00100]) + +# By default, char 7 is loaded in 'pause' state +lcd.createChar(7, charSevenBitmaps[1]) + +# Get last-used volume and station name from pickle file +try: + f = open(PICKLEFILE, 'rb') + v = pickle.load(f) + f.close() + volNew = v[0] + defaultStation = v[1] +except: + defaultStation = None + +# Show IP address (if network is available). System might be freshly +# booted and not have an address yet, so keep trying for a couple minutes +# before reporting failure. +t = time.time() +while True: + if (time.time() - t) > 120: + # No connection reached after 2 minutes + if RGB_LCD: lcd.backlight(lcd.RED) + lcd.message('Network is\nunreachable') + time.sleep(30) + exit(0) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 0)) + if RGB_LCD: lcd.backlight(lcd.GREEN) + else: lcd.backlight(lcd.ON) + lcd.message('My IP address is\n' + s.getsockname()[0]) + time.sleep(5) + break # Success -- let's hear some music! + except: + time.sleep(1) # Pause a moment, keep trying + +# Launch pianobar as pi user (to use same config data, etc.) in background: +print('Spawning pianobar...') +pianobar = pexpect.spawn('sudo -u pi pianobar') +print('Receiving station list...') +pianobar.expect('Get stations... Ok.\r\n', timeout=60) +stationList, stationIDs = getStations() +try: # Use station name from last session + stationNum = stationList.index(defaultStation) +except: # Use first station in list + stationNum = 0 +print 'Selecting station ' + stationIDs[stationNum] +pianobar.sendline(stationIDs[stationNum]) + + +# -------------------------------------------------------------------------- +# Main loop. This is not quite a straight-up state machine; there's some +# persnickety 'nesting' and canceling among mode states, so instead a few +# global booleans take care of it rather than a mode variable. + +if RGB_LCD: lcd.backlight(lcd.ON) +lastTime = 0 + +pattern_list = pianobar.compile_pattern_list(['SONG: ', 'STATION: ', 'TIME: ']) + +while pianobar.isalive(): + + # Process all pending pianobar output + while True: + try: + x = pianobar.expect(pattern_list, timeout=0) + if x == 0: + songTitle = '' + songInfo = '' + xTitle = 16 + xInfo = 16 + xTitleWrap = 0 + xInfoWrap = 0 + x = pianobar.expect(' \| ') + if x == 0: # Title | Artist | Album + print 'Song: "{}"'.format(pianobar.before) + s = pianobar.before + ' ' + n = len(s) + xTitleWrap = -n + 2 + # 1+ copies + up to 15 chars for repeating scroll + songTitle = s * (1 + (16 / n)) + s[0:16] + x = pianobar.expect(' \| ') + if x == 0: + print 'Artist: "{}"'.format(pianobar.before) + artist = pianobar.before + x = pianobar.expect('\r\n') + if x == 0: + print 'Album: "{}"'.format(pianobar.before) + s = artist + ' < ' + pianobar.before + ' > ' + n = len(s) + xInfoWrap = -n + 2 + # 1+ copies + up to 15 chars for repeating scroll + songInfo = s * (2 + (16 / n)) + s[0:16] + elif x == 1: + x = pianobar.expect(' \| ') + if x == 0: + print 'Station: "{}"'.format(pianobar.before) + elif x == 2: + # Time doesn't include newline - prints over itself. + x = pianobar.expect('\r', timeout=1) + if x == 0: + print 'Time: {}'.format(pianobar.before) + # Periodically dump state (volume and station name) + # to pickle file so it's remembered between each run. + try: + f = open(PICKLEFILE, 'wb') + pickle.dump([volCur, stationList[stationNum]], f) + f.close() + except: + pass + except pexpect.EOF: + break + except pexpect.TIMEOUT: + break + + + # Poll all buttons once, avoids repeated I2C traffic for different cases + b = lcd.buttons() + btnUp = b & (1 << lcd.UP) + btnDown = b & (1 <= HOLD_TIME: # Extended hold? + shutdown() # We're outta here + # If tapped, different things in different modes... + if staSel: # In station select menu... + pianobar.send('\n') # Cancel station select + staSel = False # Cancel menu and return to + if paused: drawPaused() # play or paused state + else: # In play/pause state... + volSet = False # Exit volume-setting mode (if there) + paused = not paused # Toggle play/pause + pianobar.send('p') # Toggle pianobar play/pause + if paused: drawPaused() # Display play/pause change + else: playMsgTime = drawPlaying() + + # Right button advances to next track in all modes, even paused, + # when setting volume, in station menu, etc. + elif btnRight: + + drawNextTrack() + if staSel: # Cancel station select, if there + pianobar.send('\n') + staSel = False + paused = False # Un-pause, if there + volSet = False + pianobar.send('n') + + # Left button enters station menu (if currently in play/pause state), + # or selects the new station and returns. + elif btnLeft: + + staSel = not staSel # Toggle station menu state + if staSel: + # Entering station selection menu. Don't return to volume + # select, regardless of outcome, just return to normal play. + pianobar.send('s') + lcd.createChar(7, charSevenBitmaps[0]) + volSet = False + cursorY = 0 # Cursor position on screen + stationNew = 0 # Cursor position in list + listTop = 0 # Top of list on screen + xStation = 0 # X scrolling for long station names +# Just keep the list we made at start-up +# stationList, stationIDs = getStations() + staBtnTime = time.time() + drawStations(stationNew, listTop, 0, staBtnTime) + else: + # Just exited station menu with selection - go play. + stationNum = stationNew # Make menu selection permanent + print 'Selecting station: "{}"'.format(stationIDs[stationNum]) + pianobar.sendline(stationIDs[stationNum]) + paused = False + + # Up/down buttons either set volume (in play/pause) or select station + elif btnUp or btnDown: + + if staSel: + # Move up or down station menu + if btnDown: + if stationNew < (len(stationList) - 1): + stationNew += 1 # Next station + if cursorY < 1: cursorY += 1 # Move cursor + else: listTop += 1 # Y-scroll + xStation = 0 # Reset X-scroll + elif stationNew > 0: # btnUp implied + stationNew -= 1 # Prev station + if cursorY > 0: cursorY -= 1 # Move cursor + else: listTop -= 1 # Y-scroll + xStation = 0 # Reset X-scroll + staBtnTime = time.time() # Reset button time + xStation = drawStations(stationNew, listTop, 0, staBtnTime) + else: + # Not in station menu + if volSet is False: + # Just entering volume-setting mode; init display + lcd.setCursor(0, 1) + volCurI = int((volCur - VOL_MIN) + 0.5) + n = int(volCurI / 5) + s = (chr(6) + ' Volume ' + + chr(5) * n + # Solid brick(s) + chr(volCurI % 5) + # Fractional brick + chr(0) * (6 - n)) # Spaces + lcd.message(s) + volSet = True + volSpeed = 1.0 + # Volume-setting mode now active (or was already there); + # act on button press. + if btnUp: + volNew = volCur + volSpeed + if volNew > VOL_MAX: volNew = VOL_MAX + else: + volNew = volCur - volSpeed + if volNew < VOL_MIN: volNew = VOL_MIN + volTime = time.time() # Time of last volume button press + volSpeed *= 1.15 # Accelerate volume change + + # Other logic specific to unpressed buttons: + else: + if staSel: + # In station menu, X-scroll active station name if long + if len(stationList[stationNew]) > 15: + xStation = drawStations(stationNew, listTop, xStation, + staBtnTime) + elif volSet: + volSpeed = 1.0 # Buttons released = reset volume speed + # If no interaction in 4 seconds, return to prior state. + # Volume bar will be erased by subsequent operations. + if (time.time() - volTime) >= 4: + volSet = False + if paused: drawPaused() + + # Various 'always on' logic independent of buttons + if not staSel: + # Play/pause/volume: draw upper line (song title) + if songTitle is not None: + xTitle = marquee(songTitle, xTitle, 0, xTitleWrap) + + # Integerize current and new volume values + volCurI = int((volCur - VOL_MIN) + 0.5) + volNewI = int((volNew - VOL_MIN) + 0.5) + volCur = volNew + # Issue change to pianobar + if volCurI != volNewI: + d = volNewI - volCurI + if d > 0: s = ')' * d + else: s = '(' * -d + pianobar.send(s) + + # Draw lower line (volume or artist/album info): + if volSet: + if volNewI != volCurI: # Draw only changes + if(volNewI > volCurI): + x = int(volCurI / 5) + n = int(volNewI / 5) - x + s = chr(5) * n + chr(volNewI % 5) + else: + x = int(volNewI / 5) + n = int(volCurI / 5) - x + s = chr(volNewI % 5) + chr(0) * n + lcd.setCursor(x + 9, 1) + lcd.message(s) + elif paused == False: + if (time.time() - playMsgTime) >= 3: + # Display artist/album (rather than 'Playing') + xInfo = marquee(songInfo, xInfo, 1, xInfoWrap) + + # Throttle frame rate, keeps screen legible + while True: + t = time.time() + if (t - lastTime) > (1.0 / MAX_FPS): break + lastTime = t diff --git a/Python-WiFi-Radio/README.md b/Python-WiFi-Radio/README.md new file mode 100644 index 00000000..bcaaee04 --- /dev/null +++ b/Python-WiFi-Radio/README.md @@ -0,0 +1,4 @@ +# Python-WiFi-Radio + +DEPRECATED CODE, originally to accompany this guide: +https://learn.adafruit.com/pi-wifi-radio diff --git a/Python-WiFi-Radio/config b/Python-WiFi-Radio/config new file mode 100644 index 00000000..dd2fa5a9 --- /dev/null +++ b/Python-WiFi-Radio/config @@ -0,0 +1,63 @@ +# This is an example configuration file for pianobar. You may remove the # from +# lines you need and copy/move this file to ~/.config/pianobar/config +# See manpage for a description of the config keys +# +# User +user = YOUR_EMAIL_ADDRESS +password = YOUR_PASSWORD +# or +#password_command = gpg --decrypt ~/password + +# Proxy (for those who are not living in the USA) +#control_proxy = http://127.0.0.1:9090/ + +# Keybindings +act_help = ? +act_songlove = + +act_songban = - +act_stationaddmusic = a +act_stationcreate = c +act_stationdelete = d +act_songexplain = e +act_stationaddbygenre = g +act_songinfo = i +act_addshared = j +act_songmove = m +act_songnext = n +act_songpause = p +act_quit = q +act_stationrename = r +act_stationchange = s +act_songtired = t +act_upcoming = u +act_stationselectquickmix = x +act_voldown = ( +act_volup = ) + +# Misc +#audio_quality = low +autostart_station = 123456 + +#event_command = /home/pi/.config/pianobar/scripts/eventcmd.sh +#fifo = /home/pi/.config/pianobar/ctl +#sort = quickmix_10_name_az +#love_icon = [+] +#ban_icon = [-] +volume = -20 + +# Format strings +format_nowplaying_song = SONG: %t | %a | %l +format_nowplaying_station = STATION: %n | %i +format_msg_time = TIME: %s +# No special prefix on songs, stations or info +format_msg_nowplaying = %s +format_msg_info = %s + +# high-quality audio (192k mp3, for Pandora One subscribers only!) +#audio_quality = high +#rpc_host = internal-tuner.pandora.com +#partner_user = pandora one +#partner_password = TVCKIBGS9AO9TSYLNNFUML0743LH82D +#device = D01 +#encrypt_password = 2%3WCL*JU$MP]4 +#decrypt_password = U#IO$RZPAB%VX2