Transport NSW / Sydney Bus, Ferry and Train schedule

First sensor to query Transport NSW for bus, ferry and train times. Works great to build a bus/train/ferry monitor with information on next transport option.

First get your free API key from https://opendata.transport.nsw.gov.au/ .

Next download the transport_nsw.py sensor into your custom_components folder

"""
Transport NSW sensor to query next leave event for a specified stop.
"""
import logging
from datetime import timedelta, datetime

import requests
import voluptuous as vol

import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION
from homeassistant.helpers.entity import Entity

_LOGGER = logging.getLogger(__name__)

ATTR_STOP_ID = 'stopid'
ATTR_ROUTE = 'route'
ATTR_DUE_IN = 'due'
ATTR_DELAY = 'delay'
ATTR_REALTIME = 'realtime'

CONF_ATTRIBUTION = "Data provided by Transport NSW"
CONF_STOP_ID = 'stopid'
CONF_ROUTE = 'route'
CONF_APIKEY = 'apikey'

DEFAULT_NAME = "Next Bus"
ICON = "mdi:bus"

SCAN_INTERVAL = timedelta(minutes=1)
TIME_STR_FORMAT = "%H:%M"

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Required(CONF_STOP_ID): cv.string,
    vol.Required(CONF_APIKEY): cv.string,
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
    vol.Optional(CONF_ROUTE, default=""): cv.string,
})


def setup_platform(hass, config, add_devices, discovery_info=None):
    """Set up the Transport NSW sensor."""
    name = config.get(CONF_NAME)
    stopid = config.get(CONF_STOP_ID)
    route = config.get(CONF_ROUTE)
    apikey = config.get(CONF_APIKEY)

    data = PublicTransportData(stopid, route, apikey)
    add_devices([TransportNSWSensor(data, stopid, route, name)], True)


class TransportNSWSensor(Entity):
    """Implementation of an Transport NSW sensor."""

    def __init__(self, data, stopid, route, name):
        """Initialize the sensor."""
        self.data = data
        self._name = name
        self._stopid = stopid
        self._route = route
        self._times = self._state = None

    @property
    def name(self):
        """Return the name of the sensor."""
        return self._name

    @property
    def state(self):
        """Return the state of the sensor."""
        return self._state

    @property
    def device_state_attributes(self):
        """Return the state attributes."""
        if self._times is not None:
            return {
                ATTR_DUE_IN: self._times[0][ATTR_DUE_IN],
                ATTR_STOP_ID: self._stopid,
                ATTR_ROUTE: self._times[0][ATTR_ROUTE],
                ATTR_DELAY: self._times[0][ATTR_DELAY],
                ATTR_REALTIME: self._times[0][ATTR_REALTIME],
                ATTR_ATTRIBUTION: CONF_ATTRIBUTION
            }

    @property
    def unit_of_measurement(self):
        """Return the unit this state is expressed in."""
        return 'min'

    @property
    def icon(self):
        """Icon to use in the frontend, if any."""
        return ICON

    def update(self):
        """Get the latest data from Transport NSW and update the states."""
        self.data.update()
        self._times = self.data.info
        try:
            self._state = self._times[0][ATTR_DUE_IN]
        except TypeError:
            pass


