Skip to content

Commit 1069233

Browse files
Thomas55555joostlekNoRi2909
authored
Add Google Air Quality integration (home-assistant#145237)
Co-authored-by: Joost Lekkerkerker <[email protected]> Co-authored-by: Norbert Rittel <[email protected]>
1 parent d2fd200 commit 1069233

File tree

23 files changed

+2336
-0
lines changed

23 files changed

+2336
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/brands/google.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"domain": "google",
33
"name": "Google",
44
"integrations": [
5+
"google_air_quality",
56
"google_assistant",
67
"google_assistant_sdk",
78
"google_cloud",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""The Google Air Quality integration."""
2+
3+
import asyncio
4+
5+
from google_air_quality_api.api import GoogleAirQualityApi
6+
from google_air_quality_api.auth import Auth
7+
8+
from homeassistant.const import CONF_API_KEY, Platform
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
11+
12+
from .const import CONF_REFERRER
13+
from .coordinator import (
14+
GoogleAirQualityConfigEntry,
15+
GoogleAirQualityRuntimeData,
16+
GoogleAirQualityUpdateCoordinator,
17+
)
18+
19+
PLATFORMS: list[Platform] = [
20+
Platform.SENSOR,
21+
]
22+
23+
24+
async def async_setup_entry(
25+
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
26+
) -> bool:
27+
"""Set up Google Air Quality from a config entry."""
28+
session = async_get_clientsession(hass)
29+
api_key = entry.data[CONF_API_KEY]
30+
referrer = entry.data.get(CONF_REFERRER)
31+
auth = Auth(session, api_key, referrer=referrer)
32+
client = GoogleAirQualityApi(auth)
33+
coordinators: dict[str, GoogleAirQualityUpdateCoordinator] = {}
34+
for subentry_id in entry.subentries:
35+
coordinators[subentry_id] = GoogleAirQualityUpdateCoordinator(
36+
hass, entry, subentry_id, client
37+
)
38+
await asyncio.gather(
39+
*(
40+
coordinator.async_config_entry_first_refresh()
41+
for coordinator in coordinators.values()
42+
)
43+
)
44+
entry.runtime_data = GoogleAirQualityRuntimeData(
45+
api=client,
46+
subentries_runtime_data=coordinators,
47+
)
48+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
49+
entry.async_on_unload(entry.add_update_listener(async_update_options))
50+
return True
51+
52+
53+
async def async_unload_entry(
54+
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
55+
) -> bool:
56+
"""Unload a config entry."""
57+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
58+
59+
60+
async def async_update_options(
61+
hass: HomeAssistant, entry: GoogleAirQualityConfigEntry
62+
) -> None:
63+
"""Update options."""
64+
await hass.config_entries.async_reload(entry.entry_id)
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Config flow for the Google Air Quality integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from google_air_quality_api.api import GoogleAirQualityApi
9+
from google_air_quality_api.auth import Auth
10+
from google_air_quality_api.exceptions import GoogleAirQualityApiError
11+
import voluptuous as vol
12+
13+
from homeassistant.config_entries import (
14+
ConfigEntry,
15+
ConfigEntryState,
16+
ConfigFlow,
17+
ConfigFlowResult,
18+
ConfigSubentryFlow,
19+
SubentryFlowResult,
20+
)
21+
from homeassistant.const import (
22+
CONF_API_KEY,
23+
CONF_LATITUDE,
24+
CONF_LOCATION,
25+
CONF_LONGITUDE,
26+
CONF_NAME,
27+
)
28+
from homeassistant.core import HomeAssistant, callback
29+
from homeassistant.data_entry_flow import section
30+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
31+
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
32+
33+
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
34+
35+
_LOGGER = logging.getLogger(__name__)
36+
37+
STEP_USER_DATA_SCHEMA = vol.Schema(
38+
{
39+
vol.Required(CONF_API_KEY): str,
40+
vol.Optional(SECTION_API_KEY_OPTIONS): section(
41+
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
42+
),
43+
}
44+
)
45+
46+
47+
async def _validate_input(
48+
user_input: dict[str, Any],
49+
api: GoogleAirQualityApi,
50+
errors: dict[str, str],
51+
description_placeholders: dict[str, str],
52+
) -> bool:
53+
try:
54+
await api.async_air_quality(
55+
lat=user_input[CONF_LOCATION][CONF_LATITUDE],
56+
long=user_input[CONF_LOCATION][CONF_LONGITUDE],
57+
)
58+
except GoogleAirQualityApiError as err:
59+
errors["base"] = "cannot_connect"
60+
description_placeholders["error_message"] = str(err)
61+
except Exception:
62+
_LOGGER.exception("Unexpected exception")
63+
errors["base"] = "unknown"
64+
else:
65+
return True
66+
return False
67+
68+
69+
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
70+
"""Return the schema for a location with default values from the hass config."""
71+
return vol.Schema(
72+
{
73+
vol.Required(CONF_NAME, default=hass.config.location_name): str,
74+
vol.Required(
75+
CONF_LOCATION,
76+
default={
77+
CONF_LATITUDE: hass.config.latitude,
78+
CONF_LONGITUDE: hass.config.longitude,
79+
},
80+
): LocationSelector(LocationSelectorConfig(radius=False)),
81+
}
82+
)
83+
84+
85+
def _is_location_already_configured(
86+
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
87+
) -> bool:
88+
"""Check if the location is already configured."""
89+
for entry in hass.config_entries.async_entries(DOMAIN):
90+
for subentry in entry.subentries.values():
91+
# A more accurate way is to use the haversine formula, but for simplicity
92+
# we use a simple distance check. The epsilon value is small anyway.
93+
# This is mostly to capture cases where the user has slightly moved the location pin.
94+
if (
95+
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
96+
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
97+
<= epsilon
98+
):
99+
return True
100+
return False
101+
102+
103+
class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
104+
"""Handle a config flow for Google AirQuality."""
105+
106+
VERSION = 1
107+
108+
async def async_step_user(
109+
self, user_input: dict[str, Any] | None = None
110+
) -> ConfigFlowResult:
111+
"""Handle the initial step."""
112+
errors: dict[str, str] = {}
113+
description_placeholders: dict[str, str] = {
114+
"api_key_url": "https://developers.google.com/maps/documentation/air-quality/get-api-key",
115+
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
116+
}
117+
if user_input is not None:
118+
api_key = user_input[CONF_API_KEY]
119+
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
120+
self._async_abort_entries_match({CONF_API_KEY: api_key})
121+
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
122+
return self.async_abort(reason="already_configured")
123+
session = async_get_clientsession(self.hass)
124+
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
125+
auth = Auth(session, user_input[CONF_API_KEY], referrer=referrer)
126+
api = GoogleAirQualityApi(auth)
127+
if await _validate_input(user_input, api, errors, description_placeholders):
128+
return self.async_create_entry(
129+
title="Google Air Quality",
130+
data={
131+
CONF_API_KEY: api_key,
132+
CONF_REFERRER: referrer,
133+
},
134+
subentries=[
135+
{
136+
"subentry_type": "location",
137+
"data": user_input[CONF_LOCATION],
138+
"title": user_input[CONF_NAME],
139+
"unique_id": None,
140+
},
141+
],
142+
)
143+
else:
144+
user_input = {}
145+
schema = STEP_USER_DATA_SCHEMA.schema.copy()
146+
schema.update(_get_location_schema(self.hass).schema)
147+
return self.async_show_form(
148+
step_id="user",
149+
data_schema=self.add_suggested_values_to_schema(
150+
vol.Schema(schema), user_input
151+
),
152+
errors=errors,
153+
description_placeholders=description_placeholders,
154+
)
155+
156+
@classmethod
157+
@callback
158+
def async_get_supported_subentry_types(
159+
cls, config_entry: ConfigEntry
160+
) -> dict[str, type[ConfigSubentryFlow]]:
161+
"""Return subentries supported by this integration."""
162+
return {"location": LocationSubentryFlowHandler}
163+
164+
165+
class LocationSubentryFlowHandler(ConfigSubentryFlow):
166+
"""Handle a subentry flow for location."""
167+
168+
async def async_step_location(
169+
self,
170+
user_input: dict[str, Any] | None = None,
171+
) -> SubentryFlowResult:
172+
"""Handle the location step."""
173+
if self._get_entry().state != ConfigEntryState.LOADED:
174+
return self.async_abort(reason="entry_not_loaded")
175+
176+
errors: dict[str, str] = {}
177+
description_placeholders: dict[str, str] = {}
178+
if user_input is not None:
179+
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
180+
return self.async_abort(reason="already_configured")
181+
api: GoogleAirQualityApi = self._get_entry().runtime_data.api
182+
if await _validate_input(user_input, api, errors, description_placeholders):
183+
return self.async_create_entry(
184+
title=user_input[CONF_NAME],
185+
data=user_input[CONF_LOCATION],
186+
)
187+
else:
188+
user_input = {}
189+
return self.async_show_form(
190+
step_id="location",
191+
data_schema=self.add_suggested_values_to_schema(
192+
_get_location_schema(self.hass), user_input
193+
),
194+
errors=errors,
195+
description_placeholders=description_placeholders,
196+
)
197+
198+
async_step_user = async_step_location
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Constants for the Google Air Quality integration."""
2+
3+
from typing import Final
4+
5+
DOMAIN = "google_air_quality"
6+
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
7+
CONF_REFERRER: Final = "referrer"
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Coordinator for fetching data from Google Air Quality API."""
2+
3+
from dataclasses import dataclass
4+
from datetime import timedelta
5+
import logging
6+
from typing import Final
7+
8+
from google_air_quality_api.api import GoogleAirQualityApi
9+
from google_air_quality_api.exceptions import GoogleAirQualityApiError
10+
from google_air_quality_api.model import AirQualityData
11+
12+
from homeassistant.config_entries import ConfigEntry
13+
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
16+
17+
from .const import DOMAIN
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
UPDATE_INTERVAL: Final = timedelta(hours=1)
22+
23+
type GoogleAirQualityConfigEntry = ConfigEntry[GoogleAirQualityRuntimeData]
24+
25+
26+
class GoogleAirQualityUpdateCoordinator(DataUpdateCoordinator[AirQualityData]):
27+
"""Coordinator for fetching Google AirQuality data."""
28+
29+
config_entry: GoogleAirQualityConfigEntry
30+
31+
def __init__(
32+
self,
33+
hass: HomeAssistant,
34+
config_entry: GoogleAirQualityConfigEntry,
35+
subentry_id: str,
36+
client: GoogleAirQualityApi,
37+
) -> None:
38+
"""Initialize DataUpdateCoordinator."""
39+
super().__init__(
40+
hass,
41+
_LOGGER,
42+
config_entry=config_entry,
43+
name=f"{DOMAIN}_{subentry_id}",
44+
update_interval=UPDATE_INTERVAL,
45+
)
46+
self.client = client
47+
subentry = config_entry.subentries[subentry_id]
48+
self.lat = subentry.data[CONF_LATITUDE]
49+
self.long = subentry.data[CONF_LONGITUDE]
50+
51+
async def _async_update_data(self) -> AirQualityData:
52+
"""Fetch air quality data for this coordinate."""
53+
try:
54+
return await self.client.async_air_quality(self.lat, self.long)
55+
except GoogleAirQualityApiError as ex:
56+
_LOGGER.debug("Cannot fetch air quality data: %s", str(ex))
57+
raise UpdateFailed(
58+
translation_domain=DOMAIN,
59+
translation_key="unable_to_fetch",
60+
) from ex
61+
62+
63+
@dataclass
64+
class GoogleAirQualityRuntimeData:
65+
"""Runtime data for the Google Air Quality integration."""
66+
67+
api: GoogleAirQualityApi
68+
subentries_runtime_data: dict[str, GoogleAirQualityUpdateCoordinator]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"entity": {
3+
"sensor": {
4+
"nitrogen_dioxide": {
5+
"default": "mdi:molecule"
6+
},
7+
"ozone": {
8+
"default": "mdi:molecule"
9+
},
10+
"sulphur_dioxide": {
11+
"default": "mdi:molecule"
12+
}
13+
}
14+
}
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"domain": "google_air_quality",
3+
"name": "Google Air Quality",
4+
"codeowners": ["@Thomas55555"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/google_air_quality",
7+
"integration_type": "service",
8+
"iot_class": "cloud_polling",
9+
"loggers": ["google_air_quality_api"],
10+
"quality_scale": "bronze",
11+
"requirements": ["google_air_quality_api==1.1.1"]
12+
}

0 commit comments

Comments
 (0)