mirror of
https://github.com/home-assistant/core.git
synced 2025-08-30 18:01:31 +02:00
Re-add aladdin_connect
integration (#149029)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -87,6 +87,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/airzone/ @Noltari
|
/tests/components/airzone/ @Noltari
|
||||||
/homeassistant/components/airzone_cloud/ @Noltari
|
/homeassistant/components/airzone_cloud/ @Noltari
|
||||||
/tests/components/airzone_cloud/ @Noltari
|
/tests/components/airzone_cloud/ @Noltari
|
||||||
|
/homeassistant/components/aladdin_connect/ @swcloudgenie
|
||||||
|
/tests/components/aladdin_connect/ @swcloudgenie
|
||||||
/homeassistant/components/alarm_control_panel/ @home-assistant/core
|
/homeassistant/components/alarm_control_panel/ @home-assistant/core
|
||||||
/tests/components/alarm_control_panel/ @home-assistant/core
|
/tests/components/alarm_control_panel/ @home-assistant/core
|
||||||
/homeassistant/components/alert/ @home-assistant/core @frenck
|
/homeassistant/components/alert/ @home-assistant/core @frenck
|
||||||
|
@@ -2,39 +2,112 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from genie_partner_sdk.client import AladdinConnectClient
|
||||||
|
from genie_partner_sdk.model import GarageDoor
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import (
|
||||||
|
aiohttp_client,
|
||||||
|
config_entry_oauth2_flow,
|
||||||
|
device_registry as dr,
|
||||||
|
)
|
||||||
|
|
||||||
DOMAIN = "aladdin_connect"
|
from . import api
|
||||||
|
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||||
|
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
|
async def async_setup_entry(
|
||||||
"""Set up Aladdin Connect from a config entry."""
|
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||||
ir.async_create_issue(
|
) -> bool:
|
||||||
hass,
|
"""Set up Aladdin Connect Genie from a config entry."""
|
||||||
DOMAIN,
|
implementation = (
|
||||||
DOMAIN,
|
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||||
is_fixable=False,
|
hass, entry
|
||||||
severity=ir.IssueSeverity.ERROR,
|
)
|
||||||
translation_key="integration_removed",
|
)
|
||||||
translation_placeholders={
|
|
||||||
"entries": "/config/integrations/integration/aladdin_connect",
|
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||||
},
|
|
||||||
|
client = AladdinConnectClient(
|
||||||
|
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
|
||||||
|
)
|
||||||
|
|
||||||
|
sdk_doors = await client.get_doors()
|
||||||
|
|
||||||
|
# Convert SDK GarageDoor objects to integration GarageDoor objects
|
||||||
|
doors = [
|
||||||
|
GarageDoor(
|
||||||
|
{
|
||||||
|
"device_id": door.device_id,
|
||||||
|
"door_number": door.door_number,
|
||||||
|
"name": door.name,
|
||||||
|
"status": door.status,
|
||||||
|
"link_status": door.link_status,
|
||||||
|
"battery_level": door.battery_level,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for door in sdk_doors
|
||||||
|
]
|
||||||
|
|
||||||
|
entry.runtime_data = {
|
||||||
|
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
|
||||||
|
for door in doors
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
remove_stale_devices(hass, entry)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(
|
||||||
|
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(
|
||||||
|
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Migrate old config."""
|
||||||
|
if config_entry.version < CONFIG_FLOW_VERSION:
|
||||||
|
config_entry.async_start_reauth(hass)
|
||||||
|
new_data = {**config_entry.data}
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry,
|
||||||
|
data=new_data,
|
||||||
|
version=CONFIG_FLOW_VERSION,
|
||||||
|
minor_version=CONFIG_FLOW_MINOR_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
def remove_stale_devices(
|
||||||
"""Unload a config entry."""
|
hass: HomeAssistant,
|
||||||
return True
|
config_entry: AladdinConnectConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Remove stale devices from device registry."""
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
device_entries = dr.async_entries_for_config_entry(
|
||||||
|
device_registry, config_entry.entry_id
|
||||||
|
)
|
||||||
|
all_device_ids = set(config_entry.runtime_data)
|
||||||
|
|
||||||
|
for device_entry in device_entries:
|
||||||
|
device_id: str | None = None
|
||||||
|
for identifier in device_entry.identifiers:
|
||||||
|
if identifier[0] == DOMAIN:
|
||||||
|
device_id = identifier[1]
|
||||||
|
break
|
||||||
|
|
||||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
if device_id and device_id not in all_device_ids:
|
||||||
"""Remove a config entry."""
|
device_registry.async_update_device(
|
||||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
device_entry.id, remove_config_entry_id=config_entry.entry_id
|
||||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
)
|
||||||
# Remove any remaining disabled or ignored entries
|
|
||||||
for _entry in hass.config_entries.async_entries(DOMAIN):
|
|
||||||
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
|
|
||||||
|
33
homeassistant/components/aladdin_connect/api.py
Normal file
33
homeassistant/components/aladdin_connect/api.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
|
||||||
|
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from genie_partner_sdk.auth import Auth
|
||||||
|
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
|
||||||
|
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncConfigEntryAuth(Auth):
|
||||||
|
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
websession: ClientSession,
|
||||||
|
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize Aladdin Connect Genie auth."""
|
||||||
|
super().__init__(
|
||||||
|
websession, API_URL, oauth_session.token["access_token"], API_KEY
|
||||||
|
)
|
||||||
|
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 cast(str, self._oauth_session.token["access_token"])
|
@@ -0,0 +1,14 @@
|
|||||||
|
"""application_credentials platform the Aladdin Connect Genie integration."""
|
||||||
|
|
||||||
|
from homeassistant.components.application_credentials import AuthorizationServer
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||||
|
"""Return authorization server."""
|
||||||
|
return AuthorizationServer(
|
||||||
|
authorize_url=OAUTH2_AUTHORIZE,
|
||||||
|
token_url=OAUTH2_TOKEN,
|
||||||
|
)
|
@@ -1,11 +1,63 @@
|
|||||||
"""Config flow for Aladdin Connect integration."""
|
"""Config flow for Aladdin Connect Genie."""
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow
|
from collections.abc import Mapping
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from . import DOMAIN
|
import jwt
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN):
|
class OAuth2FlowHandler(
|
||||||
"""Handle a config flow for Aladdin Connect."""
|
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||||
|
):
|
||||||
|
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
|
||||||
|
|
||||||
VERSION = 1
|
DOMAIN = DOMAIN
|
||||||
|
VERSION = CONFIG_FLOW_VERSION
|
||||||
|
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
|
||||||
|
|
||||||
|
async def async_step_reauth(
|
||||||
|
self, user_input: Mapping[str, Any]
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Perform reauth upon API auth error or upgrade from v1 to v2."""
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(
|
||||||
|
self, user_input: Mapping[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Dialog that informs the user that reauth is required."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm",
|
||||||
|
data_schema=vol.Schema({}),
|
||||||
|
)
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
|
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||||
|
"""Create an oauth config entry or update existing entry for reauth."""
|
||||||
|
# Extract the user ID from the JWT token's 'sub' field
|
||||||
|
token = jwt.decode(
|
||||||
|
data["token"]["access_token"], options={"verify_signature": False}
|
||||||
|
)
|
||||||
|
user_id = token["sub"]
|
||||||
|
await self.async_set_unique_id(user_id)
|
||||||
|
|
||||||
|
if self.source == SOURCE_REAUTH:
|
||||||
|
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
self._get_reauth_entry(), data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(title="Aladdin Connect", data=data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logger(self) -> logging.Logger:
|
||||||
|
"""Return logger."""
|
||||||
|
return logging.getLogger(__name__)
|
||||||
|
14
homeassistant/components/aladdin_connect/const.py
Normal file
14
homeassistant/components/aladdin_connect/const.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Constants for the Aladdin Connect Genie integration."""
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from homeassistant.components.cover import CoverEntityFeature
|
||||||
|
|
||||||
|
DOMAIN = "aladdin_connect"
|
||||||
|
CONFIG_FLOW_VERSION = 2
|
||||||
|
CONFIG_FLOW_MINOR_VERSION = 1
|
||||||
|
|
||||||
|
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
|
||||||
|
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
|
||||||
|
|
||||||
|
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
44
homeassistant/components/aladdin_connect/coordinator.py
Normal file
44
homeassistant/components/aladdin_connect/coordinator.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Coordinator for Aladdin Connect integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from genie_partner_sdk.client import AladdinConnectClient
|
||||||
|
from genie_partner_sdk.model import GarageDoor
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
|
||||||
|
SCAN_INTERVAL = timedelta(seconds=15)
|
||||||
|
|
||||||
|
|
||||||
|
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||||
|
"""Coordinator for Aladdin Connect integration."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AladdinConnectConfigEntry,
|
||||||
|
client: AladdinConnectClient,
|
||||||
|
garage_door: GarageDoor,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
logger=_LOGGER,
|
||||||
|
config_entry=entry,
|
||||||
|
name="Aladdin Connect Coordinator",
|
||||||
|
update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
self.client = client
|
||||||
|
self.data = garage_door
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> GarageDoor:
|
||||||
|
"""Fetch data from the Aladdin Connect API."""
|
||||||
|
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||||
|
return self.data
|
62
homeassistant/components/aladdin_connect/cover.py
Normal file
62
homeassistant/components/aladdin_connect/cover.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Cover Entity for Genie Garage Door."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .const import SUPPORTED_FEATURES
|
||||||
|
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||||
|
from .entity import AladdinConnectEntity
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AladdinConnectConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the cover platform."""
|
||||||
|
coordinators = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||||
|
"""Representation of Aladdin Connect cover."""
|
||||||
|
|
||||||
|
_attr_device_class = CoverDeviceClass.GARAGE
|
||||||
|
_attr_supported_features = SUPPORTED_FEATURES
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
|
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||||
|
"""Initialize the Aladdin Connect cover."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._attr_unique_id = coordinator.data.unique_id
|
||||||
|
|
||||||
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||||
|
"""Issue open command to cover."""
|
||||||
|
await self.client.open_door(self._device_id, self._number)
|
||||||
|
|
||||||
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
|
"""Issue close command to cover."""
|
||||||
|
await self.client.close_door(self._device_id, self._number)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self) -> bool | None:
|
||||||
|
"""Update is closed attribute."""
|
||||||
|
return self.coordinator.data.status == "closed"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closing(self) -> bool | None:
|
||||||
|
"""Update is closing attribute."""
|
||||||
|
return self.coordinator.data.status == "closing"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_opening(self) -> bool | None:
|
||||||
|
"""Update is opening attribute."""
|
||||||
|
return self.coordinator.data.status == "opening"
|
32
homeassistant/components/aladdin_connect/entity.py
Normal file
32
homeassistant/components/aladdin_connect/entity.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Base class for Aladdin Connect entities."""
|
||||||
|
|
||||||
|
from genie_partner_sdk.client import AladdinConnectClient
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import AladdinConnectCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
|
||||||
|
"""Defines a base Aladdin Connect entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||||
|
"""Initialize Aladdin Connect entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
device = coordinator.data
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, device.unique_id)},
|
||||||
|
manufacturer="Aladdin Connect",
|
||||||
|
name=device.name,
|
||||||
|
)
|
||||||
|
self._device_id = device.device_id
|
||||||
|
self._number = device.door_number
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> AladdinConnectClient:
|
||||||
|
"""Return the client for this entity."""
|
||||||
|
return self.coordinator.client
|
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"domain": "aladdin_connect",
|
"domain": "aladdin_connect",
|
||||||
"name": "Aladdin Connect",
|
"name": "Aladdin Connect",
|
||||||
"codeowners": [],
|
"codeowners": ["@swcloudgenie"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["application_credentials"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
|
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": []
|
"requirements": ["genie-partner-sdk==1.0.10"]
|
||||||
}
|
}
|
||||||
|
94
homeassistant/components/aladdin_connect/quality_scale.yaml
Normal file
94
homeassistant/components/aladdin_connect/quality_scale.yaml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration does not register any service actions.
|
||||||
|
appropriate-polling: done
|
||||||
|
brands: done
|
||||||
|
common-modules: done
|
||||||
|
config-flow: done
|
||||||
|
config-flow-test-coverage: todo
|
||||||
|
dependency-transparency: done
|
||||||
|
docs-actions:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration does not register any service actions.
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-removal-instructions:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
entity-event-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: Integration does not subscribe to external events.
|
||||||
|
entity-unique-id: done
|
||||||
|
has-entity-name: done
|
||||||
|
runtime-data: done
|
||||||
|
test-before-configure:
|
||||||
|
status: todo
|
||||||
|
comment: Config flow does not currently test connection during setup.
|
||||||
|
test-before-setup: todo
|
||||||
|
unique-config-entry: done
|
||||||
|
|
||||||
|
# Silver
|
||||||
|
action-exceptions: todo
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-installation-parameters:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
entity-unavailable: todo
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: todo
|
||||||
|
parallel-updates: todo
|
||||||
|
reauthentication-flow: done
|
||||||
|
test-coverage:
|
||||||
|
status: todo
|
||||||
|
comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage.
|
||||||
|
|
||||||
|
# Gold
|
||||||
|
devices: done
|
||||||
|
diagnostics: todo
|
||||||
|
discovery: todo
|
||||||
|
discovery-update-info: todo
|
||||||
|
docs-data-update:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-examples:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-known-limitations:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-supported-devices:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-supported-functions:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-troubleshooting:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
docs-use-cases:
|
||||||
|
status: todo
|
||||||
|
comment: Documentation needs to be created.
|
||||||
|
dynamic-devices: todo
|
||||||
|
entity-category: done
|
||||||
|
entity-device-class: done
|
||||||
|
entity-disabled-by-default: done
|
||||||
|
entity-translations: done
|
||||||
|
exception-translations: todo
|
||||||
|
icon-translations: todo
|
||||||
|
reconfiguration-flow: todo
|
||||||
|
repair-issues: todo
|
||||||
|
stale-devices:
|
||||||
|
status: todo
|
||||||
|
comment: Stale devices can be done dynamically
|
||||||
|
|
||||||
|
# Platinum
|
||||||
|
async-dependency: todo
|
||||||
|
inject-websession: done
|
||||||
|
strict-typing: done
|
77
homeassistant/components/aladdin_connect/sensor.py
Normal file
77
homeassistant/components/aladdin_connect/sensor.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Support for Aladdin Connect Genie sensors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from genie_partner_sdk.model import GarageDoor
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||||
|
from .entity import AladdinConnectEntity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AladdinConnectSensorEntityDescription(SensorEntityDescription):
|
||||||
|
"""Sensor entity description for Aladdin Connect."""
|
||||||
|
|
||||||
|
value_fn: Callable[[GarageDoor], float | None]
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_TYPES: tuple[AladdinConnectSensorEntityDescription, ...] = (
|
||||||
|
AladdinConnectSensorEntityDescription(
|
||||||
|
key="battery_level",
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
value_fn=lambda garage_door: garage_door.battery_level,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AladdinConnectConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Aladdin Connect sensor devices."""
|
||||||
|
coordinators = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
AladdinConnectSensor(coordinator, description)
|
||||||
|
for coordinator in coordinators.values()
|
||||||
|
for description in SENSOR_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||||
|
"""A sensor implementation for Aladdin Connect device."""
|
||||||
|
|
||||||
|
entity_description: AladdinConnectSensorEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: AladdinConnectCoordinator,
|
||||||
|
entity_description: AladdinConnectSensorEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the Aladdin Connect sensor."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = entity_description
|
||||||
|
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float | None:
|
||||||
|
"""Return the state of the sensor."""
|
||||||
|
return self.entity_description.value_fn(self.coordinator.data)
|
@@ -1,8 +1,30 @@
|
|||||||
{
|
{
|
||||||
"issues": {
|
"config": {
|
||||||
"integration_removed": {
|
"step": {
|
||||||
"title": "The Aladdin Connect integration has been removed",
|
"pick_implementation": {
|
||||||
"description": "The Aladdin Connect integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Aladdin Connect integration entries]({entries})."
|
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
"description": "Aladdin Connect needs to re-authenticate your account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
|
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||||
|
"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%]",
|
||||||
|
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||||
|
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||||
|
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||||
|
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
|
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
APPLICATION_CREDENTIALS = [
|
APPLICATION_CREDENTIALS = [
|
||||||
|
"aladdin_connect",
|
||||||
"august",
|
"august",
|
||||||
"electric_kiwi",
|
"electric_kiwi",
|
||||||
"fitbit",
|
"fitbit",
|
||||||
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -47,6 +47,7 @@ FLOWS = {
|
|||||||
"airvisual_pro",
|
"airvisual_pro",
|
||||||
"airzone",
|
"airzone",
|
||||||
"airzone_cloud",
|
"airzone_cloud",
|
||||||
|
"aladdin_connect",
|
||||||
"alarmdecoder",
|
"alarmdecoder",
|
||||||
"alexa_devices",
|
"alexa_devices",
|
||||||
"altruist",
|
"altruist",
|
||||||
|
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -1000,6 +1000,9 @@ gassist-text==0.0.14
|
|||||||
# homeassistant.components.google
|
# homeassistant.components.google
|
||||||
gcal-sync==8.0.0
|
gcal-sync==8.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.aladdin_connect
|
||||||
|
genie-partner-sdk==1.0.10
|
||||||
|
|
||||||
# homeassistant.components.geniushub
|
# homeassistant.components.geniushub
|
||||||
geniushub-client==0.7.1
|
geniushub-client==0.7.1
|
||||||
|
|
||||||
|
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -870,6 +870,9 @@ gassist-text==0.0.14
|
|||||||
# homeassistant.components.google
|
# homeassistant.components.google
|
||||||
gcal-sync==8.0.0
|
gcal-sync==8.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.aladdin_connect
|
||||||
|
genie-partner-sdk==1.0.10
|
||||||
|
|
||||||
# homeassistant.components.geniushub
|
# homeassistant.components.geniushub
|
||||||
geniushub-client==0.7.1
|
geniushub-client==0.7.1
|
||||||
|
|
||||||
|
@@ -139,7 +139,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
|||||||
"airvisual_pro",
|
"airvisual_pro",
|
||||||
"airzone",
|
"airzone",
|
||||||
"airzone_cloud",
|
"airzone_cloud",
|
||||||
"aladdin_connect",
|
|
||||||
"alarmdecoder",
|
"alarmdecoder",
|
||||||
"alert",
|
"alert",
|
||||||
"alexa",
|
"alexa",
|
||||||
|
48
tests/components/aladdin_connect/conftest.py
Normal file
48
tests/components/aladdin_connect/conftest.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Fixtures for aladdin_connect tests."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.aladdin_connect import DOMAIN
|
||||||
|
from homeassistant.components.application_credentials import (
|
||||||
|
ClientCredential,
|
||||||
|
async_import_client_credential,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from .const import CLIENT_ID, CLIENT_SECRET, USER_ID
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||||
|
"""Fixture to setup credentials."""
|
||||||
|
assert await async_setup_component(hass, "application_credentials", {})
|
||||||
|
await async_import_client_credential(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
ClientCredential(CLIENT_ID, CLIENT_SECRET),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
|
"""Define a mock config entry fixture."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
version=1,
|
||||||
|
minor_version=1,
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Aladdin Connect",
|
||||||
|
data={
|
||||||
|
"auth_implementation": DOMAIN,
|
||||||
|
"token": {
|
||||||
|
"access_token": "old-token",
|
||||||
|
"refresh_token": "old-refresh-token",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"expires_at": 1234567890,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source="user",
|
||||||
|
unique_id=USER_ID,
|
||||||
|
)
|
5
tests/components/aladdin_connect/const.py
Normal file
5
tests/components/aladdin_connect/const.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Constants for aladdin_connect tests."""
|
||||||
|
|
||||||
|
CLIENT_ID = "1234"
|
||||||
|
CLIENT_SECRET = "5678"
|
||||||
|
USER_ID = "test_user_123"
|
275
tests/components/aladdin_connect/test_config_flow.py
Normal file
275
tests/components/aladdin_connect/test_config_flow.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""Test the Aladdin Connect Garage Door config flow."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.aladdin_connect.const import (
|
||||||
|
DOMAIN,
|
||||||
|
OAUTH2_AUTHORIZE,
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
|
from .const import CLIENT_ID, USER_ID
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
from tests.typing import ClientSessionGenerator
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def access_token(hass: HomeAssistant) -> str:
|
||||||
|
"""Return a valid access token with sub field for unique ID."""
|
||||||
|
return config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"sub": USER_ID,
|
||||||
|
"aud": [],
|
||||||
|
"iat": 1234567890,
|
||||||
|
"exp": 1234567890 + 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("current_request_with_host")
|
||||||
|
async def test_full_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
access_token,
|
||||||
|
) -> None:
|
||||||
|
"""Check full flow."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["url"] == (
|
||||||
|
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||||
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await hass_client_no_auth()
|
||||||
|
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
json={
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"access_token": access_token,
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Aladdin Connect"
|
||||||
|
assert result["data"] == {
|
||||||
|
"auth_implementation": DOMAIN,
|
||||||
|
"token": {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"expires_in": 60,
|
||||||
|
"expires_at": result["data"]["token"]["expires_at"],
|
||||||
|
"type": "Bearer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert result["result"].unique_id == USER_ID
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("current_request_with_host")
|
||||||
|
async def test_duplicate_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
access_token,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Check full flow."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["url"] == (
|
||||||
|
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||||
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await hass_client_no_auth()
|
||||||
|
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
json={
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"access_token": access_token,
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("current_request_with_host")
|
||||||
|
async def test_flow_reauth(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
access_token,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test reauth flow."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
# Start reauth flow
|
||||||
|
result = await mock_config_entry.start_reauth_flow(hass)
|
||||||
|
|
||||||
|
# Should show reauth confirm form
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
|
||||||
|
# Confirm reauth
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should now go to user step (OAuth)
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["url"] == (
|
||||||
|
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||||
|
"&redirect_uri=https://example.com/auth/external/callback"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await hass_client_no_auth()
|
||||||
|
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
json={
|
||||||
|
"refresh_token": "new-refresh-token",
|
||||||
|
"access_token": access_token,
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.aladdin_connect.async_setup_entry", return_value=True
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "reauth_successful"
|
||||||
|
# Verify the entry was updated, not a new one created
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("current_request_with_host")
|
||||||
|
async def test_flow_wrong_account_reauth(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_client_no_auth: ClientSessionGenerator,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test reauth flow with wrong account."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
# Start reauth flow
|
||||||
|
result = await mock_config_entry.start_reauth_flow(hass)
|
||||||
|
|
||||||
|
# Should show reauth confirm form
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
|
||||||
|
# Create access token for a different user
|
||||||
|
different_user_token = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"sub": "different_user_456",
|
||||||
|
"aud": [],
|
||||||
|
"iat": 1234567890,
|
||||||
|
"exp": 1234567890 + 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start reauth flow
|
||||||
|
result = await mock_config_entry.start_reauth_flow(hass)
|
||||||
|
|
||||||
|
# Confirm reauth
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complete OAuth with different user
|
||||||
|
state = config_entry_oauth2_flow._encode_jwt(
|
||||||
|
hass,
|
||||||
|
{
|
||||||
|
"flow_id": result["flow_id"],
|
||||||
|
"redirect_uri": "https://example.com/auth/external/callback",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await hass_client_no_auth()
|
||||||
|
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
aioclient_mock.post(
|
||||||
|
OAUTH2_TOKEN,
|
||||||
|
json={
|
||||||
|
"refresh_token": "wrong-user-refresh-token",
|
||||||
|
"access_token": different_user_token,
|
||||||
|
"type": "Bearer",
|
||||||
|
"expires_in": 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
|
# Should abort with wrong account
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "wrong_account"
|
@@ -1,79 +1,114 @@
|
|||||||
"""Tests for the Aladdin Connect integration."""
|
"""Tests for the Aladdin Connect integration."""
|
||||||
|
|
||||||
from homeassistant.components.aladdin_connect import DOMAIN
|
from unittest.mock import AsyncMock, patch
|
||||||
from homeassistant.config_entries import (
|
|
||||||
SOURCE_IGNORE,
|
from homeassistant.components.aladdin_connect.const import DOMAIN
|
||||||
ConfigEntryDisabler,
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
ConfigEntryState,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import issue_registry as ir
|
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
async def test_aladdin_connect_repair_issue(
|
async def test_setup_entry(hass: HomeAssistant) -> None:
|
||||||
hass: HomeAssistant, issue_registry: ir.IssueRegistry
|
"""Test a successful setup entry."""
|
||||||
) -> None:
|
config_entry = MockConfigEntry(
|
||||||
"""Test the Aladdin Connect configuration entry loading/unloading handles the repair."""
|
|
||||||
config_entry_1 = MockConfigEntry(
|
|
||||||
title="Example 1",
|
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"token": {
|
||||||
|
"access_token": "test_token",
|
||||||
|
"refresh_token": "test_refresh_token",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unique_id="test_unique_id",
|
||||||
)
|
)
|
||||||
config_entry_1.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
await hass.config_entries.async_setup(config_entry_1.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert config_entry_1.state is ConfigEntryState.LOADED
|
|
||||||
|
|
||||||
# Add a second one
|
mock_door = AsyncMock()
|
||||||
config_entry_2 = MockConfigEntry(
|
mock_door.device_id = "test_device_id"
|
||||||
title="Example 2",
|
mock_door.door_number = 1
|
||||||
|
mock_door.name = "Test Door"
|
||||||
|
mock_door.status = "closed"
|
||||||
|
mock_door.link_status = "connected"
|
||||||
|
mock_door.battery_level = 100
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get_doors.return_value = [mock_door]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||||
|
return_value=AsyncMock(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.config_entry_oauth2_flow.OAuth2Session",
|
||||||
|
return_value=AsyncMock(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.AladdinConnectClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.api.AsyncConfigEntryAuth",
|
||||||
|
return_value=AsyncMock(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_entry(hass: HomeAssistant) -> None:
|
||||||
|
"""Test a successful unload entry."""
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
|
data={
|
||||||
|
"token": {
|
||||||
|
"access_token": "test_token",
|
||||||
|
"refresh_token": "test_refresh_token",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unique_id="test_unique_id",
|
||||||
)
|
)
|
||||||
config_entry_2.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
await hass.config_entries.async_setup(config_entry_2.entry_id)
|
|
||||||
|
# Mock door data
|
||||||
|
mock_door = AsyncMock()
|
||||||
|
mock_door.device_id = "test_device_id"
|
||||||
|
mock_door.door_number = 1
|
||||||
|
mock_door.name = "Test Door"
|
||||||
|
mock_door.status = "closed"
|
||||||
|
mock_door.link_status = "connected"
|
||||||
|
mock_door.battery_level = 100
|
||||||
|
|
||||||
|
# Mock client
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get_doors.return_value = [mock_door]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.config_entry_oauth2_flow.async_get_config_entry_implementation",
|
||||||
|
return_value=AsyncMock(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.config_entry_oauth2_flow.OAuth2Session",
|
||||||
|
return_value=AsyncMock(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.AladdinConnectClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.aladdin_connect.api.AsyncConfigEntryAuth",
|
||||||
|
return_value=AsyncMock(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert config_entry_2.state is ConfigEntryState.LOADED
|
assert config_entry.state is ConfigEntryState.LOADED
|
||||||
assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
|
|
||||||
|
|
||||||
# Add an ignored entry
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
config_entry_3 = MockConfigEntry(
|
|
||||||
source=SOURCE_IGNORE,
|
|
||||||
domain=DOMAIN,
|
|
||||||
)
|
|
||||||
config_entry_3.add_to_hass(hass)
|
|
||||||
await hass.config_entries.async_setup(config_entry_3.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert config_entry_3.state is ConfigEntryState.NOT_LOADED
|
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
# Add a disabled entry
|
|
||||||
config_entry_4 = MockConfigEntry(
|
|
||||||
disabled_by=ConfigEntryDisabler.USER,
|
|
||||||
domain=DOMAIN,
|
|
||||||
)
|
|
||||||
config_entry_4.add_to_hass(hass)
|
|
||||||
await hass.config_entries.async_setup(config_entry_4.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert config_entry_4.state is ConfigEntryState.NOT_LOADED
|
|
||||||
|
|
||||||
# Remove the first one
|
|
||||||
await hass.config_entries.async_remove(config_entry_1.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert config_entry_1.state is ConfigEntryState.NOT_LOADED
|
|
||||||
assert config_entry_2.state is ConfigEntryState.LOADED
|
|
||||||
assert issue_registry.async_get_issue(DOMAIN, DOMAIN)
|
|
||||||
|
|
||||||
# Remove the second one
|
|
||||||
await hass.config_entries.async_remove(config_entry_2.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert config_entry_1.state is ConfigEntryState.NOT_LOADED
|
|
||||||
assert config_entry_2.state is ConfigEntryState.NOT_LOADED
|
|
||||||
assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None
|
|
||||||
|
|
||||||
# Check the ignored and disabled entries are removed
|
|
||||||
assert not hass.config_entries.async_entries(DOMAIN)
|
|
||||||
|
Reference in New Issue
Block a user