-
-
Notifications
You must be signed in to change notification settings - Fork 37.8k
Add RMV public transport sensor #15814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
c3f61e0
b9b59aa
41c34ea
f9e88b6
221167e
504049b
db76dd0
2d9c122
485ae89
6fcc48d
741a711
1403293
f67f22e
74117c3
2accb78
661bad1
26f1227
b714364
8be8b07
d21cec3
4341b8a
72c3b7e
9f29d30
844c439
cacfd46
a9949a4
1642b47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| """ | ||
| 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.rmvdeparture/ | ||
| """ | ||
| import logging | ||
| from datetime import timedelta | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 'datetime.timedelta' imported but unused |
||
|
|
||
| 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, DEVICE_CLASS_TIMESTAMP, STATE_UNKNOWN | ||
| ) | ||
|
|
||
| REQUIREMENTS = ['PyRMVtransport==0.0.6'] | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| CONF_NEXT_DEPARTURE = 'nextdeparture' | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CONF_NEXT_DEPARTURE = 'next_departure' |
||
|
|
||
| CONF_STATION = 'station' | ||
| CONF_DESTINATIONS = 'destinations' | ||
| CONF_DIRECTIONS = 'directions' | ||
| CONF_LINES = 'lines' | ||
| CONF_PRODUCTS = 'products' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| CONF_TIMEOFFSET = 'timeoffset' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it not explicit enough or is the documentation lacking detailed information? |
||
| CONF_MAXJOURNEYS = 'max' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're correct. I added it to the documentation. |
||
|
|
||
| DEFAULT_NAME = 'RMV Journey' | ||
|
|
||
| VALID_PRODUCTS = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think that is right. I used following code to test If config validation script has issue, you may need look at that script
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It must be a dictionary not a list. As far as I can see it is working as expected. vol.Optional(CONF_PRODUCTS, default=DEFAULT_PRODUCT):
vol.All(cv.ensure_list, [vol.In(VALID_PRODUCTS)]),which uses VALID_PRODUCTS = {'U-Bahn': ['U-Bahn'], 'Tram': ['Tram'], 'Bus': ['Bus'], 'S': ['S'], 'RB': ['RB'], 'RE': ['RE'], 'EC': ['EC'], 'IC': ['IC'], 'ICE': ['ICE']}
DEFAULT_PRODUCT = list(VALID_PRODUCTS.keys()) |
||
| 'U-Bahn': ['U-Bahn'], | ||
| 'Tram': ['Tram'], | ||
| 'Bus': ['Bus'], | ||
| 'S': ['S'], | ||
| 'RB': ['RB'], | ||
| 'RE': ['RE'], | ||
| 'EC': ['EC'], | ||
| 'IC': ['IC'], | ||
| 'ICE': ['ICE'] | ||
| } | ||
| DEFAULT_PRODUCT = list(VALID_PRODUCTS.keys()) | ||
|
|
||
| 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" | ||
|
|
||
| SCAN_INTERVAL = timedelta(seconds=30) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to update more frequently than once a minute? The unit of measurement is minutes.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're probably right. Every minute should be sufficient. 30 seconds was simply to make sure not to query just before the values actually get updated and therefore miss "the bus". Does that make sense?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, you decide. |
||
|
|
||
| PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ | ||
| vol.Required(CONF_NEXT_DEPARTURE): [{ | ||
| vol.Required(CONF_STATION): cv.string, | ||
| vol.Optional(CONF_DESTINATIONS, default=['']): cv.ensure_list_csv, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We shouldn't use list csv. Just ensure list. List csv is only to allow non breaking change when migrating config schemas. Default to empty list instead. |
||
| vol.Optional(CONF_DIRECTIONS, default=['']): cv.ensure_list_csv, | ||
| vol.Optional(CONF_LINES, default=['']): cv.ensure_list_csv, | ||
| vol.Optional(CONF_PRODUCTS, default=DEFAULT_PRODUCT): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better validate each product user configured is in the default product list
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I fixed that. Thanks for the hint. |
||
| vol.All(cv.ensure_list, [vol.In(VALID_PRODUCTS)]), | ||
| vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, | ||
| vol.Optional(CONF_MAXJOURNEYS, default=5): cv.positive_int, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do we call this in other sensor commuting platforms?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok. It would be good to come up with a descriptive name and then have all platforms use the same term. But it can wait to a different PR. But please separate words by underscore in variable and constant names and strings. So change to |
||
| 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 nextdeparture in config.get(CONF_NEXT_DEPARTURE): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for next_departure in ... |
||
| sensors.append( | ||
| RMVDepartureSensor( | ||
| nextdeparture.get(CONF_STATION), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't use |
||
| nextdeparture.get(CONF_DESTINATIONS), | ||
| nextdeparture.get(CONF_DIRECTIONS), | ||
| nextdeparture.get(CONF_LINES), | ||
| nextdeparture.get(CONF_PRODUCTS), | ||
| nextdeparture.get(CONF_TIMEOFFSET), | ||
| nextdeparture.get(CONF_MAXJOURNEYS), | ||
| nextdeparture.get(CONF_NAME))) | ||
| add_entities(sensors, True) | ||
|
|
||
|
|
||
| class RMVDepartureSensor(Entity): | ||
| """Implementation of an RMV departure sensor.""" | ||
|
|
||
| def __init__(self, station, destinations, directions, | ||
| lines, products, timeoffset, maxjourneys, name): | ||
| """Initialize the sensor.""" | ||
| self._station = station | ||
| self._name = name | ||
| self.data = RMVDepartureData(station, destinations, directions, | ||
| lines, products, timeoffset, maxjourneys) | ||
| self._state = STATE_UNKNOWN | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Init state as |
||
| 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 != STATE_UNKNOWN | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't use return self._state is not NoneIs there no use to differ between unknown state and unavailable?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll check that. |
||
|
|
||
| @property | ||
| def state(self): | ||
| """Return the next departure time.""" | ||
| self._state = self.data.departures[0].get('departure_time', None) | ||
| return self._state | ||
|
|
||
| @property | ||
| def state_attributes(self): | ||
| """Return the state attributes.""" | ||
| result = {} | ||
| try: | ||
| result = { | ||
| 'next_departures': [val for val in self.data.departures[1:]], | ||
| 'direction': self.data.departures[0].get('direction'), | ||
| 'line': self.data.departures[0].get('line'), | ||
| 'departure_time': | ||
| self.data.departures[0].get('departure_time'), | ||
| 'product': self.data.departures[0].get('product'), | ||
| } | ||
| except IndexError: | ||
| pass | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could return empty dict here instead of defining it at the top. |
||
| return result | ||
|
|
||
| @property | ||
| def icon(self): | ||
| """Icon to use in the frontend, if any.""" | ||
| return self._icon | ||
|
|
||
| @property | ||
| def device_class(self): | ||
| """Return the device class of the sensor.""" | ||
| return DEVICE_CLASS_TIMESTAMP | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair enough. |
||
| # 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('departure_time', None) | ||
| self._icon = ICONS[self.data.departures[0].get('product', None)] | ||
| return | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not needed return.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So true. :-) |
||
|
|
||
|
|
||
| class RMVDepartureData: | ||
| """Pull data from the opendata.rmv.de web page.""" | ||
|
|
||
| def __init__(self, station_id, destinations, directions, | ||
| lines, products, timeoffset, maxjourneys): | ||
| """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._timeoffset = timeoffset | ||
| self._maxjourneys = maxjourneys | ||
| 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 = {} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We set this as a list in init. We probably shouldn't change the type.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well spotted. Not sure when that happened. ;)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I rather initialise as a dict.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But we use |
||
| _LOGGER.warning("Returned data not understood") | ||
| return | ||
| self.station = _data.get('station', None) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| _deps = [] | ||
| for journey in _data['journeys']: | ||
| # find the first departure meeting the criteria | ||
| _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION} | ||
| if '' not in self._destinations[:1]: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just do: 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 ('' not in self._lines[:1] and | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. elif self._lines and journey['number'] not in self._lines: |
||
| journey['number'] not in self._lines): | ||
| continue | ||
| elif journey['minutes'] < self._timeoffset: | ||
| continue | ||
| for k in ['direction', 'departure_time', 'product']: | ||
| _nextdep[k] = journey.get(k, '') | ||
| _nextdep['line'] = journey.get('number', '') | ||
| _deps.append(_nextdep) | ||
| if len(_deps) > self._maxjourneys: | ||
| break | ||
| self.departures = _deps | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The url is wrong. It should match the module name.