Skip to content

Commit 139dc8c

Browse files
committed
Add Homecast integration
Add a new integration for Homecast, which bridges Apple HomeKit smart home devices to open standards. The integration connects to the Homecast cloud API via OAuth 2.1 with PKCE and exposes HomeKit light devices as native Home Assistant light entities. Features: - OAuth 2.1 with PKCE authentication - Automatic token refresh during polling - Auth error detection triggers reauth flow - Light platform: on/off, brightness, HS color, color temperature - Cloud polling every 30 seconds with optimistic updates Uses pyhomecast (PyPI) as the third-party API client library. Additional platforms (switch, climate, lock, etc.) will follow in subsequent PRs.
1 parent 5b76fab commit 139dc8c

21 files changed

+1294
-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.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""The Homecast integration."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
import logging
7+
8+
from pyhomecast import HomecastClient
9+
10+
from homeassistant.config_entries import ConfigEntry
11+
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.exceptions import (
14+
ConfigEntryAuthFailed,
15+
ConfigEntryNotReady,
16+
OAuth2TokenRequestError,
17+
OAuth2TokenRequestReauthError,
18+
)
19+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
20+
from homeassistant.helpers.config_entry_oauth2_flow import (
21+
OAuth2Session,
22+
async_get_config_entry_implementation,
23+
)
24+
25+
from .const import API_BASE_URL, DOMAIN as DOMAIN
26+
from .coordinator import HomecastCoordinator
27+
28+
_LOGGER = logging.getLogger(__name__)
29+
30+
PLATFORMS = [
31+
Platform.LIGHT,
32+
]
33+
34+
35+
@dataclass
36+
class HomecastData:
37+
"""Runtime data for a Homecast config entry."""
38+
39+
coordinator: HomecastCoordinator
40+
client: HomecastClient
41+
42+
43+
type HomecastConfigEntry = ConfigEntry[HomecastData]
44+
45+
46+
async def async_setup_entry(hass: HomeAssistant, entry: HomecastConfigEntry) -> bool:
47+
"""Set up Homecast from a config entry."""
48+
implementation = await async_get_config_entry_implementation(hass, entry)
49+
session = OAuth2Session(hass, entry, implementation)
50+
51+
try:
52+
await session.async_ensure_token_valid()
53+
except OAuth2TokenRequestReauthError as err:
54+
raise ConfigEntryAuthFailed from err
55+
except OAuth2TokenRequestError as err:
56+
raise ConfigEntryNotReady from err
57+
58+
client = HomecastClient(session=async_get_clientsession(hass), api_url=API_BASE_URL)
59+
client.authenticate(session.token[CONF_ACCESS_TOKEN])
60+
61+
async def _refresh_token() -> None:
62+
await session.async_ensure_token_valid()
63+
client.authenticate(session.token[CONF_ACCESS_TOKEN])
64+
65+
coordinator = HomecastCoordinator(hass, client, _refresh_token)
66+
67+
try:
68+
await coordinator.async_config_entry_first_refresh()
69+
except ConfigEntryAuthFailed:
70+
raise
71+
except Exception as err:
72+
raise ConfigEntryNotReady(
73+
f"Could not fetch initial state from Homecast: {err}"
74+
) from err
75+
76+
entry.runtime_data = HomecastData(
77+
coordinator=coordinator,
78+
client=client,
79+
)
80+
81+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
82+
return True
83+
84+
85+
async def async_unload_entry(hass: HomeAssistant, entry: HomecastConfigEntry) -> bool:
86+
"""Unload a Homecast config entry."""
87+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""OAuth application credentials for Homecast."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.components.application_credentials import (
6+
AuthorizationServer,
7+
ClientCredential,
8+
)
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers.config_entry_oauth2_flow import (
11+
LocalOAuth2ImplementationWithPkce,
12+
)
13+
14+
from .const import OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL, SCOPES
15+
16+
17+
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
18+
"""Return the Homecast authorization server."""
19+
return AuthorizationServer(
20+
authorize_url=OAUTH_AUTHORIZE_URL,
21+
token_url=OAUTH_TOKEN_URL,
22+
)
23+
24+
25+
async def async_get_auth_implementation(
26+
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
27+
) -> HomecastOAuth2Implementation:
28+
"""Return a custom auth implementation with PKCE."""
29+
return HomecastOAuth2Implementation(
30+
hass,
31+
auth_domain,
32+
credential.client_id,
33+
OAUTH_AUTHORIZE_URL,
34+
OAUTH_TOKEN_URL,
35+
credential.client_secret,
36+
)
37+
38+
39+
class HomecastOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
40+
"""Homecast OAuth2 implementation with PKCE (S256)."""
41+
42+
@property
43+
def extra_authorize_data(self) -> dict:
44+
"""Extra data that needs to be appended to the authorize url."""
45+
return super().extra_authorize_data | {
46+
"scope": SCOPES,
47+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Config flow for Homecast."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
import logging
7+
from typing import Any
8+
9+
from pyhomecast import HomecastAuthError, HomecastClient, HomecastConnectionError
10+
11+
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
12+
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
13+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
14+
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
15+
16+
from .const import API_BASE_URL, DOMAIN, SCOPES
17+
18+
_LOGGER = logging.getLogger(__name__)
19+
20+
21+
class HomecastFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
22+
"""Handle a config flow for Homecast."""
23+
24+
DOMAIN = DOMAIN
25+
26+
@property
27+
def logger(self) -> logging.Logger:
28+
"""Return logger."""
29+
return _LOGGER
30+
31+
@property
32+
def extra_authorize_data(self) -> dict[str, Any]:
33+
"""Extra data to include in the authorize URL."""
34+
return {"scope": SCOPES}
35+
36+
async def async_step_reauth(
37+
self, entry_data: Mapping[str, Any]
38+
) -> ConfigFlowResult:
39+
"""Handle re-authentication."""
40+
return await self.async_step_reauth_confirm()
41+
42+
async def async_step_reauth_confirm(
43+
self, user_input: dict[str, Any] | None = None
44+
) -> ConfigFlowResult:
45+
"""Confirm re-authentication."""
46+
if user_input is None:
47+
return self.async_show_form(step_id="reauth_confirm")
48+
return await self.async_step_user()
49+
50+
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
51+
"""Create an entry after OAuth flow completes."""
52+
token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
53+
client = HomecastClient(
54+
session=async_get_clientsession(self.hass), api_url=API_BASE_URL
55+
)
56+
client.authenticate(token)
57+
58+
try:
59+
state = await client.get_state()
60+
except HomecastAuthError:
61+
return self.async_abort(reason="invalid_auth")
62+
except HomecastConnectionError:
63+
return self.async_abort(reason="cannot_connect")
64+
except Exception:
65+
_LOGGER.exception("Unexpected error during Homecast setup")
66+
return self.async_abort(reason="unknown")
67+
68+
_LOGGER.info("Homecast connected: found %d home(s)", len(state.homes))
69+
70+
await self.async_set_unique_id(DOMAIN)
71+
72+
if self.source == SOURCE_REAUTH:
73+
return self.async_update_reload_and_abort(
74+
self._get_reauth_entry(), data_updates=data
75+
)
76+
77+
self._abort_if_unique_id_configured()
78+
return self.async_create_entry(title="Homecast", data=data)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Constants for the Homecast integration."""
2+
3+
DOMAIN = "homecast"
4+
5+
API_BASE_URL = "https://api.homecast.cloud"
6+
OAUTH_AUTHORIZE_URL = f"{API_BASE_URL}/oauth/authorize"
7+
OAUTH_TOKEN_URL = f"{API_BASE_URL}/oauth/token"
8+
9+
SCOPES = "mcp:read mcp:write"
10+
11+
UPDATE_INTERVAL = 30
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""DataUpdateCoordinator for Homecast."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable, Coroutine
6+
from datetime import timedelta
7+
import logging
8+
from typing import Any
9+
10+
from pyhomecast import (
11+
HomecastAuthError,
12+
HomecastClient,
13+
HomecastConnectionError,
14+
HomecastState,
15+
)
16+
17+
from homeassistant.core import HomeAssistant
18+
from homeassistant.exceptions import ConfigEntryAuthFailed
19+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
20+
21+
from .const import DOMAIN, UPDATE_INTERVAL
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
class HomecastCoordinator(DataUpdateCoordinator[HomecastState]):
27+
"""Coordinator that polls the Homecast REST API for device state."""
28+
29+
def __init__(
30+
self,
31+
hass: HomeAssistant,
32+
client: HomecastClient,
33+
refresh_token: Callable[[], Coroutine[Any, Any, None]],
34+
) -> None:
35+
"""Initialize the coordinator."""
36+
super().__init__(
37+
hass,
38+
_LOGGER,
39+
name=DOMAIN,
40+
update_interval=timedelta(seconds=UPDATE_INTERVAL),
41+
)
42+
self.client = client
43+
self._refresh_token = refresh_token
44+
45+
async def _async_update_data(self) -> HomecastState:
46+
"""Fetch state from the Homecast API."""
47+
try:
48+
await self._refresh_token()
49+
return await self.client.get_state()
50+
except HomecastAuthError as err:
51+
raise ConfigEntryAuthFailed from err
52+
except HomecastConnectionError as err:
53+
raise UpdateFailed(f"Error communicating with Homecast: {err}") from err
54+
55+
async def async_set_state(self, updates: dict[str, Any]) -> None:
56+
"""Send a state update and request a refresh."""
57+
await self._refresh_token()
58+
await self.client.set_state(updates)
59+
await self.async_request_refresh()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Base entity for Homecast."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from pyhomecast import HomecastDevice
8+
9+
from homeassistant.helpers.device_registry import DeviceInfo
10+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
11+
12+
from .const import DOMAIN
13+
from .coordinator import HomecastCoordinator
14+
15+
16+
class HomecastEntity(CoordinatorEntity[HomecastCoordinator]):
17+
"""Base class for Homecast entities."""
18+
19+
_attr_has_entity_name = True
20+
21+
def __init__(
22+
self,
23+
coordinator: HomecastCoordinator,
24+
device: HomecastDevice,
25+
) -> None:
26+
"""Initialize the entity."""
27+
super().__init__(coordinator)
28+
self._device_id = device.unique_id
29+
self._attr_unique_id = device.unique_id
30+
31+
# Prefix room name with home name when there are multiple homes
32+
multiple_homes = (
33+
coordinator.data is not None and len(coordinator.data.homes) > 1
34+
)
35+
area = (
36+
f"{device.home_name} - {device.room_name}"
37+
if multiple_homes
38+
else device.room_name
39+
)
40+
41+
self._attr_device_info = DeviceInfo(
42+
identifiers={(DOMAIN, device.unique_id)},
43+
name=device.name,
44+
manufacturer="Homecast (HomeKit)",
45+
model=device.device_type.replace("_", " ").title(),
46+
suggested_area=area,
47+
)
48+
49+
@property
50+
def device(self) -> HomecastDevice | None:
51+
"""Return the current device data from the coordinator."""
52+
if self.coordinator.data is None:
53+
return None
54+
return self.coordinator.data.devices.get(self._device_id)
55+
56+
@property
57+
def available(self) -> bool:
58+
"""Return True if the device is available."""
59+
return super().available and self.device is not None
60+
61+
async def _async_set_state(self, props: dict[str, Any]) -> None:
62+
"""Send a state update for this device."""
63+
device = self.device
64+
if device is None:
65+
return
66+
await self.coordinator.async_set_state(
67+
{
68+
device.home_key: {
69+
device.room_key: {
70+
device.accessory_key: props,
71+
},
72+
},
73+
}
74+
)

0 commit comments

Comments
 (0)