Add local API support to Overkiz integration (Somfy TaHoma Developer Mode) (#71644)
* Add initial config flow implementation * Add initial config flow implementation * Add todos * Bugfixes * Add first zeroconf code * Fixes for new firmware * Bugfixes for local integration * Delete local token * Fix diagnostics * Update translations and improve code * Update translations and improve code * Add local integration updates * Add local integration updates * Small tweaks * Add comments * Bugfix * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Bugfixes * Small code improvements * Small code improvements * Change Config Flow (breaking change) * Remove token when integration is unloaded * Remove print * Simplify * Bugfixes * Improve configflow * Clean up unnecessary things * Catch nosuchtoken exception * Add migration for Config Flow * Add version 2 migration * Revert change in Config Flow * Fix api type * Update strings * Improve migrate entry * Implement changes * add more comments * Extend diagnostics * Ruff fixes * Clean up code * Bugfixes * Set gateway id * Start writing tests * Add first local test * Code coverage to 64% * Fixes * Remove local token on remove entry * Add debug logging + change manifest * Add developer mode check * Fix not_such_token issue * Small text changes * Bugfix * Fix tests * Address feedback * DRY * Test coverage to 77% * Coverage to 78% * Remove token removal by UUID * Add better retry methods * Clean up * Remove old data * 87% coverage * 90% code coverage * 100% code coverage * Use patch.multiple * Improve tests * Apply pre-commit after rebase * Fix breaking changes in ZeroconfServiceInfo * Add verify_ssl * Fix test import * Fix tests * Catch SSL verify failed * Revert hub to server rename * Move Config Flow version back to 1 * Add diagnostics tests * Fix tests * Fix strings * Implement feedback * Add debug logging for local connection errors * Simplify Config Flow and fix tests * Simplify Config Flow * Fix verify_ssl * Fix rebase mistake * Address feedback * Apply suggestions from code review * Update tests/components/overkiz/test_config_flow.py --------- Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
parent
01c49ba0e4
commit
75f237b587
13 changed files with 1130 additions and 233 deletions
|
@ -9,23 +9,32 @@ from typing import cast
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
from pyoverkiz.client import OverkizClient
|
from pyoverkiz.client import OverkizClient
|
||||||
from pyoverkiz.const import SUPPORTED_SERVERS
|
from pyoverkiz.const import SUPPORTED_SERVERS
|
||||||
from pyoverkiz.enums import OverkizState, UIClass, UIWidget
|
from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
|
||||||
from pyoverkiz.exceptions import (
|
from pyoverkiz.exceptions import (
|
||||||
BadCredentialsException,
|
BadCredentialsException,
|
||||||
MaintenanceException,
|
MaintenanceException,
|
||||||
NotSuchTokenException,
|
NotSuchTokenException,
|
||||||
TooManyRequestsException,
|
TooManyRequestsException,
|
||||||
)
|
)
|
||||||
from pyoverkiz.models import Device, Scenario, Setup
|
from pyoverkiz.models import Device, OverkizServer, Scenario, Setup
|
||||||
|
from pyoverkiz.utils import generate_local_server
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_TOKEN,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_API_TYPE,
|
||||||
CONF_HUB,
|
CONF_HUB,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
|
@ -48,15 +57,26 @@ class HomeAssistantOverkizData:
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Overkiz from a config entry."""
|
"""Set up Overkiz from a config entry."""
|
||||||
username = entry.data[CONF_USERNAME]
|
client: OverkizClient | None = None
|
||||||
password = entry.data[CONF_PASSWORD]
|
api_type = entry.data.get(CONF_API_TYPE, APIType.CLOUD)
|
||||||
server = SUPPORTED_SERVERS[entry.data[CONF_HUB]]
|
|
||||||
|
|
||||||
# To allow users with multiple accounts/hubs, we create a new session so they have separate cookies
|
# Local API
|
||||||
session = async_create_clientsession(hass)
|
if api_type == APIType.LOCAL:
|
||||||
client = OverkizClient(
|
client = create_local_client(
|
||||||
username=username, password=password, session=session, server=server
|
hass,
|
||||||
)
|
host=entry.data[CONF_HOST],
|
||||||
|
token=entry.data[CONF_TOKEN],
|
||||||
|
verify_ssl=entry.data[CONF_VERIFY_SSL],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Overkiz Cloud API
|
||||||
|
else:
|
||||||
|
client = create_cloud_client(
|
||||||
|
hass,
|
||||||
|
username=entry.data[CONF_USERNAME],
|
||||||
|
password=entry.data[CONF_PASSWORD],
|
||||||
|
server=SUPPORTED_SERVERS[entry.data[CONF_HUB]],
|
||||||
|
)
|
||||||
|
|
||||||
await _async_migrate_entries(hass, entry)
|
await _async_migrate_entries(hass, entry)
|
||||||
|
|
||||||
|
@ -211,3 +231,31 @@ async def _async_migrate_entries(
|
||||||
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
|
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def create_local_client(
|
||||||
|
hass: HomeAssistant, host: str, token: str, verify_ssl: bool
|
||||||
|
) -> OverkizClient:
|
||||||
|
"""Create Overkiz local client."""
|
||||||
|
session = async_create_clientsession(hass, verify_ssl=verify_ssl)
|
||||||
|
|
||||||
|
return OverkizClient(
|
||||||
|
username="",
|
||||||
|
password="",
|
||||||
|
token=token,
|
||||||
|
session=session,
|
||||||
|
server=generate_local_server(host=host),
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_cloud_client(
|
||||||
|
hass: HomeAssistant, username: str, password: str, server: OverkizServer
|
||||||
|
) -> OverkizClient:
|
||||||
|
"""Create Overkiz cloud client."""
|
||||||
|
# To allow users with multiple accounts/hubs, we create a new session so they have separate cookies
|
||||||
|
session = async_create_clientsession(hass)
|
||||||
|
|
||||||
|
return OverkizClient(
|
||||||
|
username=username, password=password, session=session, server=server
|
||||||
|
)
|
||||||
|
|
|
@ -1,31 +1,46 @@
|
||||||
"""Config flow for Overkiz (by Somfy) integration."""
|
"""Config flow for Overkiz integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientConnectorCertificateError, ClientError
|
||||||
from pyoverkiz.client import OverkizClient
|
from pyoverkiz.client import OverkizClient
|
||||||
from pyoverkiz.const import SUPPORTED_SERVERS
|
from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS
|
||||||
|
from pyoverkiz.enums import APIType, Server
|
||||||
from pyoverkiz.exceptions import (
|
from pyoverkiz.exceptions import (
|
||||||
BadCredentialsException,
|
BadCredentialsException,
|
||||||
CozyTouchBadCredentialsException,
|
CozyTouchBadCredentialsException,
|
||||||
MaintenanceException,
|
MaintenanceException,
|
||||||
|
NotSuchTokenException,
|
||||||
TooManyAttemptsBannedException,
|
TooManyAttemptsBannedException,
|
||||||
TooManyRequestsException,
|
TooManyRequestsException,
|
||||||
UnknownUserException,
|
UnknownUserException,
|
||||||
)
|
)
|
||||||
from pyoverkiz.models import obfuscate_id
|
from pyoverkiz.models import OverkizServer
|
||||||
|
from pyoverkiz.obfuscate import obfuscate_id
|
||||||
|
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.components import dhcp, zeroconf
|
from homeassistant.components import dhcp, zeroconf
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_TOKEN,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
)
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||||
|
|
||||||
from .const import CONF_HUB, DEFAULT_HUB, DOMAIN, LOGGER
|
from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER
|
||||||
|
|
||||||
|
|
||||||
|
class DeveloperModeDisabled(HomeAssistantError):
|
||||||
|
"""Error to indicate Somfy Developer Mode is disabled."""
|
||||||
|
|
||||||
|
|
||||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
@ -34,45 +49,112 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
_config_entry: ConfigEntry | None
|
_config_entry: ConfigEntry | None
|
||||||
_default_user: None | str
|
_api_type: APIType
|
||||||
_default_hub: str
|
_user: None | str
|
||||||
|
_server: str
|
||||||
|
_host: str
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize Overkiz Config Flow."""
|
"""Initialize Overkiz Config Flow."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self._config_entry = None
|
self._config_entry = None
|
||||||
self._default_user = None
|
self._api_type = APIType.CLOUD
|
||||||
self._default_hub = DEFAULT_HUB
|
self._user = None
|
||||||
|
self._server = DEFAULT_SERVER
|
||||||
|
self._host = "gateway-xxxx-xxxx-xxxx.local:8443"
|
||||||
|
|
||||||
async def async_validate_input(self, user_input: dict[str, Any]) -> None:
|
async def async_validate_input(self, user_input: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Validate user credentials."""
|
"""Validate user credentials."""
|
||||||
username = user_input[CONF_USERNAME]
|
user_input[CONF_API_TYPE] = self._api_type
|
||||||
password = user_input[CONF_PASSWORD]
|
|
||||||
server = SUPPORTED_SERVERS[user_input[CONF_HUB]]
|
|
||||||
session = async_create_clientsession(self.hass)
|
|
||||||
|
|
||||||
client = OverkizClient(
|
client = self._create_cloud_client(
|
||||||
username=username, password=password, server=server, session=session
|
username=user_input[CONF_USERNAME],
|
||||||
|
password=user_input[CONF_PASSWORD],
|
||||||
|
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
|
||||||
)
|
)
|
||||||
|
|
||||||
await client.login(register_event_listener=False)
|
await client.login(register_event_listener=False)
|
||||||
|
|
||||||
# Set first gateway id as unique id
|
# For Local API, we create and activate a local token
|
||||||
|
if self._api_type == APIType.LOCAL:
|
||||||
|
user_input[CONF_TOKEN] = await self._create_local_api_token(
|
||||||
|
cloud_client=client,
|
||||||
|
host=user_input[CONF_HOST],
|
||||||
|
verify_ssl=user_input[CONF_VERIFY_SSL],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set main gateway id as unique id
|
||||||
if gateways := await client.get_gateways():
|
if gateways := await client.get_gateways():
|
||||||
gateway_id = gateways[0].id
|
for gateway in gateways:
|
||||||
await self.async_set_unique_id(gateway_id)
|
if is_overkiz_gateway(gateway.id):
|
||||||
|
gateway_id = gateway.id
|
||||||
|
await self.async_set_unique_id(gateway_id)
|
||||||
|
|
||||||
|
return user_input
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle the initial step via config flow."""
|
"""Handle the initial step via config flow."""
|
||||||
errors = {}
|
if user_input:
|
||||||
|
self._server = user_input[CONF_HUB]
|
||||||
|
|
||||||
|
# Some Overkiz hubs do support a local API
|
||||||
|
# Users can choose between local or cloud API.
|
||||||
|
if self._server in SERVERS_WITH_LOCAL_API:
|
||||||
|
return await self.async_step_local_or_cloud()
|
||||||
|
|
||||||
|
return await self.async_step_cloud()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HUB, default=self._server): vol.In(
|
||||||
|
{key: hub.name for key, hub in SUPPORTED_SERVERS.items()}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_local_or_cloud(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Users can choose between local API or cloud API via config flow."""
|
||||||
|
if user_input:
|
||||||
|
self._api_type = user_input[CONF_API_TYPE]
|
||||||
|
|
||||||
|
if self._api_type == APIType.LOCAL:
|
||||||
|
return await self.async_step_local()
|
||||||
|
|
||||||
|
return await self.async_step_cloud()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="local_or_cloud",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_API_TYPE): vol.In(
|
||||||
|
{
|
||||||
|
APIType.LOCAL: "Local API",
|
||||||
|
APIType.CLOUD: "Cloud API",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_cloud(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the cloud authentication step via config flow."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
description_placeholders = {}
|
description_placeholders = {}
|
||||||
|
|
||||||
if user_input:
|
if user_input:
|
||||||
self._default_user = user_input[CONF_USERNAME]
|
self._user = user_input[CONF_USERNAME]
|
||||||
self._default_hub = user_input[CONF_HUB]
|
|
||||||
|
# inherit the server from previous step
|
||||||
|
user_input[CONF_HUB] = self._server
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.async_validate_input(user_input)
|
await self.async_validate_input(user_input)
|
||||||
|
@ -81,7 +163,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
except BadCredentialsException as exception:
|
except BadCredentialsException as exception:
|
||||||
# If authentication with CozyTouch auth server is valid, but token is invalid
|
# If authentication with CozyTouch auth server is valid, but token is invalid
|
||||||
# for Overkiz API server, the hardware is not supported.
|
# for Overkiz API server, the hardware is not supported.
|
||||||
if user_input[CONF_HUB] == "atlantic_cozytouch" and not isinstance(
|
if user_input[CONF_HUB] == Server.ATLANTIC_COZYTOUCH and not isinstance(
|
||||||
exception, CozyTouchBadCredentialsException
|
exception, CozyTouchBadCredentialsException
|
||||||
):
|
):
|
||||||
description_placeholders["unsupported_device"] = "CozyTouch"
|
description_placeholders["unsupported_device"] = "CozyTouch"
|
||||||
|
@ -99,9 +181,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
# the Overkiz API server. Login will return unknown user.
|
# the Overkiz API server. Login will return unknown user.
|
||||||
description_placeholders["unsupported_device"] = "Somfy Protect"
|
description_placeholders["unsupported_device"] = "Somfy Protect"
|
||||||
errors["base"] = "unsupported_hardware"
|
errors["base"] = "unsupported_hardware"
|
||||||
except Exception as exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
LOGGER.exception(exception)
|
LOGGER.exception("Unknown error")
|
||||||
else:
|
else:
|
||||||
if self._config_entry:
|
if self._config_entry:
|
||||||
if self._config_entry.unique_id != self.unique_id:
|
if self._config_entry.unique_id != self.unique_id:
|
||||||
|
@ -132,14 +214,96 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="cloud",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_USERNAME, default=self._default_user): str,
|
vol.Required(CONF_USERNAME, default=self._user): str,
|
||||||
vol.Required(CONF_PASSWORD): str,
|
vol.Required(CONF_PASSWORD): str,
|
||||||
vol.Required(CONF_HUB, default=self._default_hub): vol.In(
|
}
|
||||||
{key: hub.name for key, hub in SUPPORTED_SERVERS.items()}
|
),
|
||||||
),
|
description_placeholders=description_placeholders,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_local(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the local authentication step via config flow."""
|
||||||
|
errors = {}
|
||||||
|
description_placeholders = {}
|
||||||
|
|
||||||
|
if user_input:
|
||||||
|
self._host = user_input[CONF_HOST]
|
||||||
|
self._user = user_input[CONF_USERNAME]
|
||||||
|
|
||||||
|
# inherit the server from previous step
|
||||||
|
user_input[CONF_HUB] = self._server
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_input = await self.async_validate_input(user_input)
|
||||||
|
except TooManyRequestsException:
|
||||||
|
errors["base"] = "too_many_requests"
|
||||||
|
except BadCredentialsException:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except ClientConnectorCertificateError as exception:
|
||||||
|
errors["base"] = "certificate_verify_failed"
|
||||||
|
LOGGER.debug(exception)
|
||||||
|
except (TimeoutError, ClientError) as exception:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
LOGGER.debug(exception)
|
||||||
|
except MaintenanceException:
|
||||||
|
errors["base"] = "server_in_maintenance"
|
||||||
|
except TooManyAttemptsBannedException:
|
||||||
|
errors["base"] = "too_many_attempts"
|
||||||
|
except NotSuchTokenException:
|
||||||
|
errors["base"] = "no_such_token"
|
||||||
|
except DeveloperModeDisabled:
|
||||||
|
errors["base"] = "developer_mode_disabled"
|
||||||
|
except UnknownUserException:
|
||||||
|
# Somfy Protect accounts are not supported since they don't use
|
||||||
|
# the Overkiz API server. Login will return unknown user.
|
||||||
|
description_placeholders["unsupported_device"] = "Somfy Protect"
|
||||||
|
errors["base"] = "unsupported_hardware"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
LOGGER.exception("Unknown error")
|
||||||
|
else:
|
||||||
|
if self._config_entry:
|
||||||
|
if self._config_entry.unique_id != self.unique_id:
|
||||||
|
return self.async_abort(reason="reauth_wrong_account")
|
||||||
|
|
||||||
|
# Update existing entry during reauth
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self._config_entry,
|
||||||
|
data={
|
||||||
|
**self._config_entry.data,
|
||||||
|
**user_input,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.hass.async_create_task(
|
||||||
|
self.hass.config_entries.async_reload(
|
||||||
|
self._config_entry.entry_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
|
# Create new entry
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=user_input[CONF_HOST], data=user_input
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="local",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HOST, default=self._host): str,
|
||||||
|
vol.Required(CONF_USERNAME, default=self._user): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
vol.Required(CONF_VERIFY_SSL, default=True): bool,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
description_placeholders=description_placeholders,
|
description_placeholders=description_placeholders,
|
||||||
|
@ -150,6 +314,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle DHCP discovery."""
|
"""Handle DHCP discovery."""
|
||||||
hostname = discovery_info.hostname
|
hostname = discovery_info.hostname
|
||||||
gateway_id = hostname[8:22]
|
gateway_id = hostname[8:22]
|
||||||
|
self._host = f"gateway-{gateway_id}.local:8443"
|
||||||
|
|
||||||
LOGGER.debug("DHCP discovery detected gateway %s", obfuscate_id(gateway_id))
|
LOGGER.debug("DHCP discovery detected gateway %s", obfuscate_id(gateway_id))
|
||||||
return await self._process_discovery(gateway_id)
|
return await self._process_discovery(gateway_id)
|
||||||
|
@ -160,8 +325,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle ZeroConf discovery."""
|
"""Handle ZeroConf discovery."""
|
||||||
properties = discovery_info.properties
|
properties = discovery_info.properties
|
||||||
gateway_id = properties["gateway_pin"]
|
gateway_id = properties["gateway_pin"]
|
||||||
|
hostname = discovery_info.hostname
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
"ZeroConf discovery detected gateway %s on %s (%s)",
|
||||||
|
obfuscate_id(gateway_id),
|
||||||
|
hostname,
|
||||||
|
discovery_info.type,
|
||||||
|
)
|
||||||
|
|
||||||
|
if discovery_info.type == "_kizbox._tcp.local.":
|
||||||
|
self._host = f"gateway-{gateway_id}.local:8443"
|
||||||
|
|
||||||
|
if discovery_info.type == "_kizboxdev._tcp.local.":
|
||||||
|
self._host = f"{discovery_info.hostname[:-1]}:{discovery_info.port}"
|
||||||
|
self._api_type = APIType.LOCAL
|
||||||
|
|
||||||
LOGGER.debug("ZeroConf discovery detected gateway %s", obfuscate_id(gateway_id))
|
|
||||||
return await self._process_discovery(gateway_id)
|
return await self._process_discovery(gateway_id)
|
||||||
|
|
||||||
async def _process_discovery(self, gateway_id: str) -> FlowResult:
|
async def _process_discovery(self, gateway_id: str) -> FlowResult:
|
||||||
|
@ -183,7 +362,63 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"gateway_id": self._config_entry.unique_id
|
"gateway_id": self._config_entry.unique_id
|
||||||
}
|
}
|
||||||
|
|
||||||
self._default_user = self._config_entry.data[CONF_USERNAME]
|
self._user = self._config_entry.data[CONF_USERNAME]
|
||||||
self._default_hub = self._config_entry.data[CONF_HUB]
|
self._server = self._config_entry.data[CONF_HUB]
|
||||||
|
self._api_type = self._config_entry.data[CONF_API_TYPE]
|
||||||
|
|
||||||
|
if self._config_entry.data[CONF_API_TYPE] == APIType.LOCAL:
|
||||||
|
self._host = self._config_entry.data[CONF_HOST]
|
||||||
|
|
||||||
return await self.async_step_user(dict(entry_data))
|
return await self.async_step_user(dict(entry_data))
|
||||||
|
|
||||||
|
def _create_cloud_client(
|
||||||
|
self, username: str, password: str, server: OverkizServer
|
||||||
|
) -> OverkizClient:
|
||||||
|
session = async_create_clientsession(self.hass)
|
||||||
|
client = OverkizClient(
|
||||||
|
username=username, password=password, server=server, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def _create_local_api_token(
|
||||||
|
self, cloud_client: OverkizClient, host: str, verify_ssl: bool
|
||||||
|
) -> str:
|
||||||
|
"""Create local API token."""
|
||||||
|
# Create session on Somfy cloud server to generate an access token for local API
|
||||||
|
gateways = await cloud_client.get_gateways()
|
||||||
|
|
||||||
|
gateway_id = ""
|
||||||
|
for gateway in gateways:
|
||||||
|
# Overkiz can return multiple gateways, but we only can generate a token
|
||||||
|
# for the main gateway.
|
||||||
|
if is_overkiz_gateway(gateway.id):
|
||||||
|
gateway_id = gateway.id
|
||||||
|
|
||||||
|
developer_mode = await cloud_client.get_setup_option(
|
||||||
|
f"developerMode-{gateway_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if developer_mode is None:
|
||||||
|
raise DeveloperModeDisabled
|
||||||
|
|
||||||
|
token = await cloud_client.generate_local_token(gateway_id)
|
||||||
|
await cloud_client.activate_local_token(
|
||||||
|
gateway_id=gateway_id, token=token, label="Home Assistant/local"
|
||||||
|
)
|
||||||
|
|
||||||
|
session = async_create_clientsession(self.hass, verify_ssl=verify_ssl)
|
||||||
|
|
||||||
|
# Local API
|
||||||
|
local_client = OverkizClient(
|
||||||
|
username="",
|
||||||
|
password="",
|
||||||
|
token=token,
|
||||||
|
session=session,
|
||||||
|
server=generate_local_server(host=host),
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
await local_client.login()
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
|
@ -5,7 +5,13 @@ from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from pyoverkiz.enums import MeasuredValueType, OverkizCommandParam, UIClass, UIWidget
|
from pyoverkiz.enums import (
|
||||||
|
MeasuredValueType,
|
||||||
|
OverkizCommandParam,
|
||||||
|
Server,
|
||||||
|
UIClass,
|
||||||
|
UIWidget,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONCENTRATION_PARTS_PER_BILLION,
|
CONCENTRATION_PARTS_PER_BILLION,
|
||||||
|
@ -31,8 +37,10 @@ from homeassistant.const import (
|
||||||
DOMAIN: Final = "overkiz"
|
DOMAIN: Final = "overkiz"
|
||||||
LOGGER: logging.Logger = logging.getLogger(__package__)
|
LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||||
|
|
||||||
|
CONF_API_TYPE: Final = "api_type"
|
||||||
CONF_HUB: Final = "hub"
|
CONF_HUB: Final = "hub"
|
||||||
DEFAULT_HUB: Final = "somfy_europe"
|
DEFAULT_SERVER: Final = Server.SOMFY_EUROPE
|
||||||
|
DEFAULT_HOST: Final = "gateway-xxxx-xxxx-xxxx.local:8443"
|
||||||
|
|
||||||
UPDATE_INTERVAL: Final = timedelta(seconds=30)
|
UPDATE_INTERVAL: Final = timedelta(seconds=30)
|
||||||
UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60)
|
UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60)
|
||||||
|
|
|
@ -6,7 +6,7 @@ from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import ServerDisconnectedError
|
from aiohttp import ClientConnectorError, ServerDisconnectedError
|
||||||
from pyoverkiz.client import OverkizClient
|
from pyoverkiz.client import OverkizClient
|
||||||
from pyoverkiz.enums import EventName, ExecutionState, Protocol
|
from pyoverkiz.enums import EventName, ExecutionState, Protocol
|
||||||
from pyoverkiz.exceptions import (
|
from pyoverkiz.exceptions import (
|
||||||
|
@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||||
raise UpdateFailed("Server is down for maintenance.") from exception
|
raise UpdateFailed("Server is down for maintenance.") from exception
|
||||||
except InvalidEventListenerIdException as exception:
|
except InvalidEventListenerIdException as exception:
|
||||||
raise UpdateFailed(exception) from exception
|
raise UpdateFailed(exception) from exception
|
||||||
except TimeoutError as exception:
|
except (TimeoutError, ClientConnectorError) as exception:
|
||||||
raise UpdateFailed("Failed to connect.") from exception
|
raise UpdateFailed("Failed to connect.") from exception
|
||||||
except (ServerDisconnectedError, NotAuthenticatedException):
|
except (ServerDisconnectedError, NotAuthenticatedException):
|
||||||
self.executions = {}
|
self.executions = {}
|
||||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pyoverkiz.enums import APIType
|
||||||
from pyoverkiz.obfuscate import obfuscate_id
|
from pyoverkiz.obfuscate import obfuscate_id
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
@ -10,7 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntry
|
from homeassistant.helpers.device_registry import DeviceEntry
|
||||||
|
|
||||||
from . import HomeAssistantOverkizData
|
from . import HomeAssistantOverkizData
|
||||||
from .const import CONF_HUB, DOMAIN
|
from .const import CONF_API_TYPE, CONF_HUB, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
async def async_get_config_entry_diagnostics(
|
async def async_get_config_entry_diagnostics(
|
||||||
|
@ -23,11 +24,16 @@ async def async_get_config_entry_diagnostics(
|
||||||
data = {
|
data = {
|
||||||
"setup": await client.get_diagnostic_data(),
|
"setup": await client.get_diagnostic_data(),
|
||||||
"server": entry.data[CONF_HUB],
|
"server": entry.data[CONF_HUB],
|
||||||
"execution_history": [
|
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
|
||||||
repr(execution) for execution in await client.get_execution_history()
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Only Overkiz cloud servers expose an endpoint with execution history
|
||||||
|
if client.api_type == APIType.CLOUD:
|
||||||
|
execution_history = [
|
||||||
|
repr(execution) for execution in await client.get_execution_history()
|
||||||
|
]
|
||||||
|
data["execution_history"] = execution_history
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@ -49,11 +55,15 @@ async def async_get_device_diagnostics(
|
||||||
},
|
},
|
||||||
"setup": await client.get_diagnostic_data(),
|
"setup": await client.get_diagnostic_data(),
|
||||||
"server": entry.data[CONF_HUB],
|
"server": entry.data[CONF_HUB],
|
||||||
"execution_history": [
|
"api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only Overkiz cloud servers expose an endpoint with execution history
|
||||||
|
if client.api_type == APIType.CLOUD:
|
||||||
|
data["execution_history"] = [
|
||||||
repr(execution)
|
repr(execution)
|
||||||
for execution in await client.get_execution_history()
|
for execution in await client.get_execution_history()
|
||||||
if any(command.device_url == device_url for command in execution.commands)
|
if any(command.device_url == device_url for command in execution.commands)
|
||||||
],
|
]
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
|
@ -11,13 +11,17 @@
|
||||||
],
|
],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/overkiz",
|
"documentation": "https://www.home-assistant.io/integrations/overkiz",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||||
"requirements": ["pyoverkiz==1.13.3"],
|
"requirements": ["pyoverkiz==1.13.3"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"type": "_kizbox._tcp.local.",
|
"type": "_kizbox._tcp.local.",
|
||||||
"name": "gateway*"
|
"name": "gateway*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "_kizboxdev._tcp.local.",
|
||||||
|
"name": "gateway*"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,18 +3,40 @@
|
||||||
"flow_title": "Gateway: {gateway_id}",
|
"flow_title": "Gateway: {gateway_id}",
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"description": "The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo), Rexel (Energeasy Connect) and Atlantic (Cozytouch). Enter your application credentials and select your hub.",
|
"description": "Select your server. The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo) and Atlantic (Cozytouch).",
|
||||||
|
"data": {
|
||||||
|
"hub": "Server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"local_or_cloud": {
|
||||||
|
"description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices are not supported in local API.",
|
||||||
|
"data": {
|
||||||
|
"api_type": "API type"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloud": {
|
||||||
|
"description": "Enter your application credentials.",
|
||||||
|
"data": {
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network. \n\n After activation, enter your application credentials and change the host to include your gateway-pin or enter the IP address of your gateway.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"hub": "Hub"
|
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"certificate_verify_failed": "Cannot connect to host, certificate verify failed.",
|
||||||
|
"developer_mode_disabled": "Developer Mode disabled. Activate the Developer Mode of your Somfy TaHoma box first.",
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"no_such_token": "Cannot create a token for this gateway. Please confirm if the account is linked to this gateway.",
|
||||||
"server_in_maintenance": "Server is down for maintenance",
|
"server_in_maintenance": "Server is down for maintenance",
|
||||||
"too_many_attempts": "Too many attempts with an invalid token, temporarily banned",
|
"too_many_attempts": "Too many attempts with an invalid token, temporarily banned",
|
||||||
"too_many_requests": "Too many requests, try again later",
|
"too_many_requests": "Too many requests, try again later",
|
||||||
|
|
|
@ -4156,7 +4156,7 @@
|
||||||
"name": "Overkiz",
|
"name": "Overkiz",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
"ovo_energy": {
|
"ovo_energy": {
|
||||||
"name": "OVO Energy",
|
"name": "OVO Energy",
|
||||||
|
|
|
@ -508,6 +508,12 @@ ZEROCONF = {
|
||||||
"name": "gateway*",
|
"name": "gateway*",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"_kizboxdev._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "overkiz",
|
||||||
|
"name": "gateway*",
|
||||||
|
},
|
||||||
|
],
|
||||||
"_lookin._tcp.local.": [
|
"_lookin._tcp.local.": [
|
||||||
{
|
{
|
||||||
"domain": "lookin",
|
"domain": "lookin",
|
||||||
|
|
|
@ -12,8 +12,8 @@ from tests.components.overkiz import load_setup_fixture
|
||||||
from tests.components.overkiz.test_config_flow import (
|
from tests.components.overkiz.test_config_flow import (
|
||||||
TEST_EMAIL,
|
TEST_EMAIL,
|
||||||
TEST_GATEWAY_ID,
|
TEST_GATEWAY_ID,
|
||||||
TEST_HUB,
|
|
||||||
TEST_PASSWORD,
|
TEST_PASSWORD,
|
||||||
|
TEST_SERVER,
|
||||||
)
|
)
|
||||||
|
|
||||||
MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[])
|
MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[])
|
||||||
|
@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||||
title="Somfy TaHoma Switch",
|
title="Somfy TaHoma Switch",
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
unique_id=TEST_GATEWAY_ID,
|
unique_id=TEST_GATEWAY_ID,
|
||||||
data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB},
|
data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# serializer version: 1
|
# serializer version: 1
|
||||||
# name: test_device_diagnostics
|
# name: test_device_diagnostics
|
||||||
dict({
|
dict({
|
||||||
|
'api_type': 'cloud',
|
||||||
'device': dict({
|
'device': dict({
|
||||||
'controllable_name': 'rts:RollerShutterRTSComponent',
|
'controllable_name': 'rts:RollerShutterRTSComponent',
|
||||||
'device_url': 'rts://****-****-6867/16756006',
|
'device_url': 'rts://****-****-6867/16756006',
|
||||||
|
@ -969,6 +970,7 @@
|
||||||
# ---
|
# ---
|
||||||
# name: test_diagnostics
|
# name: test_diagnostics
|
||||||
dict({
|
dict({
|
||||||
|
'api_type': 'cloud',
|
||||||
'execution_history': list([
|
'execution_history': list([
|
||||||
]),
|
]),
|
||||||
'server': 'somfy_europe',
|
'server': 'somfy_europe',
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_HUB, TEST_PASSWORD
|
from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, mock_registry
|
from tests.common import MockConfigEntry, mock_registry
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None:
|
||||||
mock_entry = MockConfigEntry(
|
mock_entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
unique_id=TEST_GATEWAY_ID,
|
unique_id=TEST_GATEWAY_ID,
|
||||||
data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB},
|
data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER},
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_entry.add_to_hass(hass)
|
mock_entry.add_to_hass(hass)
|
||||||
|
|
Loading…
Add table
Reference in a new issue