8000 Add HomeLink integration · home-assistant/core@ef4b132 · GitHub
[go: up one dir, main page]

Skip to content

Commit ef4b132

Browse files
Add HomeLink integration
1 parent b018877 commit ef4b132

20 files changed

+827
-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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""The homelink integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from homelink.provider import Provider
9+
10+
from homeassistant.config_entries import ConfigEntry
11+
from homeassistant.const import Platform
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
14+
15+
from . import api
16+
from .const import DOMAIN
17+
from .coordinator import HomelinkCoordinator
18+
19+
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
20+
21+
type HomeLinkConfigEntry = ConfigEntry[dict[str, Any]]
22+
23+
24+
async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
25+
"""Set up homelink from a config entry."""
26+
logging.debug("Starting config entry setup")
27+
config_entry_oauth2_flow.async_register_implementation(
28+
hass, DOMAIN, api.SRPAuthImplementation(hass, DOMAIN)
29+
)
30+
31+
implementation = (
32+
await config_entry_oauth2_flow.async_get_config_entry_implementation(
33+
hass, entry
34+
)
35+
)
36+
37+
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
38+
authenticated_session = api.AsyncConfigEntryAuth(
39+
aiohttp_client.async_get_clientsession(hass), session
40+
)
41+
42+
provider = Provider(authenticated_session)
43+
coordinator = HomelinkCoordinator(hass, provider, entry)
44+
45+
entry.runtime_data = {
46+
"provider": provider,
47+
"coordinator": coordinator,
48+
"last_update_id": None,
49+
}
50+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
51+
52+
if update_listener not in entry.update_listeners:
53+
entry.add_update_listener(update_listener)
54+
55+
if update_listener not in entry.update_listeners:
56+
entry.add_update_listener(update_listener)
57+
await coordinator.async_config_entry_first_refresh()
58+
return True
59+
60+
61+
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
62+
"""Update listener."""
63+
await hass.config_entries.async_reload(entry.entry_id)
64+
65+
66+
async def async_unload_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
67+
"""Unload a config entry."""
68+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""API for homelink bound to Home Assistant OAuth."""
2+
3+
from json import JSONDecodeError
4+
import logging
5+
from typing import cast
6+
7+
from aiohttp import ClientError, ClientSession
8+
from homelink.auth.abstract_auth import AbstractAuth
9+
from homelink.settings import COGNITO_CLIENT_ID
10+
11+
from homeassistant.core import HomeAssistant
12+
from homeassistant.helpers import config_entry_oauth2_flow
13+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
14+
15+
from .const import OAUTH2_TOKEN
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
20+
class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementation):
21+
"""Base class to abstract OAuth2 authentication."""
22+
23+
def __init__(self, hass: HomeAssistant, domain) -> None:
24+
"""Initialize the SRP Auth implementation."""
25+
self.hass = hass
26+
self._domain = domain
27+
self.client_id = COGNITO_CLIENT_ID
28+
29+
@property
30+
def name(self) -> str:
31+
"""Name of the implementation."""
32+
return "SRPAuth"
33+
34+
@property
35+
def domain(self) -> str:
36+
"""Domain that is providing the implementation."""
37+
return self._domain
38+
39+
async def async_generate_authorize_url(self, flow_id: str) -> str:
40+
"""Left intentionally blank because the auth is handled by SRP."""
41+
return ""
42+
43+
async def async_resolve_external_data(self, external_data) -> dict:
44+
"""No external data required."""
45+
return {}
46+
47+
async def _token_request(self, data: dict) -> dict:
48+
"""Make a token request."""
49+
session = async_get_clientsession(self.hass)
50+
51+
data["client_id"] = self.client_id
52+
53+
_LOGGER.debug("Sending token request to %s", OAUTH2_TOKEN)
54+
resp = await session.post(OAUTH2_TOKEN, data=data)
55+
if resp.status >= 400:
56+
try:
57+
error_response = await resp.json()
58+
except (ClientError, JSONDecodeError):
59+
error_response = {}
60+
error_code = error_response.get("error", "unknown")
61+
error_description = error_response.get(
62+
"error_description", "unknown error"
63+
)
64+
_LOGGER.error(
65+
"Token request for %s failed (%s): %s",
66+
self.domain,
67+
error_code,
68+
error_description,
69+
)
70+
resp.raise_for_status()
71+
return cast(dict, await resp.json())
72+
73+
async def _async_refresh_token(self, token: dict) -> dict:
74+
"""Refresh tokens."""
75+
new_token = await self._token_request(
76+
{
77+
"grant_type": "refresh_token",
78+
"client_id": self.client_id,
79+
"refresh_token": token["refresh_token"],
80+
}
81+
)
82+
return {**token, **new_token}
83+
84+
85+
class AsyncConfigEntryAuth(AbstractAuth):
86+
"""Provide homelink authentication tied to an OAuth2 based config entry."""
87+
88+
def __init__(
89+
self,
90+
websession: ClientSession,
91+
oauth_session: config_entry_oauth2_flow.OAuth2Session,
92+
) -> None:
93+
"""Initialize homelink auth."""
94+
super().__init__(websession)
95+
self._oauth_session = oauth_session
96+
97+
async def async_get_access_token(self) -> str:
98+
"""Return a valid access token."""
99+
if not self._oauth_session.valid_token:
100+
await self._oauth_session.async_ensure_token_valid()
101+
102+
return self._oauth_session.token["access_token"]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Platform for BinarySensor integration."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
import logging
7+
import time
8+
9+
from homeassistant.components.binary_sensor import BinarySensorEntity
10+
from homeassistant.config_entries import ConfigEntry
11+
from homeassistant.core import HomeAssistant, callback
12+
import homeassistant.helpers.device_registry as dr
13+
14+
# Import the device class from the component that you want to support
15+
from homeassistant.helpers.device_registry import DeviceInfo
16+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
17+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
18+
19+
from .const import DOMAIN
20+
21+
_LOGGER = logging.getLogger(__name__)
22+
SCAN_INTERVAL = timedelta(seconds=5)
23+
24+
25+
async def async_setup_entry(
26+
hass: HomeAssistant,
27+
config_entry: ConfigEntry,
28+
async_add_entities: AddEntitiesCallback,
29+
) -> None:
30+
"""Set up homelink from a config entry."""
31+
coordinator = config_entry.runtime_data["coordinator"]
32+
provider = config_entry.runtime_data["provider"]
33+
34+
await provider.enable()
35+
36+
device_data = await provider.discover()
37+
38+
logging.info(device_data)
39+
40+
for device in device_data:
41+
device_info = DeviceInfo(
42+
identifiers={
43+
# Serial numbers are unique identifiers within a specific domain
44+
(DOMAIN, device.id)
45+
},
46+
name=device.name,
47+
)
48+
49+
buttons = [
50+
HomelinkBinarySensor(b.id, b.name, device_info, coordinator)
51+
for b in device.buttons
52+
]
53+
async_add_entities(buttons)
54+
55+
if buttons[0].device_entry is not None:
56+
registry = dr.async_get(hass)
57+
registry.async_update_device(buttons[0].device_entry.id, name=device.name)
58+
59+
60+
class HomelinkBinarySensor(CoordinatorEntity, BinarySensorEntity):
61+
"""Binary sensor."""
62+
63+
def __init__(self, id, name, device_info, coordinator) -> None:
64+
"""Initialize the button."""
65+
super().__init__(coordinator, context=id)
66+
self.id = id
67+
self.name = name
68+
self.unique_id = f"{DOMAIN}.{id}"
69+
self.device_info = device_info
70+
self.on = False
71+
self.last_request_id = None
72+
73+
@property
74+
def is_on(self) -> bool:
75+
"""Return true if the binary sensor is on."""
76+
return self.on
77+
78+
@callback
79+
def _handle_coordinator_update(self) -> None:
80+
"""Update this button."""
81+
if not self.coordinator.data or self.id not in self.coordinator.data:
82+
self.on = False
83+
else:
84+
latest_update = self.coordinator.data[self.id]
85+
self.on = (time.time() - latest_update["timestamp"]) < 10 and latest_update[
86+
"requestId"
87+
] != self.last_request_id
88+
self.last_request_id = latest_update["requestId"]
89+
self.async_write_ha_state()
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Config flow for homelink."""
2+
3+
import asyncio
4+
import time
5+
from typing import Any
6+
7+
from homelink.auth.srp_auth import SRPAuth
8+
import voluptuous as vol
9+
10+
from homeassistant import config_entries
11+
12+
from .const import DOMAIN
13+
14+
15+
@config_entries.HANDLERS.register(DOMAIN)
16+
class SRPFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
17+
"""Config flow to handle homelink OAuth2 authentication."""
18+
19+
DOMAIN = DOMAIN
20+
VERSION = 1
21+
MINOR_VERSION = 1
22+
23+
async def async_step_user(
24+
self, user_input: dict[str, Any] | None = None
25+
) -> config_entries.ConfigFlowResult:
26+
"""Ask for username and password."""
27+
errors = {}
28+
if user_input is not None:
29+
self._async_abort_entries_match({"email": user_input["email"]})
30+
31+
srp_auth = SRPAuth()
32+
loop = asyncio.get_running_loop()
33+
try:
34+
tokens = await loop.run_in_executor(
35+
None,
36+
srp_auth.async_get_access_token,
37+
user_input["email"],
38+
user_input["password"],
39+
)
40+
except Exception: # noqa: BLE001
41+
errors["base"] = "Error authenticating HomeLink account"
42+
else:
43+
new_token = {}
44+
new_token["access_token"] = tokens["AuthenticationResult"][
45+
"AccessToken"
46+
]
47+
new_token["refresh_token"] = tokens["AuthenticationResult"][
48+
"RefreshToken"
49+
]
50+
new_token["token_type"] = tokens["AuthenticationResult"]["TokenType"]
51+
new_token["expires_in"] = tokens["AuthenticationResult"]["ExpiresIn"]
52+
new_token["expires_at"] = (
53+
time.time() + tokens["AuthenticationResult"]["ExpiresIn"]
54+
)
55+
56+
return self.async_create_entry(
57+
title="Token entry",
58+
data={
59+
"token": new_token,
60+
"auth_implementation": DOMAIN,
61+
"last_update_id": None,
62+
"email": user_input["email"],
63+
},
64+
)
65+
return self.async_show_form(
66+
step_id="user",
67+
data_schema=vol.Schema(
68+
{vol.Required("email"): str, vol.Required("password"): str}
69+
),
70+
errors=errors,
71+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Constants for the homelink integration."""
2+
3+
DOMAIN = "gentex_homelink"
4+
OAUTH2_TOKEN = "https://auth.homelinkcloud.com/oauth2/token"

0 commit comments

Comments
 (0)
0