diff --git a/CODEOWNERS b/CODEOWNERS index 69fe3899c4e..a9a77d12267 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/gentex_homelink/__init__.py b/homeassistant/components/gentex_homelink/__init__.py new file mode 100644 index 00000000000..71a3dc4a2cd --- /dev/null +++ b/homeassistant/components/gentex_homelink/__init__.py @@ -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) diff --git a/homeassistant/components/gentex_homelink/application_credentials.py b/homeassistant/components/gentex_homelink/application_credentials.py new file mode 100644 index 00000000000..33b54f6255f --- /dev/null +++ b/homeassistant/components/gentex_homelink/application_credentials.py @@ -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) diff --git a/homeassistant/components/gentex_homelink/config_flow.py b/homeassistant/components/gentex_homelink/config_flow.py new file mode 100644 index 00000000000..a9041966827 --- /dev/null +++ b/homeassistant/components/gentex_homelink/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/gentex_homelink/const.py b/homeassistant/components/gentex_homelink/const.py new file mode 100644 index 00000000000..07a4f481cb1 --- /dev/null +++ b/homeassistant/components/gentex_homelink/const.py @@ -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" diff --git a/homeassistant/components/gentex_homelink/coordinator.py b/homeassistant/components/gentex_homelink/coordinator.py new file mode 100644 index 00000000000..85eb6200ed9 --- /dev/null +++ b/homeassistant/components/gentex_homelink/coordinator.py @@ -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, + ) diff --git a/homeassistant/components/gentex_homelink/event.py b/homeassistant/components/gentex_homelink/event.py new file mode 100644 index 00000000000..4cee09acef8 --- /dev/null +++ b/homeassistant/components/gentex_homelink/event.py @@ -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.""" diff --git a/homeassistant/components/gentex_homelink/manifest.json b/homeassistant/components/gentex_homelink/manifest.json new file mode 100644 index 00000000000..57ce93f674d --- /dev/null +++ b/homeassistant/components/gentex_homelink/manifest.json @@ -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"] +} diff --git a/homeassistant/components/gentex_homelink/oauth2.py b/homeassistant/components/gentex_homelink/oauth2.py new file mode 100644 index 00000000000..55bbc4ddf9b --- /dev/null +++ b/homeassistant/components/gentex_homelink/oauth2.py @@ -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"] diff --git a/homeassistant/components/gentex_homelink/quality_scale.yaml b/homeassistant/components/gentex_homelink/quality_scale.yaml new file mode 100644 index 00000000000..b60214da9bc --- /dev/null +++ b/homeassistant/components/gentex_homelink/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/gentex_homelink/strings.json b/homeassistant/components/gentex_homelink/strings.json new file mode 100644 index 00000000000..5fbfabc59b3 --- /dev/null +++ b/homeassistant/components/gentex_homelink/strings.json @@ -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" + } + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 38cd82a39d7..1f9d3e005a6 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [ "ekeybionyx", "electric_kiwi", "fitbit", + "gentex_homelink", "geocaching", "google", "google_assistant_sdk", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 92ba009cad4..725feeb1d4d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -239,6 +239,7 @@ FLOWS = { "gdacs", "generic", "geniushub", + "gentex_homelink", "geo_json_events", "geocaching", "geofency", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fe24ab35070..3b9fd263266 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 3008b0559d9..a8e9d367f4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59686d34869..6a3bb24dea9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/gentex_homelink/__init__.py b/tests/components/gentex_homelink/__init__.py new file mode 100644 index 00000000000..83a06ecb43a --- /dev/null +++ b/tests/components/gentex_homelink/__init__.py @@ -0,0 +1 @@ +"""Tests for the homelink integration.""" diff --git a/tests/components/gentex_homelink/test_config_flow.py b/tests/components/gentex_homelink/test_config_flow.py new file mode 100644 index 00000000000..10f102b9103 --- /dev/null +++ b/tests/components/gentex_homelink/test_config_flow.py @@ -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 diff --git a/tests/components/gentex_homelink/test_coordinator.py b/tests/components/gentex_homelink/test_coordinator.py new file mode 100644 index 00000000000..2947e28839a --- /dev/null +++ b/tests/components/gentex_homelink/test_coordinator.py @@ -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() diff --git a/tests/components/gentex_homelink/test_event.py b/tests/components/gentex_homelink/test_event.py new file mode 100644 index 00000000000..d3661f94492 --- /dev/null +++ b/tests/components/gentex_homelink/test_event.py @@ -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 diff --git a/tests/components/gentex_homelink/test_init.py b/tests/components/gentex_homelink/test_init.py new file mode 100644 index 00000000000..a8a7b3e7675 --- /dev/null +++ b/tests/components/gentex_homelink/test_init.py @@ -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" + )