Custom widget: Train departure times (UK Only)

Folks,

I’ve long wanted to display the local train times departing from my station. If I had this info in oH then I could a) display it as I wanted, b) create alarms and get Alexa to tell me that I need to leave in 3,2,1 or I will miss my train.

A screenprint of the HabPanel widget

So, a quick scratch around the 'net for the UK transportation API and a couple of hours writing a rule to pull it in, I now have what I want.
There is an accompanying widget listed to display the times so, if you wanted to, you could use that too.

Basic Operation

  • It uses the transportAPI.com API to get the train departure information.
  • You need to signup for a free key. Free allows 1000 requests a day
  • You can display 1-4 train times. Simply create items for each one, add it to the group, and the code works out the rest.
  • You need the 3-letter code for your local station as well as the destination station. These can be obtained from NationalRail.co.uk
  • I’ve tested this with a single train from my station to one of the main London stations. Not sure if there are any cases where the code will not work. I’ll happily help solve problems.
  • It’s a cron-based rule which fires every 5 minutes. There are 1440 minutes in a day, you have 1000 hits a day limit. Don’t overshoot it.

This needs the json binding installed into oH
This runs on oH 2.4 and probably 2.3. Don’t know about any others.
I run this on a RPi 3B+. No idea if it will run on Windows install.

Your items have to follow a pattern the code expects.

Items:

//leave these alone
Group gTrainCollection
Group gTrainCollectionItems
/// stop leaving alone

//You can have up to 4 sets below. The format must be:
//TrainDept + Your Station Code + 1/2/3/4 + Whatever is below.

String TrainDeptName (gTrainCollectionItems)
Switch TrainTimesRefresh    "Refresh Train Times"


String TrainDept1             		(gTrainCollection)
DateTime TrainDept1TimeExpected 		(gTrainCollectionItems)
String TrainDept1Status 				(gTrainCollectionItems)
Number TrainDept1Until             	(gTrainCollectionItems)

String TrainDept2             		(gTrainCollection)
DateTime TrainDept2TimeExpected 		(gTrainCollectionItems)
String TrainDept2Status 				(gTrainCollectionItems)
Number TrainDept2Until           	(gTrainCollectionItems)
	
String TrainDept3             		(gTrainCollection)
DateTime TrainDept3TimeExpected 		(gTrainCollectionItems)
String TrainDept3Status 				(gTrainCollectionItems)
Number TrainDept3Until           	(gTrainCollectionItems)
	
String TrainDept4             		(gTrainCollection)
DateTime TrainDept4TimeExpected 		(gTrainCollectionItems)
String TrainDept4Status 				(gTrainCollectionItems)
Number TrainDept4Until           	(gTrainCollectionItems)

If you have only one, it will only display the next train in the widget. If you have all 4 it will populate all 4. Up to you.

Rules:

val org.eclipse.xtext.xbase.lib.Functions$Function2<DateTimeType, NumberItem, Boolean> minutesUntil = [ DateTimeType dt, NumberItem item |
        
        if (dt === null){
            item.postUpdate(-1)
            return true;
        }
        
        val DateTime now2 = parse(now.toString("1970-01-01'T'HH:mm:ss"))
        val DateTime dtTime = parse(dt.toString())


        if (dtTime.getHourOfDay < now.getHourOfDay){
            dt.plusDays(1)
        }

        val f1 = (dt.zonedDateTime.toInstant.toEpochMilli - now2.millis) / 1000 / 60
        item.postUpdate(f1.toString)
        return true;
]


val String STA_ORIGIN       = "PAD"             //this is the station where the train starts. This is often going to be before your station.
val String STA_YOUR_DEST    = "SWI"             //this is the station your going to.
val String STA_YOUR         = "PAD"             //this is your station where you get on the train

val String API_KEY      = "<API KEY>"
val String APP_ID       = "<APP ID>"

//do not change
val BASE_URL = "https://transportapi.com/v3/uk/train/station/"

rule "Transport.GetTrainTimes"
when 
    Item TrainTimesRefresh changed to ON
