"""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 predictions = list(chain(*( listify(direction.get('prediction', [])) for direction in directions ))) # 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()