From 0596d35de638d7114f6a7f19ce2e5c5b5ee78e38 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Fri, 10 Aug 2018 19:35:09 +0200 Subject: [PATCH] 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. --- .../components/sensor/rmvtransport.py | 202 ++++++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_rmvtransport.py | 173 +++++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 homeassistant/components/sensor/rmvtransport.py create mode 100644 tests/components/sensor/test_rmvtransport.py diff --git a/homeassistant/components/sensor/rmvtransport.py b/homeassistant/components/sensor/rmvtransport.py new file mode 100644 index 00000000000000..3d7fd2aa3b70a9 --- /dev/null +++ b/homeassistant/components/sensor/rmvtransport.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index e9a68467f75eee..f6452096ff8486 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 731897e71b03c5..0b695ae97fb992 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 28c96e737ff171..7652d29086b227 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -77,6 +77,7 @@ 'pymonoprice', 'pynx584', 'pyqwikswitch', + 'PyRMVtransport', 'python-forecastio', 'python-nest', 'pytradfri\[async\]', diff --git a/tests/components/sensor/test_rmvtransport.py b/tests/components/sensor/test_rmvtransport.py new file mode 100644 index 00000000000000..9db19ecde499ed --- /dev/null +++ b/tests/components/sensor/test_rmvtransport.py @@ -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))