Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
44eea60
Add config entry for AirVisual
bachya Feb 21, 2020
acf30a5
Update coverage
bachya Feb 21, 2020
3fc9e54
Catch invalid API key from config schema
bachya Feb 21, 2020
abcb60d
Rename geographies to stations
bachya Feb 22, 2020
008538c
Revert "Rename geographies to stations"
bachya Feb 22, 2020
46fa534
Update strings
bachya Feb 22, 2020
a416c16
Update CONNECTION_CLASS
bachya Feb 22, 2020
bf4a7da
Remove options (subsequent PR)
bachya Feb 22, 2020
1c823a3
Handle import step separately
bachya Feb 22, 2020
c24357e
Code review comments and simplification
bachya Feb 22, 2020
5c7b78e
Move default geography logic to config flow
bachya Feb 22, 2020
266bff5
Register domain in config flow init
bachya Feb 22, 2020
04be2ad
Add tests
bachya Feb 22, 2020
3a6633b
Update strings
bachya Feb 22, 2020
37a41f6
Bump requirements
bachya Feb 22, 2020
014a7ce
Update homeassistant/components/airvisual/config_flow.py
bachya Feb 22, 2020
7250af0
Update homeassistant/components/airvisual/config_flow.py
bachya Feb 22, 2020
8c5879e
Make schemas stricter
bachya Feb 23, 2020
06d760d
Linting
bachya Feb 23, 2020
a4b9ed0
Linting
bachya Feb 23, 2020
ca5ab5f
Code review comments
bachya Feb 23, 2020
f4cb107
Put config flow unique ID logic into a method
bachya Feb 23, 2020
c3ce084
Fix tests
bachya Feb 23, 2020
a2c626e
Streamline
bachya Feb 25, 2020
2030d7b
Linting
bachya Feb 25, 2020
b67475a
show_on_map in options with default value
bachya Feb 28, 2020
7ca86e5
Code review comments
bachya Feb 28, 2020
98fd42f
Default options
bachya Feb 28, 2020
d8005fb
Update tests
bachya Feb 28, 2020
46a3fbf
Test update
bachya Feb 28, 2020
388bec8
Move config entry into data object (in prep for options flow)
bachya Feb 29, 2020
21eb2d8
Empty commit to re-trigger build
bachya Feb 29, 2020
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
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ omit =
homeassistant/components/airly/air_quality.py
homeassistant/components/airly/sensor.py
homeassistant/components/airly/const.py
homeassistant/components/airvisual/__init__.py
homeassistant/components/airvisual/sensor.py
homeassistant/components/aladdin_connect/cover.py
homeassistant/components/alarmdecoder/*
Expand Down
23 changes: 23 additions & 0 deletions homeassistant/components/airvisual/.translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"config": {
"abort": {
"already_configured": "This API key is already in use."
},
"error": {
"invalid_api_key": "Invalid API key"
},
"step": {
"user": {
"data": {
"api_key": "API Key",
"latitude": "Latitude",
"longitude": "Longitude",
"show_on_map": "Show monitored geography on the map"
},
"description": "Monitor air quality in a geographical location.",
"title": "Configure AirVisual"
}
},
"title": "AirVisual"
}
}
200 changes: 200 additions & 0 deletions homeassistant/components/airvisual/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,201 @@
"""The airvisual component."""
import asyncio
import logging

from pyairvisual import Client
from pyairvisual.errors import AirVisualError, InvalidKeyError
import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_SHOW_ON_MAP,
CONF_STATE,
)
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval

from .const import (
CONF_CITY,
CONF_COUNTRY,
CONF_GEOGRAPHIES,
DATA_CLIENT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
TOPIC_UPDATE,
)

_LOGGER = logging.getLogger(__name__)

DATA_LISTENER = "listener"

DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True}

CONF_NODE_ID = "node_id"

GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema(
{
vol.Required(CONF_LATITUDE): cv.latitude,
vol.Required(CONF_LONGITUDE): cv.longitude,
}
)

GEOGRAPHY_PLACE_SCHEMA = vol.Schema(
{
vol.Required(CONF_CITY): cv.string,
vol.Required(CONF_STATE): cv.string,
vol.Required(CONF_COUNTRY): cv.string,
}
)

CLOUD_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): cv.string,
vol.Optional(CONF_GEOGRAPHIES, default=[]): vol.All(
cv.ensure_list,
[vol.Any(GEOGRAPHY_COORDINATES_SCHEMA, GEOGRAPHY_PLACE_SCHEMA)],
),
}
)

CONFIG_SCHEMA = vol.Schema({DOMAIN: CLOUD_API_SCHEMA}, extra=vol.ALLOW_EXTRA)


@callback
def async_get_geography_id(geography_dict):
"""Generate a unique ID from a geography dict."""
if CONF_CITY in geography_dict:
return ",".join(
(
geography_dict[CONF_CITY],
geography_dict[CONF_STATE],
geography_dict[CONF_COUNTRY],
)
)
return ",".join(
(str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE]))
)


async def async_setup(hass, config):
"""Set up the AirVisual component."""
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_CLIENT] = {}
hass.data[DOMAIN][DATA_LISTENER] = {}

if DOMAIN not in config:
return True

conf = config[DOMAIN]

hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)

return True


async def async_setup_entry(hass, config_entry):
"""Set up AirVisual as config entry."""
entry_updates = {}
if not config_entry.unique_id:
# If the config entry doesn't already have a unique ID, set one:
entry_updates["unique_id"] = config_entry.data[CONF_API_KEY]
if not config_entry.options:
# If the config entry doesn't already have any options set, set defaults:
entry_updates["options"] = DEFAULT_OPTIONS

if entry_updates:
hass.config_entries.async_update_entry(config_entry, **entry_updates)

websession = aiohttp_client.async_get_clientsession(hass)

hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = AirVisualData(
hass, Client(websession, api_key=config_entry.data[CONF_API_KEY]), config_entry
)

try:
await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update()
except InvalidKeyError:
_LOGGER.error("Invalid API key provided")
raise ConfigEntryNotReady

hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, "sensor")
)

async def refresh(event_time):
"""Refresh data from AirVisual."""
await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update()

hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval(
hass, refresh, DEFAULT_SCAN_INTERVAL
)

return True


async def async_unload_entry(hass, config_entry):
"""Unload an AirVisual config entry."""
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)

remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id)
remove_listener()

await hass.config_entries.async_forward_entry_unload(config_entry, "sensor")

return True


class AirVisualData:
"""Define a class to manage data from the AirVisual cloud API."""

def __init__(self, hass, client, config_entry):
"""Initialize."""
self._client = client
self._hass = hass
self.data = {}
self.show_on_map = config_entry.options[CONF_SHOW_ON_MAP]

self.geographies = {
async_get_geography_id(geography): geography
for geography in config_entry.data[CONF_GEOGRAPHIES]
}

async def async_update(self):
"""Get new data for all locations from the AirVisual cloud API."""
tasks = []

for geography in self.geographies.values():
if CONF_CITY in geography:
tasks.append(
self._client.api.city(
geography[CONF_CITY],
geography[CONF_STATE],
geography[CONF_COUNTRY],
)
)
else:
tasks.append(
self._client.api.nearest_city(
geography[CONF_LATITUDE], geography[CONF_LONGITUDE],
)
)

results = await asyncio.gather(*tasks, return_exceptions=True)
for geography_id, result in zip(self.geographies, results):
if isinstance(result, AirVisualError):
_LOGGER.error("Error while retrieving data: %s", result)
self.data[geography_id] = {}
continue
self.data[geography_id] = result

_LOGGER.debug("Received new data")
async_dispatcher_send(self._hass, TOPIC_UPDATE)
92 changes: 92 additions & 0 deletions homeassistant/components/airvisual/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Define a config flow manager for AirVisual."""
from pyairvisual import Client
from pyairvisual.errors import InvalidKeyError
import voluptuous as vol

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

from .const import CONF_GEOGRAPHIES, DOMAIN # pylint: disable=unused-import


class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a AirVisual config flow."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

@property
def cloud_api_schema(self):
"""Return the data schema for the cloud API."""
return vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(
CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude,
vol.Required(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
}
)

async def _async_set_unique_id(self, unique_id):
"""Set the unique ID of the config flow and abort if it already exists."""
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()

@callback
async def _show_form(self, errors=None):
"""Show the form to the user."""
return self.async_show_form(
step_id="user", data_schema=self.cloud_api_schema, errors=errors or {},
)

async def async_step_import(self, import_config):
"""Import a config entry from configuration.yaml."""
await self._async_set_unique_id(import_config[CONF_API_KEY])

data = {**import_config}
Comment thread
Kane610 marked this conversation as resolved.
if not data.get(CONF_GEOGRAPHIES):
data[CONF_GEOGRAPHIES] = [
{
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
}
]

return self.async_create_entry(
Comment thread
bachya marked this conversation as resolved.
title=f"Cloud API (API key: {import_config[CONF_API_KEY][:4]}...)",
data=data,
)

async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
if not user_input:
return await self._show_form()

await self._async_set_unique_id(user_input[CONF_API_KEY])

websession = aiohttp_client.async_get_clientsession(self.hass)

client = Client(websession, api_key=user_input[CONF_API_KEY])

try:
await client.api.nearest_city()
except InvalidKeyError:
return await self._show_form(errors={CONF_API_KEY: "invalid_api_key"})

return self.async_create_entry(
title=f"Cloud API (API key: {user_input[CONF_API_KEY][:4]}...)",
data={
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_GEOGRAPHIES: [
{
CONF_LATITUDE: user_input[CONF_LATITUDE],
CONF_LONGITUDE: user_input[CONF_LONGITUDE],
}
],
},
)
14 changes: 14 additions & 0 deletions homeassistant/components/airvisual/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Define AirVisual constants."""
from datetime import timedelta

DOMAIN = "airvisual"

CONF_CITY = "city"
CONF_COUNTRY = "country"
CONF_GEOGRAPHIES = "geographies"

DATA_CLIENT = "client"

DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)

TOPIC_UPDATE = f"{DOMAIN}_update"
1 change: 1 addition & 0 deletions homeassistant/components/airvisual/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"domain": "airvisual",
"name": "AirVisual",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airvisual",
"requirements": ["pyairvisual==3.0.1"],
"dependencies": [],
Expand Down
Loading