2019-04-26 17:51:58 -07:00
|
|
|
"""NextBus sensor."""
|
|
|
|
import logging
|
|
|
|
from itertools import chain
|
|
|
|
|
|
|
|
import voluptuous as vol
|
|
|
|
|
|
|
|
import homeassistant.helpers.config_validation as cv
|
|
|
|
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
|
|
|
from homeassistant.const import CONF_NAME
|
|
|
|
from homeassistant.const import DEVICE_CLASS_TIMESTAMP
|
|
|
|
from homeassistant.helpers.entity import Entity
|
|
|
|
from homeassistant.util.dt import utc_from_timestamp
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
DOMAIN = 'nextbus'
|
|
|
|
|
|
|
|
CONF_AGENCY = 'agency'
|
|
|
|
CONF_ROUTE = 'route'
|
|
|
|
CONF_STOP = 'stop'
|
|
|
|
|
|
|
|
ICON = 'mdi:bus'
|
|
|
|
|
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
|
|
|
vol.Required(CONF_AGENCY): cv.string,
|
|
|
|
vol.Required(CONF_ROUTE): cv.string,
|
|
|
|
vol.Required(CONF_STOP): cv.string,
|
|
|
|
vol.Optional(CONF_NAME): cv.string,
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
def listify(maybe_list):
|
|
|
|
"""Return list version of whatever value is passed in.
|
|
|
|
|
|
|
|
This is used to provide a consistent way of interacting with the JSON
|
|
|
|
results from the API. There are several attributes that will either missing
|
|
|
|
if there are no values, a single dictionary if there is only one value, and
|
|
|
|
a list if there are multiple.
|
|
|
|
"""
|
|
|
|
if maybe_list is None:
|
|
|
|
return []
|
|
|
|
if isinstance(maybe_list, list):
|
|
|
|
return maybe_list
|
|
|
|
return [maybe_list]
|
|
|
|
|
|
|
|
|
|
|
|
def maybe_first(maybe_list):
|
|
|
|
"""Return the first item out of a list or returns back the input."""
|
|
|
|
if isinstance(maybe_list, list) and maybe_list:
|
|
|
|
return maybe_list[0]
|
|
|
|
|
|
|
|
return maybe_list
|
|
|
|
|
|
|
|
|
|
|
|
def validate_value(value_name, value, value_list):
|
|
|
|
"""Validate tag value is in the list of items and logs error if not."""
|
|
|
|
valid_values = {
|
|
|
|
v['tag']: v['title']
|
|
|
|
for v in value_list
|
|
|
|
}
|
|
|
|
if value not in valid_values:
|
|
|
|
_LOGGER.error(
|
|
|
|
'Invalid %s tag `%s`. Please use one of the following: %s',
|
|
|
|
value_name,
|
|
|
|
value,
|
|
|
|
', '.join(
|
|
|
|
'{}: {}'.format(title, tag)
|
|
|
|
for tag, title in valid_values.items()
|
|
|
|
)
|
|
|
|
)
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def validate_tags(client, agency, route, stop):
|
|
|
|
"""Validate provided tags."""
|
|
|
|
# Validate agencies
|
|
|
|
if not validate_value(
|
|
|
|
'agency',
|
|
|
|
agency,
|
|
|
|
client.get_agency_list()['agency'],
|
|
|
|
):
|
|
|
|
return False
|
|
|
|
|
|
|
|
# Validate the route
|
|
|
|
if not validate_value(
|
|
|
|
'route',
|
|
|
|
route,
|
|
|
|
client.get_route_list(agency)['route'],
|
|
|
|
):
|
|
|
|
return False
|
|
|
|
|
|
|
|
# Validate the stop
|
|
|
|
route_config = client.get_route_config(route, agency)['route']
|
|
|
|
if not validate_value(
|
|
|
|
'stop',
|
|
|
|
stop,
|
|
|
|
route_config['stop'],
|
|
|
|
):
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
|
|
"""Load values from configuration and initialize the platform."""
|
|
|
|
agency = config[CONF_AGENCY]
|
|
|
|
route = config[CONF_ROUTE]
|
|
|
|
stop = config[CONF_STOP]
|
|
|
|
name = config.get(CONF_NAME)
|
|
|
|
|
|
|
|
from py_nextbus import NextBusClient
|
|
|
|
client = NextBusClient(output_format='json')
|
|
|
|
|
|
|
|
# Ensures that the tags provided are valid, also logs out valid values
|
|
|
|
if not validate_tags(client, agency, route, stop):
|
|
|
|
_LOGGER.error('Invalid config value(s)')
|
|
|
|
return
|
|
|
|
|
|
|
|
add_entities([
|
|
|
|
NextBusDepartureSensor(
|
|
|
|
client,
|
|
|
|
agency,
|
|
|
|
route,
|
|
|
|
stop,
|
|
|
|
name,
|
|
|
|
),
|
|
|
|
], True)
|
|
|
|
|
|
|
|
|
|
|
|
class NextBusDepartureSensor(Entity):
|
|
|
|
"""Sensor class that displays upcoming NextBus times.
|
|
|
|
|
|
|
|
To function, this requires knowing the agency tag as well as the tags for
|
|
|
|
both the route and the stop.
|
|
|
|
|
|
|
|
This is possibly a little convoluted to provide as it requires making a
|
|
|
|
request to the service to get these values. Perhaps it can be simplifed in
|
|
|
|
the future using fuzzy logic and matching.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, client, agency, route, stop, name=None):
|
|
|
|
"""Initialize sensor with all required config."""
|
|
|
|
self.agency = agency
|
|
|
|
self.route = route
|
|
|
|
self.stop = stop
|
|
|
|
self._custom_name = name
|
|
|
|
# Maybe pull a more user friendly name from the API here
|
|
|
|
self._name = '{} {}'.format(agency, route)
|
|
|
|
self._client = client
|
|
|
|
|
|
|
|
# set up default state attributes
|
|
|
|
self._state = None
|
|
|
|
self._attributes = {}
|
|
|
|
|
|
|
|
def _log_debug(self, message, *args):
|
|
|
|
"""Log debug message with prefix."""
|
|
|
|
_LOGGER.debug(':'.join((
|
|
|
|
self.agency,
|
|
|
|
self.route,
|
|
|
|
self.stop,
|
|
|
|
message,
|
|
|
|
)), *args)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self):
|
|
|
|
"""Return sensor name.
|
|
|
|
|
|
|
|
Uses an auto generated name based on the data from the API unless a
|
|
|
|
custom name is provided in the configuration.
|
|
|
|
"""
|
|
|
|
if self._custom_name:
|
|
|
|
return self._custom_name
|
|
|
|
|
|
|
|
return self._name
|
|
|
|
|
|
|
|
@property
|
|
|
|
def device_class(self):
|
|
|
|
"""Return the device class."""
|
|
|
|
return DEVICE_CLASS_TIMESTAMP
|
|
|
|
|
|
|
|
@property
|
|
|
|
def state(self):
|
|
|
|
"""Return current state of the sensor."""
|
|
|
|
return self._state
|
|
|
|
|
|
|
|
@property
|
|
|
|
def device_state_attributes(self):
|
|
|
|
"""Return additional state attributes."""
|
|
|
|
return self._attributes
|
|
|
|
|
|
|
|
@property
|
|
|
|
def icon(self):
|
|
|
|
"""Return icon to be used for this sensor."""
|
|
|
|
# Would be nice if we could determine if the line is a train or bus
|
|
|
|
# however that doesn't seem to be available to us. Using bus for now.
|
|
|
|
return ICON
|
|
|
|
|
|
|
|
def update(self):
|
|
|
|
"""Update sensor with new departures times."""
|
|
|
|
# Note: using Multi because there is a bug with the single stop impl
|
|
|
|
results = self._client.get_predictions_for_multi_stops(
|
|
|
|
[{
|
|
|
|
'stop_tag': int(self.stop),
|
|
|
|
'route_tag': self.route,
|
|
|
|
}],
|
|
|
|
self.agency,
|
|
|
|
)
|
|
|
|
|
|
|
|
self._log_debug('Predictions results: %s', results)
|
|
|
|
|
|
|
|
if 'Error' in results:
|
|
|
|
self._log_debug('Could not get predictions: %s', results)
|
|
|
|
|
|
|
|
if not results.get('predictions'):
|
|
|
|
self._log_debug('No predictions available')
|
|
|
|
self._state = None
|
|
|
|
# Remove attributes that may now be outdated
|
|
|
|
self._attributes.pop('upcoming', None)
|
|
|
|
return
|
|
|
|
|
|
|
|
results = results['predictions']
|
|
|
|
|
|
|
|
# Set detailed attributes
|
|
|
|
self._attributes.update({
|
|
|
|
'agency': results.get('agencyTitle'),
|
|
|
|
'route': results.get('routeTitle'),
|
|
|
|
'stop': results.get('stopTitle'),
|
|
|
|
})
|
|
|
|
|
|
|
|
# List all messages in the attributes
|
|
|
|
messages = listify(results.get('message', []))
|
|
|
|
self._log_debug('Messages: %s', messages)
|
|
|
|
self._attributes['message'] = ' -- '.join((
|
|
|
|
message.get('text', '')
|
|
|
|
for message in messages
|
|
|
|
))
|
|
|
|
|
|
|
|
# List out all directions in the attributes
|
|
|
|
directions = listify(results.get('direction', []))
|
|
|
|
self._attributes['direction'] = ', '.join((
|
|
|
|
direction.get('title', '')
|
|
|
|
for direction in directions
|
|
|
|
))
|
|
|
|
|
|
|
|
# Chain all predictions together
|
2019-07-19 20:36:18 +00:00
|
|
|
predictions = list(chain(*(
|
2019-04-26 17:51:58 -07:00
|
|
|
listify(direction.get('prediction', []))
|
|
|
|
for direction in directions
|
2019-07-19 20:36:18 +00:00
|
|
|
)))
|
2019-04-26 17:51:58 -07:00
|
|
|
|
|
|
|
# Short circuit if we don't have any actual bus predictions
|
|
|
|
if not predictions:
|
|
|
|
self._log_debug('No upcoming predictions available')
|
|
|
|
self._state = None
|
|
|
|
self._attributes['upcoming'] = 'No upcoming predictions'
|
|
|
|
return
|
|
|
|
|
|
|
|
# Generate list of upcoming times
|
|
|
|
self._attributes['upcoming'] = ', '.join(
|
|
|
|
p['minutes'] for p in predictions
|
|
|
|
)
|
|
|
|
|
|
|
|
latest_prediction = maybe_first(predictions)
|
|
|
|
self._state = utc_from_timestamp(
|
|
|
|
int(latest_prediction['epochTime']) / 1000
|
|
|
|
).isoformat()
|