├── README.md ├── helvR08.pbm ├── helvR08.pil ├── nextbus-charlieplex.py ├── nextbus-matrix.py ├── nextbus-simple.py ├── predict.py └── routefinder.py /README.md: -------------------------------------------------------------------------------- 1 | Adafruit-NextBus 2 | ================ 3 | 4 | Python front-end for the NextBus schedule service, for Raspberry Pi, etc. 5 | 6 | routefinder.py: for selecting bus routes/stops for use with the other scripts. Crude textual interface is best used w/terminal with scroll-back ability. Only need to use this for setup, hence the very basic implementation. 7 | 8 | predict.py: class that handles periodic queries to the NextBus server. Imported by other scripts; doesn't do anything on its own. 9 | 10 | nextbus-simple.py: Minimal front-end to demonstrate use of predict.py. Prints to cosole every 5 seconds. 11 | 12 | nextbus-matrix.py: Scrolling marquee using 32x32 RGB LED matrix. Requires rpi-rgb-led-matrix library: https://github.com/adafruit/rpi-rgb-led-matrix 13 | 14 | nextbus-charlieplex.py: Scrolling marquee using Adafruit charlieplex matrices. Requires IS31FL3731 library: github.com/adafruit/Adafruit_Python_IS31FL3731 15 | -------------------------------------------------------------------------------- /helvR08.pbm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adafruit/Adafruit-NextBus/e87decc082bb10867b50d8c500fa9ec62be401eb/helvR08.pbm -------------------------------------------------------------------------------- /helvR08.pil: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adafruit/Adafruit-NextBus/e87decc082bb10867b50d8c500fa9ec62be401eb/helvR08.pil -------------------------------------------------------------------------------- /nextbus-charlieplex.py: -------------------------------------------------------------------------------- 1 | # NextBus scrolling marquee display for Adafruit CharliePlex matrices. 2 | # Requires IS31FL3731 library: github.com/adafruit/Adafruit_Python_IS31FL3731 3 | # Also PIL or Pillow library for graphics. 4 | 5 | import atexit 6 | import math 7 | import os 8 | import time 9 | from predict import predict 10 | from Adafruit_IS31FL3731 import * 11 | from PIL import Image 12 | from PIL import ImageDraw 13 | from PIL import ImageFont 14 | 15 | 16 | # Configurable stuff --------------------------------------------------------- 17 | 18 | i2c_address = 0x74 # IS31FL3731 display address 19 | 20 | # Use this declaration for an IS31FL3731 16x9 breakout board: 21 | #disp = CharlieBreakout(i2c_address) 22 | 23 | # Or this for a 16x8 Pi bonnet: 24 | disp = CharlieBonnet(i2c_address) 25 | 26 | # Or this one for a 15x7 FeatherWing: 27 | #disp = CharlieWing(i2c_address) 28 | 29 | # List of bus lines/stops to predict. Use routefinder.py to look up 30 | # lines/stops for your location, copy & paste results here. The 4th 31 | # string on each line can then be edited for brevity if desired. 32 | # Probably want to keep this short due to tiny display. 33 | stops = [ 34 | ( 'actransit', '210', '0702640', 'Ohlone College' ), 35 | ( 'actransit', '210', '0702630', 'Union Landing' ) ] 36 | 37 | # Small bitmap version of Helvetica Regular from X11R6 distribution: 38 | font = ImageFont.load(os.path.dirname(os.path.realpath(__file__)) + 39 | '/helvR08.pil') # realpath stuff allows run from rc.local 40 | y = -2 # Vertical position; moved up so descenders aren't cropped 41 | 42 | maxPredictions = 3 # NextBus shows up to 5; limit to 3 for simpler display 43 | minTime = 5 # Drop predictions below this threshold (minutes) 44 | routeColor = 128 # Brightness for route numbers/letters 45 | descColor = 16 # " for route direction/description 46 | timeColor = 128 # " for prediction times 47 | minsColor = 16 # Commas and 'minutes' labels 48 | noTimesColor = 16 # "No predictions" color 49 | 50 | image = Image.new('L', (disp.width, disp.height)) 51 | draw = ImageDraw.Draw(image) 52 | fps = disp.width # Scroll width of screen in 1 second 53 | 54 | # Draws text at position (x,y), returns x following cursor advance 55 | def advanceX(x, y, label, color): 56 | draw.text((x, y), label, font=font, fill=color) 57 | return x + font.getsize(label)[0] 58 | 59 | # Clear matrix on exit. Otherwise it's annoying if you need to break and 60 | # fiddle with some code while LEDs are blinding you. 61 | def clearOnExit(): 62 | disp.selectFrame(0) 63 | disp.clear() 64 | disp.update() 65 | disp.showFrame(0) 66 | 67 | 68 | # Main application ----------------------------------------------------------- 69 | 70 | currentTime = 0.0 71 | prevTime = 0.0 72 | backBuffer = 1 73 | xx = 0 # Cursor X position for horizontal scroll 74 | 75 | 76 | atexit.register(clearOnExit) 77 | 78 | # Populate a list of predict objects (from predict.py) from stops[] 79 | predictList = [] # Clear list 80 | for s in stops: # For each item in stops[] list... 81 | predictList.append(predict(s)) # Create object, add to predictList[] 82 | 83 | while True: # Init done; loop forever... 84 | 85 | # Clear background 86 | draw.rectangle((0, 0, disp.width, disp.height), fill=0) 87 | 88 | x = xx # Start at cursor X 89 | while x < disp.width: # Repeat until X off right edge 90 | for p in predictList: # For each bus line... 91 | x = advanceX(x, y, p.data[1] + ' ', routeColor) 92 | x = advanceX(x, y, p.data[3] + ' ', descColor) 93 | if p.predictions == []: 94 | x = advanceX(x, y, 95 | 'No Predictions', noTimesColor) 96 | else: 97 | isFirstShown = True 98 | count = 0 99 | for p2 in p.predictions: 100 | t = p2 - (currentTime - 101 | p.lastQueryTime) 102 | m = int(t / 60) 103 | if m <= minTime: continue 104 | if isFirstShown: 105 | isFirstShown = False 106 | else: 107 | x = advanceX(x, y - 2, 108 | ', ', minsColor) 109 | x = advanceX(x, y, str(m), timeColor) 110 | count += 1 111 | if count >= maxPredictions: break 112 | if count > 0: 113 | x = advanceX(x, y, ' min', minsColor) 114 | x = advanceX(x, 0, ' ', 0) # 2 spaces b/t buses 115 | # If x is off left edge after all lines have been 116 | # printed, reset cursor x to that position (outer loop 117 | # repeats so bus lines are still printed) 118 | if x < 0: xx = x 119 | 120 | # Select back buffer and push PIL image there: 121 | disp.selectFrame(backBuffer) 122 | disp.image(image) 123 | disp.update() 124 | 125 | # Try to keep timing uniform-ish; rather than sleeping a fixed time, 126 | # interval since last frame is calculated, the gap time between this 127 | # and desired frames/sec determines sleep time...occasionally if busy 128 | # (e.g. polling server) there'll be no sleep at all. 129 | currentTime = time.time() 130 | timeDelta = (1.0 / fps) - (currentTime - prevTime) 131 | if(timeDelta > 0.0): 132 | time.sleep(timeDelta) 133 | prevTime = currentTime 134 | 135 | # Display the newly-pushed image, then invert the front/back index: 136 | disp.showFrame(backBuffer) 137 | backBuffer = 1 - backBuffer 138 | 139 | xx -= 1 # Move cursor start left by 1 pixel 140 | -------------------------------------------------------------------------------- /nextbus-matrix.py: -------------------------------------------------------------------------------- 1 | # NextBus scrolling marquee display for Adafruit RGB LED matrix (64x32). 2 | # Requires rgbmatrix.so library: github.com/adafruit/rpi-rgb-led-matrix 3 | 4 | import atexit 5 | import Image 6 | import ImageDraw 7 | import ImageFont 8 | import math 9 | import os 10 | import time 11 | from predict import predict 12 | from rgbmatrix import Adafruit_RGBmatrix 13 | 14 | # Configurable stuff --------------------------------------------------------- 15 | 16 | # List of bus lines/stops to predict. Use routefinder.py to look up 17 | # lines/stops for your location, copy & paste results here. The 4th 18 | # string on each line can then be edited for brevity if desired. 19 | stops = [ 20 | ( 'actransit', '210', '0702640', 'Ohlone College' ), 21 | ( 'actransit', '232', '0704440', 'Fremont BART' ), 22 | ( 'actransit', '210', '0702630', 'Union Landing' ), 23 | ( 'actransit', '232', '0704430', 'NewPark Mall' ) ] 24 | 25 | maxPredictions = 3 # NextBus shows up to 5; limit to 3 for simpler display 26 | minTime = 0 # Drop predictions below this threshold (minutes) 27 | shortTime = 5 # Times less than this are displayed in red 28 | midTime = 10 # Times less than this are displayed yellow 29 | 30 | width = 64 # Matrix size (pixels) -- change for different matrix 31 | height = 32 # types (incl. tiling). Other code may need tweaks. 32 | matrix = Adafruit_RGBmatrix(32, 2) # rows, chain length 33 | fps = 20 # Scrolling speed (ish) 34 | 35 | routeColor = (255, 255, 255) # Color for route labels (usu. numbers) 36 | descColor = (110, 110, 110) # " for route direction/description 37 | longTimeColor = ( 0, 255, 0) # Ample arrival time = green 38 | midTimeColor = (255, 255, 0) # Medium arrival time = yellow 39 | shortTimeColor = (255, 0, 0) # Short arrival time = red 40 | minsColor = (110, 110, 110) # Commans and 'minutes' labels 41 | noTimesColor = ( 0, 0, 255) # No predictions = blue 42 | 43 | # TrueType fonts are a bit too much for the Pi to handle -- slow updates and 44 | # it's hard to get them looking good at small sizes. A small bitmap version 45 | # of Helvetica Regular taken from X11R6 standard distribution works well: 46 | font = ImageFont.load(os.path.dirname(os.path.realpath(__file__)) 47 | + '/helvR08.pil') 48 | fontYoffset = -2 # Scoot up a couple lines so descenders aren't cropped 49 | 50 | 51 | # Main application ----------------------------------------------------------- 52 | 53 | # Drawing takes place in offscreen buffer to prevent flicker 54 | image = Image.new('RGB', (width, height)) 55 | draw = ImageDraw.Draw(image) 56 | currentTime = 0.0 57 | prevTime = 0.0 58 | 59 | # Clear matrix on exit. Otherwise it's annoying if you need to break and 60 | # fiddle with some code while LEDs are blinding you. 61 | def clearOnExit(): 62 | matrix.Clear() 63 | 64 | atexit.register(clearOnExit) 65 | 66 | # Populate a list of predict objects (from predict.py) from stops[]. 67 | # While at it, also determine the widest tile width -- the labels 68 | # accompanying each prediction. The way this is written, they're all the 69 | # same width, whatever the maximum is we figure here. 70 | tileWidth = font.getsize( 71 | '88' * maxPredictions + # 2 digits for minutes 72 | ', ' * (maxPredictions-1) + # comma+space between times 73 | ' minutes')[0] # 1 space + 'minutes' at end 74 | w = font.getsize('No Predictions')[0] # Label when no times are available 75 | if w > tileWidth: # If that's wider than the route 76 | tileWidth = w # description, use as tile width. 77 | predictList = [] # Clear list 78 | for s in stops: # For each item in stops[] list... 79 | predictList.append(predict(s)) # Create object, add to predictList[] 80 | w = font.getsize(s[1] + ' ' + s[3])[0] # Route label 81 | if(w > tileWidth): # If widest yet, 82 | tileWidth = w # keep it 83 | tileWidth += 6 # Allow extra space between tiles 84 | 85 | 86 | class tile: 87 | def __init__(self, x, y, p): 88 | self.x = x 89 | self.y = y 90 | self.p = p # Corresponding predictList[] object 91 | 92 | def draw(self): 93 | x = self.x 94 | label = self.p.data[1] + ' ' # Route number or code 95 | draw.text((x, self.y + fontYoffset), label, font=font, 96 | fill=routeColor) 97 | x += font.getsize(label)[0] 98 | label = self.p.data[3] # Route direction/desc 99 | draw.text((x, self.y + fontYoffset), label, font=font, 100 | fill=descColor) 101 | x = self.x 102 | if self.p.predictions == []: # No predictions to display 103 | draw.text((x, self.y + fontYoffset + 8), 104 | 'No Predictions', font=font, fill=noTimesColor) 105 | else: 106 | isFirstShown = True 107 | count = 0 108 | for p in self.p.predictions: 109 | t = p - (currentTime - self.p.lastQueryTime) 110 | m = int(t / 60) 111 | if m <= minTime: continue 112 | elif m <= shortTime: fill=shortTimeColor 113 | elif m <= midTime: fill=midTimeColor 114 | else: fill=longTimeColor 115 | if isFirstShown: 116 | isFirstShown = False 117 | else: 118 | label = ', ' 119 | # The comma between times needs to 120 | # be drawn in a goofball position 121 | # so it's not cropped off bottom. 122 | draw.text((x + 1, 123 | self.y + fontYoffset + 8 - 2), 124 | label, font=font, fill=minsColor) 125 | x += font.getsize(label)[0] 126 | label = str(m) 127 | draw.text((x, self.y + fontYoffset + 8), 128 | label, font=font, fill=fill) 129 | x += font.getsize(label)[0] 130 | count += 1 131 | if count >= maxPredictions: 132 | break 133 | if count > 0: 134 | draw.text((x, self.y + fontYoffset + 8), 135 | ' minutes', font=font, fill=minsColor) 136 | 137 | 138 | # Allocate list of tile objects, enough to cover screen while scrolling 139 | tileList = [] 140 | if tileWidth >= width: tilesAcross = 2 141 | else: tilesAcross = int(math.ceil(width / tileWidth)) + 1 142 | 143 | nextPrediction = 0 # Index of predictList item to attach to tile 144 | for x in xrange(tilesAcross): 145 | for y in xrange(0, 2): 146 | tileList.append(tile(x * tileWidth + y * tileWidth / 2, 147 | y * 17, predictList[nextPrediction])) 148 | nextPrediction += 1 149 | if nextPrediction >= len(predictList): 150 | nextPrediction = 0 151 | 152 | # Initialization done; loop forever ------------------------------------------ 153 | while True: 154 | 155 | # Clear background 156 | draw.rectangle((0, 0, width, height), fill=(0, 0, 0)) 157 | 158 | for t in tileList: 159 | if t.x < width: # Draw tile if onscreen 160 | t.draw() 161 | t.x -= 1 # Move left 1 pixel 162 | if(t.x <= -tileWidth): # Off left edge? 163 | t.x += tileWidth * tilesAcross # Move off right & 164 | t.p = predictList[nextPrediction] # assign prediction 165 | nextPrediction += 1 # Cycle predictions 166 | if nextPrediction >= len(predictList): 167 | nextPrediction = 0 168 | 169 | # Try to keep timing uniform-ish; rather than sleeping a fixed time, 170 | # interval since last frame is calculated, the gap time between this 171 | # and desired frames/sec determines sleep time...occasionally if busy 172 | # (e.g. polling server) there'll be no sleep at all. 173 | currentTime = time.time() 174 | timeDelta = (1.0 / fps) - (currentTime - prevTime) 175 | if(timeDelta > 0.0): 176 | time.sleep(timeDelta) 177 | prevTime = currentTime 178 | 179 | # Offscreen buffer is copied to screen 180 | matrix.SetImage(image.im.id, 0, 0) 181 | -------------------------------------------------------------------------------- /nextbus-simple.py: -------------------------------------------------------------------------------- 1 | # Super simple NextBus display thing (prints to console). 2 | 3 | import time 4 | from predict import predict 5 | 6 | # List of bus lines/stops to predict. Use routefinder.py to look up 7 | # lines/stops for your location, copy & paste results here. The 4th 8 | # element on each line can then be edited for brevity if desired. 9 | stops = [ 10 | ( 'actransit', '210', '0702640', 'Ohlone College' ), 11 | ( 'actransit', '210', '0702630', 'Union Landing' ), 12 | ( 'actransit', '232', '0704440', 'Fremont BART' ), 13 | ( 'actransit', '232', '0704430', 'NewPark Mall' ), 14 | ] 15 | 16 | # Populate a list of predict objects from stops[]. Each then handles 17 | # its own periodic NextBus server queries. Can then read or extrapolate 18 | # arrival times from each object's predictions[] list (see code later). 19 | predictList = [] 20 | for s in stops: 21 | predictList.append(predict(s)) 22 | 23 | time.sleep(1) # Allow a moment for initial results 24 | 25 | while True: 26 | currentTime = time.time() 27 | print 28 | for pl in predictList: 29 | print pl.data[1] + ' ' + pl.data[3] + ':' 30 | if pl.predictions: # List of arrival times, in seconds 31 | for p in pl.predictions: 32 | # Extrapolate from predicted arrival time, 33 | # current time and time of last query, 34 | # display in whole minutes. 35 | t = p - (currentTime - pl.lastQueryTime) 36 | print '\t' + str(int(t/60)) + ' minutes' 37 | else: 38 | print '\tNo predictions' 39 | prevTime = currentTime; 40 | time.sleep(5) # Refresh every ~5 seconds 41 | -------------------------------------------------------------------------------- /predict.py: -------------------------------------------------------------------------------- 1 | # NextBus prediction class. For each route/stop, NextBus server is polled 2 | # automatically at regular intervals. Front-end app just needs to init 3 | # this with stop data, which can be found using the routefinder.py script. 4 | 5 | import threading 6 | import time 7 | import urllib 8 | from xml.dom.minidom import parseString 9 | 10 | class predict: 11 | interval = 120 # Default polling interval = 2 minutes 12 | initSleep = 0 # Stagger polling threads to avoid load spikes 13 | 14 | # predict object initializer. 1 parameter, a 4-element tuple: 15 | # First element is agengy tag (e.g. 'actransit') 16 | # Second is line tag (e.g. '210') 17 | # Third is stop tag (e.g. '0702630') 18 | # Fourth is direction -- not a tag, this element is human-readable 19 | # and editable (e.g. 'Union Landing') -- for UI purposes you may 20 | # want to keep this short. The other elements MUST be kept 21 | # verbatim as displayed by the routefinder.py script. 22 | # Each predict object spawns its own thread and will perform 23 | # periodic server queries in the background, which can then be 24 | # read via the predictions[] list (est. arrivals, in seconds). 25 | def __init__(self, data): 26 | self.data = data 27 | self.predictions = [] 28 | self.lastQueryTime = time.time() 29 | t = threading.Thread(target=self.thread) 30 | t.daemon = True 31 | t.start() 32 | 33 | # Periodically get predictions from server --------------------------- 34 | def thread(self): 35 | initSleep = predict.initSleep 36 | predict.initSleep += 5 # Thread staggering may 37 | time.sleep(initSleep) # drift over time, no problem 38 | while True: 39 | dom = predict.req('predictions' + 40 | '&a=' + self.data[0] + # Agency 41 | '&r=' + self.data[1] + # Route 42 | '&s=' + self.data[2]) # Stop 43 | if dom is None: return # Connection error 44 | self.lastQueryTime = time.time() 45 | predictions = dom.getElementsByTagName('prediction') 46 | newList = [] 47 | for p in predictions: # Build new prediction list 48 | newList.append( 49 | int(p.getAttribute('seconds'))) 50 | self.predictions = newList # Replace current list 51 | time.sleep(predict.interval) 52 | 53 | # Open URL, send request, read & parse XML response ------------------ 54 | @staticmethod 55 | def req(cmd): 56 | xml = None 57 | try: 58 | connection = urllib.urlopen( 59 | 'http://webservices.nextbus.com' + 60 | '/service/publicXMLFeed?command=' + cmd) 61 | raw = connection.read() 62 | connection.close() 63 | xml = parseString(raw) 64 | finally: 65 | return xml 66 | 67 | # Set polling interval (seconds) ------------------------------------- 68 | @staticmethod 69 | def setInterval(i): 70 | interval = i 71 | -------------------------------------------------------------------------------- /routefinder.py: -------------------------------------------------------------------------------- 1 | # NextBus configurator. Prompts user for transit agency, bus line, 2 | # direction and stop, issues a string which can then be copied & pasted 3 | # into the predictor program. Not fancy, just uses text prompts, 4 | # minimal error checking. 5 | 6 | import urllib 7 | from xml.dom.minidom import parseString 8 | 9 | # Open connection, issue request, read & parse XML response ------------------ 10 | def req(cmd): 11 | connection = urllib.urlopen( 12 | 'http://webservices.nextbus.com' + 13 | '/service/publicXMLFeed?command=' + cmd) 14 | raw = connection.read() 15 | connection.close() 16 | xml = parseString(raw) 17 | return xml 18 | 19 | # Prompt user for a number in a given range ---------------------------------- 20 | def getNum(prompt, n): 21 | while True: 22 | nb = raw_input('Enter ' + prompt + ' 0-' + str(n-1) + ': ') 23 | try: x = int(nb) 24 | except ValueError: continue # Ignore non-numbers 25 | if x >= 0 and x < n: return x # and out-of-range values 26 | 27 | # Main application ----------------------------------------------------------- 28 | 29 | # Get list of transit agencies, prompt user for selection, get agency tag. 30 | dom = req('agencyList') 31 | elements = dom.getElementsByTagName('agency') 32 | print 'TRANSIT AGENCIES:' 33 | for i, item in enumerate(elements): 34 | print str(i) + ') ' + item.getAttribute('title') 35 | n = getNum('transit agency', len(elements)) 36 | agencyTag = elements[n].getAttribute('tag') 37 | 38 | # Get list of routes for selected agency, prompt user, get route tag. 39 | dom = req('routeList&a=' + agencyTag) 40 | elements = dom.getElementsByTagName('route') 41 | print '\nROUTES:' 42 | for i, item in enumerate(elements): 43 | print str(i) + ') ' + item.getAttribute('title') 44 | n = getNum('route', len(elements)) 45 | routeTag = elements[n].getAttribute('tag') 46 | 47 | # Get list of directions for selected agency & route, prompt user... 48 | dom = req('routeConfig&a=' + agencyTag + '&r=' + routeTag) 49 | elements = dom.getElementsByTagName('direction') 50 | print 51 | print '\nDIRECTIONS:' 52 | for i, item in enumerate(elements): 53 | print str(i) + ') ' + item.getAttribute('title') 54 | n = getNum('direction', len(elements)) 55 | dirTitle = elements[n].getAttribute('title') # Save for later 56 | # ...then get list of stop numbers and descriptions -- these are 57 | # nested in different parts of the XML and must be cross-referenced 58 | stopNums = elements[n].getElementsByTagName('stop') 59 | stopDescs = dom.getElementsByTagName('stop') 60 | 61 | # Cross-reference stop numbers and descriptions to provide a readable 62 | # list of available stops for selected agency, route & direction. 63 | # Prompt user for stop number and get corresponding stop tag. 64 | print '\nSTOPS:' 65 | for i, item in enumerate(stopNums): 66 | stopNumTag = item.getAttribute('tag') 67 | for d in stopDescs: 68 | stopDescTag = d.getAttribute('tag') 69 | if stopNumTag == stopDescTag: 70 | print str(i) + ') ' + d.getAttribute('title') 71 | break 72 | n = getNum('stop', len(stopNums)) 73 | stopTag = stopNums[n].getAttribute('tag') 74 | 75 | # The prediction server wants the stop tag, NOT the stop ID, not sure 76 | # what's up with that. 77 | 78 | print '\nCOPY/PASTE INTO APPLICATION SCRIPT:' 79 | print (" ( '" + agencyTag + "', '" + routeTag + "', '" + stopTag + 80 | "', '" + dirTitle + "' ),") 81 | --------------------------------------------------------------------------------