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
This commit is contained in:
guillempages 2020-03-01 16:49:07 +01:00 committed by GitHub
parent 0431b983d2
commit f53c94ed2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 377 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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"]

View File

@ -0,0 +1,10 @@
{
"domain": "tankerkoenig",
"name": "Tankerkoenig",
"documentation": "https://www.home-assistant.io/integrations/tankerkoenig",
"requirements": ["pytankerkoenig==0.0.6"],
"dependencies": [],
"codeowners": [
"@guillempages"
]
}

View File

@ -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()

View File

@ -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