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 Airly integration #26375

Merged
merged 42 commits into from
Oct 4, 2019
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3fe4eb4
Add Airly integration
bieniu Sep 3, 2019
246deaf
Update .coveragerc file
bieniu Sep 3, 2019
756dcdf
Remove AVAILABLE_CONDITIONS and fix device_class
bieniu Sep 4, 2019
09f95a0
Don't create client on every update
bieniu Sep 5, 2019
4659cfe
Rename client to session
bieniu Sep 5, 2019
aba85f1
Rename state_attributes to device_state_attributes
bieniu Sep 5, 2019
46b5137
Remove log latitude and longitude
bieniu Sep 5, 2019
277fa45
Fix try...except
bieniu Sep 5, 2019
9c70f81
Change latitude and longitude to HA defaults
bieniu Sep 5, 2019
f27b0de
_show_config_form doesn't need coroutine
bieniu Sep 5, 2019
988a665
Simplify config_flow errors handlig
bieniu Sep 5, 2019
137299f
Preetier
bieniu Sep 5, 2019
5babee4
Remove unnecessary condition
bieniu Sep 5, 2019
1ac9ff2
Change sensor platform to air_quality
bieniu Sep 6, 2019
028810b
Remove PM1
bieniu Sep 13, 2019
f56b3d6
Make unique_id more unique
bieniu Sep 13, 2019
98080a5
Remove ,
bieniu Sep 13, 2019
d50d4c8
Add tests for config_flow
bieniu Sep 14, 2019
4ba2e2d
Move conf to CONFIG
bieniu Sep 14, 2019
7ad6a85
Merge branch 'dev' into airly
bieniu Sep 14, 2019
c784998
Remove domain from unique_id
bieniu Sep 15, 2019
08f7255
Change the way update of attrs
bieniu Sep 15, 2019
572b24e
Language and attrs
bieniu Sep 17, 2019
a620e85
Fix attrs
bieniu Sep 17, 2019
d6548cb
Merge branch 'airly' of https://github.com/bieniu/home-assistant into…
bieniu Sep 17, 2019
a8d1426
Add aiohttp error handling
bieniu Oct 1, 2019
38ad28d
Throttle as decorator
bieniu Oct 1, 2019
7681899
Suggested change
bieniu Oct 1, 2019
4b0457f
Suggested change
bieniu Oct 1, 2019
490ea7b
Invert condition
bieniu Oct 1, 2019
6354396
Cleaning
bieniu Oct 1, 2019
e475e02
Add tests
bieniu Oct 2, 2019
1f5eb77
Polish no sesnor error handling
bieniu Oct 2, 2019
bc76771
Better strings
bieniu Oct 2, 2019
8b2bff4
Fix test_invalid_api_key
bieniu Oct 2, 2019
c55f607
Fix documentation url
bieniu Oct 2, 2019
1dbb1aa
Remove unnecessary test
bieniu Oct 2, 2019
8834e72
Remove language option
bieniu Oct 2, 2019
cfaf576
Merge pull request #1 from home-assistant/dev
bieniu Oct 2, 2019
0b81c50
Fix test_invalid_api_key once again
bieniu Oct 3, 2019
703e9a8
Sort imports
bieniu Oct 3, 2019
c41d18e
Remove splits in strings
bieniu Oct 3, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ omit =
homeassistant/components/adguard/switch.py
homeassistant/components/ads/*
homeassistant/components/aftership/sensor.py
homeassistant/components/airly/__init__.py
homeassistant/components/airly/air_quality.py
homeassistant/components/airly/const.py
homeassistant/components/airvisual/sensor.py
homeassistant/components/aladdin_connect/cover.py
homeassistant/components/alarm_control_panel/manual_mqtt.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ homeassistant/scripts/check_config.py @kellerza

# Integrations
homeassistant/components/adguard/* @frenck
homeassistant/components/airly/* @bieniu
homeassistant/components/airvisual/* @bachya
homeassistant/components/alarm_control_panel/* @colinodell
homeassistant/components/alpha_vantage/* @fabaff
Expand Down
21 changes: 21 additions & 0 deletions homeassistant/components/airly/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""The Airly component."""
from homeassistant.core import Config, HomeAssistant


async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up configured Airly."""
return True


async def async_setup_entry(hass, config_entry):
"""Set up Airly as config entry."""
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."""
await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality")
return True
201 changes: 201 additions & 0 deletions homeassistant/components/airly/air_quality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Support for the Airly service."""
import asyncio
import logging
from datetime import timedelta

import async_timeout
from aiohttp.client_exceptions import ClientConnectorError
from airly import Airly
from airly.exceptions import AirlyError

from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.components.air_quality import (
AirQualityEntity,
ATTR_AQI,
ATTR_PM_10,
ATTR_PM_2_5,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import Throttle

from .const import NO_AIRLY_SENSORS

_LOGGER = logging.getLogger(__name__)

ATTRIBUTION = "Data provided by Airly"

ATTR_API_ADVICE = "ADVICE"
ATTR_API_CAQI = "CAQI"
ATTR_API_CAQI_DESCRIPTION = "DESCRIPTION"
ATTR_API_CAQI_LEVEL = "LEVEL"
ATTR_API_PM10 = "PM10"
ATTR_API_PM10_LIMIT = "PM10_LIMIT"
ATTR_API_PM10_PERCENT = "PM10_PERCENT"
ATTR_API_PM25 = "PM25"
ATTR_API_PM25_LIMIT = "PM25_LIMIT"
ATTR_API_PM25_PERCENT = "PM25_PERCENT"

LABEL_ADVICE = "advice"
LABEL_AQI_LEVEL = f"{ATTR_AQI}_level"
LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit"
LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit"
LABEL_PM_10_LIMIT = f"{ATTR_PM_10}_limit"
LABEL_PM_10_PERCENT = f"{ATTR_PM_10}_percent_of_limit"

DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)


async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add a Airly entities from a config_entry."""
api_key = config_entry.data[CONF_API_KEY]
name = config_entry.data[CONF_NAME]
latitude = config_entry.data[CONF_LATITUDE]
longitude = config_entry.data[CONF_LONGITUDE]

websession = async_get_clientsession(hass)

data = AirlyData(websession, api_key, latitude, longitude)

async_add_entities([AirlyAirQuality(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 AirlyAirQuality(AirQualityEntity):
"""Define an Airly air_quality."""

def __init__(self, airly, name):
"""Initialize."""
self.airly = airly
self.data = airly.data
self._name = name
self._pm_2_5 = None
self._pm_10 = None
self._aqi = None
self._icon = "mdi:blur"
self._attrs = {}

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

@property
def icon(self):
"""Return the icon."""
return self._icon

@property
@round_state
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
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION

@property
def state(self):
"""Return the CAQI description."""
return self.data[ATTR_API_CAQI_DESCRIPTION]

@property
def unique_id(self):
"""Return a unique_id for this entity."""
return f"{self.airly.latitude}-{self.airly.longitude}"

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

@property
def device_state_attributes(self):
"""Return the state attributes."""
self._attrs[LABEL_ADVICE] = self.data[ATTR_API_ADVICE]
self._attrs[LABEL_AQI_LEVEL] = self.data[ATTR_API_CAQI_LEVEL]
self._attrs[LABEL_PM_2_5_LIMIT] = self.data[ATTR_API_PM25_LIMIT]
self._attrs[LABEL_PM_2_5_PERCENT] = round(self.data[ATTR_API_PM25_PERCENT])
self._attrs[LABEL_PM_10_LIMIT] = self.data[ATTR_API_PM10_LIMIT]
self._attrs[LABEL_PM_10_PERCENT] = round(self.data[ATTR_API_PM10_PERCENT])
return self._attrs

async def async_update(self):
"""Get the data from Airly."""
await self.airly.async_update()

self._pm_10 = self.data[ATTR_API_PM10]
self._pm_2_5 = self.data[ATTR_API_PM25]
self._aqi = self.data[ATTR_API_CAQI]


class AirlyData:
"""Define an object to hold sensor data."""

def __init__(self, session, api_key, latitude, longitude):
"""Initialize."""
self.latitude = latitude
self.longitude = longitude
self.airly = Airly(api_key, session)
self.data = {}

@Throttle(DEFAULT_SCAN_INTERVAL)
async def async_update(self):
"""Update Airly data."""

try:
with async_timeout.timeout(10):
measurements = self.airly.create_measurements_session_point(
self.latitude, self.longitude
)
await measurements.update()

values = measurements.current["values"]
index = measurements.current["indexes"][0]
standards = measurements.current["standards"]

if index["description"] == NO_AIRLY_SENSORS:
_LOGGER.error("Can't retrieve data: no Airly sensors in this area")
return
for value in values:
self.data[value["name"]] = value["value"]
for standard in standards:
self.data[f"{standard['pollutant']}_LIMIT"] = standard["limit"]
self.data[f"{standard['pollutant']}_PERCENT"] = standard["percent"]
self.data[ATTR_API_CAQI] = index["value"]
self.data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ")
self.data[ATTR_API_CAQI_DESCRIPTION] = index["description"]
self.data[ATTR_API_ADVICE] = index["advice"]
_LOGGER.debug("Data retrieved from Airly")
except (
ValueError,
AirlyError,
asyncio.TimeoutError,
ClientConnectorError,
) as error:
_LOGGER.error(error)
self.data = {}
114 changes: 114 additions & 0 deletions homeassistant/components/airly/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Adds config flow for Airly."""
import async_timeout
import voluptuous as vol
from airly import Airly
from airly.exceptions import AirlyError

import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS


@callback
def configured_instances(hass):
"""Return a set of configured Airly instances."""
return set(
entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN)
)


class AirlyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Airly."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

def __init__(self):
"""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):
self._errors[CONF_NAME] = "name_exists"
api_key_valid = await self._test_api_key(websession, user_input["api_key"])
if not api_key_valid:
self._errors["base"] = "auth"
else:
location_valid = await self._test_location(
websession,
user_input["api_key"],
user_input["latitude"],
user_input["longitude"],
)
if not location_valid:
self._errors["base"] = "wrong_location"

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,
api_key="",
latitude=self.hass.config.latitude,
longitude=self.hass.config.longitude,
)

def _show_config_form(self, name=None, api_key=None, latitude=None, longitude=None):
"""Show the configuration form to edit data."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY, default=api_key): str,
vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude,
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Optional(CONF_NAME, default=name): str,
}
),
errors=self._errors,
)

async def _test_api_key(self, client, api_key):
"""Return true if api_key is valid."""

with async_timeout.timeout(10):
airly = Airly(api_key, client)
measurements = airly.create_measurements_session_point(
latitude=52.24131, longitude=20.99101
)
try:
await measurements.update()
except AirlyError:
return False
return True

async def _test_location(self, client, api_key, latitude, longitude):
"""Return true if location is valid."""

with async_timeout.timeout(10):
airly = Airly(api_key, client)
measurements = airly.create_measurements_session_point(
latitude=latitude, longitude=longitude
)

await measurements.update()
current = measurements.current
if current["indexes"][0]["description"] == NO_AIRLY_SENSORS:
return False
return True
4 changes: 4 additions & 0 deletions homeassistant/components/airly/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Constants for Airly integration."""
DEFAULT_NAME = "Airly"
DOMAIN = "airly"
NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet."
9 changes: 9 additions & 0 deletions homeassistant/components/airly/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"domain": "airly",
"name": "Airly",
"documentation": "https://www.home-assistant.io/integrations/airly",
"dependencies": [],
"codeowners": ["@bieniu"],
"requirements": ["airly==0.0.2"],
"config_flow": true
}
22 changes: 22 additions & 0 deletions homeassistant/components/airly/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"config": {
"title": "Airly",
"step": {
"user": {
"title": "Airly",
"description": "Set up Airly air quality integration. To generate API key go to https://developer.airly.eu/register",
"data": {
"name": "Name of the integration",
"api_key": "Airly API key",
"latitude": "Latitude",
"longitude": "Longitude"
}
}
},
"error": {
"name_exists": "Name already exists.",
"wrong_location": "No Airly measuring stations in this area.",
"auth": "API key is not correct."
}
}
}
Loading