async def async_step_geography_finish(self, user_input, error_step, error_schema): """Validate a Cloud API key.""" websession = aiohttp_client.async_get_clientsession(self.hass) cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) # If this is the first (and only the first) time we've seen this API key, check # that it's valid: valid_keys = self.hass.data.setdefault("airvisual_checked_api_keys", set()) valid_keys_lock = self.hass.data.setdefault( "airvisual_checked_api_keys_lock", asyncio.Lock() ) async with valid_keys_lock: if user_input[CONF_API_KEY] not in valid_keys: try: await cloud_api.air_quality.nearest_city() except InvalidKeyError: return self.async_show_form( step_id=error_step, data_schema=error_schema, errors={CONF_API_KEY: "invalid_api_key"}, ) valid_keys.add(user_input[CONF_API_KEY]) existing_entry = await self.async_set_unique_id(self._geo_id) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=user_input) return self.async_abort(reason="reauth_successful") return self.async_create_entry( title=f"Cloud API ({self._geo_id})", data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY}, )
async def _async_finish_geography( self, user_input: dict[str, str], integration_type: str ) -> FlowResult: """Validate a Cloud API key.""" websession = aiohttp_client.async_get_clientsession(self.hass) cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) # If this is the first (and only the first) time we've seen this API key, check # that it's valid: valid_keys = self.hass.data.setdefault("airvisual_checked_api_keys", set()) valid_keys_lock = self.hass.data.setdefault( "airvisual_checked_api_keys_lock", asyncio.Lock() ) if integration_type == INTEGRATION_TYPE_GEOGRAPHY_COORDS: coro = cloud_api.air_quality.nearest_city() error_schema = self.geography_coords_schema error_step = "geography_by_coords" else: coro = cloud_api.air_quality.city( user_input[CONF_CITY], user_input[CONF_STATE], user_input[CONF_COUNTRY] ) error_schema = GEOGRAPHY_NAME_SCHEMA error_step = "geography_by_name" async with valid_keys_lock: if user_input[CONF_API_KEY] not in valid_keys: try: await coro except InvalidKeyError: return self.async_show_form( step_id=error_step, data_schema=error_schema, errors={CONF_API_KEY: "invalid_api_key"}, ) except NotFoundError: return self.async_show_form( step_id=error_step, data_schema=error_schema, errors={CONF_CITY: "location_not_found"}, ) except AirVisualError as err: LOGGER.error(err) return self.async_show_form( step_id=error_step, data_schema=error_schema, errors={"base": "unknown"}, ) valid_keys.add(user_input[CONF_API_KEY]) existing_entry = await self.async_set_unique_id(self._geo_id) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=user_input) return self.async_abort(reason="reauth_successful") return self.async_create_entry( title=f"Cloud API ({self._geo_id})", data={**user_input, CONF_INTEGRATION_TYPE: integration_type}, )
async def _async_get_air_quality(self) -> AirQuality: cloud_api = CloudAPI(self.api_key) # ...or get it explicitly: data = await cloud_api.air_quality.nearest_city( latitude=self.latitude, longitude=self.longitude) aqi = data['current']['pollution']['aqius'] return self.convert_aqi(aqi)
async def test_node_not_found(aresponses): """Test that the proper error is raised when no Pro node is found.""" aresponses.add( "www.airvisual.com", "/api/v2/node/12345", "get", aresponses.Response( text='"node not found"', headers={"Content-Type": "application/json"}, status=200, ), ) with pytest.raises(NodeProError): async with ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) await cloud_api.node.get_by_node_id("12345")
async def test_cities(aresponses): """Test getting a list of supported cities.""" aresponses.add( "api.airvisual.com", "/v2/cities", "get", aresponses.Response( text=load_fixture("cities_response.json"), headers={"Content-Type": "application/json"}, status=200, ), ) async with aiohttp.ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) data = await cloud_api.supported.cities(TEST_COUNTRY, TEST_STATE) assert len(data) == 27
async def test_non_json_response(aresponses): """Test that the proper error is raised when the response text isn't JSON.""" aresponses.add( "www.airvisual.com", "/api/v2/node/12345", "get", aresponses.Response( text="This is a valid response, but it isn't JSON.", headers={"Content-Type": "application/json"}, status=200, ), ) with pytest.raises(AirVisualError): async with ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) await cloud_api.node.get_by_node_id("12345")
async def test_permission_denied(aresponses): """Test that the proper error is raised when permission is denied.""" aresponses.add( "api.airvisual.com", "/v2/nearest_station", "get", aresponses.Response( text=load_fixture("error_permission_denied_response.json"), headers={"Content-Type": "application/json"}, status=401, ), ) with pytest.raises(UnauthorizedError): async with ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) await cloud_api.air_quality.nearest_station()
async def test_city_not_found(aresponses): """Test that the proper error is raised when a city cannot be found.""" aresponses.add( "api.airvisual.com", "/v2/nearest_city", "get", aresponses.Response( text=load_fixture("error_city_not_found_response.json"), headers={"Content-Type": "application/json"}, status=401, ), ) with pytest.raises(NotFoundError): async with ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) await cloud_api.air_quality.nearest_city()
async def test_generic_error(aresponses): """Test that a generic error is raised appropriately.""" aresponses.add( "api.airvisual.com", "/v2/nearest_city", "get", aresponses.Response( text=load_fixture("error_generic_response.json"), headers={"Content-Type": "application/json"}, status=401, ), ) with pytest.raises(AirVisualError): async with ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) await cloud_api.air_quality.nearest_city()
async def test_incorrect_api_key(aresponses): """Test that the proper error is raised with an incorrect API key.""" aresponses.add( "api.airvisual.com", "/v2/nearest_city", "get", aresponses.Response( text=load_fixture("error_incorrect_api_key_response.json"), headers={"Content-Type": "application/json"}, status=401, ), ) with pytest.raises(InvalidKeyError): async with ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) await cloud_api.air_quality.nearest_city()
async def test_city_by_ip(aresponses): """Test getting the nearest city by IP address.""" aresponses.add( "api.airvisual.com", "/v2/nearest_city", "get", aresponses.Response( text=load_fixture("city_response.json"), headers={"Content-Type": "application/json"}, status=200, ), ) async with aiohttp.ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) data = await cloud_api.air_quality.nearest_city() assert data["city"] == "Los Angeles" assert data["state"] == "California" assert data["country"] == "USA"
async def test_no_explicit_client_session(aresponses): """Test not explicitly providing an aiohttp ClientSession.""" aresponses.add( "api.airvisual.com", "/v2/city_ranking", "get", aresponses.Response( text=load_fixture("city_ranking_response.json"), headers={"Content-Type": "application/json"}, status=200, ), ) cloud_api = CloudAPI(TEST_API_KEY) data = await cloud_api.air_quality.ranking() assert len(data) == 3 assert data[0]["city"] == "Portland" assert data[0]["state"] == "Oregon" assert data[0]["country"] == "USA"
async def async_step_geography(self, user_input=None): """Handle the initialization of the integration via the cloud API.""" if not user_input: return self.async_show_form(step_id="geography", data_schema=self.geography_schema) self._geo_id = async_get_geography_id(user_input) await self._async_set_unique_id(self._geo_id) self._abort_if_unique_id_configured() # Find older config entries without unique ID: for entry in self._async_current_entries(): if entry.version != 1: continue if any(self._geo_id == async_get_geography_id(geography) for geography in entry.data[CONF_GEOGRAPHIES]): return self.async_abort(reason="already_configured") websession = aiohttp_client.async_get_clientsession(self.hass) cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) # If this is the first (and only the first) time we've seen this API key, check # that it's valid: checked_keys = self.hass.data.setdefault("airvisual_checked_api_keys", set()) check_keys_lock = self.hass.data.setdefault( "airvisual_checked_api_keys_lock", asyncio.Lock()) async with check_keys_lock: if user_input[CONF_API_KEY] not in checked_keys: try: await cloud_api.air_quality.nearest_city() except InvalidKeyError: return self.async_show_form( step_id="geography", data_schema=self.geography_schema, errors={CONF_API_KEY: "invalid_api_key"}, ) checked_keys.add(user_input[CONF_API_KEY]) return await self.async_step_geography_finish(user_input)
async def test_node_by_id(aresponses): """Test getting a node's info by its ID from the cloud API.""" aresponses.add( "www.airvisual.com", "/api/v2/node/12345", "get", aresponses.Response( text=load_fixture("node_by_id_response.json"), headers={"Content-Type": "application/json"}, status=200, ), ) async with aiohttp.ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) data = await cloud_api.node.get_by_node_id(TEST_NODE_ID) assert data["current"]["tp"] == 2.3 assert data["current"]["hm"] == 73 assert data["current"]["p2"] == 35 assert data["current"]["co"] == 479
async def test_aqi_ranking(aresponses): """Test getting AQI ranking by city.""" aresponses.add( "api.airvisual.com", "/v2/city_ranking", "get", aresponses.Response( text=load_fixture("city_ranking_response.json"), headers={"Content-Type": "application/json"}, status=200, ), ) async with aiohttp.ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) data = await cloud_api.air_quality.ranking() assert len(data) == 3 assert data[0]["city"] == "Portland" assert data[0]["state"] == "Oregon" assert data[0]["country"] == "USA"
async def test_station_by_coordinates(aresponses): """Test getting a station by latitude and longitude.""" aresponses.add( "api.airvisual.com", "/v2/nearest_station", "get", aresponses.Response( text=load_fixture("station_response.json"), headers={"Content-Type": "application/json"}, status=200, ), ) async with aiohttp.ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) data = await cloud_api.air_quality.nearest_station( latitude=TEST_LATITUDE, longitude=TEST_LONGITUDE) assert data["city"] == "Beijing" assert data["state"] == "Beijing" assert data["country"] == "China"
async def test_station_by_name(aresponses): """Test getting a station by location name.""" aresponses.add( "api.airvisual.com", "/v2/station", "get", aresponses.Response( text=load_fixture("station_response.json"), headers={"Content-Type": "application/json"}, status=200, ), ) async with aiohttp.ClientSession() as session: cloud_api = CloudAPI(TEST_API_KEY, session=session) data = await cloud_api.air_quality.station( station=TEST_STATION_NAME, city=TEST_CITY, state=TEST_STATE, country=TEST_COUNTRY, ) assert data["city"] == "Beijing" assert data["state"] == "Beijing" assert data["country"] == "China"
async def async_setup_entry(hass, config_entry): """Set up AirVisual as config entry.""" if CONF_API_KEY in config_entry.data: _standardize_geography_config_entry(hass, config_entry) websession = aiohttp_client.async_get_clientsession(hass) cloud_api = CloudAPI(config_entry.data[CONF_API_KEY], session=websession) async def async_update_data(): """Get new data from the API.""" if CONF_CITY in config_entry.data: api_coro = cloud_api.air_quality.city( config_entry.data[CONF_CITY], config_entry.data[CONF_STATE], config_entry.data[CONF_COUNTRY], ) else: api_coro = cloud_api.air_quality.nearest_city( config_entry.data[CONF_LATITUDE], config_entry.data[CONF_LONGITUDE], ) try: return await api_coro except (InvalidKeyError, KeyExpiredError) as ex: raise ConfigEntryAuthFailed from ex except AirVisualError as err: raise UpdateFailed( f"Error while retrieving data: {err}") from err coordinator = DataUpdateCoordinator( hass, LOGGER, name=async_get_geography_id(config_entry.data), # We give a placeholder update interval in order to create the coordinator; # then, below, we use the coordinator's presence (along with any other # coordinators using the same API key) to calculate an actual, leveled # update interval: update_interval=timedelta(minutes=5), update_method=async_update_data, ) # Only geography-based entries have options: hass.data[DOMAIN][DATA_LISTENER][ config_entry.entry_id] = config_entry.add_update_listener( async_reload_entry) else: _standardize_node_pro_config_entry(hass, config_entry) async def async_update_data(): """Get new data from the API.""" try: async with NodeSamba(config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PASSWORD]) as node: return await node.async_get_latest_measurements() except NodeProError as err: raise UpdateFailed( f"Error while retrieving data: {err}") from err coordinator = DataUpdateCoordinator( hass, LOGGER, name="Node/Pro data", update_interval=DEFAULT_NODE_PRO_UPDATE_INTERVAL, update_method=async_update_data, ) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator # Reassess the interval between 2 server requests if CONF_API_KEY in config_entry.data: async_sync_geo_coordinator_update_intervals( hass, config_entry.data[CONF_API_KEY]) hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AirVisual as config entry.""" if CONF_API_KEY in entry.data: _standardize_geography_config_entry(hass, entry) websession = aiohttp_client.async_get_clientsession(hass) cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession) async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" if CONF_CITY in entry.data: api_coro = cloud_api.air_quality.city( entry.data[CONF_CITY], entry.data[CONF_STATE], entry.data[CONF_COUNTRY], ) else: api_coro = cloud_api.air_quality.nearest_city( entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE], ) try: data = await api_coro return cast(dict[str, Any], data) except (InvalidKeyError, KeyExpiredError) as ex: raise ConfigEntryAuthFailed from ex except AirVisualError as err: raise UpdateFailed( f"Error while retrieving data: {err}") from err coordinator = DataUpdateCoordinator( hass, LOGGER, name=async_get_geography_id(entry.data), # We give a placeholder update interval in order to create the coordinator; # then, below, we use the coordinator's presence (along with any other # coordinators using the same API key) to calculate an actual, leveled # update interval: update_interval=timedelta(minutes=5), update_method=async_update_data, ) # Only geography-based entries have options: entry.async_on_unload(entry.add_update_listener(async_reload_entry)) else: # Remove outdated air_quality entities from the entity registry if they exist: ent_reg = entity_registry.async_get(hass) for entity_entry in [ e for e in ent_reg.entities.values() if e.config_entry_id == entry.entry_id and e.entity_id.startswith("air_quality") ]: LOGGER.debug('Removing deprecated air_quality entity: "%s"', entity_entry.entity_id) ent_reg.async_remove(entity_entry.entity_id) _standardize_node_pro_config_entry(hass, entry) async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" try: async with NodeSamba(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD]) as node: data = await node.async_get_latest_measurements() return cast(dict[str, Any], data) except NodeProError as err: raise UpdateFailed( f"Error while retrieving data: {err}") from err coordinator = DataUpdateCoordinator( hass, LOGGER, name="Node/Pro data", update_interval=DEFAULT_NODE_PRO_UPDATE_INTERVAL, update_method=async_update_data, ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} # Reassess the interval between 2 server requests if CONF_API_KEY in entry.data: async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY]) hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True
async def async_setup_entry(hass, config_entry): """Set up AirVisual as config entry.""" if CONF_API_KEY in config_entry.data: _standardize_geography_config_entry(hass, config_entry) websession = aiohttp_client.async_get_clientsession(hass) cloud_api = CloudAPI(config_entry.data[CONF_API_KEY], session=websession) async def async_update_data(): """Get new data from the API.""" if CONF_CITY in config_entry.data: api_coro = cloud_api.air_quality.city( config_entry.data[CONF_CITY], config_entry.data[CONF_STATE], config_entry.data[CONF_COUNTRY], ) else: api_coro = cloud_api.air_quality.nearest_city( config_entry.data[CONF_LATITUDE], config_entry.data[CONF_LONGITUDE], ) try: return await api_coro except (InvalidKeyError, KeyExpiredError): matching_flows = [ flow for flow in hass.config_entries.flow.async_progress() if flow["context"]["source"] == SOURCE_REAUTH and flow["context"]["unique_id"] == config_entry.unique_id ] if not matching_flows: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, "unique_id": config_entry.unique_id, }, data=config_entry.data, )) return {} except AirVisualError as err: raise UpdateFailed( f"Error while retrieving data: {err}") from err coordinator = DataUpdateCoordinator( hass, LOGGER, name=async_get_geography_id(config_entry.data), # We give a placeholder update interval in order to create the coordinator; # then, below, we use the coordinator's presence (along with any other # coordinators using the same API key) to calculate an actual, leveled # update interval: update_interval=timedelta(minutes=5), update_method=async_update_data, ) hass.data[DOMAIN][DATA_COORDINATOR][ config_entry.entry_id] = coordinator async_sync_geo_coordinator_update_intervals( hass, config_entry.data[CONF_API_KEY]) # Only geography-based entries have options: hass.data[DOMAIN][DATA_LISTENER][ config_entry.entry_id] = config_entry.add_update_listener( async_reload_entry) else: _standardize_node_pro_config_entry(hass, config_entry) async def async_update_data(): """Get new data from the API.""" try: async with NodeSamba(config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PASSWORD]) as node: return await node.async_get_latest_measurements() except NodeProError as err: raise UpdateFailed( f"Error while retrieving data: {err}") from err coordinator = DataUpdateCoordinator( hass, LOGGER, name="Node/Pro data", update_interval=DEFAULT_NODE_PRO_UPDATE_INTERVAL, update_method=async_update_data, ) hass.data[DOMAIN][DATA_COORDINATOR][ config_entry.entry_id] = coordinator await coordinator.async_refresh() for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup( config_entry, component)) return True
async def main() -> None: """Create the aiohttp session and run the example.""" logging.basicConfig(level=logging.INFO) async with ClientSession() as session: cloud_api = CloudAPI(API_KEY, session=session) # Get supported locations (by location): try: _LOGGER.info(await cloud_api.supported.countries()) _LOGGER.info(await cloud_api.supported.states("USA")) _LOGGER.info(await cloud_api.supported.cities("USA", "Colorado")) except AirVisualError as err: _LOGGER.error("There was an error: %s", err) # Get supported locations (by station): try: _LOGGER.info(await cloud_api.supported.stations("USA", "Colorado", "Denver")) except UnauthorizedError as err: _LOGGER.error(err) except AirVisualError as err: _LOGGER.error("There was an error: %s", err) # Get data by nearest location (by IP): try: _LOGGER.info(await cloud_api.air_quality.nearest_city()) except AirVisualError as err: _LOGGER.error("There was an error: %s", err) # Get data by nearest location (coordinates or explicit location): try: _LOGGER.info(await cloud_api.air_quality.nearest_city( latitude=39.742599, longitude=-104.9942557)) _LOGGER.info(await cloud_api.air_quality.city(city="Los Angeles", state="California", country="USA")) except AirVisualError as err: _LOGGER.error("There was an error: %s", err) # Get data by nearest station (by IP): try: _LOGGER.info(await cloud_api.air_quality.nearest_station()) except UnauthorizedError as err: _LOGGER.error(err) except AirVisualError as err: _LOGGER.error("There was an error: %s", err) # Get data by nearest station (by coordinates or explicit location): try: _LOGGER.info(await cloud_api.air_quality.nearest_station( latitude=39.742599, longitude=-104.9942557)) _LOGGER.info(await cloud_api.air_quality.station( station="US Embassy in Beijing", city="Beijing", state="Beijing", country="China", )) except UnauthorizedError as err: _LOGGER.error(err) except AirVisualError as err: _LOGGER.error("There was an error: %s", err) # Get data on AQI ranking: try: _LOGGER.info(await cloud_api.air_quality.ranking()) except UnauthorizedError as err: _LOGGER.error(err) except AirVisualError as err: _LOGGER.error("There was an error: %s", err) # Get a Node/Pro unit via the Cloud API: _LOGGER.info(await cloud_api.node.get_by_node_id(NODE_PRO_ID))