Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add GIOS integration #28719

Merged
merged 25 commits into from Dec 31, 2019
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc
Expand Up @@ -259,6 +259,9 @@ omit =
homeassistant/components/geniushub/*
homeassistant/components/gearbest/sensor.py
homeassistant/components/geizhals/sensor.py
homeassistant/components/gios/__init__.py
homeassistant/components/gios/air_quality.py
homeassistant/components/gios/consts.py
homeassistant/components/github/sensor.py
homeassistant/components/gitlab_ci/sensor.py
homeassistant/components/gitter/sensor.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -119,6 +119,7 @@ homeassistant/components/geniushub/* @zxdavb
homeassistant/components/geo_rss_events/* @exxamalte
homeassistant/components/geonetnz_quakes/* @exxamalte
homeassistant/components/geonetnz_volcano/* @exxamalte
homeassistant/components/gios/* @bieniu
homeassistant/components/gitter/* @fabaff
homeassistant/components/glances/* @fabaff @engrbm87
homeassistant/components/gntp/* @robbiet480
Expand Down
91 changes: 91 additions & 0 deletions homeassistant/components/gios/__init__.py
@@ -0,0 +1,91 @@
"""The GIOS component."""
import asyncio
from datetime import timedelta
import logging

from aiohttp.client_exceptions import ClientConnectorError
from async_timeout import timeout
from gios import ApiError, Gios, NoStationError

from homeassistant.core import Config, HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import Throttle

from .const import CONF_STATION_ID, DATA_CLIENT, DOMAIN

# Term of service GIOŚ allow downloading data no more than twice an hour.
DEFAULT_SCAN_INTERVAL = timedelta(minutes=30)

_LOGGER = logging.getLogger(__name__)


async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up configured GIOS."""
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_CLIENT] = {}
return True


async def async_setup_entry(hass, config_entry):
"""Set up GIOS as config entry."""
station_id = config_entry.data[CONF_STATION_ID]
_LOGGER.debug("Using station_id: %s", station_id)

websession = async_get_clientsession(hass)

gios = GiosData(websession, station_id)

await gios.async_update()

hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = gios

config_entry.add_update_listener(update_listener)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, "air_quality")
)
return True


async def async_unload_entry(hass, config_entry):
"""Unload a config entry."""
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality")
return True


async def update_listener(hass, entry):
"""Update listener."""
await hass.config_entries.async_forward_entry_unload(entry, "air_quality")
hass.async_add_job(
hass.config_entries.async_forward_entry_setup(entry, "air_quality")
)
Kane610 marked this conversation as resolved.
Show resolved Hide resolved


class GiosData:
"""Define an object to hold GIOS data."""

def __init__(self, session, station_id):
"""Initialize."""
self._gios = Gios(station_id, session)
self.station_id = station_id
self.sensors = {}
self.latitude = None
self.longitude = None
self.station_name = None
self.available = True

@Throttle(DEFAULT_SCAN_INTERVAL)
async def async_update(self):
"""Update GIOS data."""
try:
with timeout(30):
await self._gios.update()
except asyncio.TimeoutError:
_LOGGER.error("Asyncio Timeout Error")
except (ApiError, NoStationError, ClientConnectorError) as error:
_LOGGER.error("GIOS data update failed: %s", error)
self.available = self._gios.available
self.latitude = self._gios.latitude
self.longitude = self._gios.longitude
self.station_name = self._gios.station_name
self.sensors = self._gios.data
174 changes: 174 additions & 0 deletions homeassistant/components/gios/air_quality.py
@@ -0,0 +1,174 @@
"""Support for the GIOS service."""
from homeassistant.components.air_quality import (
ATTR_CO,
ATTR_NO2,
ATTR_OZONE,
ATTR_PM_2_5,
ATTR_PM_10,
ATTR_SO2,
AirQualityEntity,
)
from homeassistant.const import CONF_NAME

from .const import ATTR_STATION, DATA_CLIENT, DOMAIN

ATTRIBUTION = "Data provided by GIOŚ"

MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add a GIOS entities from a config_entry."""
name = config_entry.data[CONF_NAME]

data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]

async_add_entities([GiosAirQuality(data, name)], True)


def round_state(func):
"""Round state."""

def _decorator(self):
res = func(self)
if isinstance(res, float):
return round(res)
return res

return _decorator


class GiosAirQuality(AirQualityEntity):
"""Define an GIOS sensor."""

def __init__(self, gios, name):
"""Initialize."""
self.gios = gios
self._name = name
self._aqi = None
self._co = None
self._no2 = None
self._o3 = None
self._pm_2_5 = None
self._pm_10 = None
self._so2 = None
self._icon = None
self._attrs = {}

@property
def name(self):
"""Return the name."""
return self._name

@property
def icon(self):
"""Return the icon."""
if self._aqi == "bardzo dobry":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be defined as constants (better even if defined in the backend library) in english.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GIOS server sends AQI states in Polish. Do you think I need to translate those to English in backend library?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no other identifier besides the (translated) string? Imo it would make sense to have those as constants as it is just cleaner that way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AQI state strings from server are only in Polish. I added consts with English names for that states. I think that it's now understandable for non-Polish people.

self._icon = "mdi:emoticon-excited"
elif self._aqi == "dobry":
self._icon = "mdi:emoticon-happy"
elif self._aqi == "umiarkowany":
self._icon = "mdi:emoticon-neutral"
elif self._aqi == "dostateczny":
self._icon = "mdi:emoticon-sad"
elif self._aqi == "zły":
self._icon = "mdi:emoticon-dead"
else:
self._icon = "mdi:blur"
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
return self._icon

@property
def air_quality_index(self):
"""Return the air quality index."""
return self._aqi

@property
@round_state
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should rounding be left to the consumers of the data? I don't personally mind, but I think someone might :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rytilahti In Poland, most air quality services on the Internet rounding off values. I think this is a good approach.

def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level."""
return self._pm_2_5

@property
@round_state
def particulate_matter_10(self):
"""Return the particulate matter 10 level."""
return self._pm_10

@property
@round_state
def ozone(self):
"""Return the O3 (ozone) level."""
return self._o3

@property
@round_state
def carbon_monoxide(self):
"""Return the CO (carbon monoxide) level."""
return self._co

@property
@round_state
def sulphur_dioxide(self):
"""Return the SO2 (sulphur dioxide) level."""
return self._so2

@property
@round_state
def nitrogen_dioxide(self):
"""Return the NO2 (nitrogen dioxide) level."""
return self._no2

@property
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION

@property
def state(self):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""Return the air quality index."""
if self.gios.available:
return self._aqi

@property
def unique_id(self):
"""Return a unique_id for this entity."""
return f"{self.gios.station_id}"
rytilahti marked this conversation as resolved.
Show resolved Hide resolved

@property
def available(self):
"""Return True if entity is available."""
return self.gios.available

@property
def device_state_attributes(self):
"""Return the state attributes."""
self._attrs[ATTR_STATION] = self.gios.station_name
return self._attrs

async def async_update(self):
"""Get the data from GIOS."""
await self.gios.async_update()

if self.gios.available:
# Different measuring stations have different sets of sensors. We don't know
# what data we will get.
if "AQI" in self.gios.sensors:
self._aqi = self.gios.sensors["AQI"]["value"]
if "CO" in self.gios.sensors:
self._co = self.gios.sensors["CO"]["value"]
self._attrs[f"{ATTR_CO}_index"] = self.gios.sensors["CO"]["index"]
if "NO2" in self.gios.sensors:
self._no2 = self.gios.sensors["NO2"]["value"]
self._attrs[f"{ATTR_NO2}_index"] = self.gios.sensors["NO2"]["index"]
if "O3" in self.gios.sensors:
self._o3 = self.gios.sensors["O3"]["value"]
self._attrs[f"{ATTR_OZONE}_index"] = self.gios.sensors["O3"]["index"]
if "PM2.5" in self.gios.sensors:
self._pm_2_5 = self.gios.sensors["PM2.5"]["value"]
self._attrs[f"{ATTR_PM_2_5}_index"] = self.gios.sensors["PM2.5"][
"index"
]
if "PM10" in self.gios.sensors:
self._pm_10 = self.gios.sensors["PM10"]["value"]
self._attrs[f"{ATTR_PM_10}_index"] = self.gios.sensors["PM10"]["index"]
if "SO2" in self.gios.sensors:
self._so2 = self.gios.sensors["SO2"]["value"]
self._attrs[f"{ATTR_SO2}_index"] = self.gios.sensors["SO2"]["index"]
97 changes: 97 additions & 0 deletions homeassistant/components/gios/config_flow.py
@@ -0,0 +1,97 @@
"""Adds config flow for GIOS."""
import logging

from async_timeout import timeout
from gios import Gios, NoStationError
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import CONF_STATION_ID, DEFAULT_NAME, DOMAIN

_LOGGER = logging.getLogger(__name__)


@callback
def configured_instances(hass, condition):
"""Return a set of configured GIOS instances."""
return set(
entry.data[condition] for entry in hass.config_entries.async_entries(DOMAIN)
)
Kane610 marked this conversation as resolved.
Show resolved Hide resolved


class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for GIOS."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

def __init__(self):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""Initialize."""
self._errors = {}

async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
self._errors = {}

websession = async_get_clientsession(self.hass)

if user_input is not None:
if user_input[CONF_NAME] in configured_instances(self.hass, CONF_NAME):
self._errors[CONF_NAME] = "name_exists"
if user_input[CONF_STATION_ID] in configured_instances(
self.hass, CONF_STATION_ID
):
self._errors[CONF_STATION_ID] = "station_id_exists"
station_id_valid = await self._test_station_id(
websession, user_input[CONF_STATION_ID]
)
if station_id_valid:
sensors_data_valid = await self._test_sensors_data(
websession, user_input[CONF_STATION_ID]
)
if not sensors_data_valid:
self._errors[CONF_STATION_ID] = "invalid_sensors_data"
else:
self._errors[CONF_STATION_ID] = "wrong_station_id"
if not self._errors:
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)

return self._show_config_form(name=DEFAULT_NAME, station_id="")

def _show_config_form(self, name=None, station_id=None):
"""Show the configuration form to edit data."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_STATION_ID, default=station_id): int,
vol.Optional(CONF_NAME, default=name): str,
}
),
errors=self._errors,
)

async def _test_station_id(self, client, station_id):
"""Return true if station_id is valid."""
try:
with timeout(30):
gios = Gios(station_id, client)
await gios.update()
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
except NoStationError:
return False
return True

async def _test_sensors_data(self, client, station_id):
"""Return true if sensors data is valid."""
with timeout(30):
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
gios = Gios(station_id, client)
await gios.update()
if gios.available:
return True
return False
7 changes: 7 additions & 0 deletions homeassistant/components/gios/const.py
@@ -0,0 +1,7 @@
"""Constants for GIOS integration."""
ATTR_NAME = "name"
ATTR_STATION = "station"
CONF_STATION_ID = "station_id"
DATA_CLIENT = "client"
DEFAULT_NAME = "GIOŚ"
DOMAIN = "gios"