From 3b4ea864a10c6923bec25e643d6d27ba022a7832 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 26 Jul 2017 20:49:52 +0100 Subject: [PATCH] Add uk_transport component. (#8600) --- .../components/sensor/uk_transport.py | 275 ++++++++++ tests/components/sensor/test_uk_transport.py | 93 ++++ tests/fixtures/uk_transport_bus.json | 110 ++++ tests/fixtures/uk_transport_train.json | 511 ++++++++++++++++++ 4 files changed, 989 insertions(+) create mode 100644 homeassistant/components/sensor/uk_transport.py create mode 100644 tests/components/sensor/test_uk_transport.py create mode 100644 tests/fixtures/uk_transport_bus.json create mode 100644 tests/fixtures/uk_transport_train.json diff --git a/homeassistant/components/sensor/uk_transport.py b/homeassistant/components/sensor/uk_transport.py new file mode 100644 index 00000000000000..b9ce98ec25743a --- /dev/null +++ b/homeassistant/components/sensor/uk_transport.py @@ -0,0 +1,275 @@ +"""Support for UK public transport data provided by transportapi.com. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.uk_transport/ +""" +import logging +import re +from datetime import datetime, timedelta +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_ATCOCODE = 'atcocode' +ATTR_LOCALITY = 'locality' +ATTR_STOP_NAME = 'stop_name' +ATTR_REQUEST_TIME = 'request_time' +ATTR_NEXT_BUSES = 'next_buses' +ATTR_STATION_CODE = 'station_code' +ATTR_CALLING_AT = 'calling_at' +ATTR_NEXT_TRAINS = 'next_trains' + +CONF_API_APP_KEY = 'app_key' +CONF_API_APP_ID = 'app_id' +CONF_QUERIES = 'queries' +CONF_MODE = 'mode' +CONF_ORIGIN = 'origin' +CONF_DESTINATION = 'destination' + +_QUERY_SCHEME = vol.Schema({ + vol.Required(CONF_MODE): + vol.All(cv.ensure_list, [vol.In(list(['bus', 'train']))]), + vol.Required(CONF_ORIGIN): cv.string, + vol.Required(CONF_DESTINATION): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_APP_ID): cv.string, + vol.Required(CONF_API_APP_KEY): cv.string, + vol.Required(CONF_QUERIES): [_QUERY_SCHEME], +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Get the uk_transport sensor.""" + sensors = [] + number_sensors = len(config.get(CONF_QUERIES)) + interval = timedelta(seconds=87*number_sensors) + + for query in config.get(CONF_QUERIES): + if 'bus' in query.get(CONF_MODE): + stop_atcocode = query.get(CONF_ORIGIN) + bus_direction = query.get(CONF_DESTINATION) + sensors.append( + UkTransportLiveBusTimeSensor( + config.get(CONF_API_APP_ID), + config.get(CONF_API_APP_KEY), + stop_atcocode, + bus_direction, + interval)) + + elif 'train' in query.get(CONF_MODE): + station_code = query.get(CONF_ORIGIN) + calling_at = query.get(CONF_DESTINATION) + sensors.append( + UkTransportLiveTrainTimeSensor( + config.get(CONF_API_APP_ID), + config.get(CONF_API_APP_KEY), + station_code, + calling_at, + interval)) + + add_devices(sensors, True) + + +class UkTransportSensor(Entity): + """ + Sensor that reads the UK transport web API. + + transportapi.com provides comprehensive transport data for UK train, tube + and bus travel across the UK via simple JSON API. Subclasses of this + base class can be used to access specific types of information. + """ + + TRANSPORT_API_URL_BASE = "https://transportapi.com/v3/uk/" + ICON = 'mdi:train' + + def __init__(self, name, api_app_id, api_app_key, url): + """Initialize the sensor.""" + self._data = {} + self._api_app_id = api_app_id + self._api_app_key = api_app_key + self._url = self.TRANSPORT_API_URL_BASE + url + self._name = name + 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 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 self.ICON + + def _do_api_request(self, params): + """Perform an API request.""" + request_params = dict({ + 'app_id': self._api_app_id, + 'app_key': self._api_app_key, + }, **params) + + response = requests.get(self._url, params=request_params) + if response.status_code != 200: + _LOGGER.warning('Invalid response from API') + elif 'error' in response.json(): + if 'exceeded' in response.json()['error']: + self._state = 'Useage limites exceeded' + if 'invalid' in response.json()['error']: + self._state = 'Credentials invalid' + else: + self._data = response.json() + + +class UkTransportLiveBusTimeSensor(UkTransportSensor): + """Live bus time sensor from UK transportapi.com.""" + + ICON = 'mdi:bus' + + def __init__(self, api_app_id, api_app_key, + stop_atcocode, bus_direction, interval): + """Construct a live bus time sensor.""" + self._stop_atcocode = stop_atcocode + self._bus_direction = bus_direction + self._next_buses = [] + self._destination_re = re.compile( + '{}'.format(bus_direction), re.IGNORECASE + ) + + sensor_name = 'Next bus to {}'.format(bus_direction) + stop_url = 'bus/stop/{}/live.json'.format(stop_atcocode) + + UkTransportSensor.__init__( + self, sensor_name, api_app_id, api_app_key, stop_url + ) + self.update = Throttle(interval)(self._update) + + def _update(self): + """Get the latest live departure data for the specified stop.""" + params = {'group': 'route', 'nextbuses': 'no'} + + self._do_api_request(params) + + if self._data != {}: + self._next_buses = [] + + for (route, departures) in self._data['departures'].items(): + for departure in departures: + if self._destination_re.search(departure['direction']): + self._next_buses.append({ + 'route': route, + 'direction': departure['direction'], + 'scheduled': departure['aimed_departure_time'], + 'estimated': departure['best_departure_estimate'] + }) + + self._state = min(map( + _delta_mins, [bus['scheduled'] for bus in self._next_buses] + )) + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attrs = {} + if self._data is not None: + for key in [ + ATTR_ATCOCODE, ATTR_LOCALITY, ATTR_STOP_NAME, + ATTR_REQUEST_TIME + ]: + attrs[key] = self._data.get(key) + attrs[ATTR_NEXT_BUSES] = self._next_buses + return attrs + + +class UkTransportLiveTrainTimeSensor(UkTransportSensor): + """Live train time sensor from UK transportapi.com.""" + + ICON = 'mdi:train' + + def __init__(self, api_app_id, api_app_key, + station_code, calling_at, interval): + """Construct a live bus time sensor.""" + self._station_code = station_code + self._calling_at = calling_at + self._next_trains = [] + + sensor_name = 'Next train to {}'.format(calling_at) + query_url = 'train/station/{}/live.json'.format(station_code) + + UkTransportSensor.__init__( + self, sensor_name, api_app_id, api_app_key, query_url + ) + self.update = Throttle(interval)(self._update) + + def _update(self): + """Get the latest live departure data for the specified stop.""" + params = {'darwin': 'false', + 'calling_at': self._calling_at, + 'train_status': 'passenger'} + + self._do_api_request(params) + self._next_trains = [] + + if self._data != {}: + if self._data['departures']['all'] == []: + self._state = 'No departures' + else: + for departure in self._data['departures']['all']: + self._next_trains.append({ + 'origin_name': departure['origin_name'], + 'destination_name': departure['destination_name'], + 'status': departure['status'], + 'scheduled': departure['aimed_departure_time'], + 'estimated': departure['expected_departure_time'], + 'platform': departure['platform'], + 'operator_name': departure['operator_name'] + }) + + self._state = min(map( + _delta_mins, + [train['scheduled'] for train in self._next_trains] + )) + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attrs = {} + if self._data is not None: + attrs[ATTR_STATION_CODE] = self._station_code + attrs[ATTR_CALLING_AT] = self._calling_at + if self._next_trains: + attrs[ATTR_NEXT_TRAINS] = self._next_trains + return attrs + + +def _delta_mins(hhmm_time_str): + """Calculate time delta in minutes to a time in hh:mm format.""" + now = datetime.now() + hhmm_time = datetime.strptime(hhmm_time_str, '%H:%M') + + hhmm_datetime = datetime( + now.year, now.month, now.day, + hour=hhmm_time.hour, minute=hhmm_time.minute + ) + if hhmm_datetime < now: + hhmm_datetime += timedelta(days=1) + + delta_mins = (hhmm_datetime - now).seconds // 60 + return delta_mins diff --git a/tests/components/sensor/test_uk_transport.py b/tests/components/sensor/test_uk_transport.py new file mode 100644 index 00000000000000..b051d8e1a1b37f --- /dev/null +++ b/tests/components/sensor/test_uk_transport.py @@ -0,0 +1,93 @@ +"""The tests for the uk_transport platform.""" +import re + +import requests_mock +import unittest + +from homeassistant.components.sensor.uk_transport import ( + UkTransportSensor, + ATTR_ATCOCODE, ATTR_LOCALITY, ATTR_STOP_NAME, ATTR_NEXT_BUSES, + ATTR_STATION_CODE, ATTR_CALLING_AT, ATTR_NEXT_TRAINS, + CONF_API_APP_KEY, CONF_API_APP_ID) +from homeassistant.setup import setup_component +from tests.common import load_fixture, get_test_home_assistant + +BUS_ATCOCODE = '340000368SHE' +BUS_DIRECTION = 'Wantage' +TRAIN_STATION_CODE = 'WIM' +TRAIN_DESTINATION_NAME = 'WAT' + +VALID_CONFIG = { + 'platform': 'uk_transport', + CONF_API_APP_ID: 'foo', + CONF_API_APP_KEY: 'ebcd1234', + 'queries': [{ + 'mode': 'bus', + 'origin': BUS_ATCOCODE, + 'destination': BUS_DIRECTION}, + { + 'mode': 'train', + 'origin': TRAIN_STATION_CODE, + 'destination': TRAIN_DESTINATION_NAME}] + } + + +class TestUkTransportSensor(unittest.TestCase): + """Test the uk_transport platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_bus(self, mock_req): + """Test for operational uk_transport sensor with proper attributes.""" + with requests_mock.Mocker() as mock_req: + uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + '*') + mock_req.get(uri, text=load_fixture('uk_transport_bus.json')) + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + + bus_state = self.hass.states.get('sensor.next_bus_to_wantage') + + assert type(bus_state.state) == str + assert bus_state.name == 'Next bus to {}'.format(BUS_DIRECTION) + assert bus_state.attributes.get(ATTR_ATCOCODE) == BUS_ATCOCODE + assert bus_state.attributes.get(ATTR_LOCALITY) == 'Harwell Campus' + assert bus_state.attributes.get(ATTR_STOP_NAME) == 'Bus Station' + assert len(bus_state.attributes.get(ATTR_NEXT_BUSES)) == 2 + + direction_re = re.compile(BUS_DIRECTION) + for bus in bus_state.attributes.get(ATTR_NEXT_BUSES): + print(bus['direction'], direction_re.match(bus['direction'])) + assert direction_re.search(bus['direction']) is not None + + @requests_mock.Mocker() + def test_train(self, mock_req): + """Test for operational uk_transport sensor with proper attributes.""" + with requests_mock.Mocker() as mock_req: + uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + '*') + mock_req.get(uri, text=load_fixture('uk_transport_train.json')) + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + + train_state = self.hass.states.get('sensor.next_train_to_WAT') + + assert type(train_state.state) == str + assert train_state.name == 'Next train to {}'.format( + TRAIN_DESTINATION_NAME) + assert train_state.attributes.get( + ATTR_STATION_CODE) == TRAIN_STATION_CODE + assert train_state.attributes.get( + ATTR_CALLING_AT) == TRAIN_DESTINATION_NAME + assert len(train_state.attributes.get(ATTR_NEXT_TRAINS)) == 25 + + assert train_state.attributes.get( + ATTR_NEXT_TRAINS)[0]['destination_name'] == 'London Waterloo' + assert train_state.attributes.get( + ATTR_NEXT_TRAINS)[0]['estimated'] == '06:13' diff --git a/tests/fixtures/uk_transport_bus.json b/tests/fixtures/uk_transport_bus.json new file mode 100644 index 00000000000000..5e1e27a4ba3147 --- /dev/null +++ b/tests/fixtures/uk_transport_bus.json @@ -0,0 +1,110 @@ +{ + "atcocode": "340000368SHE", + "bearing": "", + "departures": { + "32A": [{ + "aimed_departure_time": "10:18", + "best_departure_estimate": "10:18", + "date": "2017-05-09", + "dir": "outbound", + "direction": "Market Place (Wantage)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/10:18/timetable.json?app_id=e99&app_key=058", + "line": "32A", + "line_name": "32A", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "11:00", + "best_departure_estimate": "11:00", + "date": "2017-05-09", + "dir": "outbound", + "direction": "Stratton Way (Abingdon Town Centre)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/11:00/timetable.json?app_id=e99&app_key=058", + "line": "32A", + "line_name": "32A", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "11:18", + "best_departure_estimate": "11:18", + "date": "2017-05-09", + "dir": "outbound", + "direction": "Market Place (Wantage)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/11:18/timetable.json?app_id=e99&app_key=058", + "line": "32A", + "line_name": "32A", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + } + ], + "X32": [{ + "aimed_departure_time": "10:09", + "best_departure_estimate": "10:09", + "date": "2017-05-09", + "dir": "inbound", + "direction": "Parkway Station (Didcot)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:09/timetable.json?app_id=e99&app_key=058", + "line": "X32", + "line_name": "X32", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "10:30", + "best_departure_estimate": "10:30", + "date": "2017-05-09", + "dir": "inbound", + "direction": "Parks Road (Oxford City Centre)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:30/timetable.json?app_id=e99&app_key=058", + "line": "X32", + "line_name": "X32", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "10:39", + "best_departure_estimate": "10:39", + "date": "2017-05-09", + "dir": "inbound", + "direction": "Parkway Station (Didcot)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:39/timetable.json?app_id=e99&app_key=058", + "line": "X32", + "line_name": "X32", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + } + ] + }, + "indicator": "in", + "locality": "Harwell Campus", + "name": "Bus Station (in)", + "request_time": "2017-05-09T10:03:41+01:00", + "smscode": "oxfajwgp", + "stop_name": "Bus Station" +} diff --git a/tests/fixtures/uk_transport_train.json b/tests/fixtures/uk_transport_train.json new file mode 100644 index 00000000000000..b06e8db6ca70dd --- /dev/null +++ b/tests/fixtures/uk_transport_train.json @@ -0,0 +1,511 @@ +{ + "date": "2017-07-10", + "time_of_day": "06:10", + "request_time": "2017-07-10T06:10:05+01:00", + "station_name": "Wimbledon", + "station_code": "WIM", + "departures": { + "all": [ + { + "mode": "train", + "service": "24671405", + "train_uid": "W36814", + "platform": "8", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:13", + "aimed_arrival_time": null, + "aimed_pass_time": null, + "origin_name": "Wimbledon", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "STARTS HERE", + "expected_arrival_time": null, + "expected_departure_time": "06:13", + "best_arrival_estimate_mins": null, + "best_departure_estimate_mins": 2 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36613", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:14", + "aimed_arrival_time": "06:13", + "aimed_pass_time": null, + "origin_name": "Hampton Court", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "EARLY", + "expected_arrival_time": "06:13", + "expected_departure_time": "06:14", + "best_arrival_estimate_mins": 2, + "best_departure_estimate_mins": 3 + }, + { + "mode": "train", + "service": "24673505", + "train_uid": "W36012", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:20", + "aimed_arrival_time": "06:20", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:20", + "expected_departure_time": "06:20", + "best_arrival_estimate_mins": 9, + "best_departure_estimate_mins": 9 + }, + { + "mode": "train", + "service": "24673305", + "train_uid": "W34087", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:23", + "aimed_arrival_time": "06:23", + "aimed_pass_time": null, + "origin_name": "Dorking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "XX", + "status": "ON TIME", + "expected_arrival_time": "06:23", + "expected_departure_time": "06:23", + "best_arrival_estimate_mins": 12, + "best_departure_estimate_mins": 12 + }, + { + "mode": "train", + "service": "24671505", + "train_uid": "W37471", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:32", + "aimed_arrival_time": "06:31", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:31", + "expected_departure_time": "06:32", + "best_arrival_estimate_mins": 20, + "best_departure_estimate_mins": 21 + }, + { + "mode": "train", + "service": "24673605", + "train_uid": "W35790", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:35", + "aimed_arrival_time": "06:35", + "aimed_pass_time": null, + "origin_name": "Woking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:35", + "expected_departure_time": "06:35", + "best_arrival_estimate_mins": 24, + "best_departure_estimate_mins": 24 + }, + { + "mode": "train", + "service": "24673705", + "train_uid": "W35665", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:38", + "aimed_arrival_time": "06:38", + "aimed_pass_time": null, + "origin_name": "Epsom", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:38", + "expected_departure_time": "06:38", + "best_arrival_estimate_mins": 27, + "best_departure_estimate_mins": 27 + }, + { + "mode": "train", + "service": "24671405", + "train_uid": "W36816", + "platform": "8", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:43", + "aimed_arrival_time": "06:43", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:43", + "expected_departure_time": "06:43", + "best_arrival_estimate_mins": 32, + "best_departure_estimate_mins": 32 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36618", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:44", + "aimed_arrival_time": "06:43", + "aimed_pass_time": null, + "origin_name": "Hampton Court", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:43", + "expected_departure_time": "06:44", + "best_arrival_estimate_mins": 32, + "best_departure_estimate_mins": 33 + }, + { + "mode": "train", + "service": "24673105", + "train_uid": "W36429", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:47", + "aimed_arrival_time": "06:46", + "aimed_pass_time": null, + "origin_name": "Shepperton", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:46", + "expected_departure_time": "06:47", + "best_arrival_estimate_mins": 35, + "best_departure_estimate_mins": 36 + }, + { + "mode": "train", + "service": "24629204", + "train_uid": "W36916", + "platform": "6", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:47", + "aimed_arrival_time": "06:47", + "aimed_pass_time": null, + "origin_name": "Basingstoke", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "LATE", + "expected_arrival_time": "06:48", + "expected_departure_time": "06:48", + "best_arrival_estimate_mins": 37, + "best_departure_estimate_mins": 37 + }, + { + "mode": "train", + "service": "24673505", + "train_uid": "W36016", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:50", + "aimed_arrival_time": "06:49", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:49", + "expected_departure_time": "06:50", + "best_arrival_estimate_mins": 38, + "best_departure_estimate_mins": 39 + }, + { + "mode": "train", + "service": "24673705", + "train_uid": "W35489", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:53", + "aimed_arrival_time": "06:52", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "EARLY", + "expected_arrival_time": "06:52", + "expected_departure_time": "06:53", + "best_arrival_estimate_mins": 41, + "best_departure_estimate_mins": 42 + }, + { + "mode": "train", + "service": "24673405", + "train_uid": "W37107", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:58", + "aimed_arrival_time": "06:57", + "aimed_pass_time": null, + "origin_name": "Chessington South", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:57", + "expected_departure_time": "06:58", + "best_arrival_estimate_mins": 46, + "best_departure_estimate_mins": 47 + }, + { + "mode": "train", + "service": "24671505", + "train_uid": "W37473", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:02", + "aimed_arrival_time": "07:01", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "EARLY", + "expected_arrival_time": "07:01", + "expected_departure_time": "07:02", + "best_arrival_estimate_mins": 50, + "best_departure_estimate_mins": 51 + }, + { + "mode": "train", + "service": "24673605", + "train_uid": "W35795", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:05", + "aimed_arrival_time": "07:04", + "aimed_pass_time": null, + "origin_name": "Woking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:04", + "expected_departure_time": "07:05", + "best_arrival_estimate_mins": 53, + "best_departure_estimate_mins": 54 + }, + { + "mode": "train", + "service": "24673305", + "train_uid": "W34090", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:08", + "aimed_arrival_time": "07:07", + "aimed_pass_time": null, + "origin_name": "Dorking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "XX", + "status": "ON TIME", + "expected_arrival_time": "07:07", + "expected_departure_time": "07:08", + "best_arrival_estimate_mins": 56, + "best_departure_estimate_mins": 57 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36623", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:13", + "aimed_arrival_time": "07:12", + "aimed_pass_time": null, + "origin_name": "Hampton Court", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:12", + "expected_departure_time": "07:13", + "best_arrival_estimate_mins": 61, + "best_departure_estimate_mins": 62 + }, + { + "mode": "train", + "service": "24671405", + "train_uid": "W36819", + "platform": "8", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:13", + "aimed_arrival_time": "07:13", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:13", + "expected_departure_time": "07:13", + "best_arrival_estimate_mins": 62, + "best_departure_estimate_mins": 62 + }, + { + "mode": "train", + "service": "24673105", + "train_uid": "W36434", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:16", + "aimed_arrival_time": "07:15", + "aimed_pass_time": null, + "origin_name": "Shepperton", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:15", + "expected_departure_time": "07:16", + "best_arrival_estimate_mins": 64, + "best_departure_estimate_mins": 65 + }, + { + "mode": "train", + "service": "24673505", + "train_uid": "W36019", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:19", + "aimed_arrival_time": "07:18", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:18", + "expected_departure_time": "07:19", + "best_arrival_estimate_mins": 67, + "best_departure_estimate_mins": 68 + }, + { + "mode": "train", + "service": "24673705", + "train_uid": "W35494", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:22", + "aimed_arrival_time": "07:21", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:21", + "expected_departure_time": "07:22", + "best_arrival_estimate_mins": 70, + "best_departure_estimate_mins": 71 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36810", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:25", + "aimed_arrival_time": "07:24", + "aimed_pass_time": null, + "origin_name": "Esher", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:24", + "expected_departure_time": "07:25", + "best_arrival_estimate_mins": 73, + "best_departure_estimate_mins": 74 + }, + { + "mode": "train", + "service": "24673405", + "train_uid": "W37112", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:28", + "aimed_arrival_time": "07:27", + "aimed_pass_time": null, + "origin_name": "Chessington South", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:27", + "expected_departure_time": "07:28", + "best_arrival_estimate_mins": 76, + "best_departure_estimate_mins": 77 + }, + { + "mode": "train", + "service": "24671505", + "train_uid": "W37476", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:32", + "aimed_arrival_time": "07:31", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:31", + "expected_departure_time": "07:32", + "best_arrival_estimate_mins": 80, + "best_departure_estimate_mins": 81 + } + ] + } +}