then

    //logDebug("Transport.GetTrainTimes", "GetTrainTimes - Starting")
    
    val StringBuilder sURL = new StringBuilder
    sURL.append(BASE_URL) 
    sURL.append(STA_YOUR)
    sURL.append("/")
    sURL.append("live.json?")
    sURL.append("app_id=" + APP_ID)
    sURL.append("&app_key=" + API_KEY)
    sURL.append("&calling_at=")
    sURL.append(STA_YOUR_DEST)
    sURL.append("&origin=")
    sURL.append(STA_ORIGIN)
    sURL.append("&train_status=passenger")
    sURL.append("&darwin=true")

    var httpResponse = ""

logWarn("F", sURL.toString)

    try{
        httpResponse = sendHttpGetRequest(sURL.toString, 50000)
        if (httpResponse === null || httpResponse == ""){
            throw new Exception("Invalid HTTP response from call.")
        }
    }
    catch(Exception t) { 
        for (var int j = gTrainCollection?.members.length; j > 0;j--){
            val itemStatus      = (gTrainCollectionItems?.members.findFirst[ t |  ]        as StringItem)
            postUpdate("TrainDept" + j.toString + "Status", "ERROR")
        }
        logError("Transport.GetTrainTimes", t.getMessage())
        TrainTimesRefresh.sendCommand(OFF)
        return;
    }

    val numDept = Integer::parseInt(transform("JSONPATH", "$.departures.all.length()", httpResponse))
    var stationName = "UNKNOWN"
    var itemStationName = (gTrainCollectionItems?.members.findFirst[ t | t.name == "TrainDeptName" ]  as StringItem)
    if (itemStationName !== null){
        stationName     = transform("JSONPATH", "$.station_name", httpResponse)
        if (stationName === null){
            stationName = "Unknown"
            logWarn("Transport.GetTrainTimes", "message")
        }
        itemStationName.postUpdate(stationName)
    }
  
    for (var int j = gTrainCollection?.members.length; j > numDept;j--){
        val itemStatus      = (gTrainCollectionItems?.members.findFirst[ t | t.name == "TrainDept" + j.toString + "Status" ]        as StringItem)
        itemStatus.sendCommand("--")
    }

    for (var int i = 0;i <= numDept - 1;i++){
        val itemExpected    = (gTrainCollectionItems?.members.findFirst[ t | t.name == "TrainDept" + (i+1).toString + "TimeExpected" ]  as DateTimeItem)
        val itemStatus      = (gTrainCollectionItems?.members.findFirst[ t | t.name == "TrainDept" + (i+1).toString + "Status" ]        as StringItem)
        val itemUntil       = (gTrainCollectionItems?.members.findFirst[ t | t.name == "TrainDept" + (i+1).toString + "Until" ]         as NumberItem)

        if (itemExpected !== null){
            var String sDepart = transform( "JSONPATH", "$.departures.all[" + i.toString() + "].expected_departure_time", httpResponse)
            var DateTimeType dtDepart
            if (sDepart === null || sDepart == "" || sDepart == "null"){
                logWarn("Transport.Trains", "Unable to determine departure time for " + " - " + (i+1).toString + ". Ignoring")
            }
            else {
                //if null then it's probably because it's cancelled or something.
                dtDepart = new DateTimeType(sDepart)
            }
            
            minutesUntil.apply(dtDepart, itemUntil)

            var status = transform( "JSONPATH", "$.departures.all[" + i.toString() + "].status", httpResponse )
            if (status == "STARTS HERE"){status = "ON TIME"}

            itemExpected.postUpdate(dtDepart)
            itemStatus.postUpdate(status)
            
            logWarn("Transport.GetTrainTimes", stationName + " - " + itemStatus.state + " - " + dtDepart + " - " + status + " - " + itemUntil.state.toString)

        }

    }
  //  logWarn("LLL", "6")

    TrainTimesRefresh.sendCommand(OFF)
end 