class PublicTransportData(object):
    """The Class for handling the data retrieval."""

    def __init__(self, stopid, route, apikey):
        """Initialize the data object."""
        self.stopid = stopid
        self.route = route
        self.apikey = apikey
        self.info = [{ATTR_ROUTE: self.route,
                      ATTR_DUE_IN: 'n/a',
                      ATTR_DELAY: 'n/a',
                      ATTR_REALTIME: 'n/a'}]

    def update(self):
        """Get the latest data from Transport NSW."""

        url = ("https://api.transport.nsw.gov.au/v1/tp/departure_mon?"
               "outputFormat=rapidJSON&"
               "coordOutputFormat=EPSG%3A4326&"
               "mode=direct&"
               "type_dm=stop&"
               "name_dm="+self.stopid+"&"
               "departureMonitorMacro=true&"
               "TfNSWDM=true&"
               "version=10.2.1.42")
        auth = 'apikey ' + self.apikey
        header = {'Accept': 'application/json',
                  'Authorization': auth}
        response = requests.get(url, headers=header, timeout=10)

        _LOGGER.debug(response)

        # No valid request, set to default
        if response.status_code != 200:
            _LOGGER.error(response.status_code)
            self.info = [{ATTR_ROUTE: self.route,
                          ATTR_DUE_IN: 'n/a',
                          ATTR_DELAY: 'n/a',
                          ATTR_REALTIME: 'n/a'}]
            return

        # Parse the result as a JSON object
        result = response.json()

        # No stop events for the query
        if len(result['stopEvents']) <= 0:
            _LOGGER.error("No stop events for this query")
            self.info = [{ATTR_ROUTE: self.route,
                          ATTR_DUE_IN: 'n/a',
                          ATTR_DELAY: 'n/a',
                          ATTR_REALTIME: 'n/a'}]
            return

        # Set timestamp format and variables
        fmt = "%Y-%m-%dT%H:%M:%SZ"
        maxresults = 3
        monitor = []

        if self.route != '':
            # Find any bus leaving next
            for i in range(len(result['stopEvents'])):
                number = result['stopEvents'][i]['transportation']['number']
                if number == self.route:
                    planned = datetime.strptime(
                        result['stopEvents'][i]['departureTimePlanned'],
                        fmt)

                    realtime = 'n'
                    estimated = planned
                    delay = 0
                    if 'isRealtimeControlled' in result['stopEvents'][i]:
                        realtime = 'y'
                        estimated = datetime.strptime(
                            result['stopEvents'][i]['departureTimeEstimated'],
                            fmt)

                    if estimated > datetime.utcnow():
                        due = round((estimated - datetime.utcnow()).seconds/60)
                        if estimated >= planned:
                            delay = round((estimated-planned).seconds/60)
                        else:
                            delay = round((planned-estimated).seconds/60) * -1

                        monitor.append(
                            [number, due, delay, planned, estimated, realtime])

                    if len(monitor) >= maxresults:
                        # We found enough results, lets stop
                        break
        else:
            # Find the next stop events
            for i in range(0, maxresults):
                number = result['stopEvents'][i]['transportation']['number']

                planned = datetime.strptime(
                    result['stopEvents'][i]['departureTimePlanned'],
                    fmt)

                realtime = 'n'
                estimated = planned
                delay = 0
                if 'isRealtimeControlled' in result['stopEvents'][i]:
                    realtime = 'y'
                    estimated = datetime.strptime(
                        result['stopEvents'][i]['departureTimeEstimated'],
                        fmt)

                if estimated > datetime.utcnow():
                    due = round((estimated - datetime.utcnow()).seconds/60)
                    if estimated >= planned:
                        delay = round((estimated-planned).seconds/60)
                    else:
                        delay = round((planned-estimated).seconds/60) * -1

                    monitor.append(
                        [number, due, delay, planned, estimated, realtime])

        if len(monitor) > 0:
            self.info = [{ATTR_ROUTE: monitor[0][0],
                          ATTR_DUE_IN: monitor[0][1],
                          ATTR_DELAY: monitor[0][2],
                          ATTR_REALTIME: monitor[0][5]}]
            return
        else:
            # _LOGGER.error("No stop events for this route.")
            self.info = [{ATTR_ROUTE: self.route,
                          ATTR_DUE_IN: 'n/a',
                          ATTR_DELAY: 'n/a',
                          ATTR_REALTIME: 'n/a'}]
            return

Next create your sensor to query the data. In order to find the stop id, just go to Google maps and click on the bus/train/ferry stop. It will give you there the stop ID.

You can define a bus line, but if you donā€™t do it, the sensor will pick up the next stop event from any line servicing this stop.

- platform: transport_nsw
  name: 'Bus E80'
  stopid: '200024'
  route: 'E80'
  apikey: 'YOUR API KEY'

I have done some testing and it works fine for me. Let me know if you have any feedback.

6 Likes

If you want to take it one stop further and display your information nicely in HADashboard, you can use the following template sensor.

- platform: template
  sensors:
    busmonitor2:
      friendly_name: "Bus Mon E80"
      value_template: >-
        {% if is_state_attr('sensor.bus_e80', 'due_in', 'n/a') %}
          No schedule found
        {% else %}
          {{ states.sensor.bus_e80.attributes.route }} in {{ states.sensor.bus_e80.attributes.due }}m ({{ states.sensor.bus_e80.attributes.delay }})
        {% endif %}
1 Like

Thanks. it is working

@DownUnder thanks for developing the module. Nice to see some Australia centric development :smile:

I am having some issues getting it to work. My home assistant log gives me a 401 error (unauthorised?). I am using the sample config and my API key. I have tried creating apps using the ā€œPublic Transport - Timetables - For Realtimeā€ and ā€œPublic Transport - Timetables - Complete - GTFSā€ but I get the same error with both.

