diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..9f65311 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["buymeacoffee.com/PiotrMachowski", "paypal.me/PiMachowski"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d56b8e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Piotr Machowski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..855d3a5 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# MPK Łódź sensor + +[![buymeacoffee_badge](https://img.shields.io/badge/Donate-Buy%20Me%20a%20Coffee-ff813f?style=flat)](https://www.buymeacoffee.com/PiotrMachowski) +[![paypalme_badge](https://img.shields.io/badge/Donate-PayPal-0070ba?style=flat)](https://paypal.me/PiMachowski) + +This sensor uses unofficial API provided by MPK Łódź. + +## Configuration options + +| Key | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | `string` | `False` | `MPK Łódź` | Name of sensor | +| `stops` | `list` | `True` | - | List of stop configurations | + +### Stop configuration + +| Key | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `id` | `positive integer` | `True` | - | ID of a stop | +| `name` | `string` | `False` | id | Name of a stop | +| `lines` | `list` | `False` | all available | List of monitored lines. | +| `directions` | `list` | `False` | all available | List of monitored directions. | + +## Example usage + +``` +sensor: + - platform: mpk_lodz + stops: + - id: 2427 + lines: + - "o97A" + - id: 2873 + directions: + - "DW. ŁÓDŹ KALISKA" +``` + +## Installation + +Download [*sensor.py*](https://github.com/PiotrMachowski/Home-Assistant-custom-components-MPK-Lodz/raw/master/custom_components/mpk_lodz/sensor.py) and [*manifest.json*](https://github.com/PiotrMachowski/Home-Assistant-custom-components-MPK-Lodz/raw/master/custom_components/mpk_lodz/manifest.json) to `config/custom_components/mpk_lodz` directory: +```bash +mkdir -p custom_components/mpk_lodz +cd custom_components/mpk_lodz +wget https://github.com/PiotrMachowski/Home-Assistant-custom-components-MPK-Lodz/raw/master/custom_components/mpk_lodz/sensor.py +wget https://github.com/PiotrMachowski/Home-Assistant-custom-components-MPK-Lodz/raw/master/custom_components/mpk_lodz/manifest.json +``` + +## Hints + +* Value for `stop_id` can be retrieved from [*ITS Łódź*](http://rozklady.lodz.pl/). After choosing a desired stop open its electronical table. `stop_id` is a number visibile in URL. + +* These sensors provides attributes which can be used in [*HTML card*](https://github.com/PiotrMachowski/Home-Assistant-Lovelace-HTML-card) or [*HTML Template card*](https://github.com/PiotrMachowski/Home-Assistant-Lovelace-HTML-Template-card): `html_timetable`, `html_departures` + * HTML card: + ```yaml + - type: custom:html-card + title: 'MPK' + content: | +
Timetable
+ [[ sensor.mpk_lodz_2427.attributes.html_timetable ]] +
Departures
+ [[ sensor.mpk_lodz_2873.attributes.html_departures ]] + ``` + * HTML Template card: + ```yaml + - type: custom:html-template-card + title: 'MPK' + ignore_line_breaks: true + content: | +
Timetable

+ {{ state_attr('sensor.mpk_lodz_2427','html_timetable') }} +
Departures

+ {{ state_attr('sensor.mpk_lodz_2873','html_departures') }} + ``` + +Buy Me A Coffee diff --git a/custom_components/mpk_lodz/__init__.py b/custom_components/mpk_lodz/__init__.py new file mode 100644 index 0000000..dbbad80 --- /dev/null +++ b/custom_components/mpk_lodz/__init__.py @@ -0,0 +1 @@ +"""MPK Łódź""" \ No newline at end of file diff --git a/custom_components/mpk_lodz/manifest.json b/custom_components/mpk_lodz/manifest.json new file mode 100644 index 0000000..8f9c15c --- /dev/null +++ b/custom_components/mpk_lodz/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "mpk_lodz", + "name": "MPK Łódź", + "documentation": "https://github.com/PiotrMachowski/Home-Assistant-custom-components-MPK-Lodz", + "dependencies": [], + "codeowners": ["@PiotrMachowski"], + "requirements": ["requests"], + "version": "v1.0.0", + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/custom_components/mpk_lodz/sensor.py b/custom_components/mpk_lodz/sensor.py new file mode 100644 index 0000000..56bd918 --- /dev/null +++ b/custom_components/mpk_lodz/sensor.py @@ -0,0 +1,184 @@ +import requests +from datetime import datetime, timedelta +import xml.etree.ElementTree as ET +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT +from homeassistant.const import CONF_ID, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import async_generate_entity_id + +DEFAULT_NAME = 'MPK Łódź' + +CONF_STOPS = 'stops' +CONF_LINES = 'lines' +CONF_DIRECTIONS = 'directions' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_STOPS): vol.All(cv.ensure_list, [ + vol.Schema({ + vol.Required(CONF_ID): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LINES, default=[]): cv.ensure_list, + vol.Optional(CONF_DIRECTIONS, default=[]): cv.ensure_list + })]) +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + name = config.get(CONF_NAME) + stops = config.get(CONF_STOPS) + dev = [] + for stop in stops: + stop_id = str(stop.get(CONF_ID)) + lines = stop.get(CONF_LINES) + directions = stop.get(CONF_DIRECTIONS) + real_stop_name = MpkLodzSensor.get_stop_name(stop_id) + if real_stop_name is None: + raise Exception("Invalid stop id: {}".format(stop_id)) + stop_name = stop.get(CONF_NAME) or stop_id + uid = '{}_{}'.format(name, stop_name) + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, hass=hass) + dev.append(MpkLodzSensor(entity_id, name, stop_id, stop_name, real_stop_name, lines, directions)) + add_entities(dev, True) + + +class MpkLodzSensor(Entity): + def __init__(self, entity_id, name, stop_id, stop_name, real_stop_name, watched_lines, watched_directions): + self.entity_id = entity_id + self._name = name + self._stop_id = stop_id + self._watched_lines = watched_lines + self._watched_directions = watched_directions + self._stop_name = stop_name + self._real_stop_name = real_stop_name + self._departures = [] + self._departures_number = 0 + self._departures_by_line = dict() + + @property + def name(self): + return '{} - {}'.format(self._name, self._stop_name) + + @property + def icon(self): + return "mdi:bus-clock" + + @property + def state(self): + if self._departures_number is not None and self._departures_number > 0: + dep = self._departures[0] + return MpkLodzSensor.departure_to_str(dep) + return None + + @property + def unit_of_measurement(self): + return None + + @property + def device_state_attributes(self): + attr = dict() + attr['stop_name'] = self._real_stop_name + if self._departures is not None: + attr['list'] = self._departures + attr['html_timetable'] = self.get_html_timetable() + attr['html_departures'] = self.get_html_departures() + if self._departures_number > 0: + dep = self._departures[0] + attr['line'] = dep["line"] + attr['direction'] = dep["direction"] + attr['departure'] = dep["departure"] + attr['time_to_departure'] = dep["time_to_departure"] + return attr + + def update(self): + now = datetime.now() + data = MpkLodzSensor.get_data(self._stop_id) + if data is None: + return + departures = data[0][0] + parsed_departures = [] + for departure in departures: + line = departure.attrib["nr"] + direction = departure.attrib["dir"] + if len(self._watched_lines) > 0 and line not in self._watched_lines \ + or len(self._watched_directions) > 0 and direction not in self._watched_directions: + continue + time_in_seconds = int(departure[0].attrib["s"]) + departure = now + timedelta(seconds=time_in_seconds) + time_to_departure = time_in_seconds // 60 + parsed_departures.append( + { + "line": line, + "direction": direction, + "departure": "{:02}:{:02}".format(departure.hour, departure.minute), + "time_to_departure": int(time_to_departure), + }) + self._departures = parsed_departures + self._departures_number = len(parsed_departures) + self._departures_by_line = MpkLodzSensor.group_by_line(self._departures) + + def get_html_timetable(self): + html = '\n' + lines = list(self._departures_by_line.keys()) + lines.sort() + for line in lines: + directions = list(self._departures_by_line[line].keys()) + directions.sort() + for direction in directions: + if len(direction) == 0: + continue + html = html + ''.format( + line, direction) + departures = ', '.join(map(lambda x: x["departure"], self._departures_by_line[line][direction])) + html = html + '\n'.format(departures) + if len(lines) == 0: + html = html + '' + html = html + '
{}, kier. {}{}
Brak połączeń
' + return html + + def get_html_departures(self): + html = '\n' + for departure in self._departures: + html = html + '\n'.format( + MpkLodzSensor.departure_to_str(departure)) + html = html + '
{}
' + return html + + @staticmethod + def departure_to_str(dep): + return '{}, kier. {}: {} ({}m)'.format(dep["line"], dep["direction"], dep["departure"], + dep["time_to_departure"]) + + @staticmethod + def group_by_line(departures): + departures_by_line = dict() + for departure in departures: + line = departure["line"] + direction = departure["direction"] + if line not in departures_by_line: + departures_by_line[line] = dict() + if direction not in departures_by_line[line]: + departures_by_line[line][direction] = [] + departures_by_line[line][direction].append(departure) + return departures_by_line + + @staticmethod + def get_stop_name(stop_id): + data = MpkLodzSensor.get_data(stop_id) + if data is None: + return None + return data[0].attrib["name"] + + @staticmethod + def get_data(stop_id): + address = "http://rozklady.lodz.pl/Home/GetTimeTableReal?busStopId={}".format(stop_id) + headers = { + 'referer': address, + } + response = requests.get(address, headers=headers) + if response.status_code == 200 and response.content.__len__() > 0: + return ET.fromstring(response.text) + return None