I live at a place with a well developed public transport network, within 600 meters from my home there are 1 subway station, 2 city train stations, 3 bus-stops - so there are many alternatives and at times it is not easy to decide which way to turn when I leave my home to get the fastest connection.

They have those info boards at many stations with a count down until the next bus or train arrives. I wanted such a thing for myself, with all the nearby stations on it to check on a glance. And as I build my own smarthome I naturally wanted it to be a part of that.

Well, it can be done:

to build this it took:

  • an access key and some documentation to the database of public transport. VBB, the regional public transport org, gives it out for free if asked nicely. I assume other transport organisations will have something comparable.
  • a daemon running on a computer in the own network which fetches the data from the database, I did that in python
  • mosquitto running on a server in the home network to communicate the json to the smarthome
  • rules and items in openHab2 to receive, parse and format the connection data
  • HTML to format and display the connections into a habPanel page like the image above shows

 Now, the details:

  • The python code to fetch the data from the database:
     
#!/usr/bin/python
# -*- coding: utf-8 -*-

# 18.5.2019, v0.1

import RPi.GPIO as GPIO
import time
import os
from datetime import datetime
from datetime import timedelta
import urllib, json
import paho.mqtt.client as mqtt
import paho.mqtt.publish as publish
import sys
reload(sys)
sys.setdefaultencoding('utf-8')


baseUrl="https://demo.hafas.de/openapi/vbb-proxy/"
suffix="&accessId=get-it-from-vbb&format=json"


toScreen=1

duration=25

selection=[u'900060101N',u'900054105O', u'900054105U',u'900060107N',u'900060153N',u'900061153N',u'900060101S',u'900054105W',u'900060107S',u'900060153S',u'900061153S']


# mqtt
pubTopic="ext/vbb/timetable/state"
bannerTopic="ext/vbb/timetable/banner"
cmdTopic="ext/vbb/timetable/cmd"

mqBroker="192.168.150.1"
banner=""

boost=False
boostMin=5
noBoostMin=4
boostCount=0
slapCount=0
inProgress=False

def reset_halten():
        global halten
        halten={
        u'900060101N':{'lat': 52.47, 'lon': 13.340594, 'dist': 348, 'walk':5.0, 'dir':u'900600003', u'dep':[], u'to':'hin', 'name': u'S Friedenau (Nord)', 'extId': u'900060101'},
        u'900060101S':{'lat': 52.47, 'lon': 13.340594, 'dist': 348, 'walk':5.0, 'dir':u'900063101', u'dep':[], u'to':'her', 'name': u'S Friedenau (Süd)', 'extId': u'900060101'},
        u'900054105O':{'lat': 52.478099, 'lon': 13.342878, 'dist': 599, 'walk':8.0, 'dir':u'900054104', u'lines':'S42,S46,S45', u'dep':[], u'to':'hin', 'name': u'S Innsbrucker Platz (Ost)', 'extId': u'900054105'},
        u'900054105W':{'lat': 52.478099, 'lon': 13.342878, 'dist': 599, 'walk':8.0, 'dir':u'900044202', u'lines':'S41,S46,S45', u'dep':[], u'to':'her', 'name': u'S Innsbrucker Platz (West)', 'extId': u'900054105'},
        u'900054105U':{'lat': 52.478099, 'lon': 13.342878, 'dist': 599, 'walk':8.0, 'dir':u'900055101', u'dep':[], u'to':'hin', 'name': u'U Innsbrucker Platz', 'extId': u'900054105'},
        u'900060107N':{'lat': 52.47525, 'lon': 13.34313, 'dist': 280, 'walk':3.5, 'dir':u'900054105', u'dep':[], u'to':'hin', 'name': u'Cecilieng\xe4rten (Nord)', 'extId': u'900060107'},
        u'900060107S':{'lat': 52.47484, 'lon': 13.34302, 'dist': 230, 'walk':3.1, 'dir':u'900060104', u'dep':[], u'to':'her', 'name': u'Cecilieng\xe4rten (Süd)', 'extId': u'900060107'},
        u'900060153N':{'lat': 52.47086, 'lon': 13.34385, 'dist': 210, 'walk':2.8, 'dir':u'900054105', u'dep':[], u'to':'hin', 'name': u'Rubensstr. (Nord)', 'extId': u'900060153'},
        u'900060153S':{'lat': 52.46971, 'lon': 13.34377, 'dist': 350, 'walk':4.8, 'dir':u'900060104', u'dep':[], u'to':'her', 'name': u'Rubensstr. (Süd)', 'extId': u'900060153'},
        u'900061153N':{'lat': 52.47591, 'lon': 13.34091, 'dist': 550, 'walk':5.7, 'dir':u'900054105', u'dep':[], u'to':'hin', 'name': u'H\xe4hnelstr. (Nord)', 'extId': u'900061153'},
        u'900061153S':{'lat': 52.47531, 'lon': 13.33969, 'dist': 550, 'walk':5.7, 'dir':u'900061105', u'dep':[], u'to':'her', 'name': u'H\xe4hnelstr. (Süd)', 'extId': u'900061153'}
        }

        halten[u'callTime']=datetime.now().strftime('%Y-%m-%d %H:%M:%S')


