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:
parent
4c67670566
commit
86039de3cd
20 changed files with 1242 additions and 106 deletions
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue