Skip to content

Commit

Permalink
Add Apple WeatherKit integration (#99895)
Browse files Browse the repository at this point in the history
  • Loading branch information
tjhorner authored Sep 11, 2023
1 parent 0fe88d6 commit 17db20f
Show file tree
Hide file tree
Showing 21 changed files with 11,431 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,8 @@ build.json @home-assistant/supervisor
/tests/components/waze_travel_time/ @eifinger
/homeassistant/components/weather/ @home-assistant/core
/tests/components/weather/ @home-assistant/core
/homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner
/homeassistant/components/webhook/ @home-assistant/core
/tests/components/webhook/ @home-assistant/core
/homeassistant/components/webostv/ @thecode
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/brands/apple.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"homekit",
"ibeacon",
"icloud",
"itunes"
"itunes",
"weatherkit"
]
}
62 changes: 62 additions & 0 deletions homeassistant/components/weatherkit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Integration for Apple's WeatherKit API."""
from __future__ import annotations

from apple_weatherkit.client import (
WeatherKitApiClient,
WeatherKitApiClientAuthenticationError,
WeatherKitApiClientError,
)

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import (
CONF_KEY_ID,
CONF_KEY_PEM,
CONF_SERVICE_ID,
CONF_TEAM_ID,
DOMAIN,
LOGGER,
)
from .coordinator import WeatherKitDataUpdateCoordinator

PLATFORMS: list[Platform] = [Platform.WEATHER]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
hass.data.setdefault(DOMAIN, {})
coordinator = WeatherKitDataUpdateCoordinator(
hass=hass,
client=WeatherKitApiClient(
key_id=entry.data[CONF_KEY_ID],
service_id=entry.data[CONF_SERVICE_ID],
team_id=entry.data[CONF_TEAM_ID],
key_pem=entry.data[CONF_KEY_PEM],
session=async_get_clientsession(hass),
),
)

try:
await coordinator.update_supported_data_sets()
except WeatherKitApiClientAuthenticationError as ex:
LOGGER.error("Authentication error initializing integration: %s", ex)
return False
except WeatherKitApiClientError as ex:
raise ConfigEntryNotReady from ex

await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unloaded
126 changes: 126 additions & 0 deletions homeassistant/components/weatherkit/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Adds config flow for WeatherKit."""
from __future__ import annotations

from collections.abc import Mapping
from typing import Any

from apple_weatherkit.client import (
WeatherKitApiClient,
WeatherKitApiClientAuthenticationError,
WeatherKitApiClientCommunicationError,
WeatherKitApiClientError,
)
import voluptuous as vol

from homeassistant import config_entries, data_entry_flow
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
LocationSelector,
LocationSelectorConfig,
TextSelector,
TextSelectorConfig,
)

from .const import (
CONF_KEY_ID,
CONF_KEY_PEM,
CONF_SERVICE_ID,
CONF_TEAM_ID,
DOMAIN,
LOGGER,
)

DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LOCATION): LocationSelector(
LocationSelectorConfig(radius=False, icon="")
),
# Auth
vol.Required(CONF_KEY_ID): str,
vol.Required(CONF_SERVICE_ID): str,
vol.Required(CONF_TEAM_ID): str,
vol.Required(CONF_KEY_PEM): TextSelector(
TextSelectorConfig(
multiline=True,
)
),
}
)


class WeatherKitUnsupportedLocationError(Exception):
"""Error to indicate a location is unsupported."""


class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for WeatherKit."""

VERSION = 1

async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> data_entry_flow.FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
try:
await self._test_config(user_input)
except WeatherKitUnsupportedLocationError as exception:
LOGGER.error(exception)
errors["base"] = "unsupported_location"
except WeatherKitApiClientAuthenticationError as exception:
LOGGER.warning(exception)
errors["base"] = "invalid_auth"
except WeatherKitApiClientCommunicationError as exception:
LOGGER.error(exception)
errors["base"] = "cannot_connect"
except WeatherKitApiClientError as exception:
LOGGER.exception(exception)
errors["base"] = "unknown"
else:
# Flatten location
location = user_input.pop(CONF_LOCATION)
user_input[CONF_LATITUDE] = location[CONF_LATITUDE]
user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE]