TODO
The items are set in such a way that you can fave multiple stations. I did not code this into the rule as I don’t need it for now.
I want to try my hand at writing a binding for this instead of the rule. It will be a a while before I get the dev environment setup so this is my quick-fix.

I welcome any feedback on this. What could I have done better etc. Simpler ways to get stuff done etc.
Hope someone else can make use of this :slight_smile:

Update:20/10/2019

  • Changed the widget. This rule will not work with correctly unless you’ve updated the widget too.
  • Check your original Items. You no longer need TrainDeptXXX3TimeAimed
  • Notice that the Item names NO LONGER have the XXX station name in them. This is not needed and your items must be updated.
  • You can now set the API too correctly follow the train route.
  • There is now a Refresh button (controlled through config) which will allow you to force a refresh.
  • Changed sendCommand to postUpdate
  • Multiple refactoring to improve the way the code is run.
  • -I have noticed this rule takes longer to compile that I seem to remember. I will refactor it a bit to try work out why. I know the primitive issue with DSL. Might be, might not be. Functionally, it works.

Update:18/11/2019

  • Removed depreciated calendar code. Removes compile warnings.

C

3 Likes

I like this one, awesome, I have had a very crude JSON fetch of my local station using the same api by did not know how to deal with the quantity of data in the rules method of coding

currently i am getting, on openhab 2.4.0, linux, ubuntu

2018-12-30 19:42:03.799 [INFO ] [el.core.internal.ModelRepositoryImpl] - Validation issues 
found in configuration model 'traindeparture.rules', using it anyway:
Function2 is a raw type. References to generic type Function2<P1, P2, Result> should be 
parameterized
The method getCalendar() from the type DateTimeType is deprecated


2018-12-30 19:42:11.111 [ERROR] [ntime.internal.engine.RuleEngineImpl] - Error during the 
execution of startup rule 'Transport.GetTrainTimes': For input string: "{"date":"2018-12- 
30","time_of_day":"19:42","request_time":"2018-12- 
30T19:42:10+00:00","station_name":"Totton","station_code":"TTN","departures":{"all":[]}}"

I did have a werid error with my poor code when i encountered a bus replacement service

thanks for this much improved method

Don’t worry about the warnings and info. I’ll fix those soon anyway.

You’ve not posted enough of the error for me to see why.

Glad you like it

There is no more error unless i’ve missed it , the string looks like the initial station string, your code reads like it should work fine

There are trains currently, I have cross checked with the phone app

Write the url out to consoled the plug that into a browser. What do you get?

See it

station_name":“Totton”,“station_code”:“TTN”,“departures”:{“all”:[]}}"

It returned nothing. What are the two station codes.

I picked stations they will run to TTN, SOU, I work on the railways

a more recent, better looking responce

2018-12-31 13:01:18.165 [ERROR] [ntime.internal.engine.RuleEngineImpl] - Error during the execution of startup rule 'Transport.GetTrainTimes': For input string: "{"date":"2018-12-31","time_of_day":"13:01","request_time":"2018-12-31T13:01:17+00:00","station_name":"Totton","station_code":"TTN","departures":{"all":[{"mode":"train","service":"24620204","train_uid":"Q46172","platform":"1","operator":"SW","operator_name":"South Western Railway","aimed_departure_time":"13:40","aimed_arrival_time":"13:40","aimed_pass_time":null,"origin_name":"Bournemouth","destination_name":"Southampton Central","source":"Network Rail","category":"OO","service_timetable":{"id":"https://transportapi.com/v3/uk/train/service/train_uid:Q46172/2018-12-31/timetable.json?app_id=xxxx\u0026app_key=xxxx\u0026darwin=true\u0026live=true"},"status":"EARLY","expected_arrival_time":"13:40","expected_departure_time":"13:40","best_arrival_estimate_mins":38,"best_departure_estimate_mins":38},{"mode":"train","service":"24620204","train_uid":"Q46174","platform":"1","operator":"SW","operator_name":"South Western Railway","aimed_departure_time":"14:40","aimed_arrival_time":"14:40","aimed_pass_time":null,"origin_name":"Bournemouth","destination_name":"Southampton Central","source":"Network Rail","category":"OO","service_timetable":{"id":"https://transportapi.com/v3/uk/train/service/train_uid:Q46174/2018-12-31/timetable.json?app_id=xxxx\u0026app_key=xxxxdarwin=true\u0026live=true"},"status":"ON TIME","expected_arrival_time":"14:40","expected_departure_time":"14:40","best_arrival_estimate_mins":98,"best_departure_estimate_mins":98}]}}"

