From 2fea802e62571f7c85efb7967b0755222e4cc706 Mon Sep 17 00:00:00 2001 From: Dimitar Kukov Date: Fri, 2 Aug 2019 20:41:38 +0300 Subject: [PATCH] Initial commit --- README.md | 140 ++++++++ .../varna_public_transport/__init__.py | 1 + .../varna_public_transport/manifest.json | 8 + .../varna_public_transport/sensor.py | 301 ++++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 custom_components/varna_public_transport/__init__.py create mode 100644 custom_components/varna_public_transport/manifest.json create mode 100644 custom_components/varna_public_transport/sensor.py diff --git a/README.md b/README.md index 602b805..75928f8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,142 @@ # VPT Varna Public Transport custom component for Home Assistant + +This custom component holds data for city of Varna, Bulgaria public transport card. +The information for bus stop is scraped from https://varnatraffic.com website + +You can find the card at [https://github.com/Serios/VPT-Card](https://github.com/Serios/VPT-Card). + +### Installation +Download the files from custom_components/varna_public_transport into your +$homeassistant_config_dir/custom_components/varna_public_transport + +Once downloaded and configured as per below information you'll need to restart HomeAssistant to have the custom component and the sensors platform loaded. + +### Configuration + +Define the sensor in your yaml file with the following configuration parameters: + +| Name | Type | | Default | Description | +|------|------|---------|---------|-------------| +| platform | string | **required** | `varna_public_transport` | | +| stopId | string | **required** | | This is the bus stop id. See [How to get bus stop id](#getting-bus-stop-id) | +| stopName | string | optional | | What is the name of the stop. This will be shown as card title | +| show_mode | string | optional | all | What data to be scraped/shown in the card. See [Showing information](#showing-different-types-of-information) | +| max_schedule | int | optional | 10 | Numer of shedules times to be returned. Used in Schedules information. See [Showing information](#showing-different-types-of-information) | +| interval | int | optional | 30 | How offten the sensor shoud scrape for data (in seconds). Note: It is not advisible to put value bellow 10 | +| monitored_lines | list | optional | | Which lines, comming trough the bus stop, you want to track. See [Showing information](#showing-different-types-of-information) | + +Example of basic config + +```yaml +- platform: varna_public_transport + stopId: '553' + stopName: 'Historical Museum' +``` + +![VPT-Card Lovelace example](https://github.com/Serios/VPT-Card/blob/master/vpt_card_preview.jpg) + +###Showing different types of information +By defalut the sensor will return all data scraped for the bus stop. This include: +* live data - lines comming to the bus stop at this moment provided by vehicle tracker device. See bellow what kind of information this data contains. +* schedules - Schedule times for diffrent lines comming to the stop. See bellow what kind of information this data contains. + +By setting `show_mode` property you can control what data is scraped/returned. Available options are: +`all` - returns both live data and schedules +`live` - returns only live data +`schedule` - returns only schedule times + +#### Live data +This contains: +Vehicle type - Bus or Trolley +Line number +Next schedule time - at which the vehicle should be on the stop +Vehicle delay - from the schedule time at which ariving to the stop +Vehicle extras - like airconditioning, wheelcheer access, etc. +Minutes left = before vehicle arrival at the stop based on the delay from schedule, +Distance of the vehicle - distance left to the bus stop. + +Showing live data will use `interval` option to scrape https://varnatraffic.com website each N seconds for data + +#### Schedule times +This contains: +Line number +Next schedule times - The times (ahead from last scrape) at which the vehicles on this lines should be on the stop. + +Showing `schedule` only don't use `interval` option. Instead the data is scraped on predefined time intervals based on `max_schedule` option value. Thus reducing request to https://varnatraffic.com website to only a couple a day + +#### Limiting the number of lines for wich information is returned (both Live and Schedule) +By default the sensor will return data for all the lines that are comming to the bus stop. If you don't want that, and need only data for some lines, to be returned, you can specify the line numbers. +The syntax is simple: + +```yaml + monitored_lines: + - 41 + - 148 +``` +Important!!! +There are some special line numbers for which https://varnatraffic.com website assign diffrent number than the actual one shown on the bus and use some internal logic to track and show data. Bellow you will find the list with the line number and the actual line number you need to set, if you want to track this line. +| Line number (shown on the bus) | Internal number (which you must set) | +|----|------| +| 17a | 117 | +| 18a | 1018 | +| 31a | 131 | +| 118a | 1118 | +| 209b | 219 | + + +#### Limiting the number of schedule times +Returning all times on which the vehicle should be on the stop trough the day is nonsence. This will overflow the sensor/card with data, thus by default this is set to 10 results. You can increase/decrease thi value by your likings. + +###Some config examples + +Basic config: +```yaml +- platform: varna_public_transport + stopId: '553' + stopName: 'Historical Museum' +``` + +Basic config with data scrape every 3 minutes (3 x 60 sec): +```yaml +- platform: varna_public_transport + stopId: '553' + stopName: 'Historical Museum' + interval: 180 +``` + +Show only live data: +```yaml +- platform: varna_public_transport + stopId: '553' + stopName: 'Historical Museum' + show_mode: 'live' +``` + +Show only schedule data: +```yaml +- platform: varna_public_transport + stopId: '553' + stopName: 'Historical Museum' + show_mode: 'schedule' +``` + +Show only schedule data and limit returned times by 5: +```yaml +- platform: varna_public_transport + stopId: '553' + stopName: 'Historical Museum' + show_mode: 'schedule' + max_schedule: 5 +``` + +Show data only for lines 22, 41, 31a +```yaml +- platform: varna_public_transport + stopId: '553' + stopName: 'Historical Museum' + monitored_lines: + - 22 + - 41 + - 131 +``` diff --git a/custom_components/varna_public_transport/__init__.py b/custom_components/varna_public_transport/__init__.py new file mode 100644 index 0000000..0a8d009 --- /dev/null +++ b/custom_components/varna_public_transport/__init__.py @@ -0,0 +1 @@ +"""The varna_public_transport component.""" diff --git a/custom_components/varna_public_transport/manifest.json b/custom_components/varna_public_transport/manifest.json new file mode 100644 index 0000000..90d3064 --- /dev/null +++ b/custom_components/varna_public_transport/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "varna_public_transport", + "name": "Varna Public Transport", + "documentation": "https://github.com/Serios/VPT/", + "requirements": [], + "dependencies": [], + "codeowners": ["@Serios"] +} diff --git a/custom_components/varna_public_transport/sensor.py b/custom_components/varna_public_transport/sensor.py new file mode 100644 index 0000000..3962585 --- /dev/null +++ b/custom_components/varna_public_transport/sensor.py @@ -0,0 +1,301 @@ +""" +VarnaTrafik sensor to query events for a specified stop. +""" +import asyncio +import logging +from datetime import timedelta, datetime +import requests +import json +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +REQUIREMENTS = [ ] + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://varnatraffic.com/Ajax/FindStationDevices' + +CONF_ATTRIBUTION = "Data provided by Varna Traffic" +CONF_STOP_ID = 'stopId' +CONF_STOP_NAME = 'stopName' +CONF_SHOW_MODE = 'show_mode' +CONF_SCHEDULE_MAX = 'max_schedule' +CONF_INTERVAL = 'interval' +CONF_LINES = 'monitored_lines' + +DEFAULT_NAME = "Varna Public Transport" +DEFAULT_ICON = "mdi:bus" + +#SCAN_INTERVAL = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STOP_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STOP_NAME, default='Bus Stop'): cv.string, + vol.Optional(CONF_SHOW_MODE, default='all'): cv.string, + vol.Optional(CONF_SCHEDULE_MAX, default=10): cv.positive_int, + vol.Optional(CONF_INTERVAL, default=30): cv.positive_int, + vol.Optional(CONF_LINES, default=None): vol.All(cv.ensure_list, [cv.positive_int]), +}) + +@asyncio.coroutine +async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + + name = config.get(CONF_NAME) + stopid = config.get(CONF_STOP_ID) + stopname = config.get(CONF_STOP_NAME) + show_mode = config.get(CONF_SHOW_MODE) + max_schedule = config.get(CONF_SCHEDULE_MAX) + monitored_lines = config.get(CONF_LINES) + + session = async_get_clientsession(hass) + dev = [] + dev.append(VarnaTrafikTransportSensor(name, stopid, stopname, show_mode, max_schedule, hass, monitored_lines)) + async_add_devices(dev,update_before_add=True) + + if config.get(CONF_INTERVAL) < 10: + interval = 10 + else: + interval = config.get(CONF_INTERVAL) + + data = VarnaTrafikTransportSensorData(hass, stopid, dev, interval, show_mode, max_schedule) + # schedule the first update in 1 second from now - initial run: + await data.schedule_update(1) + +class VarnaTrafikTransportSensor(Entity): + """Implementation of an Varnatrafik sensor.""" + + def __init__(self, name, stopid, stopname, show_mode, max_schedule, hass, monitored_lines): + """Initialize the sensor.""" + self._name = name + self._stopid = stopid + self._stopname = stopname + self._mode = show_mode + self._schedule_max = max_schedule + self._state = None + self._data = None + self._icon = DEFAULT_ICON + self._hass = hass + self._monitored_lines = monitored_lines + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + attr["StopName"] = self._stopname + if self._data is not None: + if self._mode == 'live' or self._mode == 'all': + lines_count = len(self._data["liveData"]) + #Grab all bus lines nodes from live data if there are any + if lines_count != 0: + attr['stop_lines'] = {} + i = 0 + k = 0 + while i < lines_count: + if len(self._monitored_lines) != 0: + + if self._data["liveData"][i]["line"] in self._monitored_lines: + attr['stop_lines'].setdefault('line_' + str(k), {}) + attr['stop_lines']['line_' + str(k)]['LineNumber'] = self._data["liveData"][i]["line"] + attr['stop_lines']['line_' + str(k)]['arrivalTime'] = self._data["liveData"][i]["arriveTime"] + attr['stop_lines']['line_' + str(k)]['distanceLeft'] = self._data["liveData"][i]["distanceLeft"] + if 'arriveIn' in self._data["liveData"][i]: + arriveIn = self._data["liveData"][i]["arriveIn"] + else: + arriveIn = '' + attr['stop_lines']['line_' + str(k)]['arriveIn'] = arriveIn + + if 'delay' in self._data["liveData"][i]: + delay = self._data["liveData"][i]["delay"] + else: + delay = '' + attr['stop_lines']['line_' + str(k)]['delay'] = delay + attr['stop_lines']['line_' + str(k)]['vehicleKind'] = self._data["liveData"][i]["deviceKind"] + attr['stop_lines']['line_' + str(k)]['vehicleExtras'] = self._data["liveData"][i]["extrasFlags"] + k +=1 + attr['lines'] = len(attr['stop_lines']) + else: + attr['stop_lines'].setdefault('line_' + str(i), {}) + attr['stop_lines']['line_' + str(i)]['LineNumber'] = self._data["liveData"][i]["line"] + attr['stop_lines']['line_' + str(i)]['arrivalTime'] = self._data["liveData"][i]["arriveTime"] + attr['stop_lines']['line_' + str(i)]['distanceLeft'] = self._data["liveData"][i]["distanceLeft"] + if 'arriveIn' in self._data["liveData"][i]: + arriveIn = self._data["liveData"][i]["arriveIn"] + else: + arriveIn = '' + attr['stop_lines']['line_' + str(i)]['arriveIn'] = arriveIn + + if 'delay' in self._data["liveData"][i]: + delay = self._data["liveData"][i]["delay"] + else: + delay = '' + attr['stop_lines']['line_' + str(i)]['delay'] = delay + attr['stop_lines']['line_' + str(i)]['vehicleKind'] = self._data["liveData"][i]["deviceKind"] + attr['stop_lines']['line_' + str(i)]['vehicleExtras'] = self._data["liveData"][i]["extrasFlags"] + + attr['lines'] = lines_count + i += 1 + + if self._mode == 'schedule' or self._mode == 'all': + + if 'schedule' in self._data: + schedules_lines_count = len(self._data["schedule"]) + + if schedules_lines_count != 0: + attr['stop_lines_schedules'] = {} + i = 0 + k = 0 + while i < schedules_lines_count: + if len(self._monitored_lines) != 0: + + if self._data["schedule"][i]["line"] in self._monitored_lines: + attr['stop_lines_schedules'].setdefault('line_' + str(k), {})['line_number'] = self._data["schedule"][i]["line"] + attr['stop_lines_schedules'].setdefault('line_' + str(k), {})['line_times'] = {} + + #check if max results doesn't exceed available data + max_result = self._schedule_max if len(self._data["schedule"][i]["data"]) >= self._schedule_max else len(self._data["schedule"][i]["data"]) + n = 0 + while n < max_result: + attr['stop_lines_schedules']['line_' + str(k)]['line_times'][str(n)] = self._data["schedule"][i]["data"][n]["text"] + n += 1 + k += 1 + + attr["lines_schedules"] = len(attr['stop_lines_schedules']) + + else: + attr['stop_lines_schedules'].setdefault('line_' + str(i), {})['line_number'] = self._data["schedule"][i]["line"] + attr['stop_lines_schedules'].setdefault('line_' + str(i), {})['line_times'] = {} + + #check if max results doesn't exceed available data + max_result = self._schedule_max if len(self._data["schedule"][i]["data"]) >= self._schedule_max else len(self._data["schedule"][i]["data"]) + n = 0 + while n < max_result: + attr['stop_lines_schedules']['line_' + str(i)]['line_times'][str(n)] = self._data["schedule"][i]["data"][n]["text"] + n += 1 + + attr['lines_schedules'] = schedules_lines_count + i += 1 + + _LOGGER.debug(attr) + return attr + _LOGGER.debug("No data") + + def load_data(self, data): + self._state = 1 + self._data = data + return True + + + +class VarnaTrafikTransportSensorData: + def __init__(self, hass, stopid, dev, interval, show_mode, max_schedule): + self._data = {} + self._hass = hass + self._stopid = stopid + self._dev = dev + self.state = None + self._delay = interval + self._mode = show_mode + self._max_results = max_schedule + + async def update_devices(self): + """Update all devices/sensors.""" + if self._dev: + tasks = [] + # Update all devices + for dev in self._dev: + if dev.load_data(self._data): + tasks.append(dev.async_update_ha_state()) + + if tasks: + await asyncio.wait(tasks) + + async def schedule_update(self, second=10): + """Schedule an update after seconds.""" + """ + If we only getting lines schedule, no need for constant pooling. + Some aprox timing calculations based on nubers of results setted for schedule to return. + + Also since city of Varna doesn't have public transport trough the night, there is no need to pool at that time + """ + if dt_util.parse_time('23:59:00') <= dt_util.parse_time(dt_util.now().strftime('%H:%M:%S')) <= dt_util.parse_time('04:59:59'): + future = datetime.combine(dt_util.parse_datetime(dt_util.now().strftime('%Y-%m-%d %H:%M:%S%z')), dt_util.parse_time('04:59:59')) #future hour - 5:00 in the morning, when the first buses goes from the end stops + current = datetime.now() #now + tdelta = future - current #return the hours diffrence between the two times, we do calculation here to set next execute after N - hours + if tdelta.days < 0: #our interval crosses midnight end time is always earlier than the start time resulting timedalta been negative, lets account for that bellow + tdelta = timedelta(days=0, + seconds=tdelta.seconds, microseconds=tdelta.microseconds) + nxt = dt_util.utcnow() + tdelta + else: + if self._mode == 'schedule': + if second == 1: + nxt = dt_util.utcnow() + timedelta(seconds=1) + else: + if self._max_results <= 10: + nxt = dt_util.utcnow() + timedelta(hours=1) + elif self._max_results <= 20: + nxt = dt_util.utcnow() + timedelta(hours=2) + elif self._max_results <= 40: + nxt = dt_util.utcnow() + timedelta(hours=4) + else: + nxt = dt_util.utcnow() + timedelta(hours=6) + else: + nxt = dt_util.utcnow() + timedelta(seconds=second) + _LOGGER.debug("Scheduling next update at %s. UTC time", nxt.strftime('%H:%M:%S')) + async_track_point_in_utc_time(self._hass, self.async_update, nxt) + + + @asyncio.coroutine + async def async_update(self, *_): + """Get the latest data from varnatraffic.com""" + _LOGGER.debug("Bus stop update for stop id:" + self._stopid) + + params = {} + params['stationId'] = self._stopid + params['format'] = 'json' + + response = requests.get(_RESOURCE, params, timeout=20) + + # No valid request, throw error + if response.status_code != 200: + _LOGGER.error("Bus stop update error! API response " + str(response.status_code)) + self.state = None + self._data = None + self._delay = 20 + else: + # Valid request, return data + # Parse the result as a JSON object + result = response.json() + + self.state = 1 + self._data = result + + await self.update_devices() + await self.schedule_update(self._delay) +