From 15b99d10f725ca44e179202f20cbbc2be930409a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 20 Feb 2022 22:58:34 +0100 Subject: [PATCH] Squashed commit of the following: commit 7db25d76c78f7f07b5782783e1cc995634817d28 Author: Mick Vleeshouwer Date: Sun Feb 20 13:56:04 2022 -0800 Backport `binary_sensor` entity (#765) * Backport binary_sensor * Add missing descriptions * Bugfix commit f7c280d442fe0d23ea6480d14988caa2ef6f4ddf Author: Mick Vleeshouwer Date: Sun Feb 20 13:50:07 2022 -0800 Backport number entity (#766) commit d93ebc7649d28792abda323445ebd1963798117d Author: Mick Vleeshouwer Date: Sun Feb 20 13:45:52 2022 -0800 Backport button (#772) commit 6cb325cbad7bbde3d18f98705b0124d0f59480aa Author: Mick Vleeshouwer Date: Sun Feb 20 13:45:41 2022 -0800 Backport lock (#769) commit b738bd1c499f9ee6e5250475fdea84acdd265816 Author: Mick Vleeshouwer Date: Sun Feb 20 13:45:31 2022 -0800 Backport executor (#771) * Backport executor * Add linked_device commit 11a609cde2a5138934c18af241bf0f46682db8ad Author: Mick Vleeshouwer Date: Sun Feb 20 13:16:45 2022 -0800 Backport switch (#774) * Backport switch * Remove unnecessary climate platform commit ccb5fdbdd5dc3bf91d168efccce7e7090d628749 Author: Mick Vleeshouwer Date: Sun Feb 20 12:30:22 2022 -0800 Backport light (#775) commit 43c5ada63dab9841b9790f6fbe30ee2a28e92332 Author: Mick Vleeshouwer Date: Sun Feb 20 12:23:04 2022 -0800 Backport scene (#770) --- custom_components/tahoma/binary_sensor.py | 102 +++++---- custom_components/tahoma/button.py | 64 +++--- custom_components/tahoma/climate.py | 2 - .../stateless_exterior_heating.py | 28 --- custom_components/tahoma/const.py | 2 +- custom_components/tahoma/entity.py | 8 - custom_components/tahoma/executor.py | 59 +++-- custom_components/tahoma/light.py | 126 +++++------ custom_components/tahoma/lock.py | 30 +-- custom_components/tahoma/number.py | 3 +- custom_components/tahoma/scene.py | 19 +- custom_components/tahoma/switch.py | 212 +++++++++++------- 12 files changed, 336 insertions(+), 319 deletions(-) delete mode 100644 custom_components/tahoma/climate_devices/stateless_exterior_heating.py diff --git a/custom_components/tahoma/binary_sensor.py b/custom_components/tahoma/binary_sensor.py index 782a1306..9ae761bb 100644 --- a/custom_components/tahoma/binary_sensor.py +++ b/custom_components/tahoma/binary_sensor.py @@ -1,48 +1,69 @@ """Support for Overkiz binary sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + from pyoverkiz.enums import OverkizCommandParam, OverkizState +from pyoverkiz.types import StateType as OverkizStateType -from custom_components.tahoma import HomeAssistantOverkizData from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import HomeAssistantOverkizData from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES -from .entity import OverkizBinarySensorDescription, OverkizDescriptiveEntity +from .entity import OverkizDescriptiveEntity + + +@dataclass +class OverkizBinarySensorDescriptionMixin: + """Define an entity description mixin for binary sensor entities.""" + + value_fn: Callable[[OverkizStateType], bool] + + +@dataclass +class OverkizBinarySensorDescription( + BinarySensorEntityDescription, OverkizBinarySensorDescriptionMixin +): + """Class to describe an Overkiz binary sensor.""" -BINARY_SENSOR_DESCRIPTIONS = [ + +BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ # RainSensor/RainSensor OverkizBinarySensorDescription( key=OverkizState.CORE_RAIN, name="Rain", icon="mdi:weather-rainy", - is_on=lambda state: state == OverkizCommandParam.DETECTED, + value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # SmokeSensor/SmokeSensor OverkizBinarySensorDescription( key=OverkizState.CORE_SMOKE, name="Smoke", device_class=BinarySensorDeviceClass.SMOKE, - is_on=lambda state: state == OverkizCommandParam.DETECTED, + value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # WaterSensor/WaterDetectionSensor OverkizBinarySensorDescription( key=OverkizState.CORE_WATER_DETECTION, name="Water", icon="mdi:water", - is_on=lambda state: state == OverkizCommandParam.DETECTED, + value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # AirSensor/AirFlowSensor OverkizBinarySensorDescription( key=OverkizState.CORE_GAS_DETECTION, name="Gas", device_class=BinarySensorDeviceClass.GAS, - is_on=lambda state: state == OverkizCommandParam.DETECTED, + value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # OccupancySensor/OccupancySensor # OccupancySensor/MotionSensor @@ -50,85 +71,90 @@ key=OverkizState.CORE_OCCUPANCY, name="Occupancy", device_class=BinarySensorDeviceClass.OCCUPANCY, - is_on=lambda state: state == OverkizCommandParam.PERSON_INSIDE, + value_fn=lambda state: state == OverkizCommandParam.PERSON_INSIDE, ), # ContactSensor/WindowWithTiltSensor OverkizBinarySensorDescription( key=OverkizState.CORE_VIBRATION, name="Vibration", device_class=BinarySensorDeviceClass.VIBRATION, - is_on=lambda state: state == OverkizCommandParam.DETECTED, + value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # ContactSensor/ContactSensor OverkizBinarySensorDescription( key=OverkizState.CORE_CONTACT, name="Contact", device_class=BinarySensorDeviceClass.DOOR, - is_on=lambda state: state == OverkizCommandParam.OPEN, + value_fn=lambda state: state == OverkizCommandParam.OPEN, ), # Siren/SirenStatus OverkizBinarySensorDescription( key=OverkizState.CORE_ASSEMBLY, name="Assembly", device_class=BinarySensorDeviceClass.PROBLEM, - is_on=lambda state: state == OverkizCommandParam.OPEN, + value_fn=lambda state: state == OverkizCommandParam.OPEN, ), # Unknown OverkizBinarySensorDescription( key=OverkizState.IO_VIBRATION_DETECTED, name="Vibration", device_class=BinarySensorDeviceClass.VIBRATION, - is_on=lambda state: state == OverkizCommandParam.DETECTED, + value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # DomesticHotWaterProduction/WaterHeatingSystem OverkizBinarySensorDescription( key=OverkizState.IO_DHW_BOOST_MODE, name="Boost Mode", icon="hass:water-boiler-alert", - is_on=lambda state: state == OverkizCommandParam.ON, + value_fn=lambda state: state == OverkizCommandParam.ON, ), OverkizBinarySensorDescription( key=OverkizState.IO_DHW_ABSENCE_MODE, name="Away Mode", icon="hass:water-boiler-off", - is_on=lambda state: state == OverkizCommandParam.ON, + value_fn=lambda state: state == OverkizCommandParam.ON, ), OverkizBinarySensorDescription( key=OverkizState.IO_OPERATING_MODE_CAPABILITIES, name="Energy Demand Status", device_class=BinarySensorDeviceClass.HEAT, - is_on=lambda state: state.get(OverkizCommandParam.ENERGY_DEMAND_STATUS) == 1, + value_fn=lambda state: cast(dict, state).get( + OverkizCommandParam.ENERGY_DEMAND_STATUS + ) + == 1, ), ] +SUPPORTED_STATES = { + description.key: description for description in BINARY_SENSOR_DESCRIPTIONS +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -): - """Set up the Overkiz sensors from a config entry.""" +) -> None: + """Set up the Overkiz binary sensors from a config entry.""" data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - entities = [] - - key_supported_states = { - description.key: description for description in BINARY_SENSOR_DESCRIPTIONS - } + entities: list[OverkizBinarySensor] = [] for device in data.coordinator.data.values(): if ( - device.widget not in IGNORED_OVERKIZ_DEVICES - and device.ui_class not in IGNORED_OVERKIZ_DEVICES + device.widget in IGNORED_OVERKIZ_DEVICES + or device.ui_class in IGNORED_OVERKIZ_DEVICES ): - for state in device.definition.states: - if description := key_supported_states.get(state.qualified_name): - entities.append( - OverkizBinarySensor( - device.device_url, - data.coordinator, - description, - ) + continue + + for state in device.definition.states: + if description := SUPPORTED_STATES.get(state.qualified_name): + entities.append( + OverkizBinarySensor( + device.device_url, + data.coordinator, + description, ) + ) async_add_entities(entities) @@ -136,12 +162,12 @@ async def async_setup_entry( class OverkizBinarySensor(OverkizDescriptiveEntity, BinarySensorEntity): """Representation of an Overkiz Binary Sensor.""" + entity_description: OverkizBinarySensorDescription + @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the sensor.""" - state = self.device.states.get(self.entity_description.key) - - if not state: - return None + if state := self.device.states.get(self.entity_description.key): + return self.entity_description.value_fn(state.value) - return self.entity_description.is_on(state.value) + return None diff --git a/custom_components/tahoma/button.py b/custom_components/tahoma/button.py index 4d501595..8118d0fd 100644 --- a/custom_components/tahoma/button.py +++ b/custom_components/tahoma/button.py @@ -1,15 +1,17 @@ -"""Support for Overkiz number devices.""" +"""Support for Overkiz (virtual) buttons.""" +from __future__ import annotations + from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity -BUTTON_DESCRIPTIONS = [ +BUTTON_DESCRIPTIONS: list[ButtonEntityDescription] = [ # My Position (cover, light) ButtonEntityDescription( key="my", @@ -18,62 +20,70 @@ ), # Identify ButtonEntityDescription( - key="identify", # startIdentify and identify are reversed... Remove when fixed server side. + key="identify", # startIdentify and identify are reversed... Swap this when fixed in API. name="Start Identify", icon="mdi:human-greeting-variant", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), ButtonEntityDescription( key="stopIdentify", name="Stop Identify", icon="mdi:human-greeting-variant", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), ButtonEntityDescription( - key="startIdentify", # startIdentify and identify are reversed... Remove when fixed server side. + key="startIdentify", # startIdentify and identify are reversed... Swap this when fixed in API. name="Identify", icon="mdi:human-greeting-variant", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # RTDIndoorSiren / RTDOutdoorSiren + ButtonEntityDescription(key="dingDong", name="Ding Dong", icon="mdi:bell-ring"), + ButtonEntityDescription(key="bip", name="Bip", icon="mdi:bell-ring"), + ButtonEntityDescription( + key="fastBipSequence", name="Fast Bip Sequence", icon="mdi:bell-ring" ), + ButtonEntityDescription(key="ring", name="Ring", icon="mdi:bell-ring"), ] +SUPPORTED_COMMANDS = { + description.key: description for description in BUTTON_DESCRIPTIONS +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -): - """Set up the Overkiz number from a config entry.""" +) -> None: + """Set up the Overkiz button from a config entry.""" data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - - entities = [] - - supported_commands = { - description.key: description for description in BUTTON_DESCRIPTIONS - } + entities: list[ButtonEntity] = [] for device in data.coordinator.data.values(): if ( - device.widget not in IGNORED_OVERKIZ_DEVICES - and device.ui_class not in IGNORED_OVERKIZ_DEVICES + device.widget in IGNORED_OVERKIZ_DEVICES + or device.ui_class in IGNORED_OVERKIZ_DEVICES ): - for command in device.definition.commands: - if description := supported_commands.get(command.command_name): - entities.append( - OverkizButton( - device.device_url, - data.coordinator, - description, - ) + continue + + for command in device.definition.commands: + if description := SUPPORTED_COMMANDS.get(command.command_name): + entities.append( + OverkizButton( + device.device_url, + data.coordinator, + description, ) + ) async_add_entities(entities) class OverkizButton(OverkizDescriptiveEntity, ButtonEntity): - """Representation of an Overkiz Button entity.""" + """Representation of an Overkiz Button.""" async def async_press(self) -> None: """Handle the button press.""" diff --git a/custom_components/tahoma/climate.py b/custom_components/tahoma/climate.py index e51e3785..47c98f44 100644 --- a/custom_components/tahoma/climate.py +++ b/custom_components/tahoma/climate.py @@ -30,7 +30,6 @@ SomfyHeatingTemperatureInterface, ) from .climate_devices.somfy_thermostat import SomfyThermostat -from .climate_devices.stateless_exterior_heating import StatelessExteriorHeating from .const import DOMAIN TYPE = { @@ -47,7 +46,6 @@ UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: HitachiAirToWaterHeatingZone, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, - UIWidget.STATELESS_EXTERIOR_HEATING: StatelessExteriorHeating, } diff --git a/custom_components/tahoma/climate_devices/stateless_exterior_heating.py b/custom_components/tahoma/climate_devices/stateless_exterior_heating.py deleted file mode 100644 index 17edac18..00000000 --- a/custom_components/tahoma/climate_devices/stateless_exterior_heating.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Support for Stateless Exterior Heating device.""" -import logging - -from pyoverkiz.enums import OverkizCommand - -from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import HVAC_MODE_HEAT, HVAC_MODE_OFF -from homeassistant.const import TEMP_CELSIUS - -from ..entity import OverkizEntity - -_LOGGER = logging.getLogger(__name__) - - -class StatelessExteriorHeating(OverkizEntity, ClimateEntity): - """Representation of TaHoma Stateless Exterior Heating device.""" - - _attr_hvac_mode = None - _attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT] - _attr_preset_mode = None - _attr_temperature_unit = TEMP_CELSIUS # Not used but climate devices need a recognized temperature unit... - - async def async_set_hvac_mode(self, hvac_mode: str) -> None: - """Set new target hvac mode.""" - if hvac_mode == HVAC_MODE_HEAT: - await self.executor.async_execute_command(OverkizCommand.ON) - else: - await self.executor.async_execute_command(OverkizCommand.OFF) diff --git a/custom_components/tahoma/const.py b/custom_components/tahoma/const.py index 952ce1ba..bfdd557b 100644 --- a/custom_components/tahoma/const.py +++ b/custom_components/tahoma/const.py @@ -74,7 +74,7 @@ UIWidget.SIREN_STATUS: None, # widgetName, uiClass is Siren (switch) UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.SOMFY_THERMOSTAT: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) - UIWidget.STATELESS_EXTERIOR_HEATING: Platform.CLIMATE, # widgetName, uiClass is ExteriorHeatingSystem. + UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) UIClass.SWIMMING_POOL: Platform.SWITCH, UIClass.SWINGING_SHUTTER: Platform.COVER, UIClass.VENETIAN_BLIND: Platform.COVER, diff --git a/custom_components/tahoma/entity.py b/custom_components/tahoma/entity.py index e51ad39b..8f267370 100644 --- a/custom_components/tahoma/entity.py +++ b/custom_components/tahoma/entity.py @@ -7,7 +7,6 @@ from pyoverkiz.enums import OverkizAttribute, OverkizCommandParam, OverkizState from pyoverkiz.models import Device -from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.select import SelectEntityDescription from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import DeviceInfo, EntityDescription @@ -105,13 +104,6 @@ def extra_state_attributes(self) -> dict[str, Any]: return attr -@dataclass -class OverkizBinarySensorDescription(BinarySensorEntityDescription): - """Class to describe an Overkiz binary sensor.""" - - is_on: Callable[[str], bool] = lambda state: state - - @dataclass class OverkizSelectDescription(SelectEntityDescription): """Class to describe an Overkiz select entity.""" diff --git a/custom_components/tahoma/executor.py b/custom_components/tahoma/executor.py index 56e37726..96a59732 100644 --- a/custom_components/tahoma/executor.py +++ b/custom_components/tahoma/executor.py @@ -1,32 +1,34 @@ -"""Class for helpers and community with the OverKiz API.""" +"""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.enums.command import OverkizCommand from pyoverkiz.models import Command, Device +from pyoverkiz.types import StateType as OverkizStateType +from .const import LOGGER 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): + 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("#") + 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 linked_device(self, index) -> Device: + def linked_device(self, index: int) -> Device: """Return Overkiz device sharing the same base url.""" return self.coordinator.data[f"{self.base_device_url}#{index}"] @@ -39,36 +41,27 @@ 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: + def select_state(self, *states: str) -> OverkizStateType: """Select first existing active state in a list of states.""" - if self.device.states: - return next( - ( - state.value - for state in self.device.states - if state.name in list(states) - ), - None, - ) + 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: + def select_attribute(self, *attributes: str) -> OverkizStateType: """Select first existing active state in a list of states.""" - if self.device.attributes: - return next( - ( - attribute.value - for attribute in self.device.attributes - if attribute.name in list(attributes) - ), - None, - ) + 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): + async def async_execute_command(self, command_name: str, *args: Any) -> None: """Execute device command in async context.""" try: exec_id = await self.coordinator.client.execute_command( @@ -77,7 +70,7 @@ async def async_execute_command(self, command_name: str, *args: Any): "Home Assistant", ) except Exception as exception: # pylint: disable=broad-except - _LOGGER.error(exception) + LOGGER.error(exception) return # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here @@ -88,7 +81,9 @@ async def async_execute_command(self, command_name: str, *args: Any): await self.coordinator.async_refresh() - async def async_cancel_command(self, commands_to_cancel: list[str]) -> bool: + async def async_cancel_command( + self, commands_to_cancel: list[OverkizCommand] + ) -> bool: """Cancel running execution by command.""" # Cancel a running execution @@ -129,11 +124,11 @@ async def async_cancel_command(self, commands_to_cancel: list[str]) -> bool: return False - async def async_cancel_execution(self, exec_id: str): + async def async_cancel_execution(self, exec_id: str) -> None: """Cancel running execution via execution id.""" await self.coordinator.client.cancel_command(exec_id) - def get_gateway_id(self): + def get_gateway_id(self) -> str: """ Retrieve gateway id from device url. diff --git a/custom_components/tahoma/light.py b/custom_components/tahoma/light.py index 5ddb0ef6..b640a184 100644 --- a/custom_components/tahoma/light.py +++ b/custom_components/tahoma/light.py @@ -1,65 +1,60 @@ -"""Support for Overkiz light devices.""" +"""Support for Overkiz lights.""" +from __future__ import annotations + +from typing import Any, cast + from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_EFFECT, - ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_EFFECT, + ATTR_RGB_COLOR, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_ONOFF, + COLOR_MODE_RGB, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util from . import HomeAssistantOverkizData from .const import DOMAIN from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizEntity -COMMAND_SET_INTENSITY = "setIntensity" -COMMAND_SET_RGB = "setRGB" -COMMAND_WINK = "wink" - -CORE_BLUE_COLOR_INTENSITY_STATE = "core:BlueColorIntensityState" -CORE_GREEN_COLOR_INTENSITY_STATE = "core:GreenColorIntensityState" -CORE_LIGHT_INTENSITY_STATE = "core:LightIntensityState" -CORE_RED_COLOR_INTENSITY_STATE = "core:RedColorIntensityState" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -): +) -> None: """Set up the Overkiz lights from a config entry.""" data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - entities = [ + async_add_entities( OverkizLight(device.device_url, data.coordinator) for device in data.platforms[Platform.LIGHT] - ] - - async_add_entities(entities) + ) class OverkizLight(OverkizEntity, LightEntity): """Representation of an Overkiz Light.""" - def __init__(self, device_url: str, coordinator: OverkizDataUpdateCoordinator): + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: """Initialize a device.""" super().__init__(device_url, coordinator) - self._effect = None - @property - def brightness(self) -> int: - """Return the brightness of this light between 0..255.""" - brightness = self.executor.select_state(OverkizState.CORE_LIGHT_INTENSITY) - return round(brightness * 255 / 100) + self._attr_supported_color_modes = set() + + if self.executor.has_command(OverkizCommand.SET_RGB): + self._attr_supported_color_modes.add(COLOR_MODE_RGB) + if self.executor.has_command(OverkizCommand.SET_INTENSITY): + self._attr_supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + if not self.supported_color_modes: + self._attr_supported_color_modes = {COLOR_MODE_ONOFF} @property def is_on(self) -> bool: @@ -70,67 +65,46 @@ def is_on(self) -> bool: ) @property - def hs_color(self): - """Return the hue and saturation color value [float, float].""" - r = self.executor.select_state(OverkizState.CORE_RED_COLOR_INTENSITY) - g = self.executor.select_state(OverkizState.CORE_GREEN_COLOR_INTENSITY) - b = self.executor.select_state(OverkizState.CORE_BLUE_COLOR_INTENSITY) - return None if None in [r, g, b] else color_util.color_RGB_to_hs(r, g, b) - - @property - def supported_features(self) -> int: - """Flag supported features.""" - supported_features = 0 + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color value [int, int, int] (0-255).""" + red = self.executor.select_state(OverkizState.CORE_RED_COLOR_INTENSITY) + green = self.executor.select_state(OverkizState.CORE_GREEN_COLOR_INTENSITY) + blue = self.executor.select_state(OverkizState.CORE_BLUE_COLOR_INTENSITY) - if self.executor.has_command(OverkizCommand.SET_INTENSITY): - supported_features |= SUPPORT_BRIGHTNESS + if red is None or green is None or blue is None: + return None - if self.executor.has_command(OverkizCommand.WINK): - supported_features |= SUPPORT_EFFECT + return (cast(int, red), cast(int, green), cast(int, blue)) - if self.executor.has_command(OverkizCommand.SET_RGB): - supported_features |= SUPPORT_COLOR + @property + def brightness(self) -> int | None: + """Return the brightness of this light (0-255).""" + value = self.executor.select_state(OverkizState.CORE_LIGHT_INTENSITY) + if value is not None: + return round(cast(int, value) * 255 / 100) - return supported_features + return None - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_HS_COLOR in kwargs: + rgb_color = kwargs.get(ATTR_RGB_COLOR) + brightness = kwargs.get(ATTR_BRIGHTNESS) + + if rgb_color is not None: await self.executor.async_execute_command( OverkizCommand.SET_RGB, - *[ - round(float(c)) - for c in color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) - ], + *[round(float(c)) for c in kwargs[ATTR_RGB_COLOR]], ) + return - if ATTR_BRIGHTNESS in kwargs: - brightness = round(float(kwargs[ATTR_BRIGHTNESS]) / 255 * 100) + if brightness is not None: await self.executor.async_execute_command( - OverkizCommand.SET_INTENSITY, brightness + OverkizCommand.SET_INTENSITY, round(float(brightness) / 255 * 100) ) + return - elif ATTR_EFFECT in kwargs: - self._effect = kwargs[ATTR_EFFECT] - await self.executor.async_execute_command(self._effect, 100) - - else: - await self.executor.async_execute_command(OverkizCommand.ON) + await self.executor.async_execute_command(OverkizCommand.ON) - async def async_turn_off(self, **_) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self.executor.async_execute_command(OverkizCommand.OFF) - - @property - def effect_list(self) -> list: - """Return the list of supported effects.""" - return ( - [OverkizCommand.WINK] - if self.executor.has_command(OverkizCommand.WINK) - else None - ) - - @property - def effect(self) -> str: - """Return the current effect.""" - return self._effect diff --git a/custom_components/tahoma/lock.py b/custom_components/tahoma/lock.py index 97626579..8a333652 100644 --- a/custom_components/tahoma/lock.py +++ b/custom_components/tahoma/lock.py @@ -1,4 +1,8 @@ -"""Support for Overkiz lock.""" +"""Support for Overkiz locks.""" +from __future__ import annotations + +from typing import Any + from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.lock import LockEntity @@ -16,32 +20,30 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -): +) -> None: """Set up the Overkiz locks from a config entry.""" data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - entities = [ + async_add_entities( OverkizLock(device.device_url, data.coordinator) for device in data.platforms[Platform.LOCK] - ] - - async_add_entities(entities) + ) class OverkizLock(OverkizEntity, LockEntity): - """Representation of a TaHoma Lock.""" + """Representation of an Overkiz Lock.""" - async def async_unlock(self, **_): - """Unlock method.""" - await self.executor.async_execute_command(OverkizCommand.UNLOCK) - - async def async_lock(self, **_): + async def async_lock(self, **kwargs: Any) -> None: """Lock method.""" await self.executor.async_execute_command(OverkizCommand.LOCK) + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock method.""" + await self.executor.async_execute_command(OverkizCommand.UNLOCK) + @property - def is_locked(self): - """Return True if the lock is locked.""" + def is_locked(self) -> bool | None: + """Return a boolean for the state of the lock.""" return ( self.executor.select_state(OverkizState.CORE_LOCKED_UNLOCKED) == OverkizCommandParam.LOCKED diff --git a/custom_components/tahoma/number.py b/custom_components/tahoma/number.py index 5a5b2a2e..40d04b9b 100644 --- a/custom_components/tahoma/number.py +++ b/custom_components/tahoma/number.py @@ -29,7 +29,7 @@ class OverkizNumberDescription(NumberEntityDescription, OverkizNumberDescription """Class to describe an Overkiz number.""" -NUMBER_DESCRIPTIONS = [ +NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ # Cover: My Position (0 - 100) OverkizNumberDescription( key=OverkizState.CORE_MEMORIZED_1_POSITION, @@ -48,6 +48,7 @@ class OverkizNumberDescription(NumberEntityDescription, OverkizNumberDescription max_value=4, entity_category=EntityCategory.CONFIG, ), + # SomfyHeatingTemperatureInterface OverkizNumberDescription( key=OverkizState.CORE_ECO_ROOM_TEMPERATURE, name="Eco Room Temperature", diff --git a/custom_components/tahoma/scene.py b/custom_components/tahoma/scene.py index 3d9a5c1d..464b19d8 100644 --- a/custom_components/tahoma/scene.py +++ b/custom_components/tahoma/scene.py @@ -1,10 +1,12 @@ """Support for Overkiz scenes.""" +from __future__ import annotations + from typing import Any from pyoverkiz.client import OverkizClient from pyoverkiz.models import Scenario -from homeassistant.components.scene import DOMAIN as SCENE, Scene +from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -17,26 +19,25 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -): +) -> None: """Set up the Overkiz scenes from a config entry.""" data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - entities = [ - OverkizScene(scene, data.coordinator.client) for scene in data.platforms[SCENE] - ] - async_add_entities(entities) + async_add_entities( + OverkizScene(scene, data.coordinator.client) for scene in data.scenarios + ) class OverkizScene(Scene): - """Representation of an Overkiz scene entity.""" + """Representation of an Overkiz Scene.""" - def __init__(self, scenario: Scenario, client: OverkizClient): + def __init__(self, scenario: Scenario, client: OverkizClient) -> None: """Initialize the scene.""" self.scenario = scenario self.client = client self._attr_name = self.scenario.label self._attr_unique_id = self.scenario.oid - async def async_activate(self, **_: Any) -> None: + async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" await self.client.execute_scenario(self.scenario.oid) diff --git a/custom_components/tahoma/switch.py b/custom_components/tahoma/switch.py index 09ec537c..8fd38816 100644 --- a/custom_components/tahoma/switch.py +++ b/custom_components/tahoma/switch.py @@ -1,115 +1,161 @@ """Support for Overkiz switches.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState - -from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity +from pyoverkiz.enums.ui import UIClass, UIWidget +from pyoverkiz.types import StateType as OverkizStateType + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from . import HomeAssistantOverkizData from .const import DOMAIN -from .coordinator import OverkizDataUpdateCoordinator -from .entity import OverkizEntity +from .entity import OverkizDescriptiveEntity + + +@dataclass +class OverkizSwitchDescriptionMixin: + """Define an entity description mixin for switch entities.""" + + turn_on: Callable[[Callable[..., Awaitable[None]]], Awaitable[None]] + turn_off: Callable[[Callable[..., Awaitable[None]]], Awaitable[None]] + + +@dataclass +class OverkizSwitchDescription(SwitchEntityDescription, OverkizSwitchDescriptionMixin): + """Class to describe an Overkiz switch.""" + + is_on: Callable[[Callable[[str], OverkizStateType]], bool] | None = None + + +SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [ + OverkizSwitchDescription( + key=UIWidget.DOMESTIC_HOT_WATER_TANK, + turn_on=lambda execute_command: execute_command( + OverkizCommand.SET_FORCE_HEATING, OverkizCommandParam.ON + ), + turn_off=lambda execute_command: execute_command( + OverkizCommand.SET_FORCE_HEATING, OverkizCommandParam.OFF + ), + is_on=lambda select_state: ( + select_state(OverkizState.IO_FORCE_HEATING) == OverkizCommandParam.ON + ), + icon="mdi:water-boiler", + ), + OverkizSwitchDescription( + key=UIClass.ON_OFF, + turn_on=lambda execute_command: execute_command(OverkizCommand.ON), + turn_off=lambda execute_command: execute_command(OverkizCommand.OFF), + is_on=lambda select_state: ( + select_state(OverkizState.CORE_ON_OFF) == OverkizCommandParam.ON + ), + device_class=SwitchDeviceClass.OUTLET, + ), + OverkizSwitchDescription( + key=UIClass.SWIMMING_POOL, + turn_on=lambda execute_command: execute_command(OverkizCommand.ON), + turn_off=lambda execute_command: execute_command(OverkizCommand.OFF), + is_on=lambda select_state: ( + select_state(OverkizState.CORE_ON_OFF) == OverkizCommandParam.ON + ), + icon="mdi:pool", + ), + OverkizSwitchDescription( + key=UIWidget.RTD_INDOOR_SIREN, + turn_on=lambda execute_command: execute_command(OverkizCommand.ON), + turn_off=lambda execute_command: execute_command(OverkizCommand.OFF), + icon="mdi:bell", + ), + OverkizSwitchDescription( + key=UIWidget.RTD_OUTDOOR_SIREN, + turn_on=lambda execute_command: execute_command(OverkizCommand.ON), + turn_off=lambda execute_command: execute_command(OverkizCommand.OFF), + icon="mdi:bell", + ), + OverkizSwitchDescription( + key=UIWidget.STATELESS_ALARM_CONTROLLER, + turn_on=lambda execute_command: execute_command(OverkizCommand.ALARM_ON), + turn_off=lambda execute_command: execute_command(OverkizCommand.ALARM_OFF), + icon="mdi:shield-lock", + ), + OverkizSwitchDescription( + key=UIWidget.STATELESS_EXTERIOR_HEATING, + turn_on=lambda execute_command: execute_command(OverkizCommand.ON), + turn_off=lambda execute_command: execute_command(OverkizCommand.OFF), + icon="mdi:radiator", + ), + OverkizSwitchDescription( + key=UIWidget.MY_FOX_SECURITY_CAMERA, + name="Camera Shutter", + turn_on=lambda execute_command: execute_command(OverkizCommand.OPEN), + turn_off=lambda execute_command: execute_command(OverkizCommand.CLOSE), + icon="mdi:camera-lock", + is_on=lambda select_state: ( + select_state(OverkizState.MYFOX_SHUTTER_STATUS) + == OverkizCommandParam.OPENED + ), + entity_category=EntityCategory.CONFIG, + ), +] + +SUPPORTED_DEVICES = { + description.key: description for description in SWITCH_DESCRIPTIONS +} async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -): +) -> None: """Set up the Overkiz switch from a config entry.""" data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - - entities = [ - OverkizSwitch(device.device_url, data.coordinator) - for device in data.platforms[Platform.SWITCH] - ] - - entities.extend( - [ - OverkizLowSpeedCoverSwitch(device.device_url, data.coordinator) - for device in data.platforms[Platform.COVER] - if OverkizCommand.SET_CLOSURE_AND_LINEAR_SPEED in device.definition.commands - ] - ) + entities: list[OverkizSwitch] = [] + + for device in data.platforms[Platform.SWITCH]: + if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get( + device.ui_class + ): + entities.append( + OverkizSwitch( + device.device_url, + data.coordinator, + description, + ) + ) async_add_entities(entities) -class OverkizSwitch(OverkizEntity, SwitchEntity): - """Representation an Overkiz Switch.""" +class OverkizSwitch(OverkizDescriptiveEntity, SwitchEntity): + """Representation of an Overkiz Switch.""" - _attr_device_class = DEVICE_CLASS_SWITCH - - async def async_turn_on(self, **_): - """Send the on command.""" - if self.executor.has_command(OverkizCommand.ON): - await self.executor.async_execute_command(OverkizCommand.ON) - elif self.executor.has_command(OverkizCommand.SET_FORCE_HEATING): - await self.executor.async_execute_command( - OverkizCommand.SET_FORCE_HEATING, OverkizCommandParam.ON - ) - - async def async_turn_off(self, **_): - """Send the off command.""" - if self.executor.has_command(OverkizCommand.OFF): - await self.executor.async_execute_command(OverkizCommand.OFF) - elif self.executor.has_command(OverkizCommand.SET_FORCE_HEATING): - await self.executor.async_execute_command( - OverkizCommand.SET_FORCE_HEATING, OverkizCommandParam.OFF - ) - - async def async_toggle(self, **_): - """Click the switch.""" - if self.executor.has_command(OverkizCommand.CYCLE): - await self.executor.async_execute_command(OverkizCommand.CYCLE) + entity_description: OverkizSwitchDescription @property - def is_on(self): - """Get whether the switch is in on state.""" - return ( - self.executor.select_state( - OverkizState.CORE_ON_OFF, OverkizState.IO_FORCE_HEATING - ) - == OverkizCommandParam.ON - ) - - -class OverkizLowSpeedCoverSwitch(OverkizEntity, SwitchEntity, RestoreEntity): - """Representation of Low Speed Switch.""" + def is_on(self) -> bool | None: + """Return True if entity is on.""" + if self.entity_description.is_on: + return self.entity_description.is_on(self.executor.select_state) - _attr_icon = "mdi:feather" - - def __init__(self, device_url: str, coordinator: OverkizDataUpdateCoordinator): - """Initialize the low speed switch.""" - super().__init__(device_url, coordinator) - self._is_on = False - self._attr_name = f"{super().name} low speed" - self._attr_entity_category = EntityCategory.CONFIG - - async def async_added_to_hass(self): - """Run when entity about to be added.""" - await super().async_added_to_hass() - state = await self.async_get_last_state() - if state: - self._is_on = state.state == OverkizCommandParam.ON - - @property - def is_on(self) -> bool: - """Get whether the switch is in on state.""" - return self._is_on + return None async def async_turn_on(self, **kwargs: Any) -> None: - """Send the on command.""" - self._is_on = True - self.async_write_ha_state() + """Turn the entity on.""" + await self.entity_description.turn_on(self.executor.async_execute_command) async def async_turn_off(self, **kwargs: Any) -> None: - """Send the off command.""" - self._is_on = False - self.async_write_ha_state() + """Turn the entity off.""" + await self.entity_description.turn_off(self.executor.async_execute_command)