I am also having trouble finding the stop id as google maps doesnā€™t seem to display it for me. I noticed the transport nsw trip planner seems to show a stop id. e.g. https://transportnsw.info/stop?q=200024#/ Can I use that number from the URL?

Cheers

The stop ID looks fine, so I assume that your API key is not working. Have you tried to log on to the Transport website and send test queries there ? When doing that you can see the complete query including API key and stop ID.

I found that to be the best place to debug any authentication issues in the first place.

This is awesome! Thanks for doing this and writing about it. Iā€™ve integrated it with my La Metric Clock and Alexa!

2 Likes

Thanks so much for making this @DownUnder.

How do I specify the direction/platform when it comes to trains? Iā€™m guessing it has something to do with the route specified but Iā€™m having trouble figuring out what to enter as the route ID in the settings.

EDIT: I initially deleted this post because I didnā€™t want to bug the dev with too many questions, but since I figured it out:

After removing the ā€˜routeā€™ from my config it defaulted to the next train arriving at my stop and gave me the answer. The route is actually the full name of the line as found in the response body of the API call, e.g. ā€œT1 North Shore, Northern & Western Lineā€. For now Iā€™ve left ā€˜routeā€™ blank because all of the trains that pass by the platform Iā€™ve specified go to Central, but at least I know how to fine-tune it if I need to.

@DownUnder thanks a million for making this!

A couple of questions:

  1. Is there any way to add some kind of offset? I live about 10 minutes away from the train station so Iā€™d like to ignore trains that are coming in the next 10 minutes.

  2. How can I display the departure time instead of the time until the next train comes?

I got this working. A couple of things to note:

  • Add the component in the custom_components/sensor folder.
  • I ended up adding all the API keys to my application and it fixed my 401 errors.
  • The example bus doesnā€™t seem to run on weekends so you will often get NA results.
  • Use transportnswā€™s site to find station idā€™s (in the url).

Thanks for the awesome component.

1 Like

assume you mean custom_components/sensor???
I am getting a validation error Platform not found: sensor.transport_nsw
I copied the text in post 1 to transport_nsw.py and put in folder and made it executable (755)
Put the platform under sensor:
What am I doing wrong?

1 Like

Please see above post. I am getting a sensor error. Any advice?

@DavidFW1960 Fixed my typo. Thanks mate.

I will give you some details about my setup. My component is saved as:

/ā€¦/homeassistant/custom_components/sensor/transport_nsw.py

ls -al output:

-rw-rā€“r-- 1 root root 8093 Mar 31 15:18 transport_nsw.py

Add the sensor configuration from OP. Keep the single quotes.

Fully restart your container/ha setup.

I can confirm its working on 0.66.0.

Cheers,

Chris

I am using hass.io and /config/custom_components/transport_nsw.py
my config yaml file - I have this:

# Weather prediction
sensor:
  - platform: yr

  - platform: transport_nsw
    name: 'Kariong-Gosford'
    stopid: '2250275'
    apikey: '****'

I get the error shown.

Unless your Home Assistant directory is called /config, it shouldnā€™t actually be in /config/custom_components.

The ā€˜/configā€™ part merely represents the root directory of your Home Assistant install i.e. where configuration.yaml lives by default.

1 Like

in hassio the config is /config - itā€™s pretty well documented

Really? I donā€™t currently use hass.io but did previously, and I never had a directory called /config. If your base directory is /config it sounds like youā€™re putting it in the right place and Iā€™m not sure what you need to change.

config yaml and all yaml files are in /config/ and as far as I know always have been in hassio

OK so this will make you laugh.

I think itā€™s because I had not restarted ha after I added that component so it didnā€™t ā€˜seeā€™ it.

So I just restarted Hassio and then edited config yaml to remove the #'s and it validates. Restarting now.

1 Like

Iā€™m assuming I can add another route?
How do I add a Train as well? I donā€™t see any stop id for a train?

The Trip Planner API contains both trains and buses, so the information you get will depend on what you feed it.

You can set up multiple sensors like so:

- platform: transport_nsw
  name: Next train to _____
  stopid: 'Train station ID'
  route: 'Train route ID'
  apikey: ''

- platform: transport_nsw
  name: Next bus to ______
  stopid: 'Bus stop ID'
  route: 'Bus stop route ID'
  apikey: ''