Odd. If I check the API from SOU to TTN I get 3 departures now. If I check from TTN to SOU I get none.
Compare this to nationalrail.co.uk and I get departures for both directions.

I’ll muck about with it a bit in a bit and see why it’s breaking for your second example. It should not.

Yes on my Realtime Rail app I get 1 in each direction from TTN,

What is a better method of getting the info back in basic UI rather than a

Group item=gTrainCollectionItems

TBH, I don’t know. I don’t use basic UI at all. Only habpanel.
The items are individual though so you should be able to setup the sitemap as you wish?

maybe i should look at habpanel, i have the items in extra display groups,

would the latest item set always be TTN1 ?

I need to have more information, like all trains from one station for example to gauge if its all gone horribly wrong(delays) etc, i think i can figure it with this excellent start

thanks

You couldn’t use the whole thing live timetable. My station is a through stopping someone only real one important thing for me me …

Made some changes to it and added them above.

  1. Added a new item called TrainDeptXXXName which the code looks for and when found, adds the departure station name. This is handy for the widget.
  2. Added a new item called TrainTimesRefresh which the rule uses as an OR trigger. Pressing it will cause it to get the latest times. This is handy for the widget.

I’ve fixed a number of things which would have caused it to fail for your options. If there are no results it spits back an error and says as much rather than failing.

Your TNN -> SOU brings back no results in the API while it does on nationalrail.co.uk. I’ve no idea why so might be best to contact transportapi.com and ask them.

https://transportapi.com/v3/uk/train/station/TTN/live.json?app_id=aaaaaaaaaaaaaaaaa&app_key=xxxxxxxxxxxxxxxxxxxx&calling_at=TTN&destination=SOU&train_status=passenger&darwin=true

Using your key and app-id, try the above and see

great stuff, its not helping these bank holidays with script checkings ;-D

not necessarily on topic, but I was trying to combine the status and platform in an extra
item ( itemMessage )

val message = status + platform
itemMessage.sendCommand(message)

I am guessing that is the wrong method, ive added all duplicate like itemPlatform > itemMessage
etc etc

yet receiving no itemMessage

Post your full code. Where are you getting platform from?

I am adding extra stuff, your code is working fine, I could start an extra Thread?

val itemPlatform  = (gTrainCollectionItems?.members.findFirst[ t | t.name == "TrainDept" + STA_ORIGIN + (i+1).toString + "Platform" ]       as NumberItem)
val itemMessage   = (gTrainCollectionItems?.members.findFirst[ t | t.name == "TrainDept" + STA_ORIGIN + (i+1).toString + "Message" ]        as StringItem)

val platform = transform( "JSONPATH", "$.departures.all[" + i.toString() + "].platform", value )

itemPlatform.sendCommand(platform)

val message = status + platform    // I guess this is not correct
itemMessage.sendCommand(message)

trying to combine from the group function rather than combine the items after there updated

I am trying to understand how add to a val variable with another val variable I guess.
Or I need to approach it from another angle?

Try changing val to var or bracket you concatenation. I wonder if java sees the value of as the first variable and then ses the concatenation as illegality final-variable change?

Came across this recently, far better than the solution I was using. Namely a Perl script and few cron jobs. This far more flexible and a lot cleaner.

I’ve found a small issue and would try to fix it, but I’m not the greatest of coders and would end up making a mess of it. When there is a bus replacement service not data is returned. Which means the items are no updated, so the previously available data is left.