Add RMV public transport sensor (#15814)

* Add new public transport sensor for RMV (Rhein-Main area).

* Add required module.

* Fix naming problem.

* Add unit test.

* Update dependency version to 0.0.5.

* Add new requirements.

* Fix variable name.

* Fix issues pointed out in review.

* Remove unnecessary code.

* Fix linter error.

* Fix config value validation.

* Replace minutes as state by departure timestamp. (see ##14983)

* More work on the timestamp. (see ##14983)

* Revert timestamp work until #14983 gets merged.

* Simplify product validation.

* Remove redundant code.

* Address code change requests.

* Address more code change requests.

* Address even more code change requests.

* Simplify destination check.

* Fix linter problem.

* Bump dependency version to 0.0.7.

* Name variable more explicit.

* Only query once a minute.

* Update test case.

* Fix config validation.

* Remove unneeded import.
pull/15921/merge
cgtobi 2018-08-10 19:35:09 +02:00 committed by Martin Hjelmare
parent 81604a9326
commit 055e35b297
5 changed files with 382 additions and 0 deletions

View File

@ -0,0 +1,202 @@
"""
Support for real-time departure information for Rhein-Main public transport.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.rmvtransport/
"""
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION)
REQUIREMENTS = ['PyRMVtransport==0.0.7']
_LOGGER = logging.getLogger(__name__)
CONF_NEXT_DEPARTURE = 'next_departure'
CONF_STATION = 'station'
CONF_DESTINATIONS = 'destinations'
CONF_DIRECTIONS = 'directions'
CONF_LINES = 'lines'
CONF_PRODUCTS = 'products'
CONF_TIME_OFFSET = 'time_offset'
CONF_MAX_JOURNEYS = 'max_journeys'
DEFAULT_NAME = 'RMV Journey'
VALID_PRODUCTS = ['U-Bahn', 'Tram', 'Bus', 'S', 'RB', 'RE', 'EC', 'IC', 'ICE']
ICONS = {
'U-Bahn': 'mdi:subway',
'Tram': 'mdi:tram',
'Bus': 'mdi:bus',
'S': 'mdi:train',
'RB': 'mdi:train',
'RE': 'mdi:train',
'EC': 'mdi:train',
'IC': 'mdi:train',
'ICE': 'mdi:train',
'SEV': 'mdi:checkbox-blank-circle-outline',
None: 'mdi:clock'
}
ATTRIBUTION = "Data provided by opendata.rmv.de"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_NEXT_DEPARTURE): [{
vol.Required(CONF_STATION): cv.string,
vol.Optional(CONF_DESTINATIONS, default=[]):
vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_DIRECTIONS, default=[]):
vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_LINES, default=[]):
vol.All(cv.ensure_list, [cv.positive_int, cv.string]),
vol.Optional(CONF_PRODUCTS, default=VALID_PRODUCTS):
vol.All(cv.ensure_list, [vol.In(VALID_PRODUCTS)]),
vol.Optional(CONF_TIME_OFFSET, default=0): cv.positive_int,
vol.Optional(CONF_MAX_JOURNEYS, default=5): cv.positive_int,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string}]
})
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the RMV departure sensor."""
sensors = []
for next_departure in config.get(CONF_NEXT_DEPARTURE):
sensors.append(
RMVDepartureSensor(
next_departure[CONF_STATION],
next_departure.get(CONF_DESTINATIONS),
next_departure.get(CONF_DIRECTIONS),
next_departure.get(CONF_LINES),
next_departure.get(CONF_PRODUCTS),
next_departure.get(CONF_TIME_OFFSET),
next_departure.get(CONF_MAX_JOURNEYS),
next_departure.get(CONF_NAME)))
add_entities(sensors, True)
class RMVDepartureSensor(Entity):
"""Implementation of an RMV departure sensor."""
def __init__(self, station, destinations, directions,
lines, products, time_offset, max_journeys, name):
"""Initialize the sensor."""
self._station = station
self._name = name
self._state = None
self.data = RMVDepartureData(station, destinations, directions, lines,
products, time_offset, max_journeys)
self._icon = ICONS[None]
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def available(self):
"""Return True if entity is available."""
return self._state is not None
@property
def state(self):
"""Return the next departure time."""
return self._state
@property
def state_attributes(self):
"""Return the state attributes."""
try:
return {
'next_departures': [val for val in self.data.departures[1:]],
'direction': self.data.departures[0].get('direction'),
'line': self.data.departures[0].get('line'),
'minutes': self.data.departures[0].get('minutes'),
'departure_time':
self.data.departures[0].get('departure_time'),
'product': self.data.departures[0].get('product'),
}
except IndexError:
return {}
@property
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return "min"
def update(self):
"""Get the latest data and update the state."""
self.data.update()
if not self.data.departures:
self._state = None
self._icon = ICONS[None]
return
if self._name == DEFAULT_NAME:
self._name = self.data.station
self._station = self.data.station
self._state = self.data.departures[0].get('minutes')
self._icon = ICONS[self.data.departures[0].get('product')]
class RMVDepartureData:
"""Pull data from the opendata.rmv.de web page."""
def __init__(self, station_id, destinations, directions,
lines, products, time_offset, max_journeys):
"""Initialize the sensor."""
import RMVtransport
self.station = None
self._station_id = station_id
self._destinations = destinations
self._directions = directions
self._lines = lines
self._products = products
self._time_offset = time_offset
self._max_journeys = max_journeys
self.rmv = RMVtransport.RMVtransport()
self.departures = []
def update(self):
"""Update the connection data."""
try:
_data = self.rmv.get_departures(self._station_id,
products=self._products,
maxJourneys=50)
except ValueError:
self.departures = []
_LOGGER.warning("Returned data not understood")
return
self.station = _data.get('station')
_deps = []
for journey in _data['journeys']:
# find the first departure meeting the criteria
_nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION}
if self._destinations:
dest_found = False
for dest in self._destinations:
if dest in journey['stops']:
dest_found = True
_nextdep['destination'] = dest
if not dest_found:
continue
elif self._lines and journey['number'] not in self._lines:
continue
elif journey['minutes'] < self._time_offset:
continue
for attr in ['direction', 'departure_time', 'product', 'minutes']:
_nextdep[attr] = journey.get(attr, '')
_nextdep['line'] = journey.get('number', '')
_deps.append(_nextdep)
if len(_deps) > self._max_journeys:
break
self.departures = _deps

View File

@ -47,6 +47,9 @@ PyMVGLive==1.1.4
# homeassistant.components.arduino
PyMata==2.14
# homeassistant.components.sensor.rmvtransport
PyRMVtransport==0.0.7
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.9.5

View File

@ -24,6 +24,9 @@ HAP-python==2.2.2
# homeassistant.components.notify.html5
PyJWT==1.6.0
# homeassistant.components.sensor.rmvtransport
PyRMVtransport==0.0.7
# homeassistant.components.sonos
SoCo==0.14

View File

@ -77,6 +77,7 @@ TEST_REQUIREMENTS = (
'pymonoprice',
'pynx584',
'pyqwikswitch',
'PyRMVtransport',
'python-forecastio',
'python-nest',
'pytradfri\[async\]',

View File

@ -0,0 +1,173 @@
"""The tests for the rmvtransport platform."""
import unittest
from unittest.mock import patch
import datetime
from homeassistant.setup import setup_component
from tests.common import get_test_home_assistant
VALID_CONFIG_MINIMAL = {'sensor': {'platform': 'rmvtransport',
'next_departure': [{'station': '3000010'}]}}
VALID_CONFIG_NAME = {'sensor': {
'platform': 'rmvtransport',
'next_departure': [
{
'station': '3000010',
'name': 'My Station',
}
]}}
VALID_CONFIG_MISC = {'sensor': {
'platform': 'rmvtransport',
'next_departure': [
{
'station': '3000010',
'lines': [21, 'S8'],
'max_journeys': 2,
'time_offset': 10
}
]}}
VALID_CONFIG_DEST = {'sensor': {
'platform': 'rmvtransport',
'next_departure': [
{
'station': '3000010',
'destinations': ['Frankfurt (Main) Flughafen Regionalbahnhof',
'Frankfurt (Main) Stadion']
}
]}}
def get_departuresMock(stationId, maxJourneys,
products): # pylint: disable=invalid-name
"""Mock rmvtransport departures loading."""
data = {'station': 'Frankfurt (Main) Hauptbahnhof',
'stationId': '3000010', 'filter': '11111111111', 'journeys': [
{'product': 'Tram', 'number': 12, 'trainId': '1123456',
'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
'departure_time': datetime.datetime(2018, 8, 6, 14, 21),
'minutes': 7, 'delay': 3, 'stops': [
'Frankfurt (Main) Willy-Brandt-Platz',
'Frankfurt (Main) Römer/Paulskirche',
'Frankfurt (Main) Börneplatz',
'Frankfurt (Main) Konstablerwache',
'Frankfurt (Main) Bornheim Mitte',
'Frankfurt (Main) Saalburg-/Wittelsbacherallee',
'Frankfurt (Main) Eissporthalle/Festplatz',
'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'],
'info': None, 'info_long': None,
'icon': 'https://products/32_pic.png'},
{'product': 'Bus', 'number': 21, 'trainId': '1234567',
'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
'departure_time': datetime.datetime(2018, 8, 6, 14, 22),
'minutes': 8, 'delay': 1, 'stops': [
'Frankfurt (Main) Weser-/Münchener Straße',
'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'],
'info': None, 'info_long': None,
'icon': 'https://products/32_pic.png'},
{'product': 'Bus', 'number': 12, 'trainId': '1234568',
'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
'minutes': 11, 'delay': 1, 'stops': [
'Frankfurt (Main) Stadion'],
'info': None, 'info_long': None,
'icon': 'https://products/32_pic.png'},
{'product': 'Bus', 'number': 21, 'trainId': '1234569',
'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
'minutes': 11, 'delay': 1, 'stops': [],
'info': None, 'info_long': None,
'icon': 'https://products/32_pic.png'},
{'product': 'Bus', 'number': 12, 'trainId': '1234570',
'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
'minutes': 11, 'delay': 1, 'stops': [],
'info': None, 'info_long': None,
'icon': 'https://products/32_pic.png'},
{'product': 'Bus', 'number': 21, 'trainId': '1234571',
'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
'minutes': 11, 'delay': 1, 'stops': [],
'info': None, 'info_long': None,
'icon': 'https://products/32_pic.png'}
]}
return data
def get_errDeparturesMock(stationId, maxJourneys,
products): # pylint: disable=invalid-name
"""Mock rmvtransport departures erroneous loading."""
raise ValueError
class TestRMVtransportSensor(unittest.TestCase):
"""Test the rmvtransport sensor."""
def setUp(self):
"""Set up things to run when tests begin."""
self.hass = get_test_home_assistant()
self.config = VALID_CONFIG_MINIMAL
self.reference = {}
self.entities = []
def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
@patch('RMVtransport.RMVtransport.get_departures',
side_effect=get_departuresMock)
def test_rmvtransport_min_config(self, mock_get_departures):
"""Test minimal rmvtransport configuration."""
assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL)
state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof')
self.assertEqual(state.state, '7')
self.assertEqual(state.attributes['departure_time'],
datetime.datetime(2018, 8, 6, 14, 21))
self.assertEqual(state.attributes['direction'],
'Frankfurt (Main) Hugo-Junkers-Straße/Schleife')
self.assertEqual(state.attributes['product'], 'Tram')
self.assertEqual(state.attributes['line'], 12)
self.assertEqual(state.attributes['icon'], 'mdi:tram')
self.assertEqual(state.attributes['friendly_name'],
'Frankfurt (Main) Hauptbahnhof')
@patch('RMVtransport.RMVtransport.get_departures',
side_effect=get_departuresMock)
def test_rmvtransport_name_config(self, mock_get_departures):
"""Test custom name configuration."""
assert setup_component(self.hass, 'sensor', VALID_CONFIG_NAME)
state = self.hass.states.get('sensor.my_station')
self.assertEqual(state.attributes['friendly_name'], 'My Station')
@patch('RMVtransport.RMVtransport.get_departures',
side_effect=get_errDeparturesMock)
def test_rmvtransport_err_config(self, mock_get_departures):
"""Test erroneous rmvtransport configuration."""
assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL)
@patch('RMVtransport.RMVtransport.get_departures',
side_effect=get_departuresMock)
def test_rmvtransport_misc_config(self, mock_get_departures):
"""Test misc configuration."""
assert setup_component(self.hass, 'sensor', VALID_CONFIG_MISC)
state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof')
self.assertEqual(state.attributes['friendly_name'],
'Frankfurt (Main) Hauptbahnhof')
self.assertEqual(state.attributes['line'], 21)
@patch('RMVtransport.RMVtransport.get_departures',
side_effect=get_departuresMock)
def test_rmvtransport_dest_config(self, mock_get_departures):
"""Test misc configuration."""
assert setup_component(self.hass, 'sensor', VALID_CONFIG_DEST)
state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof')
self.assertEqual(state.state, '11')
self.assertEqual(state.attributes['direction'],
'Frankfurt (Main) Hugo-Junkers-Straße/Schleife')
self.assertEqual(state.attributes['line'], 12)
self.assertEqual(state.attributes['minutes'], 11)
self.assertEqual(state.attributes['departure_time'],
datetime.datetime(2018, 8, 6, 14, 25))