Add local API support to elmax (#94392)

* Add support for local (lan) panel integration

* Fix merge conflicts

* Remove executable flag from non-executable files

* Fix tests

* Update homeassistant/components/elmax/__init__.py

Shorten comment

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Fix typehint

* Rename DummyPanel into DirectPanel

* Update homeassistant/components/elmax/__init__.py

Rewording

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/elmax/__init__.py

Rewording

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Refactor option step into menu step

* Change requirement statement

* Refactor dictionary key entries to use existing constants

* Align step names to new constants

* Align step names to new constants amd align tests

* Align step names to new constants amd align tests

* Align step names to new constants

* Simplify logic to handle entire entry instead of a portion of the state

* Simplify working mode checks

* Add data_description dictionary to better explain SSL and FOLLOW_MDSN options

* Add support for local (lan) panel integration

* Fix merge conflicts

* Remove executable flag from non-executable files

* Fix tests

* Update homeassistant/components/elmax/__init__.py

Shorten comment

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Fix typehint

* Rename DummyPanel into DirectPanel

* Update homeassistant/components/elmax/__init__.py

Rewording

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/elmax/__init__.py

Rewording

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Refactor option step into menu step

* Change requirement statement

* Refactor dictionary key entries to use existing constants

* Align step names to new constants

* Align step names to new constants amd align tests

* Align step names to new constants amd align tests

* Align step names to new constants

* Simplify logic to handle entire entry instead of a portion of the state

* Simplify working mode checks

* Add data_description dictionary to better explain SSL and FOLLOW_MDSN options

* Add newline at end of file

* Remove CONF_ELMAX_MODE_DIRECT_FOLLOW_MDNS option

* Fix Ruff pre-check

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Alberto Geniola 2024-03-04 11:39:13 +01:00 committed by GitHub
parent 4c67670566
commit 86039de3cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1242 additions and 106 deletions

View file

@ -1,10 +1,11 @@
"""Elmax integration common classes and utilities."""
from __future__ import annotations
import asyncio
from asyncio import timeout
from datetime import timedelta
import logging
from logging import Logger
import ssl
from elmax_api.exceptions import (
ElmaxApiError,
@ -13,12 +14,14 @@ from elmax_api.exceptions import (
ElmaxNetworkError,
ElmaxPanelBusyError,
)
from elmax_api.http import Elmax
from elmax_api.http import Elmax, GenericElmax
from elmax_api.model.actuator import Actuator
from elmax_api.model.area import Area
from elmax_api.model.cover import Cover
from elmax_api.model.endpoint import DeviceEndpoint
from elmax_api.model.panel import PanelEntry, PanelStatus
from httpx import ConnectError, ConnectTimeout
from packaging import version
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
@ -29,11 +32,50 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed,
)
from .const import DEFAULT_TIMEOUT, DOMAIN
from .const import (
DEFAULT_TIMEOUT,
DOMAIN,
ELMAX_LOCAL_API_PATH,
MIN_APIV2_SUPPORTED_VERSION,
)
_LOGGER = logging.getLogger(__name__)
def get_direct_api_url(host: str, port: int, use_ssl: bool) -> str:
"""Return the direct API url given the base URI."""
schema = "https" if use_ssl else "http"
return f"{schema}://{host}:{port}/{ELMAX_LOCAL_API_PATH}"
def build_direct_ssl_context(cadata: str) -> ssl.SSLContext:
"""Create a custom SSL context for direct-api verification."""
context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_REQUIRED
context.load_verify_locations(cadata=cadata)
return context
def check_local_version_supported(api_version: str | None) -> bool:
"""Check whether the given API version is supported."""
if api_version is None:
return False
return version.parse(api_version) >= version.parse(MIN_APIV2_SUPPORTED_VERSION)
class DirectPanel(PanelEntry):
"""Helper class for wrapping a directly accessed Elmax Panel."""
def __init__(self, panel_uri):
"""Construct the object."""
super().__init__(panel_uri, True, {})
def get_name_by_user(self, username: str) -> str:
"""Return the panel name."""
return f"Direct Panel {self.hash}"
class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): # pylint: disable=hass-enforce-coordinator-module
"""Coordinator helper to handle Elmax API polling."""
@ -41,25 +83,21 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): # pylint: disable=h
self,
hass: HomeAssistant,
logger: Logger,
username: str,
password: str,
panel_id: str,
panel_pin: str,
elmax_api_client: GenericElmax,
panel: PanelEntry,
name: str,
update_interval: timedelta,
) -> None:
"""Instantiate the object."""
self._client = Elmax(username=username, password=password)
self._panel_id = panel_id
self._panel_pin = panel_pin
self._panel_entry = None
self._client = elmax_api_client
self._panel_entry = panel
self._state_by_endpoint = None
super().__init__(
hass=hass, logger=logger, name=name, update_interval=update_interval
)
@property
def panel_entry(self) -> PanelEntry | None:
def panel_entry(self) -> PanelEntry:
"""Return the panel entry."""
return self._panel_entry
@ -92,54 +130,46 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): # pylint: disable=h
"""Return the current http client being used by this instance."""
return self._client
@http_client.setter
def http_client(self, client: GenericElmax):
"""Set the client library instance for Elmax API."""
self._client = client
async def _async_update_data(self):
try:
async with asyncio.timeout(DEFAULT_TIMEOUT):
# Retrieve the panel online status first
panels = await self._client.list_control_panels()
panel = next(
(panel for panel in panels if panel.hash == self._panel_id), None
)
async with timeout(DEFAULT_TIMEOUT):
# The following command might fail in case of the panel is offline.
# We handle this case in the following exception blocks.
status = await self._client.get_current_panel_status()
# If the panel is no more available within the given. Raise config error as the user must
# reconfigure it in order to make it work again
if not panel:
raise ConfigEntryAuthFailed(
f"Panel ID {self._panel_id} is no more linked to this user"
" account"
)
self._panel_entry = panel
# If the panel is online, proceed with fetching its state
# and return it right away
if panel.online:
status = await self._client.get_panel_status(
control_panel_id=panel.hash, pin=self._panel_pin
) # type: PanelStatus
# Store a dictionary for fast endpoint state access
self._state_by_endpoint = {
k.endpoint_id: k for k in status.all_endpoints
}
return status
# Otherwise, return None. Listeners will know that this means the device is offline
return None
# Store a dictionary for fast endpoint state access
self._state_by_endpoint = {
k.endpoint_id: k for k in status.all_endpoints
}
return status
except ElmaxBadPinError as err:
raise ConfigEntryAuthFailed("Control panel pin was refused") from err
except ElmaxBadLoginError as err:
raise ConfigEntryAuthFailed("Refused username/password") from err
raise ConfigEntryAuthFailed("Refused username/password/pin") from err
except ElmaxApiError as err:
raise UpdateFailed(f"Error communicating with ELMAX API: {err}") from err
except ElmaxPanelBusyError as err:
raise UpdateFailed(
"Communication with the panel failed, as it is currently busy"
) from err
except ElmaxNetworkError as err:
except (ConnectError, ConnectTimeout, ElmaxNetworkError) as err:
if isinstance(self._client, Elmax):
raise UpdateFailed(
"A communication error has occurred. "
"Make sure HA can reach the internet and that "
"your firewall allows communication with the Meross Cloud."
) from err
raise UpdateFailed(
"A network error occurred while communicating with Elmax cloud."
"A communication error has occurred. "
"Make sure the panel is online and that "
"your firewall allows communication with it."
) from err
@ -148,20 +178,18 @@ class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]):
def __init__(
self,
panel: PanelEntry,
elmax_device: DeviceEndpoint,
panel_version: str,
coordinator: ElmaxCoordinator,
) -> None:
"""Construct the object."""
super().__init__(coordinator=coordinator)
self._panel = panel
self._device = elmax_device
self._attr_unique_id = elmax_device.endpoint_id
self._attr_name = elmax_device.name
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, panel.hash)},
name=panel.get_name_by_user(
identifiers={(DOMAIN, coordinator.panel_entry.hash)},
name=coordinator.panel_entry.get_name_by_user(
coordinator.http_client.get_authenticated_username()
),
manufacturer="Elmax",
@ -172,4 +200,4 @@ class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]):
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._panel.online
return super().available and self.coordinator.panel_entry.online