Skip to content

Commit f1bfe2f

Browse files
Add HomeLink integration (home-assistant#136460)
Co-authored-by: Nicholas Aelick <[email protected]>
1 parent 34cc603 commit f1bfe2f

21 files changed

+957
-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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""The homelink integration."""
2+
3+
from __future__ import annotations
4+
5+
from homelink.mqtt_provider import MQTTProvider
6+
7+
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
10+
11+
from . import oauth2
12+
from .const import DOMAIN
13+
from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkData
14+
15+
PLATFORMS: list[Platform] = [Platform.EVENT]
16+
17+
18+
async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
19+
"""Set up homelink from a config entry."""
20+
auth_implementation = oauth2.SRPAuthImplementation(hass, DOMAIN)
21+
22+
config_entry_oauth2_flow.async_register_implementation(
23+
hass, DOMAIN, auth_implementation
24+
)
25+
26+
implementation = (
27+
await config_entry_oauth2_flow.async_get_config_entry_implementation(
28+
hass, entry
29+
)
30+
)
31+
32+
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
33+
authenticated_session = oauth2.AsyncConfigEntryAuth(
34+
aiohttp_client.async_get_clientsession(hass), session
35+
)
36+
37+
provider = MQTTProvider(authenticated_session)
38+
coordinator = HomeLinkCoordinator(hass, provider, entry)
39+
40+
entry.async_on_unload(
41+
hass.bus.async_listen_once(
42+
EVENT_HOMEASSISTANT_STOP, coordinator.async_on_unload
43+
)
44+
)
45+
46+
await coordinator.async_config_entry_first_refresh()
47+
entry.runtime_data = HomeLinkData(
48+
provider=provider, coordinator=coordinator, last_update_id=None
49+
)
50+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
51+
52+
return True
53+
54+
55+
async def async_unload_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
56+
"""Unload a config entry."""
57+
await entry.runtime_data.coordinator.async_on_unload(None)
58+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""application_credentials platform for the gentex homelink integration."""
2+
3+
from homeassistant.components.application_credentials import ClientCredential
4+
from homeassistant.core import HomeAssistant
5+
from homeassistant.helpers import config_entry_oauth2_flow
6+
7+
from . import oauth2
8+
9+
10+
async def async_get_auth_implementation(
11+
hass: HomeAssistant, auth_domain: str, _credential: ClientCredential
12+
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
13+
"""Return custom SRPAuth implementation."""
14+
return oauth2.SRPAuthImplementation(hass, auth_domain)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Config flow for homelink."""
2+
3+
import logging
4+
from typing import Any
5+
6+
import botocore.exceptions
7+
from homelink.auth.srp_auth import SRPAuth
8+
import voluptuous as vol
9+
10+
from homeassistant import config_entries
11+
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
12+
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
13+
14+
from .const import DOMAIN
15+
from .oauth2 import SRPAuthImplementation
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
20+
class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
21+
"""Config flow to handle homelink OAuth2 authentication."""
22+
23+
DOMAIN = DOMAIN
24+
25+
def __init__(self) -> None:
26+
"""Set up the flow handler."""
27+
super().__init__()
28+
self.flow_impl = SRPAuthImplementation(self.hass, DOMAIN)
29+
30+
@property
31+
def logger(self):
32+
"""Get the logger."""
33+
return _LOGGER
34+
35+
async def async_step_user(
36+
self, user_input: dict[str, Any] | None = None
37+
) -> config_entries.ConfigFlowResult:
38+
"""Ask for username and password."""
39+
errors: dict[str, str] = {}
40+
if user_input is not None:
41+
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
42+
43+
srp_auth = SRPAuth()
44+
try:
45+
tokens = await self.hass.async_add_executor_job(
46+
srp_auth.async_get_access_token,
47+
user_input[CONF_EMAIL],
48+
user_input[CONF_PASSWORD],
49+
)
50+
except botocore.exceptions.ClientError:
51+
_LOGGER.exception("Error authenticating homelink account")
52+
errors["base"] = "srp_auth_failed"
53+
except Exception:
54+
_LOGGER.exception("An unexpected error occurred")
55+
errors["base"] = "unknown"
56+
else:
57+
self.external_data = {"tokens": tokens}
58+
return await self.async_step_creation()
59+
60+
return self.async_show_form(
61+
step_id="user",
62+
data_schema=vol.Schema(
63+
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
64+
),
65+
errors=errors,
66+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Constants for the homelink integration."""
2+
3+
DOMAIN = "gentex_homelink"
4+
OAUTH2_TOKEN = "https://auth.homelinkcloud.com/oauth2/token"
5+
POLLING_INTERVAL = 5
6+
7+
EVENT_PRESSED = "Pressed"
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from dataclasses import dataclass
7+
from functools import partial
8+
import logging
9+
from typing import TYPE_CHECKING, TypedDict
10+
11+
from homelink.model.device import Device
12+
from homelink.mqtt_provider import MQTTProvider
13+
14+
from homeassistant.config_entries import ConfigEntry
15+
from homeassistant.core import HomeAssistant, callback
16+
from homeassistant.util.ssl import get_default_context
17+
18+
if TYPE_CHECKING:
19+
from .event import HomeLinkEventEntity
20+
21+
_LOGGER = logging.getLogger(__name__)
22+
23+
type HomeLinkConfigEntry = ConfigEntry[HomeLinkData]
24+
type EventCallback = Callable[[HomeLinkEventData], None]
25+
26+
27+
@dataclass
28+
class HomeLinkData:
29+
"""Class for HomeLink integration runtime data."""
30+
31+
provider: MQTTProvider
32+
coordinator: HomeLinkCoordinator
33+
last_update_id: str | None
34+
35+
36+
class HomeLinkEventData(TypedDict):
37+
"""Data for a single event."""
38+
39+
requestId: str
40+
timestamp: int
41+
42+
43+
class HomeLinkMQTTMessage(TypedDict):
44+
"""HomeLink MQTT Event message."""
45+
46+
type: str
47+
data: dict[str, HomeLinkEventData] # Each key is a button id
48+
49+
50+
class HomeLinkCoordinator:
51+
"""HomeLink integration coordinator."""
52+
53+
def __init__(
54+
self,
55+
hass: HomeAssistant,
56+
provider: MQTTProvider,
57+
config_entry: HomeLinkConfigEntry,
58+
) -> None:
59+
"""Initialize my coordinator."""
60+
self.hass = hass
61+
self.config_entry = config_entry
62+
self.provider = provider
63+
self.device_data: list[Device] = []
64+
self.buttons: list[HomeLinkEventEntity] = []
65+
self._listeners: dict[str, EventCallback] = {}
66+
67+
@callback
68+
def async_add_event_listener(
69+
self, update_callback: EventCallback, target_event_id: str
70+
) -> Callable[[], None]:
71+
"""Listen for updates."""
72+
self._listeners[target_event_id] = update_callback
73+
return partial(self.__async_remove_listener_internal, target_event_id)
74+
75+
def __async_remove_listener_internal(self, listener_id: str):
76+
del self._listeners[listener_id]
77+
78+
@callback
79+
def async_handle_state_data(self, data: dict[str, HomeLinkEventData]):
80+
"""Notify listeners."""
81+
for button_id, event in data.items():
82+
if listener := self._listeners.get(button_id):
83+
listener(event)
84+
85+
async def async_config_entry_first_refresh(self) -> None:
86+
"""Refresh data for the first time when a config entry is setup."""
87+
await self._async_setup()
88+
89+
async def async_on_unload(self, _event):
90+
"""Disconnect and unregister when unloaded."""
91+
await self.provider.disable()
92+
93+
async def _async_setup(self) -> None:
94+
"""Set up the coordinator."""
95+
await self.provider.enable(get_default_context())
96+
await self.discover_devices()
97+
self.provider.listen(self.on_message)
98+
99+
async def discover_devices(self):
100+
"""Discover devices and build the Entities."""
101+
self.device_data = await self.provider.discover()
102+
103+
def on_message(
104+
self: HomeLinkCoordinator, _topic: str, message: HomeLinkMQTTMessage
105+
):
106+
"MQTT Callback function."
107+
if message["type"] == "state":
108+
self.hass.add_job(self.async_handle_state_data, message["data"])
109+
if message["type"] == "requestSync":
110+
self.hass.add_job(
111+
self.hass.config_entries.async_reload,
112+
self.config_entry.entry_id,
113+
)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Platform for Event integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.components.event import EventDeviceClass, EventEntity
6+
from homeassistant.config_entries import ConfigEntry
7+
from homeassistant.core import HomeAssistant, callback
8+
from homeassistant.helpers.device_registry import DeviceInfo
9+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
10+
11+
from .const import DOMAIN, EVENT_PRESSED
12+
from .coordinator import HomeLinkCoordinator, HomeLinkEventData
13+
14+
15+
async def async_setup_entry(
16+
hass: HomeAssistant,
17+
config_entry: ConfigEntry,
18+
async_add_entities: AddConfigEntryEntitiesCallback,
19+
) -> None:
20+
"""Add the entities for the binary sensor."""
21+
coordinator = config_entry.runtime_data.coordinator
22+
for device in coordinator.device_data:
23+
buttons = [
24+
HomeLinkEventEntity(b.id, b.name, device.id, device.name, coordinator)
25+
for b in device.buttons
26+
]
27+
coordinator.buttons.extend(buttons)
28+
29+
async_add_entities(coordinator.buttons)
30+
31+
32+
# Updates are centralized by the coordinator.
33+
PARALLEL_UPDATES = 0
34+
35+
36+
class HomeLinkEventEntity(EventEntity):
37+
"""Event Entity."""
38+
39+
_attr_has_entity_name = True
40+
_attr_event_types = [EVENT_PRESSED]
41+
_attr_device_class = EventDeviceClass.BUTTON
42+
43+
def __init__(
44+
self,
45+
id: str,
46+
param_name: str,
47+
device_id: str,
48+
device_name: str,
49+
coordinator: HomeLinkCoordinator,
50+
) -> None:
51+
"""Initialize the event entity."""
52+
53+
self.id: str = id
54+
self._attr_name: str = param_name
55+
self._attr_unique_id: str = id
56+
self._attr_device_info = DeviceInfo(
57+
identifiers={(DOMAIN, device_id)},
58+
name=device_name,
59+
)
60+
self.coordinator = coordinator
61+
self.last_request_id: str | None = None
62+
63+
async def async_added_to_hass(self) -> None:
64+
"""When entity is added to hass."""
65+
await super().async_added_to_hass()
66+
self.async_on_remove(
67+
self.coordinator.async_add_event_listener(
68+
self._handle_event_data_update, self.id
69+
)
70+
)
71+
72+
@callback
73+
def _handle_event_data_update(self, update_data: HomeLinkEventData) -> None:
74+
"""Update this button."""
75+
76+
if update_data["requestId"] != self.last_request_id:
77+
self._trigger_event(EVENT_PRESSED)
78+
self.last_request_id = update_data["requestId"]
79+
80+
self.async_write_ha_state()
81+
82+
async def async_update(self):
83+
"""Request early polling. Left intentionally blank because it's not possible in this implementation."""
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"domain": "gentex_homelink",
3+
"name": "HomeLink",
4+
"codeowners": ["@niaexa", "@ryanjones-gentex"],
5+
"config_flow": true,
6+
"dependencies": ["application_credentials"],
7+
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
8+
"iot_class": "cloud_push",
9+
"quality_scale": "bronze",
10+
"requirements": ["homelink-integration-api==0.0.1"]
11+
}

0 commit comments

Comments
 (0)