Add Overkiz integration (with base + sensor entity) (#62640)
This commit is contained in:
parent
f422dd418b
commit
3605c4f32f
17 changed files with 1386 additions and 0 deletions
|
@ -802,6 +802,11 @@ omit =
|
||||||
homeassistant/components/orvibo/switch.py
|
homeassistant/components/orvibo/switch.py
|
||||||
homeassistant/components/osramlightify/light.py
|
homeassistant/components/osramlightify/light.py
|
||||||
homeassistant/components/otp/sensor.py
|
homeassistant/components/otp/sensor.py
|
||||||
|
homeassistant/components/overkiz/__init__.py
|
||||||
|
homeassistant/components/overkiz/coordinator.py
|
||||||
|
homeassistant/components/overkiz/entity.py
|
||||||
|
homeassistant/components/overkiz/executor.py
|
||||||
|
homeassistant/components/overkiz/sensor.py
|
||||||
homeassistant/components/ovo_energy/__init__.py
|
homeassistant/components/ovo_energy/__init__.py
|
||||||
homeassistant/components/ovo_energy/const.py
|
homeassistant/components/ovo_energy/const.py
|
||||||
homeassistant/components/ovo_energy/sensor.py
|
homeassistant/components/ovo_energy/sensor.py
|
||||||
|
|
|
@ -668,6 +668,8 @@ homeassistant/components/opnsense/* @mtreinish
|
||||||
tests/components/opnsense/* @mtreinish
|
tests/components/opnsense/* @mtreinish
|
||||||
homeassistant/components/orangepi_gpio/* @pascallj
|
homeassistant/components/orangepi_gpio/* @pascallj
|
||||||
homeassistant/components/oru/* @bvlaicu
|
homeassistant/components/oru/* @bvlaicu
|
||||||
|
homeassistant/components/overkiz/* @imicknl @vlebourl @tetienne
|
||||||
|
tests/components/overkiz/* @imicknl @vlebourl @tetienne
|
||||||
homeassistant/components/ovo_energy/* @timmo001
|
homeassistant/components/ovo_energy/* @timmo001
|
||||||
tests/components/ovo_energy/* @timmo001
|
tests/components/ovo_energy/* @timmo001
|
||||||
homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare
|
homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare
|
||||||
|
|
125
homeassistant/components/overkiz/__init__.py
Normal file
125
homeassistant/components/overkiz/__init__.py
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
"""The Overkiz (by Somfy) integration."""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiohttp import ClientError, ServerDisconnectedError
|
||||||
|
from pyoverkiz.client import OverkizClient
|
||||||
|
from pyoverkiz.const import SUPPORTED_SERVERS
|
||||||
|
from pyoverkiz.exceptions import (
|
||||||
|
BadCredentialsException,
|
||||||
|
MaintenanceException,
|
||||||
|
TooManyRequestsException,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_HUB,
|
||||||
|
DOMAIN,
|
||||||
|
PLATFORMS,
|
||||||
|
UPDATE_INTERVAL,
|
||||||
|
UPDATE_INTERVAL_ALL_ASSUMED_STATE,
|
||||||
|
)
|
||||||
|
from .coordinator import OverkizDataUpdateCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HomeAssistantOverkizData:
|
||||||
|
"""Overkiz data stored in the Home Assistant data object."""
|
||||||
|
|
||||||
|
coordinator: OverkizDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Overkiz from a config entry."""
|
||||||
|
username = entry.data[CONF_USERNAME]
|
||||||
|
password = entry.data[CONF_PASSWORD]
|
||||||
|
server = SUPPORTED_SERVERS[entry.data[CONF_HUB]]
|
||||||
|
|
||||||
|
# To allow users with multiple accounts/hubs, we create a new session so they have separate cookies
|
||||||
|
session = async_create_clientsession(hass)
|
||||||
|
client = OverkizClient(
|
||||||
|
username=username, password=password, session=session, server=server
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.login()
|
||||||
|
setup = await client.get_setup()
|
||||||
|
except BadCredentialsException:
|
||||||
|
_LOGGER.error("Invalid authentication")
|
||||||
|
return False
|
||||||
|
except TooManyRequestsException as exception:
|
||||||
|
raise ConfigEntryNotReady("Too many requests, try again later") from exception
|
||||||
|
except (TimeoutError, ClientError, ServerDisconnectedError) as exception:
|
||||||
|
raise ConfigEntryNotReady("Failed to connect") from exception
|
||||||
|
except MaintenanceException as exception:
|
||||||
|
raise ConfigEntryNotReady("Server is down for maintenance") from exception
|
||||||
|
except Exception as exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception(exception)
|
||||||
|
return False
|
||||||
|
|
||||||
|
coordinator = OverkizDataUpdateCoordinator(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name="device events",
|
||||||
|
client=client,
|
||||||
|
devices=setup.devices,
|
||||||
|
places=setup.root_place,
|
||||||
|
update_interval=UPDATE_INTERVAL,
|
||||||
|
config_entry_id=entry.entry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
if coordinator.is_stateless:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"All devices have an assumed state. Update interval has been reduced to: %s",
|
||||||
|
UPDATE_INTERVAL_ALL_ASSUMED_STATE,
|
||||||
|
)
|
||||||
|
coordinator.update_interval = UPDATE_INTERVAL_ALL_ASSUMED_STATE
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantOverkizData(
|
||||||
|
coordinator=coordinator,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map Overkiz device to Home Assistant platform
|
||||||
|
for device in coordinator.data.values():
|
||||||
|
_LOGGER.debug(
|
||||||
|
"The following device has been retrieved. Report an issue if not supported correctly (%s)",
|
||||||
|
device,
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
device_registry = await dr.async_get_registry(hass)
|
||||||
|
|
||||||
|
for gateway in setup.gateways:
|
||||||
|
_LOGGER.debug("Added gateway (%s)", gateway)
|
||||||
|
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=entry.entry_id,
|
||||||
|
identifiers={(DOMAIN, gateway.id)},
|
||||||
|
model=gateway.sub_type.beautify_name if gateway.sub_type else None,
|
||||||
|
manufacturer=server.manufacturer,
|
||||||
|
name=gateway.type.beautify_name,
|
||||||
|
sw_version=gateway.connectivity.protocol_version,
|
||||||
|
configuration_url=server.configuration_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
110
homeassistant/components/overkiz/config_flow.py
Normal file
110
homeassistant/components/overkiz/config_flow.py
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
"""Config flow for Overkiz (by Somfy) integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import ClientError
|
||||||
|
from pyoverkiz.client import OverkizClient
|
||||||
|
from pyoverkiz.const import SUPPORTED_SERVERS
|
||||||
|
from pyoverkiz.exceptions import (
|
||||||
|
BadCredentialsException,
|
||||||
|
MaintenanceException,
|
||||||
|
TooManyRequestsException,
|
||||||
|
)
|
||||||
|
from pyoverkiz.models import obfuscate_id
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components import dhcp
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
|
from .const import CONF_HUB, DEFAULT_HUB, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Overkiz (by Somfy)."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_validate_input(self, user_input: dict[str, Any]) -> None:
|
||||||
|
"""Validate user credentials."""
|
||||||
|
username = user_input[CONF_USERNAME]
|
||||||
|
password = user_input[CONF_PASSWORD]
|
||||||
|
server = SUPPORTED_SERVERS[user_input[CONF_HUB]]
|
||||||
|
|
||||||
|
async with OverkizClient(
|
||||||
|
username=username, password=password, server=server
|
||||||
|
) as client:
|
||||||
|
await client.login()
|
||||||
|
|
||||||
|
# Set first gateway as unique id
|
||||||
|
if gateways := await client.get_gateways():
|
||||||
|
gateway_id = gateways[0].id
|
||||||
|
await self.async_set_unique_id(gateway_id)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step via config flow."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input:
|
||||||
|
try:
|
||||||
|
await self.async_validate_input(user_input)
|
||||||
|
except TooManyRequestsException:
|
||||||
|
errors["base"] = "too_many_requests"
|
||||||
|
except BadCredentialsException:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except (TimeoutError, ClientError):
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except MaintenanceException:
|
||||||
|
errors["base"] = "server_in_maintenance"
|
||||||
|
except Exception as exception: # pylint: disable=broad-except
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
_LOGGER.exception(exception)
|
||||||
|
else:
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=user_input[CONF_USERNAME], data=user_input
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USERNAME): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
vol.Required(CONF_HUB, default=DEFAULT_HUB): vol.In(
|
||||||
|
{key: hub.name for key, hub in SUPPORTED_SERVERS.items()}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
|
||||||
|
"""Handle DHCP discovery."""
|
||||||
|
hostname = discovery_info.hostname
|
||||||
|
gateway_id = hostname[8:22]
|
||||||
|
|
||||||
|
_LOGGER.debug("DHCP discovery detected gateway %s", obfuscate_id(gateway_id))
|
||||||
|
|
||||||
|
if self._gateway_already_configured(gateway_id):
|
||||||
|
_LOGGER.debug("Gateway %s is already configured", obfuscate_id(gateway_id))
|
||||||
|
return self.async_abort(reason="already_configured")
|
||||||
|
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
|
def _gateway_already_configured(self, gateway_id: str) -> bool:
|
||||||
|
"""See if we already have a gateway matching the id."""
|
||||||
|
device_registry = dr.async_get(self.hass)
|
||||||
|
return bool(
|
||||||
|
device_registry.async_get_device(
|
||||||
|
identifiers={(DOMAIN, gateway_id)}, connections=set()
|
||||||
|
)
|
||||||
|
)
|
27
homeassistant/components/overkiz/const.py
Normal file
27
homeassistant/components/overkiz/const.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
"""Constants for the Overkiz (by Somfy) integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from pyoverkiz.enums import UIClass
|
||||||
|
from pyoverkiz.enums.ui import UIWidget
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
|
DOMAIN: Final = "overkiz"
|
||||||
|
|
||||||
|
CONF_HUB: Final = "hub"
|
||||||
|
DEFAULT_HUB: Final = "somfy_europe"
|
||||||
|
|
||||||
|
UPDATE_INTERVAL: Final = timedelta(seconds=30)
|
||||||
|
UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60)
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.SENSOR,
|
||||||
|
]
|
||||||
|
|
||||||
|
IGNORED_OVERKIZ_DEVICES: list[UIClass | UIWidget] = [
|
||||||
|
UIClass.PROTOCOL_GATEWAY,
|
||||||
|
UIClass.POD,
|
||||||
|
]
|
189
homeassistant/components/overkiz/coordinator.py
Normal file
189
homeassistant/components/overkiz/coordinator.py
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
"""Helpers to help coordinate updates."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import timedelta
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import ServerDisconnectedError
|
||||||
|
from pyoverkiz.client import OverkizClient
|
||||||
|
from pyoverkiz.enums import EventName, ExecutionState
|
||||||
|
from pyoverkiz.exceptions import (
|
||||||
|
BadCredentialsException,
|
||||||
|
MaintenanceException,
|
||||||
|
NotAuthenticatedException,
|
||||||
|
TooManyRequestsException,
|
||||||
|
)
|
||||||
|
from pyoverkiz.models import DataType, Device, Place, State
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN, UPDATE_INTERVAL
|
||||||
|
|
||||||
|
DATA_TYPE_TO_PYTHON: dict[DataType, Callable[[DataType], Any]] = {
|
||||||
|
DataType.INTEGER: int,
|
||||||
|
DataType.DATE: int,
|
||||||
|
DataType.STRING: str,
|
||||||
|
DataType.FLOAT: float,
|
||||||
|
DataType.BOOLEAN: bool,
|
||||||
|
DataType.JSON_ARRAY: json.loads,
|
||||||
|
DataType.JSON_OBJECT: json.loads,
|
||||||
|
}
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OverkizDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
|
"""Class to manage fetching data from Overkiz platform."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
logger: logging.Logger,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
client: OverkizClient,
|
||||||
|
devices: list[Device],
|
||||||
|
places: Place,
|
||||||
|
update_interval: timedelta | None = None,
|
||||||
|
config_entry_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize global data updater."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
logger,
|
||||||
|
name=name,
|
||||||
|
update_interval=update_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.data = {}
|
||||||
|
self.client = client
|
||||||
|
self.devices: dict[str, Device] = {d.device_url: d for d in devices}
|
||||||
|
self.is_stateless = all(
|
||||||
|
device.device_url.startswith("rts://")
|
||||||
|
or device.device_url.startswith("internal://")
|
||||||
|
for device in devices
|
||||||
|
)
|
||||||
|
self.executions: dict[str, dict[str, str]] = {}
|
||||||
|
self.areas = self.places_to_area(places)
|
||||||
|
self._config_entry_id = config_entry_id
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, Device]:
|
||||||
|
"""Fetch Overkiz data via event listener."""
|
||||||
|
try:
|
||||||
|
events = await self.client.fetch_events()
|
||||||
|
except BadCredentialsException as exception:
|
||||||
|
raise UpdateFailed("Invalid authentication") from exception
|
||||||
|
except TooManyRequestsException as exception:
|
||||||
|
raise UpdateFailed("Too many requests, try again later.") from exception
|
||||||
|
except MaintenanceException as exception:
|
||||||
|
raise UpdateFailed("Server is down for maintenance.") from exception
|
||||||
|
except TimeoutError as exception:
|
||||||
|
raise UpdateFailed("Failed to connect.") from exception
|
||||||
|
except (ServerDisconnectedError, NotAuthenticatedException):
|
||||||
|
self.executions = {}
|
||||||
|
|
||||||
|
# During the relogin, similar exceptions can be thrown.
|
||||||
|
try:
|
||||||
|
await self.client.login()
|
||||||
|
self.devices = await self._get_devices()
|
||||||
|
except BadCredentialsException as exception:
|
||||||
|
raise UpdateFailed("Invalid authentication") from exception
|
||||||
|
except TooManyRequestsException as exception:
|
||||||
|
raise UpdateFailed("Too many requests, try again later.") from exception
|
||||||
|
|
||||||
|
return self.devices
|
||||||
|
except Exception as exception:
|
||||||
|
_LOGGER.debug(exception)
|
||||||
|
raise UpdateFailed(exception) from exception
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
|
||||||
|
if event.name == EventName.DEVICE_AVAILABLE:
|
||||||
|
self.devices[event.device_url].available = True
|
||||||
|
|
||||||
|
elif event.name in [
|
||||||
|
EventName.DEVICE_UNAVAILABLE,
|
||||||
|
EventName.DEVICE_DISABLED,
|
||||||
|
]:
|
||||||
|
self.devices[event.device_url].available = False
|
||||||
|
|
||||||
|
elif event.name in [
|
||||||
|
EventName.DEVICE_CREATED,
|
||||||
|
EventName.DEVICE_UPDATED,
|
||||||
|
]:
|
||||||
|
self.hass.async_create_task(
|
||||||
|
self.hass.config_entries.async_reload(self._config_entry_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event.name == EventName.DEVICE_REMOVED:
|
||||||
|
base_device_url, *_ = event.device_url.split("#")
|
||||||
|
registry = await device_registry.async_get_registry(self.hass)
|
||||||
|
|
||||||
|
if registered_device := registry.async_get_device(
|
||||||
|
{(DOMAIN, base_device_url)}
|
||||||
|
):
|
||||||
|
registry.async_remove_device(registered_device.id)
|
||||||
|
|
||||||
|
del self.devices[event.device_url]
|
||||||
|
|
||||||
|
elif event.name == EventName.DEVICE_STATE_CHANGED:
|
||||||
|
for state in event.device_states:
|
||||||
|
device = self.devices[event.device_url]
|
||||||
|
if state.name not in device.states:
|
||||||
|
device.states[state.name] = state
|
||||||
|
device.states[state.name].value = self._get_state(state)
|
||||||
|
|
||||||
|
elif event.name == EventName.EXECUTION_REGISTERED:
|
||||||
|
if event.exec_id not in self.executions:
|
||||||
|
self.executions[event.exec_id] = {}
|
||||||
|
|
||||||
|
if not self.is_stateless:
|
||||||
|
self.update_interval = timedelta(seconds=1)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
event.name == EventName.EXECUTION_STATE_CHANGED
|
||||||
|
and event.exec_id in self.executions
|
||||||
|
and event.new_state in [ExecutionState.COMPLETED, ExecutionState.FAILED]
|
||||||
|
):
|
||||||
|
del self.executions[event.exec_id]
|
||||||
|
|
||||||
|
if not self.executions:
|
||||||
|
self.update_interval = UPDATE_INTERVAL
|
||||||
|
|
||||||
|
return self.devices
|
||||||
|
|
||||||
|
async def _get_devices(self) -> dict[str, Device]:
|
||||||
|
"""Fetch devices."""
|
||||||
|
_LOGGER.debug("Fetching all devices and state via /setup/devices")
|
||||||
|
return {d.device_url: d for d in await self.client.get_devices(refresh=True)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_state(
|
||||||
|
state: State,
|
||||||
|
) -> dict[Any, Any] | list[Any] | float | int | bool | str | None:
|
||||||
|
"""Cast string value to the right type."""
|
||||||
|
data_type = DataType(state.type)
|
||||||
|
|
||||||
|
if data_type == DataType.NONE:
|
||||||
|
return state.value
|
||||||
|
|
||||||
|
cast_to_python = DATA_TYPE_TO_PYTHON[data_type]
|
||||||
|
return cast_to_python(state.value)
|
||||||
|
|
||||||
|
def places_to_area(self, place):
|
||||||
|
"""Convert places with sub_places to a flat dictionary."""
|
||||||
|
areas = {}
|
||||||
|
if isinstance(place, Place):
|
||||||
|
areas[place.oid] = place.label
|
||||||
|
|
||||||
|
if isinstance(place.sub_places, list):
|
||||||
|
for sub_place in place.sub_places:
|
||||||
|
areas.update(self.places_to_area(sub_place))
|
||||||
|
|
||||||
|
return areas
|
108
homeassistant/components/overkiz/entity.py
Normal file
108
homeassistant/components/overkiz/entity.py
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
"""Parent class for every Overkiz device."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from pyoverkiz.enums import OverkizAttribute, OverkizState
|
||||||
|
from pyoverkiz.models import Device
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorEntityDescription
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import OverkizDataUpdateCoordinator
|
||||||
|
from .executor import OverkizExecutor
|
||||||
|
|
||||||
|
|
||||||
|
class OverkizEntity(CoordinatorEntity):
|
||||||
|
"""Representation of an Overkiz device entity."""
|
||||||
|
|
||||||
|
coordinator: OverkizDataUpdateCoordinator
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the device."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.device_url = device_url
|
||||||
|
self.base_device_url, *_ = self.device_url.split("#")
|
||||||
|
self.executor = OverkizExecutor(device_url, coordinator)
|
||||||
|
|
||||||
|
self._attr_assumed_state = not self.device.states
|
||||||
|
self._attr_available = self.device.available
|
||||||
|
self._attr_unique_id = self.device.device_url
|
||||||
|
self._attr_name = self.device.label
|
||||||
|
|
||||||
|
self._attr_device_info = self.generate_device_info()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> Device:
|
||||||
|
"""Return Overkiz device linked to this entity."""
|
||||||
|
return self.coordinator.data[self.device_url]
|
||||||
|
|
||||||
|
def generate_device_info(self) -> DeviceInfo:
|
||||||
|
"""Return device registry information for this entity."""
|
||||||
|
# Some devices, such as the Smart Thermostat have several devices in one physical device,
|
||||||
|
# with same device url, terminated by '#' and a number.
|
||||||
|
# In this case, we use the base device url as the device identifier.
|
||||||
|
if "#" in self.device_url and not self.device_url.endswith("#1"):
|
||||||
|
# Only return the url of the base device, to inherit device name and model from parent device.
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, self.executor.base_device_url)},
|
||||||
|
}
|
||||||
|
|
||||||
|
manufacturer = (
|
||||||
|
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
|
||||||
|
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
|
||||||
|
or self.coordinator.client.server.manufacturer
|
||||||
|
)
|
||||||
|
|
||||||
|
model = (
|
||||||
|
self.executor.select_state(
|
||||||
|
OverkizState.CORE_MODEL,
|
||||||
|
OverkizState.CORE_PRODUCT_MODEL_NAME,
|
||||||
|
OverkizState.IO_MODEL,
|
||||||
|
)
|
||||||
|
or self.device.widget
|
||||||
|
)
|
||||||
|
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self.executor.base_device_url)},
|
||||||
|
name=self.device.label,
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
model=model,
|
||||||
|
sw_version=self.executor.select_attribute(
|
||||||
|
OverkizAttribute.CORE_FIRMWARE_REVISION
|
||||||
|
),
|
||||||
|
hw_version=self.device.controllable_name,
|
||||||
|
suggested_area=self.coordinator.areas[self.device.place_oid],
|
||||||
|
via_device=self.executor.get_gateway_id(),
|
||||||
|
configuration_url=self.coordinator.client.server.configuration_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OverkizSensorDescription(SensorEntityDescription):
|
||||||
|
"""Class to describe an Overkiz sensor."""
|
||||||
|
|
||||||
|
native_value: Callable[
|
||||||
|
[str | int | float], str | int | float
|
||||||
|
] | None = lambda val: val
|
||||||
|
|
||||||
|
|
||||||
|
class OverkizDescriptiveEntity(OverkizEntity):
|
||||||
|
"""Representation of a Overkiz device entity based on a description."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_url: str,
|
||||||
|
coordinator: OverkizDataUpdateCoordinator,
|
||||||
|
description: OverkizSensorDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the device."""
|
||||||
|
super().__init__(device_url, coordinator)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_name = f"{super().name} {self.entity_description.name}"
|
||||||
|
self._attr_unique_id = f"{super().unique_id}-{self.entity_description.key}"
|
132
homeassistant/components/overkiz/executor.py
Normal file
132
homeassistant/components/overkiz/executor.py
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
"""Class for helpers and communication with the OverKiz API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from pyoverkiz.models import Command, Device
|
||||||
|
|
||||||
|
from .coordinator import OverkizDataUpdateCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OverkizExecutor:
|
||||||
|
"""Representation of an Overkiz device with execution handler."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the executor."""
|
||||||
|
self.device_url = device_url
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.base_device_url = self.device_url.split("#")[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> Device:
|
||||||
|
"""Return Overkiz device linked to this entity."""
|
||||||
|
return self.coordinator.data[self.device_url]
|
||||||
|
|
||||||
|
def select_command(self, *commands: str) -> str | None:
|
||||||
|
"""Select first existing command in a list of commands."""
|
||||||
|
existing_commands = self.device.definition.commands
|
||||||
|
return next((c for c in commands if c in existing_commands), None)
|
||||||
|
|
||||||
|
def has_command(self, *commands: str) -> bool:
|
||||||
|
"""Return True if a command exists in a list of commands."""
|
||||||
|
return self.select_command(*commands) is not None
|
||||||
|
|
||||||
|
def select_state(self, *states) -> str | None:
|
||||||
|
"""Select first existing active state in a list of states."""
|
||||||
|
for state in states:
|
||||||
|
if current_state := self.device.states[state]:
|
||||||
|
return current_state.value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_state(self, *states: str) -> bool:
|
||||||
|
"""Return True if a state exists in self."""
|
||||||
|
return self.select_state(*states) is not None
|
||||||
|
|
||||||
|
def select_attribute(self, *attributes) -> str | None:
|
||||||
|
"""Select first existing active state in a list of states."""
|
||||||
|
for attribute in attributes:
|
||||||
|
if current_attribute := self.device.attributes[attribute]:
|
||||||
|
return current_attribute.value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_execute_command(self, command_name: str, *args: Any):
|
||||||
|
"""Execute device command in async context."""
|
||||||
|
try:
|
||||||
|
exec_id = await self.coordinator.client.execute_command(
|
||||||
|
self.device.device_url,
|
||||||
|
Command(command_name, list(args)),
|
||||||
|
"Home Assistant",
|
||||||
|
)
|
||||||
|
except Exception as exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.error(exception)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here
|
||||||
|
self.coordinator.executions[exec_id] = {
|
||||||
|
"device_url": self.device.device_url,
|
||||||
|
"command_name": command_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.coordinator.async_refresh()
|
||||||
|
|
||||||
|
async def async_cancel_command(self, commands_to_cancel: list[str]) -> bool:
|
||||||
|
"""Cancel running execution by command."""
|
||||||
|
|
||||||
|
# Cancel a running execution
|
||||||
|
# Retrieve executions initiated via Home Assistant from Data Update Coordinator queue
|
||||||
|
exec_id = next(
|
||||||
|
(
|
||||||
|
exec_id
|
||||||
|
# Reverse dictionary to cancel the last added execution
|
||||||
|
for exec_id, execution in reversed(self.coordinator.executions.items())
|
||||||
|
if execution.get("device_url") == self.device.device_url
|
||||||
|
and execution.get("command_name") in commands_to_cancel
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if exec_id:
|
||||||
|
await self.async_cancel_execution(exec_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Retrieve executions initiated outside Home Assistant via API
|
||||||
|
executions = await self.coordinator.client.get_current_executions()
|
||||||
|
exec_id = next(
|
||||||
|
(
|
||||||
|
execution.id
|
||||||
|
for execution in executions
|
||||||
|
# Reverse dictionary to cancel the last added execution
|
||||||
|
for action in reversed(execution.action_group.get("actions"))
|
||||||
|
for command in action.get("commands")
|
||||||
|
if action.get("device_url") == self.device.device_url
|
||||||
|
and command.get("name") in commands_to_cancel
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if exec_id:
|
||||||
|
await self.async_cancel_execution(exec_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_cancel_execution(self, exec_id: str):
|
||||||
|
"""Cancel running execution via execution id."""
|
||||||
|
await self.coordinator.client.cancel_command(exec_id)
|
||||||
|
|
||||||
|
def get_gateway_id(self):
|
||||||
|
"""
|
||||||
|
Retrieve gateway id from device url.
|
||||||
|
|
||||||
|
device URL (<protocol>://<gatewayId>/<deviceAddress>[#<subsystemId>])
|
||||||
|
"""
|
||||||
|
url = urlparse(self.device_url)
|
||||||
|
return url.netloc
|
21
homeassistant/components/overkiz/manifest.json
Normal file
21
homeassistant/components/overkiz/manifest.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"domain": "overkiz",
|
||||||
|
"name": "Overkiz (by Somfy)",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/overkiz",
|
||||||
|
"requirements": [
|
||||||
|
"pyoverkiz==1.0.0"
|
||||||
|
],
|
||||||
|
"dhcp": [
|
||||||
|
{
|
||||||
|
"hostname": "gateway*",
|
||||||
|
"macaddress": "F8811A*"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"codeowners": [
|
||||||
|
"@imicknl",
|
||||||
|
"@vlebourl",
|
||||||
|
"@tetienne"
|
||||||
|
],
|
||||||
|
"iot_class": "cloud_polling"
|
||||||
|
}
|
418
homeassistant/components/overkiz/sensor.py
Normal file
418
homeassistant/components/overkiz/sensor.py
Normal file
|
@ -0,0 +1,418 @@
|
||||||
|
"""Support for Overkiz sensors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pyoverkiz.enums import OverkizAttribute, OverkizState, UIWidget
|
||||||
|
|
||||||
|
from homeassistant.components.overkiz import HomeAssistantOverkizData
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONCENTRATION_PARTS_PER_MILLION,
|
||||||
|
ENERGY_WATT_HOUR,
|
||||||
|
LIGHT_LUX,
|
||||||
|
PERCENTAGE,
|
||||||
|
POWER_WATT,
|
||||||
|
SIGNAL_STRENGTH_DECIBELS,
|
||||||
|
TEMP_CELSIUS,
|
||||||
|
TIME_SECONDS,
|
||||||
|
VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR,
|
||||||
|
VOLUME_LITERS,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES
|
||||||
|
from .coordinator import OverkizDataUpdateCoordinator
|
||||||
|
from .entity import OverkizDescriptiveEntity, OverkizEntity, OverkizSensorDescription
|
||||||
|
|
||||||
|
SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_BATTERY_LEVEL,
|
||||||
|
name="Battery Level",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_BATTERY,
|
||||||
|
name="Battery",
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
native_value=lambda value: str(value).capitalize(),
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_RSSI_LEVEL,
|
||||||
|
name="RSSI Level",
|
||||||
|
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||||
|
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
native_value=lambda value: round(float(value)),
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_EXPECTED_NUMBER_OF_SHOWER,
|
||||||
|
name="Expected Number Of Shower",
|
||||||
|
icon="mdi:shower-head",
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_NUMBER_OF_SHOWER_REMAINING,
|
||||||
|
name="Number of Shower Remaining",
|
||||||
|
icon="mdi:shower-head",
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# V40 is measured in litres (L) and shows the amount of warm (mixed) water with a temperature of 40 C, which can be drained from a switched off electric water heater.
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_V40_WATER_VOLUME_ESTIMATION,
|
||||||
|
name="Water Volume Estimation at 40 °C",
|
||||||
|
icon="mdi:water",
|
||||||
|
native_unit_of_measurement=VOLUME_LITERS,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_WATER_CONSUMPTION,
|
||||||
|
name="Water Consumption",
|
||||||
|
icon="mdi:water",
|
||||||
|
native_unit_of_measurement=VOLUME_LITERS,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.IO_OUTLET_ENGINE,
|
||||||
|
name="Outlet Engine",
|
||||||
|
icon="mdi:fan-chevron-down",
|
||||||
|
native_unit_of_measurement=VOLUME_LITERS,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.IO_INLET_ENGINE,
|
||||||
|
name="Inlet Engine",
|
||||||
|
icon="mdi:fan-chevron-up",
|
||||||
|
native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.HLRRWIFI_ROOM_TEMPERATURE,
|
||||||
|
name="Room Temperature",
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.IO_MIDDLE_WATER_TEMPERATURE,
|
||||||
|
name="Middle Water Temperature",
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_FOSSIL_ENERGY_CONSUMPTION,
|
||||||
|
name="Fossil Energy Consumption",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_GAS_CONSUMPTION,
|
||||||
|
name="Gas Consumption",
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_THERMAL_ENERGY_CONSUMPTION,
|
||||||
|
name="Thermal Energy Consumption",
|
||||||
|
),
|
||||||
|
# LightSensor/LuminanceSensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_LUMINANCE,
|
||||||
|
name="Luminance",
|
||||||
|
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||||
|
native_unit_of_measurement=LIGHT_LUX, # core:MeasuredValueType = core:LuminanceInLux
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# ElectricitySensor/CumulativeElectricPowerConsumptionSensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_ELECTRIC_ENERGY_CONSUMPTION,
|
||||||
|
name="Electric Energy Consumption",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh (not for modbus:YutakiV2DHWElectricalEnergyConsumptionComponent)
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING, # core:MeasurementCategory attribute = electric/overall
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_ELECTRIC_POWER_CONSUMPTION,
|
||||||
|
name="Electric Power Consumption",
|
||||||
|
device_class=SensorDeviceClass.POWER,
|
||||||
|
native_unit_of_measurement=POWER_WATT, # core:MeasuredValueType = core:ElectricalEnergyInWh (not for modbus:YutakiV2DHWElectricalEnergyConsumptionComponent)
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF1,
|
||||||
|
name="Consumption Tariff 1",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF2,
|
||||||
|
name="Consumption Tariff 2",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF3,
|
||||||
|
name="Consumption Tariff 3",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF4,
|
||||||
|
name="Consumption Tariff 4",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF5,
|
||||||
|
name="Consumption Tariff 5",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF6,
|
||||||
|
name="Consumption Tariff 6",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF7,
|
||||||
|
name="Consumption Tariff 7",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF8,
|
||||||
|
name="Consumption Tariff 8",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CONSUMPTION_TARIFF9,
|
||||||
|
name="Consumption Tariff 9",
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# HumiditySensor/RelativeHumiditySensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_RELATIVE_HUMIDITY,
|
||||||
|
name="Relative Humidity",
|
||||||
|
native_value=lambda value: round(float(value), 2),
|
||||||
|
device_class=SensorDeviceClass.HUMIDITY,
|
||||||
|
native_unit_of_measurement=PERCENTAGE, # core:MeasuredValueType = core:RelativeValueInPercentage
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# TemperatureSensor/TemperatureSensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_TEMPERATURE,
|
||||||
|
name="Temperature",
|
||||||
|
native_value=lambda value: round(float(value), 2),
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
native_unit_of_measurement=TEMP_CELSIUS, # core:MeasuredValueType = core:TemperatureInCelcius
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# WeatherSensor/WeatherForecastSensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_WEATHER_STATUS,
|
||||||
|
name="Weather Status",
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_MINIMUM_TEMPERATURE,
|
||||||
|
name="Minimum Temperature",
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_MAXIMUM_TEMPERATURE,
|
||||||
|
name="Maximum Temperature",
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
native_unit_of_measurement=TEMP_CELSIUS,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# AirSensor/COSensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CO_CONCENTRATION,
|
||||||
|
name="CO Concentration",
|
||||||
|
device_class=SensorDeviceClass.CO,
|
||||||
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# AirSensor/CO2Sensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_CO2_CONCENTRATION,
|
||||||
|
name="CO2 Concentration",
|
||||||
|
device_class=SensorDeviceClass.CO2,
|
||||||
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# SunSensor/SunEnergySensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_SUN_ENERGY,
|
||||||
|
name="Sun Energy",
|
||||||
|
native_value=lambda value: round(float(value), 2),
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
icon="mdi:solar-power",
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# WindSensor/WindSpeedSensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_WIND_SPEED,
|
||||||
|
name="Wind Speed",
|
||||||
|
native_value=lambda value: round(float(value), 2),
|
||||||
|
icon="mdi:weather-windy",
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
# SmokeSensor/SmokeSensor
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.IO_SENSOR_ROOM,
|
||||||
|
name="Sensor Room",
|
||||||
|
native_value=lambda value: str(value).capitalize(),
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.IO_PRIORITY_LOCK_ORIGINATOR,
|
||||||
|
name="Priority Lock Originator",
|
||||||
|
native_value=lambda value: str(value).capitalize(),
|
||||||
|
icon="mdi:lock",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_PRIORITY_LOCK_TIMER,
|
||||||
|
name="Priority Lock Timer",
|
||||||
|
icon="mdi:lock-clock",
|
||||||
|
native_unit_of_measurement=TIME_SECONDS,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.CORE_DISCRETE_RSSI_LEVEL,
|
||||||
|
name="Discrete RSSI Level",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
native_value=lambda value: str(value).capitalize(),
|
||||||
|
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
),
|
||||||
|
# DomesticHotWaterProduction/WaterHeatingSystem
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.IO_HEAT_PUMP_OPERATING_TIME,
|
||||||
|
name="Heat Pump Operating Time",
|
||||||
|
),
|
||||||
|
OverkizSensorDescription(
|
||||||
|
key=OverkizState.IO_ELECTRIC_BOOSTER_OPERATING_TIME,
|
||||||
|
name="Electric Booster Operating Time",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
):
|
||||||
|
"""Set up the Overkiz sensors from a config entry."""
|
||||||
|
data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
entities: list[SensorEntity] = []
|
||||||
|
|
||||||
|
key_supported_states = {
|
||||||
|
description.key: description for description in SENSOR_DESCRIPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
for device in data.coordinator.data.values():
|
||||||
|
if (
|
||||||
|
device.widget not in IGNORED_OVERKIZ_DEVICES
|
||||||
|
and device.ui_class not in IGNORED_OVERKIZ_DEVICES
|
||||||
|
):
|
||||||
|
for state in device.definition.states:
|
||||||
|
if description := key_supported_states.get(state.qualified_name):
|
||||||
|
entities.append(
|
||||||
|
OverkizStateSensor(
|
||||||
|
device.device_url,
|
||||||
|
data.coordinator,
|
||||||
|
description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if device.widget == UIWidget.HOMEKIT_STACK:
|
||||||
|
entities.append(
|
||||||
|
OverkizHomeKitSetupCodeSensor(
|
||||||
|
device.device_url,
|
||||||
|
data.coordinator,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
|
||||||
|
"""Representation of an Overkiz Sensor."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
|
"""Return the value of the sensor."""
|
||||||
|
state = self.device.states.get(self.entity_description.key)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Transform the value with a lambda function
|
||||||
|
if hasattr(self.entity_description, "native_value"):
|
||||||
|
return self.entity_description.native_value(state.value)
|
||||||
|
|
||||||
|
return state.value
|
||||||
|
|
||||||
|
|
||||||
|
class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity):
|
||||||
|
"""Representation of an Overkiz HomeKit Setup Code."""
|
||||||
|
|
||||||
|
_attr_icon = "mdi:shield-home"
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, device_url: str, coordinator: OverkizDataUpdateCoordinator
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the device."""
|
||||||
|
super().__init__(device_url, coordinator)
|
||||||
|
self._attr_name = "HomeKit Setup Code"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
|
"""Return the value of the sensor."""
|
||||||
|
return self.device.attributes.get(OverkizAttribute.HOMEKIT_SETUP_CODE).value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
"""Return device registry information for this entity."""
|
||||||
|
# By default this sensor will be listed at a virtual HomekitStack device,
|
||||||
|
# but it makes more sense to show this at the gateway device in the entity registry.
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, self.executor.get_gateway_id())},
|
||||||
|
}
|
25
homeassistant/components/overkiz/strings.json
Normal file
25
homeassistant/components/overkiz/strings.json
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"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.",
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"hub": "Hub"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"server_in_maintenance": "Server is down for maintenance",
|
||||||
|
"too_many_requests": "Too many requests, try again later.",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
homeassistant/components/overkiz/translations/en.json
Normal file
21
homeassistant/components/overkiz/translations/en.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "Host",
|
||||||
|
"password": "Password",
|
||||||
|
"username": "Username"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -224,6 +224,7 @@ FLOWS = [
|
||||||
"opentherm_gw",
|
"opentherm_gw",
|
||||||
"openuv",
|
"openuv",
|
||||||
"openweathermap",
|
"openweathermap",
|
||||||
|
"overkiz",
|
||||||
"ovo_energy",
|
"ovo_energy",
|
||||||
"owntracks",
|
"owntracks",
|
||||||
"ozw",
|
"ozw",
|
||||||
|
|
|
@ -201,6 +201,11 @@ DHCP = [
|
||||||
"domain": "nuki",
|
"domain": "nuki",
|
||||||
"hostname": "nuki_bridge_*"
|
"hostname": "nuki_bridge_*"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"domain": "overkiz",
|
||||||
|
"hostname": "gateway*",
|
||||||
|
"macaddress": "F8811A*"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "powerwall",
|
"domain": "powerwall",
|
||||||
"hostname": "1118431-*",
|
"hostname": "1118431-*",
|
||||||
|
|
|
@ -1724,6 +1724,9 @@ pyotgw==1.1b1
|
||||||
# homeassistant.components.otp
|
# homeassistant.components.otp
|
||||||
pyotp==2.6.0
|
pyotp==2.6.0
|
||||||
|
|
||||||
|
# homeassistant.components.overkiz
|
||||||
|
pyoverkiz==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.openweathermap
|
# homeassistant.components.openweathermap
|
||||||
pyowm==3.2.0
|
pyowm==3.2.0
|
||||||
|
|
||||||
|
|
|
@ -1066,6 +1066,9 @@ pyotgw==1.1b1
|
||||||
# homeassistant.components.otp
|
# homeassistant.components.otp
|
||||||
pyotp==2.6.0
|
pyotp==2.6.0
|
||||||
|
|
||||||
|
# homeassistant.components.overkiz
|
||||||
|
pyoverkiz==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.openweathermap
|
# homeassistant.components.openweathermap
|
||||||
pyowm==3.2.0
|
pyowm==3.2.0
|
||||||
|
|
||||||
|
|
191
tests/components/overkiz/test_config_flow.py
Normal file
191
tests/components/overkiz/test_config_flow.py
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
"""Tests for Overkiz (by Somfy) config flow."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from aiohttp import ClientError
|
||||||
|
from pyoverkiz.exceptions import (
|
||||||
|
BadCredentialsException,
|
||||||
|
MaintenanceException,
|
||||||
|
TooManyRequestsException,
|
||||||
|
)
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries, data_entry_flow
|
||||||
|
from homeassistant.components import dhcp
|
||||||
|
from homeassistant.components.overkiz.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, mock_device_registry
|
||||||
|
|
||||||
|
TEST_EMAIL = "test@testdomain.com"
|
||||||
|
TEST_EMAIL2 = "test@testdomain.nl"
|
||||||
|
TEST_PASSWORD = "test-password"
|
||||||
|
TEST_PASSWORD2 = "test-password2"
|
||||||
|
TEST_HUB = "somfy_europe"
|
||||||
|
TEST_HUB2 = "hi_kumo_europe"
|
||||||
|
TEST_GATEWAY_ID = "1234-5678-9123"
|
||||||
|
TEST_GATEWAY_ID2 = "4321-5678-9123"
|
||||||
|
|
||||||
|
MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form(hass: HomeAssistant) -> None:
|
||||||
|
"""Test we get the form."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch(
|
||||||
|
"pyoverkiz.client.OverkizClient.get_gateways", return_value=None
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.overkiz.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == "create_entry"
|
||||||
|
assert result2["title"] == TEST_EMAIL
|
||||||
|
assert result2["data"] == {
|
||||||
|
"username": TEST_EMAIL,
|
||||||
|
"password": TEST_PASSWORD,
|
||||||
|
"hub": TEST_HUB,
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"side_effect, error",
|
||||||
|
[
|
||||||
|
(BadCredentialsException, "invalid_auth"),
|
||||||
|
(TooManyRequestsException, "too_many_requests"),
|
||||||
|
(TimeoutError, "cannot_connect"),
|
||||||
|
(ClientError, "cannot_connect"),
|
||||||
|
(MaintenanceException, "server_in_maintenance"),
|
||||||
|
(Exception, "unknown"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_form_invalid_auth(
|
||||||
|
hass: HomeAssistant, side_effect: Exception, error: str
|
||||||
|
) -> None:
|
||||||
|
"""Test we handle invalid auth."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"base": error}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None:
|
||||||
|
"""Test config flow aborts Config Flow on duplicate entries."""
|
||||||
|
MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id=TEST_GATEWAY_ID,
|
||||||
|
data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB},
|
||||||
|
).add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch(
|
||||||
|
"pyoverkiz.client.OverkizClient.get_gateways",
|
||||||
|
return_value=MOCK_GATEWAY_RESPONSE,
|
||||||
|
), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"username": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == "abort"
|
||||||
|
assert result2["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None:
|
||||||
|
"""Test config flow allows Config Flow unique entries."""
|
||||||
|
MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id="test2@testdomain.com",
|
||||||
|
data={"username": "test2@testdomain.com", "password": TEST_PASSWORD},
|
||||||
|
).add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch(
|
||||||
|
"pyoverkiz.client.OverkizClient.get_gateways",
|
||||||
|
return_value=MOCK_GATEWAY_RESPONSE,
|
||||||
|
), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == "create_entry"
|
||||||
|
assert result2["title"] == TEST_EMAIL
|
||||||
|
assert result2["data"] == {
|
||||||
|
"username": TEST_EMAIL,
|
||||||
|
"password": TEST_PASSWORD,
|
||||||
|
"hub": TEST_HUB,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dhcp_flow(hass: HomeAssistant) -> None:
|
||||||
|
"""Test that DHCP discovery for new bridge works."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
data=dhcp.DhcpServiceInfo(
|
||||||
|
hostname="gateway-1234-5678-9123",
|
||||||
|
ip="192.168.1.4",
|
||||||
|
macaddress="F8811A000000",
|
||||||
|
),
|
||||||
|
context={"source": config_entries.SOURCE_DHCP},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == config_entries.SOURCE_USER
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None:
|
||||||
|
"""Test that DHCP doesn't setup already configured gateways."""
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id=TEST_EMAIL,
|
||||||
|
data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB},
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
device_registry = mock_device_registry(hass)
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=config_entry.entry_id,
|
||||||
|
identifiers={(DOMAIN, "1234-5678-9123")},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
data=dhcp.DhcpServiceInfo(
|
||||||
|
hostname="gateway-1234-5678-9123",
|
||||||
|
ip="192.168.1.4",
|
||||||
|
macaddress="F8811A000000",
|
||||||
|
),
|
||||||
|
context={"source": config_entries.SOURCE_DHCP},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
Loading…
Add table
Reference in a new issue