Add HomeLink integration (#136460)

Co-authored-by: Nicholas Aelick <niaexa@syntronic.com>
This commit is contained in:
ryanjones-gentex
2025-12-04 04:32:02 -05:00
committed by GitHub
parent 34cc6036b9
commit f1bfe2f11e
21 changed files with 957 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -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

View 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)

View File

@@ -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)

View 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,
)

View 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"

View 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,
)

View 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."""

View 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"]
}

View 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"]

View 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

View 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"
}
}
}
}
}

View File

@@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [
"ekeybionyx",
"electric_kiwi",
"fitbit",
"gentex_homelink",
"geocaching",
"google",
"google_assistant_sdk",

View File

@@ -239,6 +239,7 @@ FLOWS = {
"gdacs",
"generic",
"geniushub",
"gentex_homelink",
"geo_json_events",
"geocaching",
"geofency",

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Tests for the homelink integration."""

View 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

View 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()

View 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

View 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"
)