############### MQTT section ##################

# when connecting to mqtt do this;

def on_connect(client, userdata, flags, rc):
        #print("Connected with result code "+str(rc))
        client.subscribe(cmdTopic)

# when receiving a mqtt message do this;

def on_message(client, userdata, msg):
        global boostCount,boost
        message = str(msg.payload)
        print(msg.topic+" "+message)

        if message == "boost":
                boost=True
                boostCount=0
                checkOnce()

def on_publish(mosq, obj, mid):
        pass
        #print("mid: " + str(mid))


def publishDict(obj):
        json_string = json.dumps(obj)
        client.publish(pubTopic,json_string,1)

def publishBanner(obj):
        json_string = json.dumps(obj)
        client.publish(bannerTopic,json_string,1)

def checkOnce():
        global inProgress
        if inProgress:
                return
        inProgress=True
        getAllTables()
        publishDict(halten)
        inProgress=False

def getAllTables():
        global selection,banners
        reset_halten()
        banners=""
        for halteKey in selection:
                getTableByHaltekey(halteKey)

def doNothing():
        pass

def getTableByHaltekey(halteKey):
        global halten, baseurl,suffix,duration,toScreen,banners

        halte=halten[halteKey]
        walk=halte[u'walk']
        extId=halte[u'extId']
        direction=halte[u'dir'] 
        reqTime=datetime.now() + timedelta(minutes=walk)
        datum = reqTime.strftime('%Y-%m-%d')
        zeit  = reqTime.strftime("%H:%M")
        datetimeFormat = '%Y-%m-%d %H:%M:%S'

        banner = "{} {} m {} min. Weg".format( halte[u'name'],halte[u'dist'],int(round(halte[u'walk'])) )
        #banners += banner + '\r\n'
        if toScreen:
                print "\n"+banner
                #print "{} {} m {} min. Weg".format( halte[u'name'],halte[u'dist'],round(halte[u'walk']) )

        # fuer die Ringbahn werden, wie im Witz, Richtungen nicht unterschieden...
        if u'lines' in halte:
                lines=halte[u'lines']
                uri = baseUrl+"departureBoard?extId={}&date={}&time={}&rtMode=FULL&duration={}&direction={}&lines={}".format(extId,datum,zeit,duration,direction,lines)+suffix
        else:
                uri = baseUrl+"departureBoard?extId={}&date={}&time={}&rtMode=FULL&duration={}&direction={}".format(extId,datum,zeit,duration,direction)+suffix

        # im debug die uri ausgeben
        if toScreen:
                pass
                #print uri

        response = urllib.urlopen(uri)
        dat = json.loads(response.read())

        if u'Departure' in dat:
                data=dat["Departure"]
                if len(data)==0:
                        return
        else:
                return 

        # kann sein, dass rtTime in einzelnen records fehlt, und manche records sind unplausibel
        delRecs=[]
        for recnum in range(len(data)):
                record=data[recnum]
                if u'rtTime' in record:
                        # verspätung als rtTime-time
                        planTime=record[u'date']+' '+record[u'time']
                        realTime=record[u'rtDate']+' '+record[u'rtTime']
                        lateSec=datetime.strptime(realTime, datetimeFormat)-datetime.strptime(planTime, datetimeFormat)
                        late=int(round(lateSec.seconds / 60.0) )
                        record[u'late']=late

                else:
                        # ha! record ohne rtTime, rtDate -> diese aus time, date kopieren
                        record[u'rtDate']=record[u'date']
                        record[u'rtTime']=record[u'time']
                        record[u'late']=0

                # ausgefallene Züge (?) bleiben im Suchergebnis stehen, obwohl ihre Zeit längs um wäre
                leaveTime=record[u'rtDate']+' '+record[u'rtTime']
                diff=datetime.strptime(leaveTime, datetimeFormat)-datetime.now()
                diffMin=int(round((diff.seconds / 60.0) - walk))
                if diffMin>100:
                        print "strange time: {}  rtTime: {} late: {} noch: {}".format(record[u'time'],record[u'rtTime'],record[u'late'],diffMin)
                        delRecs.append(recnum)
                if diffMin<-10:
                        print "spooky  time: {}  rtTime: {} late: {} noch: {}".format(record[u'time'],record[u'rtTime'],record[u'late'],diffMin)
                        delRecs.append(recnum)

                # Busse, die zu früh kommen, haben 1440 min offset
                if record[u'late'] > 1000:
                        record[u'late'] -= 1440

        for recnum in reversed(delRecs):
                del data[recnum]
        delRecs=[]

        dat=sorted(data, key = lambda i: (i['rtDate'], i['rtTime']))


        for pos in [0,1]:
                if pos==1 and len(dat)==1:
                        continue

                par=dat[pos]
                useDate=par["rtDate"]
                useTime=par["rtTime"]

                leaveTime=useDate+' '+useTime
                diff=datetime.strptime(leaveTime, datetimeFormat)-datetime.now()
                diffMin=int(round((diff.seconds / 60.0) - walk))
                # fix the occasional 1437 diffMin (instead of -3)
                if diffMin > 100:
                        diffMin -= 14400

                splitter = useTime.split(':')
                useTime = splitter[0]+':'+splitter[1]

                aline = "   {} {} {} {} noch: {} late: {}".format( par[u'name'].lstrip(),par[u'direction'],useDate,useTime,diffMin,par[u'late'])
                banner = banner + "
