-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add Homepluscontrol integration #46783
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
13d6428
Add initial Legrand Home+ Control integration
chemaaa fdae480
Add user input validation.
chemaaa cc7a580
Refactor API data handling and add refresh intervals.
chemaaa ce4b978
Add tests.
chemaaa 2b62dda
Inject aiohttp client session into API object.
chemaaa aed8679
Add test fixtures and additional tests.
chemaaa 558ff42
Override switch entity availability property to include module reacha…
chemaaa d305869
Refactor entity handling for adding, removing and updating.
chemaaa e201887
Add integration tests and timeout tests.
chemaaa 73bf382
Add exception handling in plant topology update flow.
chemaaa 3c3c908
Refactor test code.
chemaaa d0519e0
Add Configuration Options Flow
chemaaa e3c0cf2
Add config options flow test.
chemaaa 932ca5b
Update dependency version and code cleanup.
chemaaa e633d8d
Address code review issues.
chemaaa d3771dc
Rename component as per the code review.
chemaaa 3a9e000
Apply suggestions from code review
chemaaa d3cee50
Fixing previous code changes to pass all tests.
chemaaa 919fc4e
Remove options flow and refactor most api functions out to the library.
chemaaa ba8e5eb
Test cleanup as per the code review.
chemaaa 98e1c8b
Move coordinator to init module.
chemaaa fbe719f
Apply suggestions from code review
chemaaa e173bca
Code style fixes to previous commit.
chemaaa cbff5f9
Apply suggestions from code review
chemaaa d024422
Apply code review suggestions to test code.
chemaaa 7625249
Clean up init and platform test code.
chemaaa 9874afc
Apply suggestions from code review
chemaaa c00026e
Upgrade library version dependency.
chemaaa 926f843
Change test to assert on hass.states
chemaaa e0a9597
Remove assertion logic from test helper function.
chemaaa 75da180
Fix tests to prevent integration detail access.
chemaaa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| """The Legrand Home+ Control integration.""" | ||
| import asyncio | ||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| import async_timeout | ||
| from homepluscontrol.homeplusapi import HomePlusControlApiError | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers import ( | ||
| config_entry_oauth2_flow, | ||
| config_validation as cv, | ||
| dispatcher, | ||
| ) | ||
| from homeassistant.helpers.device_registry import async_get as async_get_device_registry | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from . import config_flow, helpers | ||
| from .api import HomePlusControlAsyncApi | ||
| from .const import ( | ||
| API, | ||
| CONF_SUBSCRIPTION_KEY, | ||
| DATA_COORDINATOR, | ||
| DISPATCHER_REMOVERS, | ||
| DOMAIN, | ||
| ENTITY_UIDS, | ||
| SIGNAL_ADD_ENTITIES, | ||
| ) | ||
|
|
||
| # Configuration schema for component in configuration.yaml | ||
| CONFIG_SCHEMA = vol.Schema( | ||
| { | ||
| DOMAIN: vol.Schema( | ||
| { | ||
| vol.Required(CONF_CLIENT_ID): cv.string, | ||
| vol.Required(CONF_CLIENT_SECRET): cv.string, | ||
| vol.Required(CONF_SUBSCRIPTION_KEY): cv.string, | ||
| } | ||
| ) | ||
| }, | ||
| extra=vol.ALLOW_EXTRA, | ||
| ) | ||
|
|
||
| # The Legrand Home+ Control platform is currently limited to "switch" entities | ||
| PLATFORMS = ["switch"] | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def async_setup(hass: HomeAssistant, config: dict) -> bool: | ||
| """Set up the Legrand Home+ Control component from configuration.yaml.""" | ||
| hass.data[DOMAIN] = {} | ||
|
|
||
| if DOMAIN not in config: | ||
| return True | ||
|
|
||
| # Register the implementation from the config information | ||
| config_flow.HomePlusControlFlowHandler.async_register_implementation( | ||
| hass, | ||
| helpers.HomePlusControlOAuth2Implementation(hass, config[DOMAIN]), | ||
| ) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: | ||
| """Set up Legrand Home+ Control from a config entry.""" | ||
| hass_entry_data = hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) | ||
|
|
||
| # Retrieve the registered implementation | ||
| implementation = ( | ||
| await config_entry_oauth2_flow.async_get_config_entry_implementation( | ||
| hass, config_entry | ||
| ) | ||
| ) | ||
|
|
||
| # Using an aiohttp-based API lib, so rely on async framework | ||
| # Add the API object to the domain's data in HA | ||
| api = hass_entry_data[API] = HomePlusControlAsyncApi( | ||
| hass, config_entry, implementation | ||
| ) | ||
|
|
||
| # Set of entity unique identifiers of this integration | ||
| uids = hass_entry_data[ENTITY_UIDS] = set() | ||
|
|
||
| # Integration dispatchers | ||
| hass_entry_data[DISPATCHER_REMOVERS] = [] | ||
|
chemaaa marked this conversation as resolved.
|
||
|
|
||
| device_registry = async_get_device_registry(hass) | ||
|
|
||
| # Register the Data Coordinator with the integration | ||
| async def async_update_data(): | ||
| """Fetch data from API endpoint. | ||
|
|
||
| This is the place to pre-process the data to lookup tables | ||
| so entities can quickly look up their data. | ||
| """ | ||
| try: | ||
| # Note: asyncio.TimeoutError and aiohttp.ClientError are already | ||
| # handled by the data update coordinator. | ||
| async with async_timeout.timeout(10): | ||
| module_data = await api.async_get_modules() | ||
| except HomePlusControlApiError as err: | ||
| raise UpdateFailed( | ||
| f"Error communicating with API: {err} [{type(err)}]" | ||
| ) from err | ||
|
|
||
| # Remove obsolete entities from Home Assistant | ||
| entity_uids_to_remove = uids - set(module_data) | ||
| for uid in entity_uids_to_remove: | ||
| uids.remove(uid) | ||
| device = device_registry.async_get_device({(DOMAIN, uid)}) | ||
| device_registry.async_remove_device(device.id) | ||
|
|
||
| # Send out signal for new entity addition to Home Assistant | ||
| new_entity_uids = set(module_data) - uids | ||
| if new_entity_uids: | ||
| uids.update(new_entity_uids) | ||
| dispatcher.async_dispatcher_send( | ||
| hass, | ||
| SIGNAL_ADD_ENTITIES, | ||
| new_entity_uids, | ||
| coordinator, | ||
| ) | ||
|
|
||
| return module_data | ||
|
|
||
| coordinator = DataUpdateCoordinator( | ||
| hass, | ||
| _LOGGER, | ||
| # Name of the data. For logging purposes. | ||
| name="home_plus_control_module", | ||
| update_method=async_update_data, | ||
| # Polling interval. Will only be polled if there are subscribers. | ||
| update_interval=timedelta(seconds=60), | ||
| ) | ||
| hass_entry_data[DATA_COORDINATOR] = coordinator | ||
|
|
||
| async def start_platforms(): | ||
| """Continue setting up the platforms.""" | ||
| await asyncio.gather( | ||
| *[ | ||
| hass.config_entries.async_forward_entry_setup(config_entry, platform) | ||
| for platform in PLATFORMS | ||
| ] | ||
| ) | ||
| # Only refresh the coordinator after all platforms are loaded. | ||
| await coordinator.async_refresh() | ||
|
|
||
| hass.async_create_task(start_platforms()) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: | ||
| """Unload the Legrand Home+ Control config entry.""" | ||
| unload_ok = all( | ||
| await asyncio.gather( | ||
| *[ | ||
| hass.config_entries.async_forward_entry_unload(config_entry, component) | ||
| for component in PLATFORMS | ||
| ] | ||
| ) | ||
| ) | ||
| if unload_ok: | ||
| # Unsubscribe the config_entry signal dispatcher connections | ||
| dispatcher_removers = hass.data[DOMAIN][config_entry.entry_id].pop( | ||
| "dispatcher_removers" | ||
| ) | ||
| for remover in dispatcher_removers: | ||
| remover() | ||
|
|
||
| # And finally unload the domain config entry data | ||
| hass.data[DOMAIN].pop(config_entry.entry_id) | ||
|
|
||
| return unload_ok | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| """API for Legrand Home+ Control bound to Home Assistant OAuth.""" | ||
| from homepluscontrol.homeplusapi import HomePlusControlAPI | ||
|
|
||
| from homeassistant import config_entries, core | ||
| from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow | ||
|
|
||
| from .const import DEFAULT_UPDATE_INTERVALS | ||
|
|
||
|
|
||
| class HomePlusControlAsyncApi(HomePlusControlAPI): | ||
| """Legrand Home+ Control object that interacts with the OAuth2-based API of the provider. | ||
|
|
||
| This API is bound the HomeAssistant Config Entry that corresponds to this component. | ||
|
|
||
| Attributes:. | ||
| hass (HomeAssistant): HomeAssistant core object. | ||
| config_entry (ConfigEntry): ConfigEntry object that configures this API. | ||
| implementation (AbstractOAuth2Implementation): OAuth2 implementation that handles AA and | ||
| token refresh. | ||
| _oauth_session (OAuth2Session): OAuth2Session object within implementation. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: core.HomeAssistant, | ||
| config_entry: config_entries.ConfigEntry, | ||
| implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, | ||
| ) -> None: | ||
| """Initialize the HomePlusControlAsyncApi object. | ||
|
|
||
| Initialize the authenticated API for the Legrand Home+ Control component. | ||
|
|
||
| Args:. | ||
| hass (HomeAssistant): HomeAssistant core object. | ||
| config_entry (ConfigEntry): ConfigEntry object that configures this API. | ||
| implementation (AbstractOAuth2Implementation): OAuth2 implementation that handles AA | ||
| and token refresh. | ||
| """ | ||
| self._oauth_session = config_entry_oauth2_flow.OAuth2Session( | ||
| hass, config_entry, implementation | ||
| ) | ||
|
|
||
| # Create the API authenticated client - external library | ||
| super().__init__( | ||
| subscription_key=implementation.subscription_key, | ||
| oauth_client=aiohttp_client.async_get_clientsession(hass), | ||
| update_intervals=DEFAULT_UPDATE_INTERVALS, | ||
| ) | ||
|
|
||
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| """Config flow for Legrand Home+ Control.""" | ||
| import logging | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.helpers import config_entry_oauth2_flow | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
|
|
||
| class HomePlusControlFlowHandler( | ||
| config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN | ||
| ): | ||
| """Config flow to handle Home+ Control OAuth2 authentication.""" | ||
|
|
||
| DOMAIN = DOMAIN | ||
|
|
||
| # Pick the Cloud Poll class | ||
| CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL | ||
|
|
||
| @property | ||
| def logger(self) -> logging.Logger: | ||
| """Return logger.""" | ||
| return logging.getLogger(__name__) | ||
|
|
||
| async def async_step_user(self, user_input=None): | ||
| """Handle a flow start initiated by the user.""" | ||
| await self.async_set_unique_id(DOMAIN) | ||
|
|
||
| if self._async_current_entries(): | ||
| return self.async_abort(reason="single_instance_allowed") | ||
|
|
||
| return await super().async_step_user(user_input) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """Constants for the Legrand Home+ Control integration.""" | ||
| API = "api" | ||
| CONF_SUBSCRIPTION_KEY = "subscription_key" | ||
| CONF_PLANT_UPDATE_INTERVAL = "plant_update_interval" | ||
| CONF_PLANT_TOPOLOGY_UPDATE_INTERVAL = "plant_topology_update_interval" | ||
| CONF_MODULE_STATUS_UPDATE_INTERVAL = "module_status_update_interval" | ||
|
|
||
| DATA_COORDINATOR = "coordinator" | ||
| DOMAIN = "home_plus_control" | ||
| ENTITY_UIDS = "entity_unique_ids" | ||
| DISPATCHER_REMOVERS = "dispatcher_removers" | ||
|
|
||
| # Legrand Model Identifiers - https://developer.legrand.com/documentation/product-cluster-list/# | ||
| HW_TYPE = { | ||
| "NLC": "NLC - Cable Outlet", | ||
| "NLF": "NLF - On-Off Dimmer Switch w/o Neutral", | ||
| "NLP": "NLP - Socket (Connected) Outlet", | ||
| "NLPM": "NLPM - Mobile Socket Outlet", | ||
| "NLM": "NLM - Micromodule Switch", | ||
| "NLV": "NLV - Shutter Switch with Neutral", | ||
| "NLLV": "NLLV - Shutter Switch with Level Control", | ||
| "NLL": "NLL - On-Off Toggle Switch with Neutral", | ||
| "NLT": "NLT - Remote Switch", | ||
| "NLD": "NLD - Double Gangs On-Off Remote Switch", | ||
| } | ||
|
|
||
| # Legrand OAuth2 URIs | ||
| OAUTH2_AUTHORIZE = "https://partners-login.eliotbylegrand.com/authorize" | ||
| OAUTH2_TOKEN = "https://partners-login.eliotbylegrand.com/token" | ||
|
|
||
| # The Legrand Home+ Control API has very limited request quotas - at the time of writing, it is | ||
| # limited to 500 calls per day (resets at 00:00) - so we want to keep updates to a minimum. | ||
| DEFAULT_UPDATE_INTERVALS = { | ||
| # Seconds between API checks for plant information updates. This is expected to change very | ||
| # little over time because a user's plants (homes) should rarely change. | ||
| CONF_PLANT_UPDATE_INTERVAL: 7200, # 120 minutes | ||
| # Seconds between API checks for plant topology updates. This is expected to change little | ||
| # over time because the modules in the user's plant should be relatively stable. | ||
| CONF_PLANT_TOPOLOGY_UPDATE_INTERVAL: 3600, # 60 minutes | ||
| # Seconds between API checks for module status updates. This can change frequently so we | ||
| # check often | ||
| CONF_MODULE_STATUS_UPDATE_INTERVAL: 300, # 5 minutes | ||
| } | ||
|
|
||
| SIGNAL_ADD_ENTITIES = "home_plus_control_add_entities_signal" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| """Helper classes and functions for the Legrand Home+ Control integration.""" | ||
| from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers import config_entry_oauth2_flow | ||
|
|
||
| from .const import CONF_SUBSCRIPTION_KEY, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN | ||
|
|
||
|
|
||
| class HomePlusControlOAuth2Implementation( | ||
| config_entry_oauth2_flow.LocalOAuth2Implementation | ||
| ): | ||
| """OAuth2 implementation that extends the HomeAssistant local implementation. | ||
|
|
||
| It provides the name of the integration and adds support for the subscription key. | ||
|
|
||
| Attributes: | ||
| hass (HomeAssistant): HomeAssistant core object. | ||
| client_id (str): Client identifier assigned by the API provider when registering an app. | ||
| client_secret (str): Client secret assigned by the API provider when registering an app. | ||
| subscription_key (str): Subscription key obtained from the API provider. | ||
| authorize_url (str): Authorization URL initiate authentication flow. | ||
| token_url (str): URL to retrieve access/refresh tokens. | ||
| name (str): Name of the implementation (appears in the HomeAssitant GUI). | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| config_data: dict, | ||
| ): | ||
| """HomePlusControlOAuth2Implementation Constructor. | ||
|
|
||
| Initialize the authentication implementation for the Legrand Home+ Control API. | ||
|
|
||
| Args: | ||
| hass (HomeAssistant): HomeAssistant core object. | ||
| config_data (dict): Configuration data that complies with the config Schema | ||
| of this component. | ||
| """ | ||
| super().__init__( | ||
| hass=hass, | ||
| domain=DOMAIN, | ||
| client_id=config_data[CONF_CLIENT_ID], | ||
| client_secret=config_data[CONF_CLIENT_SECRET], | ||
| authorize_url=OAUTH2_AUTHORIZE, | ||
| token_url=OAUTH2_TOKEN, | ||
| ) | ||
| self.subscription_key = config_data[CONF_SUBSCRIPTION_KEY] | ||
|
|
||
| @property | ||
| def name(self) -> str: | ||
| """Name of the implementation.""" | ||
| return "Home+ Control" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.