From 1de700a7ded3475c45aaeb7458406df88860994e Mon Sep 17 00:00:00 2001 From: fermartv Date: Sat, 4 Dec 2021 15:29:10 +0100 Subject: [PATCH] Fix platform not found error #2 --- .gitignore | 16 ++ LICENSE | 21 +++ README.md | 99 ++++++++++ custom_components/emt_madrid/__init__.py | 1 + custom_components/emt_madrid/manifest.json | 8 + custom_components/emt_madrid/sensor.py | 199 +++++++++++++++++++++ example.png | Bin 0 -> 12246 bytes hacs.json | 6 + 8 files changed, 350 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 custom_components/emt_madrid/__init__.py create mode 100755 custom_components/emt_madrid/manifest.json create mode 100755 custom_components/emt_madrid/sensor.py create mode 100644 example.png create mode 100644 hacs.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b60aeed --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/.HA_VERSION +/.cloud +/.storage +/.vscode +/automations.yaml +/configuration.yaml +/deps +/groups.yaml +/home-assistant.log +/home-assistant_v2.db +/scenes.yaml +/scripts.yaml +/secrets.yaml +/tts +/custom_components/emt_madrid/__pycache__ +*.pyc \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8581b65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Fernando Martínez + +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..65c9647 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +_Please :star: this repo if you find it useful_ + +# EMT Madrid bus platform for Home Assistant + +This is a custom sensor for Home Assistant that allows you tu have the waiting time for a specific Madrid-EMT bus stop. Each sensor will provide the arrival time for the next 2 buses of the line specified in the configuration. + +Thanks to [EMT Madrid MobilityLabs](https://mobilitylabs.emtmadrid.es/) for providing the data and [documentation](https://apidocs.emtmadrid.es/). + +![Example](example.png) + +## Prerequisites + +To use the EMT Mobilitylabs API you need to register in their [website](https://mobilitylabs.emtmadrid.es/). You have to provide a valid email account and a password that will be used to configure the sensor. Once you are registered you will receive a confirmation email to activate your account. It will not work until you have completed all the steps. + +## Manual Installation + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +1. If you do not have a `custom_components` directory (folder) there, you need to create it. +1. In the `custom_components` directory (folder) create a new folder called `emt_madrid`. +1. Download _all_ the files from the `custom_components/emt_madrid/` directory (folder) in this repository. +1. Place the files you downloaded in the new directory (folder) you created. +1. Restart Home Assistant +1. Add `emt_madrid` sensor to your `configuration.yaml` file: + + ```yaml + # Example configuration.yaml entry + sensor: + - platform: emt_madrid + email: !secret EMT_EMAIL + password: !secret EMT_PASSWORD + stop: "72" + line: "27" + name: "Bus 27 en Cibeles" + icon: "mdi:fountain" + ``` + +### Configuration Variables + +**email**:\ + _(string) (Required)_\ + Email account used to register in the EMT Madrid API. + +**password**:\ + _(string) (Required)_\ + Password used to register in the EMT Madrid API. + +**stop**:\ + _(string) (Required)_\ + Bus stop ID. + +**line**:\ + _(string) (Required)_\ + Bus line that stops at the previous bus stop. + +**name**:\ + _(string) (Optional)_\ + Name to use in the frontend. +_Default value: "Bus at "_ + +**icon**:\ + _(string) (Optional)_\ + Icon to use in the frontend. +_Default value: "mdi:bus"_ + +## Sensor status and attributes + +Once you have you sensor up and running it will update the data automatically every 30 seconds and you should have the following data: + +**state**:\ + _(int)_\ + Arrival time in minutes for the next bus. It will show "-" when there are no more buses coming and 30 when the arrival time is over 30 minutes. + +### Attributes + +**later_bus**:\ + _(int)_\ + Arrival time in minutes for the second bus. It will show "-" when there are no more buses coming and 30 when the arrival time is over 30 minutes. + +**bus_stop_id**:\ + _(int)_\ + Bus stop id given in the configuration. + +**bus_line**:\ + _(int)_\ + Bus line given in the configuration. + +### Second bus sensor + +If you want to have a specific sensor to show the arrival time for the second bus, you can add the following lines to your `configuration.yaml` file below the `emt_madrid` bus sensor. See the official Home Assistant [template sensor](https://www.home-assistant.io/integrations/template/) for more information. + +```yaml +# Example configuration.yaml entry +- platform: template + sensors: + siguiente_27: + friendly_name: "Siguiente bus 27" + unit_of_measurement: "min" + value_template: "{{ state_attr('sensor.bus_27_en_cibeles', 'later_bus') }}" +``` diff --git a/custom_components/emt_madrid/__init__.py b/custom_components/emt_madrid/__init__.py new file mode 100755 index 0000000..c449013 --- /dev/null +++ b/custom_components/emt_madrid/__init__.py @@ -0,0 +1 @@ +"""Madrid EMT bus sensor.""" \ No newline at end of file diff --git a/custom_components/emt_madrid/manifest.json b/custom_components/emt_madrid/manifest.json new file mode 100755 index 0000000..be37f4e --- /dev/null +++ b/custom_components/emt_madrid/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "emt_madrid", + "name": "EMT Madrid", + "documentation": "https://github.com/fermartv/EMT-Madrid/", + "issue_tracker": "https://github.com/fermartv/EMT-Madrid/issues", + "codeowners": ["@FerMartV"], + "version": "1.0.1" +} diff --git a/custom_components/emt_madrid/sensor.py b/custom_components/emt_madrid/sensor.py new file mode 100755 index 0000000..9276fe5 --- /dev/null +++ b/custom_components/emt_madrid/sensor.py @@ -0,0 +1,199 @@ +import requests +import json +import math + +import logging + +import voluptuous as vol + +"""Platform for sensor integration.""" +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_EMAIL, + CONF_ICON, + CONF_NAME, + CONF_PASSWORD, + TIME_MINUTES, +) + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +ATTRIBUTION = "Data provided by EMT Madrid MobilityLabs" + +CONF_STOP = "stop" +CONF_LINE = "line" + +DEFAULT_NAME = "EMT Madrid" +DEFAULT_ICON = "mdi:bus" + +ATTR_NEXT_UP = "later_bus" +ATTR_BUS_STOP = "bus_stop_id" +ATTR_BUS_LINE = "bus_line" + +BASE_URL = "https://openapi.emtmadrid.es/" +ENDPOINT_LOGIN = "v1/mobilitylabs/user/login/" +ENDPOINT_ARRIVAL_TIME = "v2/transport/busemtmad/stops/" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_STOP): cv.string, + vol.Required(CONF_LINE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the sensor platform.""" + email = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + bus_stop = config.get(CONF_STOP) + line = config.get(CONF_LINE) + name = config.get(CONF_NAME) + icon = config.get(CONF_ICON) + api_emt = APIEMT(email, password) + api_emt.update(bus_stop, line) + add_entities([BusStopSensor(api_emt, bus_stop, line, name, icon)]) + + +class BusStopSensor(Entity): + """Implementation of an EMT-Madrid bus stop sensor.""" + + def __init__(self, api_emt, bus_stop, line, name, icon): + """Initialize the sensor.""" + self._state = None + self._api_emt = api_emt + self._bus_stop = bus_stop + self._bus_line = line + self._icon = icon + self._name = name + if self._name == DEFAULT_NAME: + self._name = "Next {} at {}".format(self._bus_line, self._bus_stop) + + @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._api_emt.get_stop_data(self._bus_line, "arrival") + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TIME_MINUTES + + @property + def icon(self): + """Return sensor specific icon.""" + return self._icon + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + return { + ATTR_NEXT_UP: self._api_emt.get_stop_data(self._bus_line, "next_arrival"), + ATTR_BUS_STOP: self._bus_stop, + ATTR_BUS_LINE: self._bus_line, + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + def update(self): + """Fetch new state data for the sensor.""" + self._api_emt.update(self._bus_stop, self._bus_line) + + +class APIEMT: + """ + Interface for the EMT REST API. Docs at https://apidocs.emtmadrid.es/ + """ + + def __init__(self, user, password): + """ Initialize the class and retrieve the token from the API. """ + self._user = user + self._password = password + self.token = self.update_token() + + def update(self, stop, line): + """ Update the arrival times data from the API. """ + url = "{}{}{}/arrives/{}/".format(BASE_URL, ENDPOINT_ARRIVAL_TIME, stop, line) + headers = {"accessToken": self.token} + data = {"stopId": stop, "lineArrive": line, "Text_EstimationsRequired_YN": "Y"} + response = self.api_call(url, headers, data) + try: + self.set_stop_data(response, line) + except: + _LOGGER.error("Invalid stop ID") + raise Exception("Unable to get the arrival times from the API") + + def set_stop_data(self, data, target_line): + """ Create a dictionary to store the arrival time data in the following format: + { + line:{ + 'arrival': arrival_time, + 'next_arrival': arrival_time + } + } + """ + arrival_data = {} + + for bus in data["data"][0]["Arrive"]: + estimated_time = math.trunc(bus["estimateArrive"] / 60) + if estimated_time > 30: + estimated_time = 30 + line = bus["line"] + if line not in arrival_data: + arrival_data[line] = {"arrival": estimated_time} + elif "next_arrival" not in arrival_data[line]: + arrival_data[line]["next_arrival"] = estimated_time + + if target_line not in arrival_data: + arrival_data[target_line] = {"arrival": "-"} + if "next_arrival" not in arrival_data[target_line]: + arrival_data[target_line]["next_arrival"] = "-" + self._arrival_time = arrival_data + + def get_stop_data(self, line, bus): + """ Get the data of the next bus ("arrival") or the one after that ("next_arrival") """ + return self._arrival_time[str(line)][bus] + + def api_call(self, url, headers, payload=None, method="POST"): + """ Request data from the API. """ + if method == "POST": + payload = json.dumps(payload) + response = requests.post(url, data=payload, headers=headers) + + elif method == "GET": + response = requests.get(url, headers=headers) + + else: + raise Exception("Invalid HTTP method: " + method) + + if response.status_code != 200: + _LOGGER.error("Invalid response: %s", response.status_code) + return response.json() + + def update_token(self): + """ Update or create the token when the sensor is initialized. """ + headers = {"email": self._user, "password": self._password} + url = BASE_URL + ENDPOINT_LOGIN + response = self.api_call(url, headers, None, "GET") + try: + self.token = response["data"][0]["accessToken"] + except: + if response["code"] == "80": + _LOGGER.error("Invalid credentials") + else: + _LOGGER.error("Unable to retrieve the token from the API") + raise Exception("Unable to connect to the API") + return self.token + diff --git a/example.png b/example.png new file mode 100644 index 0000000000000000000000000000000000000000..4f697b99782d8b07677998517fc2b68ef7f89f4e GIT binary patch literal 12246 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