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/osramlightify/light.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/const.py
|
||||
homeassistant/components/ovo_energy/sensor.py
|
||||
|
|
|
@ -668,6 +668,8 @@ homeassistant/components/opnsense/* @mtreinish
|
|||
tests/components/opnsense/* @mtreinish
|
||||
homeassistant/components/orangepi_gpio/* @pascallj
|
||||
homeassistant/components/oru/* @bvlaicu
|
||||
homeassistant/components/overkiz/* @imicknl @vlebourl @tetienne
|
||||
tests/components/overkiz/* @imicknl @vlebourl @tetienne
|
||||
homeassistant/components/ovo_energy/* @timmo001
|
||||
tests/components/ovo_energy/* @timmo001
|
||||
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",
|
||||
"openuv",
|
||||
"openweathermap",
|
||||
"overkiz",
|
||||
"ovo_energy",
|
||||
"owntracks",
|
||||
"ozw",
|
||||
|
|
|
@ -201,6 +201,11 @@ DHCP = [
|
|||
"domain": "nuki",
|
||||
"hostname": "nuki_bridge_*"
|
||||
},
|
||||
{
|
||||
"domain": "overkiz",
|
||||
"hostname": "gateway*",
|
||||
"macaddress": "F8811A*"
|
||||
},
|
||||
{
|
||||
"domain": "powerwall",
|
||||
"hostname": "1118431-*",
|
||||
|
|
|
@ -1724,6 +1724,9 @@ pyotgw==1.1b1
|
|||
# homeassistant.components.otp
|
||||
pyotp==2.6.0
|
||||
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.0.0
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
pyowm==3.2.0
|
||||
|
||||
|
|
|
@ -1066,6 +1066,9 @@ pyotgw==1.1b1
|
|||
# homeassistant.components.otp
|
||||
pyotp==2.6.0
|
||||
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.0.0
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
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