mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
Add HomeLink integration (#136460)
Co-authored-by: Nicholas Aelick <niaexa@syntronic.com>
This commit is contained in:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -571,6 +571,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
|
||||
58
homeassistant/components/gentex_homelink/__init__.py
Normal file
58
homeassistant/components/gentex_homelink/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""The homelink integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homelink.mqtt_provider import MQTTProvider
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
|
||||
from . import oauth2
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkData
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
|
||||
"""Set up homelink from a config entry."""
|
||||
auth_implementation = oauth2.SRPAuthImplementation(hass, DOMAIN)
|
||||
|
||||
config_entry_oauth2_flow.async_register_implementation(
|
||||
hass, DOMAIN, auth_implementation
|
||||
)
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
authenticated_session = oauth2.AsyncConfigEntryAuth(
|
||||
aiohttp_client.async_get_clientsession(hass), session
|
||||
)
|
||||
|
||||
provider = MQTTProvider(authenticated_session)
|
||||
coordinator = HomeLinkCoordinator(hass, provider, entry)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, coordinator.async_on_unload
|
||||
)
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = HomeLinkData(
|
||||
provider=provider, coordinator=coordinator, last_update_id=None
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.coordinator.async_on_unload(None)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""application_credentials platform for the gentex homelink integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import oauth2
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, _credential: ClientCredential
|
||||
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
|
||||
"""Return custom SRPAuth implementation."""
|
||||
return oauth2.SRPAuthImplementation(hass, auth_domain)
|
||||
66
homeassistant/components/gentex_homelink/config_flow.py
Normal file
66
homeassistant/components/gentex_homelink/config_flow.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Config flow for homelink."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import botocore.exceptions
|
||||
from homelink.auth.srp_auth import SRPAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import DOMAIN
|
||||
from .oauth2 import SRPAuthImplementation
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Config flow to handle homelink OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up the flow handler."""
|
||||
super().__init__()
|
||||
self.flow_impl = SRPAuthImplementation(self.hass, DOMAIN)
|
||||
|
||||
@property
|
||||
def logger(self):
|
||||
"""Get the logger."""
|
||||
return _LOGGER
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Ask for username and password."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
|
||||
|
||||
srp_auth = SRPAuth()
|
||||
try:
|
||||
tokens = await self.hass.async_add_executor_job(
|
||||
srp_auth.async_get_access_token,
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
except botocore.exceptions.ClientError:
|
||||
_LOGGER.exception("Error authenticating homelink account")
|
||||
errors["base"] = "srp_auth_failed"
|
||||
except Exception:
|
||||
_LOGGER.exception("An unexpected error occurred")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self.external_data = {"tokens": tokens}
|
||||
return await self.async_step_creation()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
7
homeassistant/components/gentex_homelink/const.py
Normal file
7
homeassistant/components/gentex_homelink/const.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Constants for the homelink integration."""
|
||||
|
||||
DOMAIN = "gentex_homelink"
|
||||
OAUTH2_TOKEN = "https://auth.homelinkcloud.com/oauth2/token"
|
||||
POLLING_INTERVAL = 5
|
||||
|
||||
EVENT_PRESSED = "Pressed"
|
||||
113
homeassistant/components/gentex_homelink/coordinator.py
Normal file
113
homeassistant/components/gentex_homelink/coordinator.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from homelink.model.device import Device
|
||||
from homelink.mqtt_provider import MQTTProvider
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .event import HomeLinkEventEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HomeLinkConfigEntry = ConfigEntry[HomeLinkData]
|
||||
type EventCallback = Callable[[HomeLinkEventData], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HomeLinkData:
|
||||
"""Class for HomeLink integration runtime data."""
|
||||
|
||||
provider: MQTTProvider
|
||||
coordinator: HomeLinkCoordinator
|
||||
last_update_id: str | None
|
||||
|
||||
|
||||
class HomeLinkEventData(TypedDict):
|
||||
"""Data for a single event."""
|
||||
|
||||
requestId: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class HomeLinkMQTTMessage(TypedDict):
|
||||
"""HomeLink MQTT Event message."""
|
||||
|
||||
type: str
|
||||
data: dict[str, HomeLinkEventData] # Each key is a button id
|
||||
|
||||
|
||||
class HomeLinkCoordinator:
|
||||
"""HomeLink integration coordinator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
provider: MQTTProvider,
|
||||
config_entry: HomeLinkConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize my coordinator."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.provider = provider
|
||||
self.device_data: list[Device] = []
|
||||
self.buttons: list[HomeLinkEventEntity] = []
|
||||
self._listeners: dict[str, EventCallback] = {}
|
||||
|
||||
@callback
|
||||
def async_add_event_listener(
|
||||
self, update_callback: EventCallback, target_event_id: str
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for updates."""
|
||||
self._listeners[target_event_id] = update_callback
|
||||
return partial(self.__async_remove_listener_internal, target_event_id)
|
||||
|
||||
def __async_remove_listener_internal(self, listener_id: str):
|
||||
del self._listeners[listener_id]
|
||||
|
||||
@callback
|
||||
def async_handle_state_data(self, data: dict[str, HomeLinkEventData]):
|
||||
"""Notify listeners."""
|
||||
for button_id, event in data.items():
|
||||
if listener := self._listeners.get(button_id):
|
||||
listener(event)
|
||||
|
||||
async def async_config_entry_first_refresh(self) -> None:
|
||||
"""Refresh data for the first time when a config entry is setup."""
|
||||
await self._async_setup()
|
||||
|
||||
async def async_on_unload(self, _event):
|
||||
"""Disconnect and unregister when unloaded."""
|
||||
await self.provider.disable()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
await self.provider.enable(get_default_context())
|
||||
await self.discover_devices()
|
||||
self.provider.listen(self.on_message)
|
||||
|
||||
async def discover_devices(self):
|
||||
"""Discover devices and build the Entities."""
|
||||
self.device_data = await self.provider.discover()
|
||||
|
||||
def on_message(
|
||||
self: HomeLinkCoordinator, _topic: str, message: HomeLinkMQTTMessage
|
||||
):
|
||||
"MQTT Callback function."
|
||||
if message["type"] == "state":
|
||||
self.hass.add_job(self.async_handle_state_data, message["data"])
|
||||
if message["type"] == "requestSync":
|
||||
self.hass.add_job(
|
||||
self.hass.config_entries.async_reload,
|
||||
self.config_entry.entry_id,
|
||||
)
|
||||
83
homeassistant/components/gentex_homelink/event.py
Normal file
83
homeassistant/components/gentex_homelink/event.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Platform for Event integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, EVENT_PRESSED
|
||||
from .coordinator import HomeLinkCoordinator, HomeLinkEventData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the entities for the binary sensor."""
|
||||
coordinator = config_entry.runtime_data.coordinator
|
||||
for device in coordinator.device_data:
|
||||
buttons = [
|
||||
HomeLinkEventEntity(b.id, b.name, device.id, device.name, coordinator)
|
||||
for b in device.buttons
|
||||
]
|
||||
coordinator.buttons.extend(buttons)
|
||||
|
||||
async_add_entities(coordinator.buttons)
|
||||
|
||||
|
||||
# Updates are centralized by the coordinator.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class HomeLinkEventEntity(EventEntity):
|
||||
"""Event Entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_event_types = [EVENT_PRESSED]
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
param_name: str,
|
||||
device_id: str,
|
||||
device_name: str,
|
||||
coordinator: HomeLinkCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the event entity."""
|
||||
|
||||
self.id: str = id
|
||||
self._attr_name: str = param_name
|
||||
self._attr_unique_id: str = id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self.coordinator = coordinator
|
||||
self.last_request_id: str | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_event_listener(
|
||||
self._handle_event_data_update, self.id
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_event_data_update(self, update_data: HomeLinkEventData) -> None:
|
||||
"""Update this button."""
|
||||
|
||||
if update_data["requestId"] != self.last_request_id:
|
||||
self._trigger_event(EVENT_PRESSED)
|
||||
self.last_request_id = update_data["requestId"]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self):
|
||||
"""Request early polling. Left intentionally blank because it's not possible in this implementation."""
|
||||
11
homeassistant/components/gentex_homelink/manifest.json
Normal file
11
homeassistant/components/gentex_homelink/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "gentex_homelink",
|
||||
"name": "HomeLink",
|
||||
"codeowners": ["@niaexa", "@ryanjones-gentex"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["homelink-integration-api==0.0.1"]
|
||||
}
|
||||
114
homeassistant/components/gentex_homelink/oauth2.py
Normal file
114
homeassistant/components/gentex_homelink/oauth2.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""API for homelink bound to Home Assistant OAuth."""
|
||||
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientError, ClientSession
|
||||
from homelink.auth.abstract_auth import AbstractAuth
|
||||
from homelink.settings import COGNITO_CLIENT_ID
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import OAUTH2_TOKEN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementation):
|
||||
"""Base class to abstract OAuth2 authentication."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, domain) -> None:
|
||||
"""Initialize the SRP Auth implementation."""
|
||||
|
||||
self.hass = hass
|
||||
self._domain = domain
|
||||
self.client_id = COGNITO_CLIENT_ID
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Name of the implementation."""
|
||||
return "SRPAuth"
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
"""Domain that is providing the implementation."""
|
||||
return self._domain
|
||||
|
||||
async def async_generate_authorize_url(self, flow_id: str) -> str:
|
||||
"""Left intentionally blank because the auth is handled by SRP."""
|
||||
return ""
|
||||
|
||||
async def async_resolve_external_data(self, external_data) -> dict:
|
||||
"""Format the token from the source appropriately for HomeAssistant."""
|
||||
tokens = external_data["tokens"]
|
||||
new_token = {}
|
||||
new_token["access_token"] = tokens["AuthenticationResult"]["AccessToken"]
|
||||
new_token["refresh_token"] = tokens["AuthenticationResult"]["RefreshToken"]
|
||||
new_token["token_type"] = tokens["AuthenticationResult"]["TokenType"]
|
||||
new_token["expires_in"] = tokens["AuthenticationResult"]["ExpiresIn"]
|
||||
new_token["expires_at"] = (
|
||||
time.time() + tokens["AuthenticationResult"]["ExpiresIn"]
|
||||
)
|
||||
|
||||
return new_token
|
||||
|
||||
async def _token_request(self, data: dict) -> dict:
|
||||
"""Make a token request."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
data["client_id"] = self.client_id
|
||||
|
||||
_LOGGER.debug("Sending token request to %s", OAUTH2_TOKEN)
|
||||
resp = await session.post(OAUTH2_TOKEN, data=data)
|
||||
if resp.status >= 400:
|
||||
try:
|
||||
error_response = await resp.json()
|
||||
except (ClientError, JSONDecodeError):
|
||||
error_response = {}
|
||||
error_code = error_response.get("error", "unknown")
|
||||
error_description = error_response.get(
|
||||
"error_description", "unknown error"
|
||||
)
|
||||
_LOGGER.error(
|
||||
"Token request for %s failed (%s): %s",
|
||||
self.domain,
|
||||
error_code,
|
||||
error_description,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(dict, await resp.json())
|
||||
|
||||
async def _async_refresh_token(self, token: dict) -> dict:
|
||||
"""Refresh tokens."""
|
||||
new_token = await self._token_request(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"refresh_token": token["refresh_token"],
|
||||
}
|
||||
)
|
||||
return {**token, **new_token}
|
||||
|
||||
|
||||
class AsyncConfigEntryAuth(AbstractAuth):
|
||||
"""Provide homelink authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize homelink auth."""
|
||||
super().__init__(websession)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
if not self._oauth_session.valid_token:
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return self._oauth_session.token["access_token"]
|
||||
76
homeassistant/components/gentex_homelink/quality_scale.yaml
Normal file
76
homeassistant/components/gentex_homelink/quality_scale.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: Integration does not poll
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: It is not necessary to update IP addresses of devices or services in this Integration
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: done
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Entities are not noisy and are expected to be enabled by default
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Entity properties are user-defined, and therefore cannot be translated
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Entities in this integration do not use icons, and therefore do not require translation
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
38
homeassistant/components/gentex_homelink/strings.json
Normal file
38
homeassistant/components/gentex_homelink/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"error": {
|
||||
"srp_auth_failed": "Error authenticating HomeLink account",
|
||||
"unknown": "An unknown error occurred. Please try again later"
|
||||
},
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Email address associated with your HomeLink account",
|
||||
"password": "Password associated with your HomeLink account"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"ekeybionyx",
|
||||
"electric_kiwi",
|
||||
"fitbit",
|
||||
"gentex_homelink",
|
||||
"geocaching",
|
||||
"google",
|
||||
"google_assistant_sdk",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -239,6 +239,7 @@ FLOWS = {
|
||||
"gdacs",
|
||||
"generic",
|
||||
"geniushub",
|
||||
"gentex_homelink",
|
||||
"geo_json_events",
|
||||
"geocaching",
|
||||
"geofency",
|
||||
|
||||
@@ -2295,6 +2295,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"gentex_homelink": {
|
||||
"name": "HomeLink",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"geo_json_events": {
|
||||
"name": "GeoJSON",
|
||||
"integration_type": "service",
|
||||
|
||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -1209,6 +1209,9 @@ home-assistant-frontend==20251203.0
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.12.2
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
|
||||
# homeassistant.components.homematicip_cloud
|
||||
homematicip==2.4.0
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -1067,6 +1067,9 @@ home-assistant-frontend==20251203.0
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.12.2
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
|
||||
# homeassistant.components.homematicip_cloud
|
||||
homematicip==2.4.0
|
||||
|
||||
|
||||
1
tests/components/gentex_homelink/__init__.py
Normal file
1
tests/components/gentex_homelink/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the homelink integration."""
|
||||
91
tests/components/gentex_homelink/test_config_flow.py
Normal file
91
tests/components/gentex_homelink/test_config_flow.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Test the homelink config flow."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import botocore.exceptions
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.gentex_homelink.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
|
||||
async def test_show_user_form(hass: HomeAssistant) -> None:
|
||||
"""Test that the user set up form is served."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
|
||||
|
||||
async def test_full_flow(hass: HomeAssistant) -> None:
|
||||
"""Check full flow."""
|
||||
with patch(
|
||||
"homeassistant.components.gentex_homelink.config_flow.SRPAuth"
|
||||
) as MockSRPAuth:
|
||||
instance = MockSRPAuth.return_value
|
||||
instance.async_get_access_token.return_value = {
|
||||
"AuthenticationResult": {
|
||||
"AccessToken": "access",
|
||||
"RefreshToken": "refresh",
|
||||
"TokenType": "bearer",
|
||||
"ExpiresIn": 3600,
|
||||
}
|
||||
}
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={"email": "test@test.com", "password": "SomePassword"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"]
|
||||
assert result["data"]["token"]
|
||||
assert result["data"]["token"]["access_token"] == "access"
|
||||
assert result["data"]["token"]["refresh_token"] == "refresh"
|
||||
assert result["data"]["token"]["expires_in"] == 3600
|
||||
assert result["data"]["token"]["expires_at"]
|
||||
|
||||
|
||||
async def test_boto_error(hass: HomeAssistant) -> None:
|
||||
"""Test exceptions from boto are handled correctly."""
|
||||
with patch(
|
||||
"homeassistant.components.gentex_homelink.config_flow.SRPAuth"
|
||||
) as MockSRPAuth:
|
||||
instance = MockSRPAuth.return_value
|
||||
instance.async_get_access_token.side_effect = botocore.exceptions.ClientError(
|
||||
{"Error": {}}, "Some operation"
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={"email": "test@test.com", "password": "SomePassword"},
|
||||
)
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||
|
||||
|
||||
async def test_generic_error(hass: HomeAssistant) -> None:
|
||||
"""Test exceptions from boto are handled correctly."""
|
||||
with patch(
|
||||
"homeassistant.components.gentex_homelink.config_flow.SRPAuth"
|
||||
) as MockSRPAuth:
|
||||
instance = MockSRPAuth.return_value
|
||||
instance.async_get_access_token.side_effect = Exception("Some error")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={"email": "test@test.com", "password": "SomePassword"},
|
||||
)
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||
160
tests/components/gentex_homelink/test_coordinator.py
Normal file
160
tests/components/gentex_homelink/test_coordinator.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Tests for the homelink coordinator."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from homelink.model.button import Button
|
||||
from homelink.model.device import Device
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.gentex_homelink import async_setup_entry
|
||||
from homeassistant.components.gentex_homelink.const import EVENT_PRESSED
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
DOMAIN = "gentex_homelink"
|
||||
|
||||
deviceInst = Device(id="TestDevice", name="TestDevice")
|
||||
deviceInst.buttons = [
|
||||
Button(id="Button 1", name="Button 1", device=deviceInst),
|
||||
Button(id="Button 2", name="Button 2", device=deviceInst),
|
||||
Button(id="Button 3", name="Button 3", device=deviceInst),
|
||||
]
|
||||
|
||||
|
||||
async def test_get_state_updates(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test state updates.
|
||||
|
||||
Tests that get_state calls are called by home assistant, and the homeassistant components respond appropriately to the data returned.
|
||||
"""
|
||||
with patch(
|
||||
"homeassistant.components.gentex_homelink.MQTTProvider", autospec=True
|
||||
) as MockProvider:
|
||||
instance = MockProvider.return_value
|
||||
instance.discover.return_value = [deviceInst]
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=None,
|
||||
version=1,
|
||||
data={
|
||||
"auth_implementation": "gentex_homelink",
|
||||
"token": {"expires_at": time.time() + 10000, "access_token": ""},
|
||||
"last_update_id": None,
|
||||
},
|
||||
state=ConfigEntryState.LOADED,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await async_setup_entry(hass, config_entry)
|
||||
# Assert configuration worked without errors
|
||||
assert result
|
||||
|
||||
provider = config_entry.runtime_data.provider
|
||||
state_data = {
|
||||
"type": "state",
|
||||
"data": {
|
||||
"Button 1": {"requestId": "rid1", "timestamp": time.time()},
|
||||
"Button 2": {"requestId": "rid2", "timestamp": time.time()},
|
||||
"Button 3": {"requestId": "rid3", "timestamp": time.time()},
|
||||
},
|
||||
}
|
||||
|
||||
# Test successful setup and first data fetch. The buttons should be unknown at the start
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
states = hass.states.async_all()
|
||||
assert states, "No states were loaded"
|
||||
assert all(state != STATE_UNAVAILABLE for state in states), (
|
||||
"At least one state was not initialized as STATE_UNAVAILABLE"
|
||||
)
|
||||
buttons_unknown = [s.state == "unknown" for s in states]
|
||||
assert buttons_unknown and all(buttons_unknown), (
|
||||
"At least one button state was not initialized to unknown"
|
||||
)
|
||||
|
||||
provider.listen.mock_calls[0].args[0](None, state_data)
|
||||
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
await asyncio.gather(*asyncio.all_tasks() - {asyncio.current_task()})
|
||||
await asyncio.sleep(0.01)
|
||||
states = hass.states.async_all()
|
||||
|
||||
assert all(state != STATE_UNAVAILABLE for state in states), (
|
||||
"Some button became unavailable"
|
||||
)
|
||||
buttons_pressed = [s.attributes["event_type"] == EVENT_PRESSED for s in states]
|
||||
assert buttons_pressed and all(buttons_pressed), (
|
||||
"At least one button was not pressed"
|
||||
)
|
||||
|
||||
|
||||
async def test_request_sync(hass: HomeAssistant) -> None:
|
||||
"""Test that the config entry is reloaded when a requestSync request is sent."""
|
||||
updatedDeviceInst = Device(id="TestDevice", name="TestDevice")
|
||||
updatedDeviceInst.buttons = [
|
||||
Button(id="Button 1", name="New Button 1", device=deviceInst),
|
||||
Button(id="Button 2", name="New Button 2", device=deviceInst),
|
||||
Button(id="Button 3", name="New Button 3", device=deviceInst),
|
||||
]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.gentex_homelink.MQTTProvider", autospec=True
|
||||
) as MockProvider:
|
||||
instance = MockProvider.return_value
|
||||
instance.discover.side_effect = [[deviceInst], [updatedDeviceInst]]
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=None,
|
||||
version=1,
|
||||
data={
|
||||
"auth_implementation": "gentex_homelink",
|
||||
"token": {"expires_at": time.time() + 10000, "access_token": ""},
|
||||
"last_update_id": None,
|
||||
},
|
||||
state=ConfigEntryState.LOADED,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await async_setup_entry(hass, config_entry)
|
||||
# Assert configuration worked without errors
|
||||
assert result
|
||||
|
||||
# Check to see if the correct buttons names were loaded
|
||||
comp = er.async_get(hass)
|
||||
button_names = {"Button 1", "Button 2", "Button 3"}
|
||||
registered_button_names = {b.original_name for b in comp.entities.values()}
|
||||
|
||||
assert button_names == registered_button_names, (
|
||||
"Expect button names to be correct for the initial config"
|
||||
)
|
||||
|
||||
provider = config_entry.runtime_data.provider
|
||||
coordinator = config_entry.runtime_data.coordinator
|
||||
|
||||
with patch.object(
|
||||
coordinator.hass.config_entries, "async_reload"
|
||||
) as async_reload_mock:
|
||||
# Mimic request sync event
|
||||
state_data = {
|
||||
"type": "requestSync",
|
||||
}
|
||||
# async reload should not be called yet
|
||||
async_reload_mock.assert_not_called()
|
||||
# Send the request sync
|
||||
provider.listen.mock_calls[0].args[0](None, state_data)
|
||||
# Wait for the request to be processed
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
await asyncio.gather(*asyncio.all_tasks() - {asyncio.current_task()})
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Now async reload should have been called
|
||||
async_reload_mock.assert_called()
|
||||
77
tests/components/gentex_homelink/test_event.py
Normal file
77
tests/components/gentex_homelink/test_event.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Test that the devices and entities are correctly configured."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homelink.model.button import Button
|
||||
from homelink.model.device import Device
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.gentex_homelink import async_setup_entry
|
||||
from homeassistant.components.gentex_homelink.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
TEST_CONFIG_ENTRY_ID = "ABC123"
|
||||
|
||||
"""Mock classes for testing."""
|
||||
|
||||
|
||||
deviceInst = Device(id="TestDevice", name="TestDevice")
|
||||
deviceInst.buttons = [
|
||||
Button(id="1", name="Button 1", device=deviceInst),
|
||||
Button(id="2", name="Button 2", device=deviceInst),
|
||||
Button(id="3", name="Button 3", device=deviceInst),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_setup_config(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Setup config entry."""
|
||||
with patch(
|
||||
"homeassistant.components.gentex_homelink.MQTTProvider", autospec=True
|
||||
) as MockProvider:
|
||||
instance = MockProvider.return_value
|
||||
instance.discover.return_value = [deviceInst]
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=None,
|
||||
version=1,
|
||||
data={"auth_implementation": "gentex_homelink"},
|
||||
state=ConfigEntryState.LOADED,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await async_setup_entry(hass, config_entry)
|
||||
|
||||
# Assert configuration worked without errors
|
||||
assert result
|
||||
|
||||
|
||||
async def test_device_registered(hass: HomeAssistant, test_setup_config) -> None:
|
||||
"""Check if a device is registered."""
|
||||
# Assert we got a device with the test ID
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get_device([(DOMAIN, "TestDevice")])
|
||||
assert device
|
||||
assert device.name == "TestDevice"
|
||||
|
||||
|
||||
def test_entities_registered(hass: HomeAssistant, test_setup_config) -> None:
|
||||
"""Check if the entities are registered."""
|
||||
comp = er.async_get(hass)
|
||||
button_names = {"Button 1", "Button 2", "Button 3"}
|
||||
registered_button_names = {b.original_name for b in comp.entities.values()}
|
||||
|
||||
assert button_names == registered_button_names
|
||||
32
tests/components/gentex_homelink/test_init.py
Normal file
32
tests/components/gentex_homelink/test_init.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Test that the integration is initialized correctly."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components import gentex_homelink
|
||||
from homeassistant.components.gentex_homelink.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_load_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test the entry can be loaded and unloaded."""
|
||||
with patch("homeassistant.components.gentex_homelink.MQTTProvider", autospec=True):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=None,
|
||||
version=1,
|
||||
data={"auth_implementation": "gentex_homelink"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN, {}) is True, (
|
||||
"Component is not set up"
|
||||
)
|
||||
|
||||
assert await gentex_homelink.async_unload_entry(hass, entry), (
|
||||
"Component not unloaded"
|
||||
)
|
||||
Reference in New Issue
Block a user