Add Overkiz integration (with base + sensor entity) (#62640)

This commit is contained in:
Mick Vleeshouwer 2021-12-23 10:34:35 -08:00 committed by GitHub
parent f422dd418b
commit 3605c4f32f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1386 additions and 0 deletions

View file

@ -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

View file

@ -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

View 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

View 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()
)
)

View 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,
]

View 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

View 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}"

View 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

View 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"
}

View 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())},
}

View 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%]"
}
}
}

View 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"
}
}
}
}
}

View file

@ -224,6 +224,7 @@ FLOWS = [
"opentherm_gw",
"openuv",
"openweathermap",
"overkiz",
"ovo_energy",
"owntracks",
"ozw",

View file

@ -201,6 +201,11 @@ DHCP = [
"domain": "nuki",
"hostname": "nuki_bridge_*"
},
{
"domain": "overkiz",
"hostname": "gateway*",
"macaddress": "F8811A*"
},
{
"domain": "powerwall",
"hostname": "1118431-*",

View file

@ -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

View file

@ -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

View 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"