diff --git a/.coveragerc b/.coveragerc index 70d8f867e0e42db724c88ad35c4832b45bdc5f81..10d56c4701d09d15b2bff2cd39b8b11db214170f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -258,6 +258,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 diff --git a/CODEOWNERS b/CODEOWNERS index 1df0d2741cdfdee25be504514a9f812ea9d875ad..f5357d1348c2cf6982880df7466979d38495d9f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..981de6395deaed481cc91af7e0e02992b78f60cc --- /dev/null +++ b/homeassistant/components/gios/__init__.py @@ -0,0 +1,78 @@ +"""The GIOS component.""" +import asyncio +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, DEFAULT_SCAN_INTERVAL, DOMAIN + +_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 + + 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 + + +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 diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..f7285c8cc5a851eaea8d9ca6598fc0794283e7ff --- /dev/null +++ b/homeassistant/components/gios/air_quality.py @@ -0,0 +1,158 @@ +"""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, DEFAULT_SCAN_INTERVAL, DOMAIN, ICONS_MAP + +ATTRIBUTION = "Data provided by GIOŚ" +SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL + + +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._attrs = {} + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def icon(self): + """Return the icon.""" + if self._aqi in ICONS_MAP: + return ICONS_MAP[self._aqi] + return "mdi:blur" + + @property + def air_quality_index(self): + """Return the air quality index.""" + return self._aqi + + @property + @round_state + 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 unique_id(self): + """Return a unique_id for this entity.""" + return self.gios.station_id + + @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"] diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..368d610c22640e28eb4cd683ded5ed2a71964233 --- /dev/null +++ b/homeassistant/components/gios/config_flow.py @@ -0,0 +1,65 @@ +"""Adds config flow for GIOS.""" +import asyncio + +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +from gios import ApiError, Gios, NoStationError +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_STATION_ID, DEFAULT_NAME, DOMAIN # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATION_ID): int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + } +) + + +class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for GIOS.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + try: + await self.async_set_unique_id( + user_input[CONF_STATION_ID], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + websession = async_get_clientsession(self.hass) + + with timeout(30): + gios = Gios(user_input[CONF_STATION_ID], websession) + await gios.update() + + if not gios.available: + raise InvalidSensorsData() + + return self.async_create_entry( + title=user_input[CONF_STATION_ID], data=user_input, + ) + except (ApiError, ClientConnectorError, asyncio.TimeoutError): + errors["base"] = "cannot_connect" + except NoStationError: + errors[CONF_STATION_ID] = "wrong_station_id" + except InvalidSensorsData: + errors[CONF_STATION_ID] = "invalid_sensors_data" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class InvalidSensorsData(exceptions.HomeAssistantError): + """Error to indicate invalid sensors data.""" diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py new file mode 100644 index 0000000000000000000000000000000000000000..3588b5e8dfcf0678a66e3b6ea3f3f73eb138922b --- /dev/null +++ b/homeassistant/components/gios/const.py @@ -0,0 +1,25 @@ +"""Constants for GIOS integration.""" +from datetime import timedelta + +ATTR_NAME = "name" +ATTR_STATION = "station" +CONF_STATION_ID = "station_id" +DATA_CLIENT = "client" +DEFAULT_NAME = "GIOŚ" +# Term of service GIOŚ allow downloading data no more than twice an hour. +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) +DOMAIN = "gios" + +AQI_GOOD = "dobry" +AQI_MODERATE = "umiarkowany" +AQI_POOR = "dostateczny" +AQI_VERY_GOOD = "bardzo dobry" +AQI_VERY_POOR = "zły" + +ICONS_MAP = { + AQI_VERY_GOOD: "mdi:emoticon-excited", + AQI_GOOD: "mdi:emoticon-happy", + AQI_MODERATE: "mdi:emoticon-neutral", + AQI_POOR: "mdi:emoticon-sad", + AQI_VERY_POOR: "mdi:emoticon-dead", +} diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..b3d125d8ab6424fe380c82380726d6df3f965433 --- /dev/null +++ b/homeassistant/components/gios/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "gios", + "name": "GIOŚ", + "documentation": "https://www.home-assistant.io/integrations/gios", + "dependencies": [], + "codeowners": ["@bieniu"], + "requirements": ["gios==0.0.3"], + "config_flow": true +} diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..cc05a471b4a1387b32151c1a7e29449f1c77ad83 --- /dev/null +++ b/homeassistant/components/gios/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "GIOŚ", + "step": { + "user": { + "title": "GIOŚ (Polish Chief Inspectorate Of Environmental Protection)", + "description": "Set up GIOŚ (Polish Chief Inspectorate Of Environmental Protection) air quality integration. If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/gios", + "data": { + "name": "Name of the integration", + "station_id": "ID of the measuring station" + } + } + }, + "error": { + "wrong_station_id": "ID of the measuring station is not correct.", + "invalid_sensors_data": "Invalid sensors' data for this measuring station.", + "cannot_connect": "Cannot connect to the GIOŚ server." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 88ff92a57b05930ed01ba188d25507f74e877fc5..55a4d76fdcd21aea3b7317bf0fd7ccfe17bd7830 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -26,6 +26,7 @@ FLOWS = [ "geofency", "geonetnz_quakes", "geonetnz_volcano", + "gios", "glances", "gpslogger", "hangouts", diff --git a/requirements_all.txt b/requirements_all.txt index ed6a433616a031f8413e03a610ca53a374bde326..d84815a42f312d7ace525fa33b6b7c6f55c4d351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -578,6 +578,9 @@ georss_qld_bushfire_alert_client==0.3 # homeassistant.components.nmap_tracker getmac==0.8.1 +# homeassistant.components.gios +gios==0.0.3 + # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf62fd652888f66a59652b3d72a9a77b05c31cff..d197ed0196afa311ab583fd8400bf2ddb75d243c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,6 +197,9 @@ georss_qld_bushfire_alert_client==0.3 # homeassistant.components.nmap_tracker getmac==0.8.1 +# homeassistant.components.gios +gios==0.0.3 + # homeassistant.components.glances glances_api==0.2.0 diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..98528fda9f91b923cf8e91a1f44ed89d79ae593b --- /dev/null +++ b/tests/components/gios/__init__.py @@ -0,0 +1 @@ +"""Tests for GIOS.""" diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..3a4aff6d9ad6950c30b402465ba52d9bf05c0a9c --- /dev/null +++ b/tests/components/gios/test_config_flow.py @@ -0,0 +1,104 @@ +"""Define tests for the GIOS config flow.""" +from asynctest import patch +from gios import ApiError + +from homeassistant import data_entry_flow +from homeassistant.components.gios import config_flow +from homeassistant.components.gios.const import CONF_STATION_ID +from homeassistant.const import CONF_NAME + +CONFIG = { + CONF_NAME: "Foo", + CONF_STATION_ID: 123, +} + +VALID_STATIONS = [ + {"id": 123, "stationName": "Test Name 1", "gegrLat": "99.99", "gegrLon": "88.88"}, + {"id": 321, "stationName": "Test Name 2", "gegrLat": "77.77", "gegrLon": "66.66"}, +] + +VALID_STATION = [ + {"id": 3764, "param": {"paramName": "particulate matter PM10", "paramCode": "PM10"}} +] + +VALID_INDEXES = { + "stIndexLevel": {"id": 1, "indexLevelName": "Good"}, + "pm10IndexLevel": {"id": 0, "indexLevelName": "Very good"}, +} + +VALID_SENSOR = {"key": "PM10", "values": [{"value": 11.11}]} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.GiosFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_invalid_station_id(hass): + """Test that errors are shown when measuring station ID is invalid.""" + with patch("gios.Gios._get_stations", return_value=VALID_STATIONS): + flow = config_flow.GiosFlowHandler() + flow.hass = hass + flow.context = {} + + result = await flow.async_step_user( + user_input={CONF_NAME: "Foo", CONF_STATION_ID: 0} + ) + + assert result["errors"] == {CONF_STATION_ID: "wrong_station_id"} + + +async def test_invalid_sensor_data(hass): + """Test that errors are shown when sensor data is invalid.""" + with patch("gios.Gios._get_stations", return_value=VALID_STATIONS), patch( + "gios.Gios._get_station", return_value=VALID_STATION + ), patch("gios.Gios._get_station", return_value=VALID_STATION), patch( + "gios.Gios._get_sensor", return_value={} + ): + flow = config_flow.GiosFlowHandler() + flow.hass = hass + flow.context = {} + + result = await flow.async_step_user(user_input=CONFIG) + + assert result["errors"] == {CONF_STATION_ID: "invalid_sensors_data"} + + +async def test_cannot_connect(hass): + """Test that errors are shown when cannot connect to GIOS server.""" + with patch("gios.Gios._async_get", side_effect=ApiError("error")): + flow = config_flow.GiosFlowHandler() + flow.hass = hass + flow.context = {} + + result = await flow.async_step_user(user_input=CONFIG) + + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_create_entry(hass): + """Test that the user step works.""" + with patch("gios.Gios._get_stations", return_value=VALID_STATIONS), patch( + "gios.Gios._get_station", return_value=VALID_STATION + ), patch("gios.Gios._get_station", return_value=VALID_STATION), patch( + "gios.Gios._get_sensor", return_value=VALID_SENSOR + ), patch( + "gios.Gios._get_indexes", return_value=VALID_INDEXES + ): + flow = config_flow.GiosFlowHandler() + flow.hass = hass + flow.context = {} + + result = await flow.async_step_user(user_input=CONFIG) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONFIG[CONF_STATION_ID] + assert result["data"][CONF_STATION_ID] == CONFIG[CONF_STATION_ID] + + assert flow.context["unique_id"] == CONFIG[CONF_STATION_ID]