From fbe85a2eb29054abaeb1679211a3a8899e7bfc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Mrozek?= Date: Sun, 22 Sep 2019 23:49:09 +0200 Subject: [PATCH] Add Kaiterra integration (#26661) * add Kaiterra integration * fix: split to multiple platforms * fix lint issues * fix formmating * fix: docstrings * fix: pylint issues * Apply suggestions from code review Co-Authored-By: Martin Hjelmare * Adjust code based on suggestions * Update homeassistant/components/kaiterra/sensor.py Co-Authored-By: Martin Hjelmare --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/kaiterra/__init__.py | 92 ++++++++++++++ .../components/kaiterra/air_quality.py | 115 ++++++++++++++++++ homeassistant/components/kaiterra/api_data.py | 109 +++++++++++++++++ homeassistant/components/kaiterra/const.py | 57 +++++++++ .../components/kaiterra/manifest.json | 8 ++ homeassistant/components/kaiterra/sensor.py | 95 +++++++++++++++ requirements_all.txt | 3 + 9 files changed, 481 insertions(+) create mode 100644 homeassistant/components/kaiterra/__init__.py create mode 100644 homeassistant/components/kaiterra/air_quality.py create mode 100644 homeassistant/components/kaiterra/api_data.py create mode 100644 homeassistant/components/kaiterra/const.py create mode 100644 homeassistant/components/kaiterra/manifest.json create mode 100644 homeassistant/components/kaiterra/sensor.py diff --git a/.coveragerc b/.coveragerc index 302ff946554..65ee6e9e259 100644 --- a/.coveragerc +++ b/.coveragerc @@ -318,6 +318,7 @@ omit = homeassistant/components/itunes/media_player.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/* + homeassistant/components/kaiterra/* homeassistant/components/kankun/switch.py homeassistant/components/keba/* homeassistant/components/keenetic_ndms2/device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index abd3379221a..640a9a7bcc0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -146,6 +146,7 @@ homeassistant/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/izone/* @Swamp-Ig homeassistant/components/jewish_calendar/* @tsvi +homeassistant/components/kaiterra/* @Michsior14 homeassistant/components/keba/* @dannerph homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills diff --git a/homeassistant/components/kaiterra/__init__.py b/homeassistant/components/kaiterra/__init__.py new file mode 100644 index 00000000000..8c61ad54184 --- /dev/null +++ b/homeassistant/components/kaiterra/__init__.py @@ -0,0 +1,92 @@ +"""Support for Kaiterra devices.""" +import voluptuous as vol + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers import config_validation as cv + +from homeassistant.const import ( + CONF_API_KEY, + CONF_DEVICES, + CONF_DEVICE_ID, + CONF_SCAN_INTERVAL, + CONF_TYPE, + CONF_NAME, +) + +from .const import ( + AVAILABLE_AQI_STANDARDS, + AVAILABLE_UNITS, + AVAILABLE_DEVICE_TYPES, + CONF_AQI_STANDARD, + CONF_PREFERRED_UNITS, + DOMAIN, + DEFAULT_AQI_STANDARD, + DEFAULT_PREFERRED_UNIT, + DEFAULT_SCAN_INTERVAL, + KAITERRA_COMPONENTS, +) + +from .api_data import KaiterraApiData + +KAITERRA_DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_TYPE): vol.In(AVAILABLE_DEVICE_TYPES), + vol.Optional(CONF_NAME): cv.string, + } +) + +KAITERRA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [KAITERRA_DEVICE_SCHEMA]), + vol.Optional(CONF_AQI_STANDARD, default=DEFAULT_AQI_STANDARD): vol.In( + AVAILABLE_AQI_STANDARDS + ), + vol.Optional(CONF_PREFERRED_UNITS, default=DEFAULT_PREFERRED_UNIT): vol.All( + cv.ensure_list, [vol.In(AVAILABLE_UNITS)] + ), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, + } +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: KAITERRA_SCHEMA}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Kaiterra components.""" + + conf = config[DOMAIN] + scan_interval = conf[CONF_SCAN_INTERVAL] + devices = conf[CONF_DEVICES] + session = async_get_clientsession(hass) + api = hass.data[DOMAIN] = KaiterraApiData(hass, conf, session) + + await api.async_update() + + async def _update(now=None): + """Periodic update.""" + await api.async_update() + + async_track_time_interval(hass, _update, scan_interval) + + # Load platforms for each device + for device in devices: + device_name, device_id = ( + device.get(CONF_NAME) or device[CONF_TYPE], + device[CONF_DEVICE_ID], + ) + for component in KAITERRA_COMPONENTS: + hass.async_create_task( + async_load_platform( + hass, + component, + DOMAIN, + {CONF_NAME: device_name, CONF_DEVICE_ID: device_id}, + config, + ) + ) + + return True diff --git a/homeassistant/components/kaiterra/air_quality.py b/homeassistant/components/kaiterra/air_quality.py new file mode 100644 index 00000000000..4dfe04f9c2e --- /dev/null +++ b/homeassistant/components/kaiterra/air_quality.py @@ -0,0 +1,115 @@ +"""Support for Kaiterra Air Quality Sensors.""" +from homeassistant.components.air_quality import AirQualityEntity + +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME + +from .const import ( + DOMAIN, + ATTR_VOC, + ATTR_AQI_LEVEL, + ATTR_AQI_POLLUTANT, + DISPATCHER_KAITERRA, +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the air_quality kaiterra sensor.""" + if discovery_info is None: + return + + api = hass.data[DOMAIN] + name = discovery_info[CONF_NAME] + device_id = discovery_info[CONF_DEVICE_ID] + + async_add_entities([KaiterraAirQuality(api, name, device_id)]) + + +class KaiterraAirQuality(AirQualityEntity): + """Implementation of a Kaittera air quality sensor.""" + + def __init__(self, api, name, device_id): + """Initialize the sensor.""" + self._api = api + self._name = f"{name} Air Quality" + self._device_id = device_id + + def _data(self, key): + return self._device.get(key, {}).get("value") + + @property + def _device(self): + return self._api.data.get(self._device_id, {}) + + @property + def should_poll(self): + """Return that the sensor should not be polled.""" + return False + + @property + def available(self): + """Return the availability of the sensor.""" + return self._api.data.get(self._device_id) is not None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def air_quality_index(self): + """Return the Air Quality Index (AQI).""" + return self._data("aqi") + + @property + def air_quality_index_level(self): + """Return the Air Quality Index level.""" + return self._data("aqi_level") + + @property + def air_quality_index_pollutant(self): + """Return the Air Quality Index level.""" + return self._data("aqi_pollutant") + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self._data("rpm25c") + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self._data("rpm10c") + + @property + def volatile_organic_compounds(self): + """Return the VOC (Volatile Organic Compounds) level.""" + return self._data("rtvoc") + + @property + def unique_id(self): + """Return the sensor's unique id.""" + return f"{self._device_id}_air_quality" + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {} + attributes = [ + (ATTR_VOC, self.volatile_organic_compounds), + (ATTR_AQI_LEVEL, self.air_quality_index_level), + (ATTR_AQI_POLLUTANT, self.air_quality_index_pollutant), + ] + + for attr, value in attributes: + if value is not None: + data[attr] = value + + return data + + async def async_added_to_hass(self): + """Register callback.""" + async_dispatcher_connect( + self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + ) diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py new file mode 100644 index 00000000000..0c2d6d93661 --- /dev/null +++ b/homeassistant/components/kaiterra/api_data.py @@ -0,0 +1,109 @@ +"""Data for all Kaiterra devices.""" +from logging import getLogger + +import asyncio + +import async_timeout + +from aiohttp.client_exceptions import ClientResponseError + +from kaiterra_async_client import KaiterraAPIClient, AQIStandard, Units + +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_DEVICE_ID, CONF_TYPE + +from .const import ( + AQI_SCALE, + AQI_LEVEL, + CONF_AQI_STANDARD, + CONF_PREFERRED_UNITS, + DISPATCHER_KAITERRA, +) + +_LOGGER = getLogger(__name__) + +POLLUTANTS = {"rpm25c": "PM2.5", "rpm10c": "PM10", "rtvoc": "TVOC"} + + +class KaiterraApiData: + """Get data from Kaiterra API.""" + + def __init__(self, hass, config, session): + """Initialize the API data object.""" + + api_key = config[CONF_API_KEY] + aqi_standard = config[CONF_AQI_STANDARD] + devices = config[CONF_DEVICES] + units = config[CONF_PREFERRED_UNITS] + + self._hass = hass + self._api = KaiterraAPIClient( + session, + api_key=api_key, + aqi_standard=AQIStandard.from_str(aqi_standard), + preferred_units=[Units.from_str(unit) for unit in units], + ) + self._devices_ids = [device[CONF_DEVICE_ID] for device in devices] + self._devices = [ + f"/{device[CONF_TYPE]}s/{device[CONF_DEVICE_ID]}" for device in devices + ] + self._scale = AQI_SCALE[aqi_standard] + self._level = AQI_LEVEL[aqi_standard] + self._update_listeners = [] + self.data = {} + + async def async_update(self) -> None: + """Get the data from Kaiterra API.""" + + try: + with async_timeout.timeout(10): + data = await self._api.get_latest_sensor_readings(self._devices) + except (ClientResponseError, asyncio.TimeoutError): + _LOGGER.debug("Couldn't fetch data") + self.data = {} + async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) + + _LOGGER.debug("New data retrieved: %s", data) + + try: + self.data = {} + for i, device in enumerate(data): + if not device: + self.data[self._devices_ids[i]] = {} + continue + + aqi, main_pollutant = None, None + for sensor_name, sensor in device.items(): + points = sensor.get("points") + + if not points: + continue + + point = points[0] + sensor["value"] = point.get("value") + + if "aqi" not in point: + continue + + sensor["aqi"] = point["aqi"] + if not aqi or aqi < point["aqi"]: + aqi = point["aqi"] + main_pollutant = POLLUTANTS.get(sensor_name) + + level = None + for j in range(1, len(self._scale)): + if aqi <= self._scale[j]: + level = self._level[j - 1] + break + + device["aqi"] = {"value": aqi} + device["aqi_level"] = {"value": level} + device["aqi_pollutant"] = {"value": main_pollutant} + + self.data[self._devices_ids[i]] = device + + async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) + except IndexError as err: + _LOGGER.error("Parsing error %s", err) + async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py new file mode 100644 index 00000000000..7e23edb1259 --- /dev/null +++ b/homeassistant/components/kaiterra/const.py @@ -0,0 +1,57 @@ +"""Consts for Kaiterra integration.""" + +from datetime import timedelta + +DOMAIN = "kaiterra" + +DISPATCHER_KAITERRA = "kaiterra_update" + +AQI_SCALE = { + "cn": [0, 50, 100, 150, 200, 300, 400, 500], + "in": [0, 50, 100, 200, 300, 400, 500], + "us": [0, 50, 100, 150, 200, 300, 500], +} +AQI_LEVEL = { + "cn": [ + "Good", + "Satisfactory", + "Moderate", + "Unhealthy for sensitive groups", + "Unhealthy", + "Very unhealthy", + "Hazardous", + ], + "in": [ + "Good", + "Satisfactory", + "Moderately polluted", + "Poor", + "Very poor", + "Severe", + ], + "us": [ + "Good", + "Moderate", + "Unhealthy for sensitive groups", + "Unhealthy", + "Very unhealthy", + "Hazardous", + ], +} + +ATTR_VOC = "volatile_organic_compounds" +ATTR_AQI_LEVEL = "air_quality_index_level" +ATTR_AQI_POLLUTANT = "air_quality_index_pollutant" + +AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"] +AVAILABLE_UNITS = ["x", "%", "C", "F", "mg/m³", "µg/m³", "ppm", "ppb"] +AVAILABLE_DEVICE_TYPES = ["laseregg", "sensedge"] + +CONF_AQI_STANDARD = "aqi_standard" +CONF_PREFERRED_UNITS = "preferred_units" + +DEFAULT_AQI_STANDARD = "us" +DEFAULT_PREFERRED_UNIT = [] +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +KAITERRA_COMPONENTS = ["sensor", "air_quality"] diff --git a/homeassistant/components/kaiterra/manifest.json b/homeassistant/components/kaiterra/manifest.json new file mode 100644 index 00000000000..926f73fa4db --- /dev/null +++ b/homeassistant/components/kaiterra/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "kaiterra", + "name": "Kaiterra", + "documentation": "https://www.home-assistant.io/components/kaiterra", + "requirements": ["kaiterra-async-client==0.0.2"], + "codeowners": ["@Michsior14"], + "dependencies": [] +} \ No newline at end of file diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py new file mode 100644 index 00000000000..4ff6435b64d --- /dev/null +++ b/homeassistant/components/kaiterra/sensor.py @@ -0,0 +1,95 @@ +"""Support for Kaiterra Temperature ahn Humidity Sensors.""" +from homeassistant.helpers.entity import Entity + +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from .const import DOMAIN, DISPATCHER_KAITERRA + +SENSORS = [ + {"name": "Temperature", "prop": "rtemp", "device_class": "temperature"}, + {"name": "Humidity", "prop": "rhumid", "device_class": "humidity"}, +] + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the kaiterra temperature and humidity sensor.""" + if discovery_info is None: + return + + api = hass.data[DOMAIN] + name = discovery_info[CONF_NAME] + device_id = discovery_info[CONF_DEVICE_ID] + + async_add_entities( + [KaiterraSensor(api, name, device_id, sensor) for sensor in SENSORS] + ) + + +class KaiterraSensor(Entity): + """Implementation of a Kaittera sensor.""" + + def __init__(self, api, name, device_id, sensor): + """Initialize the sensor.""" + self._api = api + self._name = f'{name} {sensor["name"]}' + self._device_id = device_id + self._kind = sensor["name"].lower() + self._property = sensor["prop"] + self._device_class = sensor["device_class"] + + @property + def _sensor(self): + """Return the sensor data.""" + return self._api.data.get(self._device_id, {}).get(self._property, {}) + + @property + def should_poll(self): + """Return that the sensor should not be polled.""" + return False + + @property + def available(self): + """Return the availability of the sensor.""" + return self._api.data.get(self._device_id) is not None + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._sensor.get("value") + + @property + def unique_id(self): + """Return the sensor's unique id.""" + return f"{self._device_id}_{self._kind}" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if not self._sensor.get("units"): + return None + + value = self._sensor["units"].value + + if value == "F": + return TEMP_FAHRENHEIT + if value == "C": + return TEMP_CELSIUS + return value + + async def async_added_to_hass(self): + """Register callback.""" + async_dispatcher_connect( + self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + ) diff --git a/requirements_all.txt b/requirements_all.txt index 939fcc27978..250643f745d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -707,6 +707,9 @@ jsonrpc-async==0.6 # homeassistant.components.kodi jsonrpc-websocket==0.6 +# homeassistant.components.kaiterra +kaiterra-async-client==0.0.2 + # homeassistant.components.keba keba-kecontact==0.2.0