From f53c94ed2aee76123e4d0fd6b67de2d3728b8080 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sun, 1 Mar 2020 16:49:07 +0100 Subject: [PATCH] Add Tankerkoenig integration (#28661) * Initial version Parse configuration, but return a fixed value * Add basic functionality Request real data from the server Currently the prices are not getting updated, but the petrol station data is real * Update values regularly The tankerkoenig values get updated regularly with real data. * Move base functionality for the sensor to a base class And move that to an own file, so that it can be inherited * Reduce calls to tankerkoenig api Use a master/slave concept for sensors; one master gets the data and updates it into the slaves. * Update requirements files * Update all gas stations at once * Remove tests directory Currently there are no tests for the integration; will be added in a future commit. * Fix slaves not being updated Let the base class regularly poll, so that slaves are also updated * Refactor entity creation Create an auxiliary method to add a station to the entity list, in preparation to allowing extra stations. * Add possibility to manually add stations Add a new configuration option "stations" to manually add extra stations * Fix style issues Make the code more pythonic * Remove redundant code Implement suggestions from the code review * Change to platform component Remove the master/slave concept, in favor of a platform with dummy sensors. The platform takes care of contacting the server and fetching updates atomically, and updating the data on the sensors. * Rename ATTR_STATE Rename the attribute to "IS_OPEN", to avoid confusion with the sensor state. * Minor updates Combine two consecutive error logs into a single one. Update the sensor's icon * Separate address into different fields * Style updates Use "[]" syntax instead of ".get()" for required parameters Use warning log level for not available fuel types * Implement review comments Fix style issues Improve error messages Remove redundant options * Refactor using DataUpdateCoordinator Use the new DataUpdateCoordinator to fetch the global data from the API, instead of implementing an own method. Also fix comments from the PR * Implement PR comments Implement suggestions to improve code readability and keep the Home Assistant style. Also separate fetching data to an async thread --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/tankerkoenig/__init__.py | 203 ++++++++++++++++++ .../components/tankerkoenig/const.py | 9 + .../components/tankerkoenig/manifest.json | 10 + .../components/tankerkoenig/sensor.py | 150 +++++++++++++ requirements_all.txt | 3 + 7 files changed, 377 insertions(+) create mode 100755 homeassistant/components/tankerkoenig/__init__.py create mode 100644 homeassistant/components/tankerkoenig/const.py create mode 100755 homeassistant/components/tankerkoenig/manifest.json create mode 100755 homeassistant/components/tankerkoenig/sensor.py diff --git a/.coveragerc b/.coveragerc index 44ca6709eca..56084a049a0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -701,6 +701,7 @@ omit = homeassistant/components/tado/device_tracker.py homeassistant/components/tahoma/* homeassistant/components/tank_utility/sensor.py + homeassistant/components/tankerkoenig/* homeassistant/components/tapsaff/binary_sensor.py homeassistant/components/tautulli/sensor.py homeassistant/components/ted5000/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index d72697f65c0..30611cbf757 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,6 +347,7 @@ homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff homeassistant/components/tado/* @michaelarnauts homeassistant/components/tahoma/* @philklei +homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue @tetienne diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py new file mode 100755 index 00000000000..fde2f1c57cd --- /dev/null +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -0,0 +1,203 @@ +"""Ask tankerkoenig.de for petrol price information.""" +from datetime import timedelta +import logging + +import pytankerkoenig +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, +) +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN, FUEL_TYPES + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_RADIUS = 2 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_FUEL_TYPES, default=FUEL_TYPES): vol.All( + cv.ensure_list, [vol.In(FUEL_TYPES)] + ), + vol.Inclusive( + CONF_LATITUDE, + "coordinates", + "Latitude and longitude must exist together", + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, + "coordinates", + "Latitude and longitude must exist together", + ): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.All( + cv.positive_int, vol.Range(min=1) + ), + vol.Optional(CONF_STATIONS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set the tankerkoenig component up.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + _LOGGER.debug("Setting up integration") + + tankerkoenig = TankerkoenigData(hass, conf) + + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + radius = conf[CONF_RADIUS] + additional_stations = conf[CONF_STATIONS] + + setup_ok = await hass.async_add_executor_job( + tankerkoenig.setup, latitude, longitude, radius, additional_stations + ) + if not setup_ok: + _LOGGER.error("Could not setup integration") + return False + + hass.data[DOMAIN] = tankerkoenig + + hass.async_create_task( + async_load_platform( + hass, + SENSOR_DOMAIN, + DOMAIN, + discovered=tankerkoenig.stations, + hass_config=conf, + ) + ) + + return True + + +class TankerkoenigData: + """Get the latest data from the API.""" + + def __init__(self, hass, conf): + """Initialize the data object.""" + self._api_key = conf[CONF_API_KEY] + self.stations = {} + self.fuel_types = conf[CONF_FUEL_TYPES] + self.update_interval = conf[CONF_SCAN_INTERVAL] + self._hass = hass + + def setup(self, latitude, longitude, radius, additional_stations): + """Set up the tankerkoenig API. + + Read the initial data from the server, to initialize the list of fuel stations to monitor. + """ + _LOGGER.debug("Fetching data for (%s, %s) rad: %s", latitude, longitude, radius) + try: + data = pytankerkoenig.getNearbyStations( + self._api_key, latitude, longitude, radius, "all", "dist" + ) + except pytankerkoenig.customException as err: + data = {"ok": False, "message": err, "exception": True} + _LOGGER.debug("Received data: %s", data) + if not data["ok"]: + _LOGGER.error( + "Setup for sensors was unsuccessful. Error occurred while fetching data from tankerkoenig.de: %s", + data["message"], + ) + return False + + # Add stations found via location + radius + nearby_stations = data["stations"] + if not nearby_stations: + if not additional_stations: + _LOGGER.error( + "Could not find any station in range." + "Try with a bigger radius or manually specify stations in additional_stations" + ) + return False + _LOGGER.warning( + "Could not find any station in range. Will only use manually specified stations" + ) + else: + for station in data["stations"]: + self.add_station(station) + + # Add manually specified additional stations + for station_id in additional_stations: + try: + additional_station_data = pytankerkoenig.getStationData( + self._api_key, station_id + ) + except pytankerkoenig.customException as err: + additional_station_data = { + "ok": False, + "message": err, + "exception": True, + } + + if not additional_station_data["ok"]: + _LOGGER.error( + "Error when adding station %s:\n %s", + station_id, + additional_station_data["message"], + ) + return False + self.add_station(additional_station_data["station"]) + return True + + async def fetch_data(self): + """Get the latest data from tankerkoenig.de.""" + _LOGGER.debug("Fetching new data from tankerkoenig.de") + station_ids = list(self.stations) + data = await self._hass.async_add_executor_job( + pytankerkoenig.getPriceList, self._api_key, station_ids + ) + + if data["ok"]: + _LOGGER.debug("Received data: %s", data) + if "prices" not in data: + _LOGGER.error("Did not receive price information from tankerkoenig.de") + raise TankerkoenigError("No prices in data") + else: + _LOGGER.error( + "Error fetching data from tankerkoenig.de: %s", data["message"] + ) + raise TankerkoenigError(data["message"]) + return data["prices"] + + def add_station(self, station: dict): + """Add fuel station to the entity list.""" + station_id = station["id"] + if station_id in self.stations: + _LOGGER.warning( + "Sensor for station with id %s was already created", station_id + ) + return + + self.stations[station_id] = station + _LOGGER.debug("add_station called for station: %s", station) + + +class TankerkoenigError(HomeAssistantError): + """An error occurred while contacting tankerkoenig.de.""" diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py new file mode 100644 index 00000000000..04e6e08ba37 --- /dev/null +++ b/homeassistant/components/tankerkoenig/const.py @@ -0,0 +1,9 @@ +"""Constants for the tankerkoenig integration.""" + +DOMAIN = "tankerkoenig" +NAME = "tankerkoenig" + +CONF_FUEL_TYPES = "fuel_types" +CONF_STATIONS = "stations" + +FUEL_TYPES = ["e5", "e10", "diesel"] diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json new file mode 100755 index 00000000000..1b22e62d5ef --- /dev/null +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tankerkoenig", + "name": "Tankerkoenig", + "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", + "requirements": ["pytankerkoenig==0.0.6"], + "dependencies": [], + "codeowners": [ + "@guillempages" + ] +} diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py new file mode 100755 index 00000000000..c9e25d94a4b --- /dev/null +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -0,0 +1,150 @@ +"""Tankerkoenig sensor integration.""" + +import logging + +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, NAME + +_LOGGER = logging.getLogger(__name__) + +ATTR_BRAND = "brand" +ATTR_CITY = "city" +ATTR_FUEL_TYPE = "fuel_type" +ATTR_HOUSE_NUMBER = "house_number" +ATTR_IS_OPEN = "is_open" +ATTR_POSTCODE = "postcode" +ATTR_STATION_NAME = "station_name" +ATTR_STREET = "street" +ATTRIBUTION = "Data provided by https://creativecommons.tankerkoenig.de" + +ICON = "mdi:gas-station" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the tankerkoenig sensors.""" + + if discovery_info is None: + return + + tankerkoenig = hass.data[DOMAIN] + + async def async_update_data(): + """Fetch data from API endpoint.""" + try: + return await tankerkoenig.fetch_data() + except LookupError: + raise UpdateFailed + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=NAME, + update_method=async_update_data, + update_interval=tankerkoenig.update_interval, + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + stations = discovery_info.values() + entities = [] + for station in stations: + for fuel in tankerkoenig.fuel_types: + if fuel not in station: + _LOGGER.warning( + "Station %s does not offer %s fuel", station["id"], fuel + ) + continue + sensor = FuelPriceSensor( + fuel, station, coordinator, f"{NAME}_{station['name']}_{fuel}" + ) + entities.append(sensor) + _LOGGER.debug("Added sensors %s", entities) + + async_add_entities(entities) + + +class FuelPriceSensor(Entity): + """Contains prices for fuel in a given station.""" + + def __init__(self, fuel_type, station, coordinator, name): + """Initialize the sensor.""" + self._station = station + self._station_id = station["id"] + self._fuel_type = fuel_type + self._coordinator = coordinator + self._name = name + self._latitude = station["lat"] + self._longitude = station["lng"] + self._city = station["place"] + self._house_number = station["houseNumber"] + self._postcode = station["postCode"] + self._street = station["street"] + self._price = station[fuel_type] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + @property + def unit_of_measurement(self): + """Return unit of measurement.""" + return "€" + + @property + def should_poll(self): + """No need to poll. Coordinator notifies of updates.""" + return False + + @property + def state(self): + """Return the state of the device.""" + # key Fuel_type is not available when the fuel station is closed, use "get" instead of "[]" to avoid exceptions + return self._coordinator.data[self._station_id].get(self._fuel_type) + + @property + def device_state_attributes(self): + """Return the attributes of the device.""" + data = self._coordinator.data[self._station_id] + + attrs = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_BRAND: self._station["brand"], + ATTR_FUEL_TYPE: self._fuel_type, + ATTR_STATION_NAME: self._station["name"], + ATTR_STREET: self._street, + ATTR_HOUSE_NUMBER: self._house_number, + ATTR_POSTCODE: self._postcode, + ATTR_CITY: self._city, + ATTR_LATITUDE: self._latitude, + ATTR_LONGITUDE: self._longitude, + } + if data is not None and "status" in data: + attrs[ATTR_IS_OPEN] = data["status"] == "open" + return attrs + + @property + def available(self): + """Return if entity is available.""" + return self._coordinator.last_update_success + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self._coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self._coordinator.async_remove_listener(self.async_write_ha_state) + + async def async_update(self): + """Update the entity.""" + await self._coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index ea649fc93a0..7a120543c36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1547,6 +1547,9 @@ pysupla==0.0.3 # homeassistant.components.syncthru pysyncthru==0.5.0 +# homeassistant.components.tankerkoenig +pytankerkoenig==0.0.6 + # homeassistant.components.tautulli pytautulli==0.5.0