Skip to content
Merged
Show file tree
Hide file tree
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 Dec 7, 2020
fdae480
Add user input validation.
chemaaa Dec 13, 2020
cc7a580
Refactor API data handling and add refresh intervals.
chemaaa Dec 19, 2020
ce4b978
Add tests.
chemaaa Dec 26, 2020
2b62dda
Inject aiohttp client session into API object.
chemaaa Dec 27, 2020
aed8679
Add test fixtures and additional tests.
chemaaa Dec 27, 2020
558ff42
Override switch entity availability property to include module reacha…
chemaaa Dec 31, 2020
d305869
Refactor entity handling for adding, removing and updating.
chemaaa Dec 31, 2020
e201887
Add integration tests and timeout tests.
chemaaa Dec 31, 2020
73bf382
Add exception handling in plant topology update flow.
chemaaa Jan 1, 2021
3c3c908
Refactor test code.
chemaaa Jan 1, 2021
d0519e0
Add Configuration Options Flow
chemaaa Jan 16, 2021
e3c0cf2
Add config options flow test.
chemaaa Jan 17, 2021
932ca5b
Update dependency version and code cleanup.
chemaaa Feb 14, 2021
e633d8d
Address code review issues.
chemaaa Feb 28, 2021
d3771dc
Rename component as per the code review.
chemaaa Mar 6, 2021
3a9e000
Apply suggestions from code review
chemaaa Mar 9, 2021
d3cee50
Fixing previous code changes to pass all tests.
chemaaa Mar 9, 2021
919fc4e
Remove options flow and refactor most api functions out to the library.
chemaaa Mar 13, 2021
ba8e5eb
Test cleanup as per the code review.
chemaaa Mar 17, 2021
98e1c8b
Move coordinator to init module.
chemaaa Mar 17, 2021
fbe719f
Apply suggestions from code review
chemaaa Mar 19, 2021
e173bca
Code style fixes to previous commit.
chemaaa Mar 19, 2021
cbff5f9
Apply suggestions from code review
chemaaa Mar 19, 2021
d024422
Apply code review suggestions to test code.
chemaaa Mar 19, 2021
7625249
Clean up init and platform test code.
chemaaa Mar 20, 2021
9874afc
Apply suggestions from code review
chemaaa Mar 20, 2021
c00026e
Upgrade library version dependency.
chemaaa Mar 20, 2021
926f843
Change test to assert on hass.states
chemaaa Mar 20, 2021
e0a9597
Remove assertion logic from test helper function.
chemaaa Mar 23, 2021
75da180
Fix tests to prevent integration detail access.
chemaaa Mar 24, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,9 @@ omit =
homeassistant/components/homematic/climate.py
homeassistant/components/homematic/cover.py
homeassistant/components/homematic/notify.py
homeassistant/components/home_plus_control/api.py
homeassistant/components/home_plus_control/helpers.py
homeassistant/components/home_plus_control/switch.py
homeassistant/components/homeworks/*
homeassistant/components/honeywell/climate.py
homeassistant/components/horizon/media_player.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ homeassistant/components/history/* @home-assistant/core
homeassistant/components/hive/* @Rendili @KJonline
homeassistant/components/hlk_sw16/* @jameshilliard
homeassistant/components/home_connect/* @DavidMStraub
homeassistant/components/home_plus_control/* @chemaaa
homeassistant/components/homeassistant/* @home-assistant/core
homeassistant/components/homekit/* @bdraco
homeassistant/components/homekit_controller/* @Jc2k
Expand Down
179 changes: 179 additions & 0 deletions homeassistant/components/home_plus_control/__init__.py
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
Comment thread
chemaaa marked this conversation as resolved.

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] = []
Comment thread
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
55 changes: 55 additions & 0 deletions homeassistant/components/home_plus_control/api.py
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"]
32 changes: 32 additions & 0 deletions homeassistant/components/home_plus_control/config_flow.py
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)
45 changes: 45 additions & 0 deletions homeassistant/components/home_plus_control/const.py
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"
53 changes: 53 additions & 0 deletions homeassistant/components/home_plus_control/helpers.py
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"
Loading