" + aline

                if toScreen:
                        print aline

                zug={u'name':par[u'name'].lstrip(),u'direction':par[u'direction'],u'date':useDate,u'time':useTime,u'left':diffMin,u'late':par[u'late'] }
                halte['dep'].append(zug)
                halte['banner']=banner
                #banners += banner +'
'
                banner=""



# mqtt
client = mqtt.Client()
client.on_connect = on_connect
client.on_message = on_message
client.on_publish = on_publish
client.username_pw_set(username="user",password="password")
client.connect(mqBroker, 1883, 90)
time.sleep(4)
client.loop_start()


try:
        # loop
        while 1:
                if boost or (slapCount % noBoostMin == 0):
                        checkOnce()
                        #publishBanner({u'banner':banners})
                        boostCount+=1

                        if boostCount>=boostMin:
                                boost=False
                        print "boost: {} boostCount: {}".format(boost,boostCount)

                slapCount += 1
                time.sleep(60)

finally:
        print 'bye'

  • openHab items to receive or send data to/from mosquitto:
     
String mqVbb "incoming" { mqtt="<[mqttBroker:ext/vbb/timetable/state:state:default]" }  
String mqVbbCmd "outgoing" { mqtt=">[mqttBroker:ext/vbb/timetable/cmd:state:*:default]" } 
  • openHab items to obtain the parsed data; this is trivial and not shown
     
  • an openHab rule to parse and format the connection info into dedicated items (only the first part of it is shown)
     
rule "rule triggered  by mqVbb changed"
when 	
    Item  mqVbb changed 