return self.async_create_entry(
title=f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}",
data=user_input,
)

suggested_values: Mapping[str, Any] = {
CONF_LOCATION: {
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
}
}

data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, suggested_values)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)

async def _test_config(self, user_input: dict[str, Any]) -> None:
"""Validate credentials."""
client = WeatherKitApiClient(
key_id=user_input[CONF_KEY_ID],
service_id=user_input[CONF_SERVICE_ID],
team_id=user_input[CONF_TEAM_ID],
key_pem=user_input[CONF_KEY_PEM],
session=async_get_clientsession(self.hass),
)

location = user_input[CONF_LOCATION]
availability = await client.get_availability(
location[CONF_LATITUDE],
location[CONF_LONGITUDE],
)

if len(availability) == 0:
raise WeatherKitUnsupportedLocationError(
"API does not support this location"
)
13 changes: 13 additions & 0 deletions homeassistant/components/weatherkit/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Constants for WeatherKit."""
from logging import Logger, getLogger

LOGGER: Logger = getLogger(__package__)

NAME = "Apple WeatherKit"
DOMAIN = "weatherkit"
ATTRIBUTION = "Data provided by Apple Weather. https://developer.apple.com/weatherkit/data-source-attribution/"

CONF_KEY_ID = "key_id"
CONF_SERVICE_ID = "service_id"
CONF_TEAM_ID = "team_id"
CONF_KEY_PEM = "key_pem"
70 changes: 70 additions & 0 deletions homeassistant/components/weatherkit/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""DataUpdateCoordinator for WeatherKit integration."""
from __future__ import annotations

from datetime import timedelta

from apple_weatherkit import DataSetType
from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN, LOGGER

REQUESTED_DATA_SETS = [
DataSetType.CURRENT_WEATHER,
DataSetType.DAILY_FORECAST,
DataSetType.HOURLY_FORECAST,
]


class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""

config_entry: ConfigEntry
supported_data_sets: list[DataSetType] | None = None

def __init__(
self,
hass: HomeAssistant,
client: WeatherKitApiClient,
) -> None:
"""Initialize."""
self.client = client
super().__init__(
hass=hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=15),
)

async def update_supported_data_sets(self):
"""Obtain the supported data sets for this location and store them."""
supported_data_sets = await self.client.get_availability(
self.config_entry.data[CONF_LATITUDE],
self.config_entry.data[CONF_LONGITUDE],
)

self.supported_data_sets = [
data_set
for data_set in REQUESTED_DATA_SETS
if data_set in supported_data_sets
]

LOGGER.debug("Supported data sets: %s", self.supported_data_sets)

async def _async_update_data(self):
"""Update the current weather and forecasts."""
try:
if not self.supported_data_sets:
await self.update_supported_data_sets()

return await self.client.get_weather_data(
self.config_entry.data[CONF_LATITUDE],
self.config_entry.data[CONF_LONGITUDE],
self.supported_data_sets,
)
except WeatherKitApiClientError as exception:
raise UpdateFailed(exception) from exception
9 changes: 9 additions & 0 deletions homeassistant/components/weatherkit/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"domain": "weatherkit",
"name": "Apple WeatherKit",
"codeowners": ["@tjhorner"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/weatherkit",
"iot_class": "cloud_polling",
"requirements": ["apple_weatherkit==1.0.1"]
}
25 changes: 25 additions & 0 deletions homeassistant/components/weatherkit/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"config": {
"step": {
"user": {
"title": "WeatherKit setup",
"description": "Enter your location details and WeatherKit authentication credentials below.",
"data": {
"name": "Name",
"location": "[%key:common::config_flow::data::location%]",
"key_id": "Key ID",
"team_id": "Apple team ID",
"service_id": "Service ID",
"key_pem": "Private key (.p8)"
}
}
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"unsupported_location": "Apple WeatherKit does not provide data for this location.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
}
}
Loading

0 comments on commit 17db20f

Please sign in to comment.