mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 21:27:38 +00:00
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.
This commit is contained in:
parent
81604a9326
commit
055e35b297
202
homeassistant/components/sensor/rmvtransport.py
Normal file
202
homeassistant/components/sensor/rmvtransport.py
Normal 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
|
@ -47,6 +47,9 @@ PyMVGLive==1.1.4
|
|||||||
# homeassistant.components.arduino
|
# homeassistant.components.arduino
|
||||||
PyMata==2.14
|
PyMata==2.14
|
||||||
|
|
||||||
|
# homeassistant.components.sensor.rmvtransport
|
||||||
|
PyRMVtransport==0.0.7
|
||||||
|
|
||||||
# homeassistant.components.xiaomi_aqara
|
# homeassistant.components.xiaomi_aqara
|
||||||
PyXiaomiGateway==0.9.5
|
PyXiaomiGateway==0.9.5
|
||||||
|
|
||||||
|
@ -24,6 +24,9 @@ HAP-python==2.2.2
|
|||||||
# homeassistant.components.notify.html5
|
# homeassistant.components.notify.html5
|
||||||
PyJWT==1.6.0
|
PyJWT==1.6.0
|
||||||
|
|
||||||
|
# homeassistant.components.sensor.rmvtransport
|
||||||
|
PyRMVtransport==0.0.7
|
||||||
|
|
||||||
# homeassistant.components.sonos
|
# homeassistant.components.sonos
|
||||||
SoCo==0.14
|
SoCo==0.14
|
||||||
|
|
||||||
|
@ -77,6 +77,7 @@ TEST_REQUIREMENTS = (
|
|||||||
'pymonoprice',
|
'pymonoprice',
|
||||||
'pynx584',
|
'pynx584',
|
||||||
'pyqwikswitch',
|
'pyqwikswitch',
|
||||||
|
'PyRMVtransport',
|
||||||
'python-forecastio',
|
'python-forecastio',
|
||||||
'python-nest',
|
'python-nest',
|
||||||
'pytradfri\[async\]',
|
'pytradfri\[async\]',
|
||||||
|
173
tests/components/sensor/test_rmvtransport.py
Normal file
173
tests/components/sensor/test_rmvtransport.py
Normal 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))
|
Loading…
x
Reference in New Issue
Block a user