then
    var String halte=""
    var String station=""
    var String zug1=""
    var String zug2="" 
    var String noch1=""
    var String noch2=""

    var String json = (mqVbb.state as StringType).toString
    
    halte="900060101N" // S Friedenau Nord
    
    station = transform("JSONPATH", "$['"+halte+"'].name", json) + " (" + transform("JSONPATH", "$['"+halte+"'].dist", json) + "m / " + transform("JSONPATH", "$['"+halte+"'].walk", json) + " min) "
    zug1= transform("JSONPATH", "$['"+halte+"'].dep[0].name", json) +" -> " + transform("JSONPATH", "$['"+halte+"'].dep[0].direction", json) +": " +transform("JSONPATH", "$['"+halte+"'].dep[0].time", json)+" ("+ transform("JSONPATH", "$['"+halte+"'].dep[0].late", json)+") "
    zug2= transform("JSONPATH", "$['"+halte+"'].dep[1].name", json) +" -> " + transform("JSONPATH", "$['"+halte+"'].dep[1].direction", json) +": " +transform("JSONPATH", "$['"+halte+"'].dep[1].time", json)+" ("+ transform("JSONPATH", "$['"+halte+"'].dep[1].late", json)+") "
    noch1=transform("JSONPATH", "$['"+halte+"'].dep[0].left", json)
    noch2=transform("JSONPATH", "$['"+halte+"'].dep[1].left", json)

    vbbHalte1.sendCommand(station)
    if (zug1.length()<100){vbbHalte1Zug1.sendCommand(zug1);vbbHalte1Noch1.sendCommand(noch1)}else{vbbHalte1Zug1.sendCommand(" ");vbbHalte1Noch1.sendCommand(" ")}
    if (zug2.length()<100){vbbHalte1Zug2.sendCommand(zug2);vbbHalte1Noch2.sendCommand(noch2)}else{vbbHalte1Zug2.sendCommand(" ");vbbHalte1Noch2.sendCommand(" ")}



    
    halte="900060101S" // S Friedenau Süd
    station = transform("JSONPATH", "$['"+halte+"'].name", json) + " (" + transform("JSONPATH", "$['"+halte+"'].dist", json) + "m / " + transform("JSONPATH", "$['"+halte+"'].walk", json) + " min) "
    zug1= transform("JSONPATH", "$['"+halte+"'].dep[0].name", json) +" -> " + transform("JSONPATH", "$['"+halte+"'].dep[0].direction", json) +": " +transform("JSONPATH", "$['"+halte+"'].dep[0].time", json)+" ("+ transform("JSONPATH", "$['"+halte+"'].dep[0].late", json)+") "
    zug2= transform("JSONPATH", "$['"+halte+"'].dep[1].name", json) +" -> " + transform("JSONPATH", "$['"+halte+"'].dep[1].direction", json) +": " +transform("JSONPATH", "$['"+halte+"'].dep[1].time", json)+" ("+ transform("JSONPATH", "$['"+halte+"'].dep[1].late", json)+") "
    noch1=transform("JSONPATH", "$['"+halte+"'].dep[0].left", json)
    noch2=transform("JSONPATH", "$['"+halte+"'].dep[1].left", json)

    vbbHalte2.sendCommand(station)
    if (zug1.length()<100){vbbHalte2Zug1.sendCommand(zug1);vbbHalte2Noch1.sendCommand(noch1)}else{vbbHalte2Zug1.sendCommand(" ");vbbHalte2Noch1.sendCommand(" ")}
    if (zug2.length()<100){vbbHalte2Zug2.sendCommand(zug2);vbbHalte2Noch2.sendCommand(noch2)}else{vbbHalte2Zug2.sendCommand(" ");vbbHalte2Noch2.sendCommand(" ")}


and finally some HTML to go into the template widget of a HabPanel page:

<div align="left" class "row">

  <div class="col-sm-6"><span style="color: red; font-size: 12pt">{{itemValue('vbbHalte1')}} </span>
    <ul>
      <li>  <span style="color: orange; font-size: 9pt">{{itemValue('vbbHalte1Zug1')}} </span><span style="color: cyan; font-size: 10pt">{{itemValue('vbbHalte1Noch1')}} </span></li>
      <li>  <span style="color: orange; font-size: 9pt">{{itemValue('vbbHalte1Zug2')}} </span><span style="color: cyan; font-size: 10pt">{{itemValue('vbbHalte1Noch2')}} </span></li>
    </ul>  
  </div>

  <div class="col-sm-6"><span style="color: red; font-size: 12pt">{{itemValue('vbbHalte2')}} </span>
    <ul>
      <li>  <span style="color: orange; font-size: 9pt">{{itemValue('vbbHalte2Zug1')}} </span><span style="color: cyan; font-size: 10pt">{{itemValue('vbbHalte2Noch1')}} </span></li>
      <li>  <span style="color: orange; font-size: 9pt">{{itemValue('vbbHalte2Zug2')}} </span><span style="color: cyan; font-size: 10pt">{{itemValue('vbbHalte2Noch2')}} </span></li>
    </ul>  
  </div>

Again this is just the start of it and those lines are repeated for all the stations and their items.

This is all just Alpha and there's room left for improvements, But 'm glad it works!

My little thing uses but a tiny subset of what functionality the api offers

(Page 1 of 1, totaling 1 entries)