Skip to content

Commit 114c0b9

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 devices as native Home Assistant entities. Features: - OAuth 2.1 with PKCE (dynamic client registration via RFC 7591) - WebSocket push for real-time state updates (<1s latency) - Incremental characteristic + service group updates with group propagation - REST polling every 5 minutes as safety-net resync - Auth error detection triggers reauth flow - Cloud and Community mode (local Mac server) support - Light platform: on/off, brightness, HS color, color temperature - Identifies as client_type=homeassistant in Homecast admin panel Uses pyhomecast (PyPI) as the third-party API client library. Additional platforms (switch, climate, lock, etc.) will follow in subsequent PRs.
1 parent 3b9a9ca commit 114c0b9

22 files changed

+2727
-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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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, HomecastWebSocket
9+
10+
from homeassistant.components.application_credentials import AuthorizationServer
11+
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import (
15+
ConfigEntryAuthFailed,
16+
ConfigEntryNotReady,
17+
OAuth2TokenRequestError,
18+
OAuth2TokenRequestReauthError,
19+
)
20+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
21+
from homeassistant.helpers.config_entry_oauth2_flow import (
22+
OAuth2Session,
23+
async_get_config_entry_implementation,
24+
)
25+
26+
from .application_credentials import authorization_server_context
27+
from .const import (
28+
API_BASE_URL,
29+
CONF_API_URL,
30+
CONF_MODE,
31+
CONF_OAUTH_AUTHORIZE_URL,
32+
CONF_OAUTH_TOKEN_URL,
33+
DOMAIN as DOMAIN,
34+
MODE_COMMUNITY,
35+
OAUTH_AUTHORIZE_URL,
36+
OAUTH_TOKEN_URL,
37+
)
38+
from .coordinator import HomecastCoordinator
39+
40+
_LOGGER = logging.getLogger(__name__)
41+
42+
PLATFORMS = [
43+
Platform.LIGHT,
44+
]
45+
46+
47+
@dataclass
48+
class HomecastData:
49+
"""Runtime data for a Homecast config entry."""
50+
51+
coordinator: HomecastCoordinator
52+
client: HomecastClient
53+
54+
55+
type HomecastConfigEntry = ConfigEntry[HomecastData]
56+
57+
58+
async def async_setup_entry(hass: HomeAssistant, entry: HomecastConfigEntry) -> bool:
59+
"""Set up Homecast from a config entry."""
60+
mode = entry.data.get(CONF_MODE)
61+
api_url = entry.data.get(CONF_API_URL, API_BASE_URL)
62+
63+
authorize_url = entry.data.get(CONF_OAUTH_AUTHORIZE_URL, OAUTH_AUTHORIZE_URL)
64+
token_url = entry.data.get(CONF_OAUTH_TOKEN_URL, OAUTH_TOKEN_URL)
65+
66+
with authorization_server_context(
67+
AuthorizationServer(authorize_url=authorize_url, token_url=token_url)
68+
):
69+
implementation = await async_get_config_entry_implementation(hass, entry)
70+
71+
session = OAuth2Session(hass, entry, implementation)
72+
73+
try:
74+
await session.async_ensure_token_valid()
75+
except OAuth2TokenRequestReauthError as err:
76+
raise ConfigEntryAuthFailed from err
77+
except OAuth2TokenRequestError as err:
78+
raise ConfigEntryNotReady from err
79+
80+
http_session = async_get_clientsession(hass)
81+
client = HomecastClient(session=http_session, api_url=api_url)
82+
client.authenticate(session.token[CONF_ACCESS_TOKEN])
83+
84+
device_id = f"ha_{entry.entry_id[:12]}"
85+
ws = HomecastWebSocket(
86+
session=http_session,
87+
api_url=api_url,
88+
device_id=device_id,
89+
community=(mode == MODE_COMMUNITY),
90+
)
91+
92+
async def _refresh_token() -> str:
93+
"""Refresh the OAuth token and return the new access token."""
94+
await session.async_ensure_token_valid()
95+
token = session.token[CONF_ACCESS_TOKEN]
96+
client.authenticate(token)
97+
if ws:
98+
ws.set_token(token)
99+
return token
100+
101+
coordinator = HomecastCoordinator(
102+
hass,
103+
entry,
104+
client,
105+
_refresh_token,
106+
ws=ws,
107+
initial_token=session.token[CONF_ACCESS_TOKEN],
108+
)
109+
110+
await coordinator.async_config_entry_first_refresh()
111+
112+
# Start WebSocket after initial state is available
113+
await coordinator.async_setup_websocket()
114+
115+
entry.runtime_data = HomecastData(coordinator=coordinator, client=client)
116+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
117+
return True
118+
119+
120+
async def async_unload_entry(hass: HomeAssistant, entry: HomecastConfigEntry) -> bool:
121+
"""Unload a Homecast config entry."""
122+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""OAuth application credentials for Homecast."""
2+
3+
from __future__ import annotations
4+
5+
from contextlib import contextmanager
6+
import contextvars
7+
8+
from homeassistant.components.application_credentials import (
9+
AuthorizationServer,
10+
ClientCredential,
11+
)
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.helpers.config_entry_oauth2_flow import (
14+
LocalOAuth2ImplementationWithPkce,
15+
)
16+
17+
from .const import OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL, SCOPES
18+
19+
# Context variable for dynamic OAuth server URLs (community mode).
20+
# Set before calling into AbstractOAuth2FlowHandler so application_credentials
21+
# resolves to the correct server (local community or cloud).
22+
_server_context: contextvars.ContextVar[AuthorizationServer | None] = (
23+
contextvars.ContextVar("homecast_authorization_server", default=None)
24+
)
25+
26+
27+
@contextmanager
28+
def authorization_server_context(server: AuthorizationServer):
29+
"""Temporarily override the authorization server URLs."""
30+
token = _server_context.set(server)
31+
try:
32+
yield
33+
finally:
34+
_server_context.reset(token)
35+
36+
37+
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
38+
"""Return the Homecast authorization server.
39+
40+
Uses the context variable if set (community mode), otherwise cloud defaults.
41+
"""
42+
if (server := _server_context.get()) is not None:
43+
return server
44+
return AuthorizationServer(
45+
authorize_url=OAUTH_AUTHORIZE_URL,
46+
token_url=OAUTH_TOKEN_URL,
47+
)
48+
49+
50+
async def async_get_auth_implementation(
51+
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
52+
) -> HomecastOAuth2Implementation:
53+
"""Return a custom auth implementation with PKCE."""
54+
server = _server_context.get()
55+
authorize_url = server.authorize_url if server else OAUTH_AUTHORIZE_URL
56+
token_url = server.token_url if server else OAUTH_TOKEN_URL
57+
58+
return HomecastOAuth2Implementation(
59+
hass,
60+
auth_domain,
61+
credential.client_id,
62+
authorize_url,
63+
token_url,
64+
credential.client_secret,
65+
)
66+
67+
68+
class HomecastOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
69+
"""Homecast OAuth2 implementation with PKCE (S256)."""
70+
71+
@property
72+
def extra_authorize_data(self) -> dict:
73+
"""Extra data that needs to be appended to the authorize url."""
74+
return super().extra_authorize_data | {
75+
"scope": SCOPES,
76+
}

0 commit comments

Comments
 (0)