-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
Add config entry for AirVisual #32072
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
Merged
Merged
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 acf30a5
Update coverage
bachya 3fc9e54
Catch invalid API key from config schema
bachya abcb60d
Rename geographies to stations
bachya 008538c
Revert "Rename geographies to stations"
bachya 46fa534
Update strings
bachya a416c16
Update CONNECTION_CLASS
bachya bf4a7da
Remove options (subsequent PR)
bachya 1c823a3
Handle import step separately
bachya c24357e
Code review comments and simplification
bachya 5c7b78e
Move default geography logic to config flow
bachya 266bff5
Register domain in config flow init
bachya 04be2ad
Add tests
bachya 3a6633b
Update strings
bachya 37a41f6
Bump requirements
bachya 014a7ce
Update homeassistant/components/airvisual/config_flow.py
bachya 7250af0
Update homeassistant/components/airvisual/config_flow.py
bachya 8c5879e
Make schemas stricter
bachya 06d760d
Linting
bachya a4b9ed0
Linting
bachya ca5ab5f
Code review comments
bachya f4cb107
Put config flow unique ID logic into a method
bachya c3ce084
Fix tests
bachya a2c626e
Streamline
bachya 2030d7b
Linting
bachya b67475a
show_on_map in options with default value
bachya 7ca86e5
Code review comments
bachya 98fd42f
Default options
bachya d8005fb
Update tests
bachya 46a3fbf
Test update
bachya 388bec8
Move config entry into data object (in prep for options flow)
bachya 21eb2d8
Empty commit to re-trigger build
bachya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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} | ||
| 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( | ||
|
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], | ||
| } | ||
| ], | ||
| }, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.