From a06f4a1f62cca00b7d79565eaa8ad0ad300f16c4 Mon Sep 17 00:00:00 2001 From: "mike.toggweiler" Date: Tue, 19 Nov 2024 06:56:42 +0000 Subject: [PATCH] Added support for additional platform types: * text input * selects based on int and str enums * time --- custom_components/askoheat/__init__.py | 3 + custom_components/askoheat/api.py | 249 +++++++++++------- custom_components/askoheat/api_conf_desc.py | 70 ++++- custom_components/askoheat/api_desc.py | 52 +++- custom_components/askoheat/const.py | 4 +- custom_components/askoheat/data.py | 2 +- custom_components/askoheat/entity.py | 8 +- custom_components/askoheat/model.py | 72 ++++- custom_components/askoheat/select.py | 106 ++++++++ custom_components/askoheat/text.py | 76 ++++++ custom_components/askoheat/time.py | 73 +++++ .../askoheat/translations/en.json | 19 +- 12 files changed, 607 insertions(+), 127 deletions(-) create mode 100644 custom_components/askoheat/select.py create mode 100644 custom_components/askoheat/text.py create mode 100644 custom_components/askoheat/time.py diff --git a/custom_components/askoheat/__init__.py b/custom_components/askoheat/__init__.py index bf7f379..8fcd944 100644 --- a/custom_components/askoheat/__init__.py +++ b/custom_components/askoheat/__init__.py @@ -30,6 +30,9 @@ Platform.BINARY_SENSOR, Platform.SWITCH, Platform.NUMBER, + Platform.TIME, + Platform.TEXT, + Platform.SELECT, ] diff --git a/custom_components/askoheat/api.py b/custom_components/askoheat/api.py index c5b815a..8b0f402 100644 --- a/custom_components/askoheat/api.py +++ b/custom_components/askoheat/api.py @@ -3,8 +3,8 @@ from __future__ import annotations from datetime import time -from enum import StrEnum -from typing import TYPE_CHECKING, Any, Coroutine, TypeVar, cast +from enum import ReprEnum +from typing import TYPE_CHECKING, Any, TypeVar, cast import numpy as np from numpy import number @@ -15,27 +15,23 @@ ByteRegisterInputDescriptor, FlagRegisterInputDescriptor, Float32RegisterInputDescriptor, + IntEnumInputDescriptor, RegisterBlockDescriptor, RegisterInputDescriptor, SignedIntRegisterInputDescriptor, + StrEnumInputDescriptor, StringRegisterInputDescriptor, + TimeRegisterInputDescriptor, UnsignedIntRegisterInputDescriptor, ) from custom_components.askoheat.api_ema_desc import EMA_REGISTER_BLOCK_DESCRIPTOR from custom_components.askoheat.const import ( LOGGER, - Baurate, - BinarySensorAttrKey, - EnergyMeterType, - SelectAttrKey, - SwitchAttrKey, - TextAttrKey, - TimeAttrKey, ) from custom_components.askoheat.data import AskoheatDataBlock if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Coroutine from pymodbus.pdu import ModbusPDU @@ -111,7 +107,9 @@ async def async_write_config_data( ) -> AskoheatDataBlock: """Write EMA parameter.""" LOGGER.debug( - f"async write config parameter at {api_desc.starting_register}, value={value}" + "async write config parameter at %i, value=%r", + api_desc.starting_register, + value, ) register_values = await self._prepare_register_value( api_desc, @@ -176,28 +174,28 @@ async def _prepare_register_value( read_current_register_value: Callable[..., Coroutine[Any, Any, int]], ) -> list[int | bytes]: match desc: - case FlagRegisterInputDescriptor(starting_register, bit): + case FlagRegisterInputDescriptor(_, bit): # first re-read value as this register might have changed current_value = await read_current_register_value() result = _prepare_flag( register_value=current_value, flag=value, index=bit ) - case ByteRegisterInputDescriptor(starting_register): + case ByteRegisterInputDescriptor(): result = _prepare_byte(value) - case UnsignedIntRegisterInputDescriptor(starting_register): + case UnsignedIntRegisterInputDescriptor(): result = _prepare_uint16(value) - case SignedIntRegisterInputDescriptor(starting_register): + case SignedIntRegisterInputDescriptor(): result = _prepare_int16(value) - case Float32RegisterInputDescriptor(starting_register): + case Float32RegisterInputDescriptor(): result = _prepare_float32(value) - case StringRegisterInputDescriptor(starting_register, number_of_bytes): - # TODO Support - # result = _read_str( - # data.registers[ - # starting_register : starting_register + number_of_bytes + 1 - # ] - # ) - result = [] + case StringRegisterInputDescriptor(): + result = _prepare_str(value) + case TimeRegisterInputDescriptor(value): + result = _prepare_time(value) + case StrEnumInputDescriptor(): + result = _prepare_str(value.value) # type: ignore # noqa: PGH003 + case IntEnumInputDescriptor(): + result = _prepare_byte(value.value) # type: ignore # noqa: PGH003 case _: LOGGER.error("Cannot read number input from descriptor %r", desc) result = [] @@ -238,82 +236,47 @@ def __map_data( }.items() if v is not None } + text_inputs = { + k: v + for k, v in { + item.key: _read_register_string_input(data, item.api_descriptor) # type: ignore # noqa: PGH003 + for item in descr.text_inputs + }.items() + if v is not None + } + time_inputs = { + k: v + for k, v in { + item.key: _read_register_time_input(data, item.api_descriptor) # type: ignore # noqa: PGH003 + for item in descr.time_inputs + }.items() + if v is not None + } + select_inputs = { + k: v + for k, v in { + item.key: _read_register_enum_input(data, item.api_descriptor) # type: ignore # noqa: PGH003 + for item in descr.select_inputs + }.items() + if v is not None + } return AskoheatDataBlock( binary_sensors=binary_sensors, sensors=sensors, switches=switches, number_inputs=number_inputs, + text_inputs=text_inputs, + time_inputs=time_inputs, + select_inputs=select_inputs, ) - def __map_config_data(self, data: ModbusPDU) -> AskoheatDataBlock: - """Map modbus result of config data block.""" - return AskoheatDataBlock( - number_inputs={}, - switches={}, - # TODO: Move/merge to api_conf_desc.py - time_inputs={ - TimeAttrKey.CON_LEGIO_PROTECTION_PREFERRED_START_TIME: _read_time( - register_value_hours=data.registers[12], - register_value_minutes=data.registers[13], - ), - TimeAttrKey.CON_LOW_TARIFF_START_TIME: time( - hour=_read_byte(data.registers[52]), - minute=_read_byte(data.registers[53]), - ), - TimeAttrKey.CON_LOW_TARIFF_END_TIME: time( - hour=_read_byte(data.registers[54]), - minute=_read_byte(data.registers[55]), - ), - }, - # TODO: Move/merge to api_conf_desc.py - text_inputs={ - TextAttrKey.CON_INFO_STRING: _read_str(data.registers[22:38]), - }, - # TODO: Move/merge to api_conf_desc.py - select_inputs={ - SelectAttrKey.CON_RTU_BAUDRATE: _read_enum( - data.registers[46:49], Baurate - ), - SelectAttrKey.CON_ENERGY_METER_TYPE: EnergyMeterType( - _read_byte(data.registers[51]) - ), - }, - ) - - def _map_register_to_status( - self, register_value: int - ) -> dict[BinarySensorAttrKey, bool]: - """Map modbus register status.""" - return { - # low byte values - BinarySensorAttrKey.HEATER1_ACTIVE: _read_flag(register_value, 0), - BinarySensorAttrKey.HEATER2_ACTIVE: _read_flag(register_value, 1), - BinarySensorAttrKey.HEATER3_ACTIVE: _read_flag(register_value, 2), - BinarySensorAttrKey.PUMP_ACTIVE: _read_flag(register_value, 3), - BinarySensorAttrKey.RELAY_BOARD_CONNECTED: _read_flag(register_value, 4), - # bit 5 ignored - BinarySensorAttrKey.HEAT_PUMP_REQUEST_ACTIVE: _read_flag(register_value, 6), - BinarySensorAttrKey.EMERGENCY_MODE_ACTIVE: _read_flag(register_value, 7), - # high byte values - BinarySensorAttrKey.LEGIONELLA_PROTECTION_ACTIVE: _read_flag( - register_value, 8 - ), - BinarySensorAttrKey.ANALOG_INPUT_ACTIVE: _read_flag(register_value, 9), - BinarySensorAttrKey.SETPOINT_ACTIVE: _read_flag(register_value, 10), - BinarySensorAttrKey.LOAD_FEEDIN_ACTIVE: _read_flag(register_value, 11), - BinarySensorAttrKey.AUTOHEATER_ACTIVE: _read_flag(register_value, 12), - BinarySensorAttrKey.PUMP_RELAY_FOLLOW_UP_TIME_ACTIVE: _read_flag( - register_value, 13 - ), - BinarySensorAttrKey.TEMP_LIMIT_REACHED: _read_flag(register_value, 14), - BinarySensorAttrKey.ERROR_OCCURED: _read_flag(register_value, 15), - } - def _read_register_input(data: ModbusPDU, desc: RegisterInputDescriptor) -> object: match desc: case FlagRegisterInputDescriptor(starting_register, bit): result = _read_flag(data.registers[starting_register], bit) + case IntEnumInputDescriptor(starting_register, factory): + result = factory(_read_byte(data.registers[starting_register])) case ByteRegisterInputDescriptor(starting_register): result = _read_byte(data.registers[starting_register]) case UnsignedIntRegisterInputDescriptor(starting_register): @@ -324,11 +287,22 @@ def _read_register_input(data: ModbusPDU, desc: RegisterInputDescriptor) -> obje result = _read_float32( data.registers[starting_register : starting_register + 2] ) - case StringRegisterInputDescriptor(starting_register, number_of_bytes): + case StrEnumInputDescriptor(starting_register, number_of_words, factory): + result = factory( + _read_str( + data.registers[ + starting_register : starting_register + number_of_words + ] + ) + ) + case StringRegisterInputDescriptor(starting_register, number_of_words): result = _read_str( - data.registers[ - starting_register : starting_register + number_of_bytes + 1 - ] + data.registers[starting_register : starting_register + number_of_words] + ) + case TimeRegisterInputDescriptor(starting_register): + result = _read_time( + register_value_hours=data.registers[starting_register], + register_value_minutes=data.registers[starting_register + 1], ) case _: LOGGER.error("Cannot read number input from descriptor %r", desc) @@ -369,6 +343,51 @@ def _read_register_number_input( return None +def _read_register_string_input( + data: ModbusPDU, desc: RegisterInputDescriptor +) -> str | None: + result = _read_register_input(data, desc) + if isinstance(result, str): + return result + + LOGGER.error( + "Cannot read str input from descriptor %r, unsupported value %r", + desc, + result, + ) + return None + + +def _read_register_time_input( + data: ModbusPDU, desc: RegisterInputDescriptor +) -> time | None: + result = _read_register_input(data, desc) + if isinstance(result, time): + return result + + LOGGER.error( + "Cannot read time input from descriptor %r, unsupported value %r", + desc, + result, + ) + return None + + +def _read_register_enum_input( + data: ModbusPDU, desc: RegisterInputDescriptor +) -> ReprEnum | None: + result = _read_register_input(data, desc) + if isinstance(result, ReprEnum): + return result + + LOGGER.error( + "Cannot read enum input from descriptor %r, unsupported value %r", + desc, + result, + ) + return None + + def _read_time(register_value_hours: int, register_value_minutes: int) -> time | None: """Read register values as string and parse as time.""" hours = _read_uint16(register_value_hours) @@ -376,6 +395,17 @@ def _read_time(register_value_hours: int, register_value_minutes: int) -> time | return time(hour=hours, minute=minutes) +def _prepare_time(value: object) -> list[int]: + """Prepare time represented as two register values for writing to registers.""" + if not isinstance(value, time): + LOGGER.error( + "Cannot convert value %s as time, wrong datatype %r", value, type(value) + ) + return [] + time_value = cast(time, value) + return _prepare_uint16(time_value.hour).__add__(_prepare_uint16(time_value.minute)) + + T = TypeVar("T") @@ -387,7 +417,7 @@ def _read_enum(register_values: list[int], factory: Callable[[str], T]) -> T: def _read_str(register_values: list[int]) -> str: """Read register values as str.""" - # custom implementation as strings a represented with little endian + # custom implementation as strings are represented with little endian byte_list = bytearray() for x in register_values: byte_list.extend(int.to_bytes(x, 2, "little")) @@ -396,6 +426,23 @@ def _read_str(register_values: list[int]) -> str: return byte_list.decode("utf-8") +def _prepare_str(value: object) -> list[int]: + """Prepare string value for writing to registers.""" + if not isinstance(value, str): + LOGGER.error( + "Cannot convert value %s as string, wrong datatype %r", value, type(value) + ) + return [] + str_value = cast(str, value) + byte_list = str_value.encode("utf-8") + size = int(len(byte_list) / 2) + result = [] + for index in range(0, size): + b = byte_list[index * 2 : index * 2 + 1] + result.append(int.from_bytes(b, "little")) + return result + + def _read_byte(register_value: int) -> np.byte: """Read register value as byte.""" return np.byte( @@ -406,13 +453,17 @@ def _read_byte(register_value: int) -> np.byte: def _prepare_byte(value: object) -> list[int]: - """Prepare byte value to be able to write to a register.""" - if not isinstance(value, number | float): + """Prepare byte value for writing to registers.""" + if not isinstance(value, number | float | bool): LOGGER.error( "Cannot convert value %s as byte, wrong datatype %r", value, type(value) ) return [] + # special case, map true to 1 and false to 0 + if isinstance(value, bool): + value = 1 if value else 0 + return ModbusClient.convert_to_registers(int(value), ModbusClient.DATATYPE.INT16) @@ -426,7 +477,7 @@ def _read_int16(register_value: int) -> np.int16: def _prepare_int16(value: object) -> list[int]: - """Prepare signed int value to be able to write to a register.""" + """Prepare signed int value for writing to registers.""" if not isinstance(value, number | float): LOGGER.error( "Cannot convert value %s as signed int, wrong datatype %r", @@ -447,7 +498,7 @@ def _read_uint16(register_value: int) -> np.uint16: def _prepare_uint16(value: object) -> list[int]: - """Prepare unsigned int value to be able to write to a register.""" + """Prepare unsigned int value for writing to registers.""" if not isinstance(value, number | float): LOGGER.error( "Cannot convert value %s as unsigned int, wrong datatype %r", @@ -469,7 +520,7 @@ def _read_float32(register_values: list[int]) -> np.float32: def _prepare_float32(value: object) -> list[int]: - """Prepare float32 value to be able to write to a register.""" + """Prepare float32 value writing to registers.""" if not isinstance(value, number | float): LOGGER.error( "Cannot convert value %s as float32, wrong datatype %r", value, type(value) @@ -486,7 +537,7 @@ def _read_flag(register_value: int, index: int) -> bool: def _prepare_flag(register_value: int, flag: object, index: int) -> list[int]: - """Prepare flag value to be able to write to a register, mask with current value.""" + """Prepare flag value as mask with current value for writing to a register.""" if not isinstance(flag, bool): LOGGER.error( "Cannot convert value %s as flag, wrong datatype %r", diff --git a/custom_components/askoheat/api_conf_desc.py b/custom_components/askoheat/api_conf_desc.py index 37a3236..612c7cc 100644 --- a/custom_components/askoheat/api_conf_desc.py +++ b/custom_components/askoheat/api_conf_desc.py @@ -17,17 +17,29 @@ ByteRegisterInputDescriptor, FlagRegisterInputDescriptor, Float32RegisterInputDescriptor, + IntEnumInputDescriptor, RegisterBlockDescriptor, SignedIntRegisterInputDescriptor, + StrEnumInputDescriptor, + StringRegisterInputDescriptor, + TimeRegisterInputDescriptor, UnsignedIntRegisterInputDescriptor, ) from custom_components.askoheat.const import ( + Baudrate, + EnergyMeterType, NumberAttrKey, + SelectAttrKey, SwitchAttrKey, + TextAttrKey, + TimeAttrKey, ) from custom_components.askoheat.model import ( AskoheatNumberEntityDescription, + AskoheatSelectEntityDescription, AskoheatSwitchEntityDescription, + AskoheatTextEntityDescription, + AskoheatTimeEntityDescription, ) CONF_REGISTER_BLOCK_DESCRIPTOR = RegisterBlockDescriptor( @@ -782,9 +794,9 @@ api_descriptor=FlagRegisterInputDescriptor(20, 7), ), ### house type settings register - end - ### Summer time int 1/0 as bool + ### is Summer time int 1/0 as bool, 1 hour offset AskoheatSwitchEntityDescription( - key=SwitchAttrKey.CON_SUMMER_TIME_ENABLED, + key=SwitchAttrKey.CON_SUMMER_TIME, entity_category=EntityCategory.CONFIG, icon="mdi:calendar-clock", api_descriptor=ByteRegisterInputDescriptor(42), @@ -856,4 +868,58 @@ ), ### temperature settings register - end ], + time_inputs=[ + AskoheatTimeEntityDescription( + key=TimeAttrKey.CON_LEGIO_PROTECTION_PREFERRED_START_TIME, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-play", + api_descriptor=TimeRegisterInputDescriptor(12), + ), + AskoheatTimeEntityDescription( + key=TimeAttrKey.CON_LOW_TARIFF_START_TIME, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-play", + api_descriptor=TimeRegisterInputDescriptor(52), + ), + AskoheatTimeEntityDescription( + key=TimeAttrKey.CON_LOW_TARIFF_END_TIME, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-remove", + api_descriptor=TimeRegisterInputDescriptor(54), + ), + ], + text_inputs=[ + AskoheatTextEntityDescription( + key=TextAttrKey.CON_INFO_STRING, + entity_category=EntityCategory.CONFIG, + native_max=32, + icon="mdi:information", + api_descriptor=StringRegisterInputDescriptor( + starting_register=22, number_of_words=16 + ), + ), + ], + select_inputs=[ + AskoheatSelectEntityDescription( + key=SelectAttrKey.CON_RTU_BAUDRATE, + entity_category=EntityCategory.CONFIG, + icon="mdi:speedometer", + api_descriptor=StrEnumInputDescriptor( + starting_register=46, + number_of_words=3, + factory=Baudrate, + values=[e.value for e in Baudrate], + ), + ), + AskoheatSelectEntityDescription( + key=SelectAttrKey.CON_ENERGY_METER_TYPE, + entity_category=EntityCategory.CONFIG, + icon="mdi:list-box", + api_descriptor=IntEnumInputDescriptor( + starting_register=51, + factory=EnergyMeterType, + values=[e.value for e in EnergyMeterType], + ), + ), + ], ) diff --git a/custom_components/askoheat/api_desc.py b/custom_components/askoheat/api_desc.py index 0be5d61..7e59036 100644 --- a/custom_components/askoheat/api_desc.py +++ b/custom_components/askoheat/api_desc.py @@ -5,8 +5,10 @@ import typing from abc import ABC from dataclasses import dataclass, field -from enum import StrEnum -from typing import TYPE_CHECKING +from enum import IntEnum, StrEnum +from typing import TYPE_CHECKING, Callable, TypeVar + +import numpy if TYPE_CHECKING: from custom_components.askoheat.model import ( @@ -14,6 +16,9 @@ AskoheatNumberEntityDescription, AskoheatSensorEntityDescription, AskoheatSwitchEntityDescription, + AskoheatTimeEntityDescription, + AskoheatSelectEntityDescription, + AskoheatTextEntityDescription, ) @@ -59,7 +64,34 @@ class UnsignedIntRegisterInputDescriptor(RegisterInputDescriptor): class StringRegisterInputDescriptor(RegisterInputDescriptor): """Input register representing a string.""" - number_of_bytes: int + number_of_words: int + + +@dataclass(frozen=True) +class TimeRegisterInputDescriptor(RegisterInputDescriptor): + """Input register representing a time string combined of two following registers.""" + + +E = TypeVar("E", bound=StrEnum) + + +@dataclass(frozen=True) +class StrEnumInputDescriptor[E](StringRegisterInputDescriptor): + """Input register representing a string based enum value.""" + + factory: Callable[[str], E] + values: list[E] + + +E2 = TypeVar("E2", bound=IntEnum) + + +@dataclass(frozen=True) +class IntEnumInputDescriptor[E2](ByteRegisterInputDescriptor): + """Input register representing a int based enum value.""" + + factory: Callable[[numpy.byte], E2] + values: list[E2] @dataclass(frozen=True) @@ -75,18 +107,10 @@ class RegisterBlockDescriptor: sensors: list[AskoheatSensorEntityDescription] = field(default_factory=list) switches: list[AskoheatSwitchEntityDescription] = field(default_factory=list) number_inputs: list[AskoheatNumberEntityDescription] = field(default_factory=list) + time_inputs: list[AskoheatTimeEntityDescription] = field(default_factory=list) + text_inputs: list[AskoheatTextEntityDescription] = field(default_factory=list) + select_inputs: list[AskoheatSelectEntityDescription] = field(default_factory=list) def absolute_register_index(self, desc: RegisterInputDescriptor) -> int: """Return absolute index of register.""" return self.starting_register + desc.starting_register - - # TODO: add more type of inputs as soon as supported - # text_inputs: list[TextAttrKey, RegisterInputDescriptor] = field( - # default_factory=dict - # ) - # select_inputs: dict[SelectAttrKey, RegisterInputDescriptor] = field( - # default_factory=dict - # ) - # time_inputs: dict[TimeAttrKey, RegisterInputDescriptor] = field( - # default_factory=dict - # ) diff --git a/custom_components/askoheat/const.py b/custom_components/askoheat/const.py index 54e4919..2932fb6 100644 --- a/custom_components/askoheat/const.py +++ b/custom_components/askoheat/const.py @@ -231,7 +231,7 @@ class SwitchAttrKey(StrEnum): CON_HOUSE_TYPE_COMMERCIAL_BUILDING = "house_type_commercial_building" # from house type register -- end - CON_SUMMER_TIME_ENABLED = "summer_time_enabled" + CON_SUMMER_TIME = "is_summer_time" # from rtu settings register -- begin # low byte @@ -292,7 +292,7 @@ class SensorAttrKey(StrEnum): EXTERNAL_TEMPERATUR_SENSOR4_VALUE = "external_temp_sensor4" -class Baurate(StrEnum): +class Baudrate(StrEnum): """Available Baudrates.""" BAUD_RATE_1200 = "1200" diff --git a/custom_components/askoheat/data.py b/custom_components/askoheat/data.py index edca6a3..5ff67d6 100644 --- a/custom_components/askoheat/data.py +++ b/custom_components/askoheat/data.py @@ -53,4 +53,4 @@ class AskoheatDataBlock: text_inputs: dict[TextAttrKey, str] | None = None select_inputs: dict[SelectAttrKey, ReprEnum] | None = None number_inputs: dict[NumberAttrKey, number] | None = None - time_inputs: dict[TimeAttrKey, time | None] | None = None + time_inputs: dict[TimeAttrKey, time] | None = None diff --git a/custom_components/askoheat/entity.py b/custom_components/askoheat/entity.py index 1c778e6..3abd1c2 100644 --- a/custom_components/askoheat/entity.py +++ b/custom_components/askoheat/entity.py @@ -40,7 +40,7 @@ def __init__( ) self.entity_description = entity_description self.translation_key = ( - entity_description.translation_key or entity_description.key.value + entity_description.translation_key or entity_description.key.value # type: ignore ) async def async_added_to_hass(self) -> None: @@ -61,10 +61,10 @@ def _handle_coordinator_update(self) -> None: icon_state = self._attr_state if hasattr(self, "_attr_is_on"): icon_state = self._attr_is_on # type: ignore # noqa: PGH003 - if descr.icon_by_state is not None and icon_state in descr.icon_by_state: - self._attr_icon = descr.icon_by_state.get(icon_state) + if descr.icon_by_state is not None and icon_state in descr.icon_by_state: # type: ignore + self._attr_icon = descr.icon_by_state.get(icon_state) # type: ignore else: - self._attr_icon = descr.icon + self._attr_icon = descr.icon # type: ignore super()._handle_coordinator_update() self.async_write_ha_state() diff --git a/custom_components/askoheat/model.py b/custom_components/askoheat/model.py index 63b53c6..da442c6 100644 --- a/custom_components/askoheat/model.py +++ b/custom_components/askoheat/model.py @@ -2,15 +2,18 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import StrEnum from functools import cached_property from typing import TYPE_CHECKING, TypeVar from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.number import NumberEntityDescription, NumberMode +from homeassistant.components.select import SelectEntityDescription from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.switch import SwitchEntityDescription +from homeassistant.components.text import TextEntityDescription +from homeassistant.components.time import TimeEntityDescription from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription @@ -18,16 +21,23 @@ ByteRegisterInputDescriptor, FlagRegisterInputDescriptor, Float32RegisterInputDescriptor, + IntEnumInputDescriptor, RegisterInputDescriptor, SignedIntRegisterInputDescriptor, + StrEnumInputDescriptor, + StringRegisterInputDescriptor, + TimeRegisterInputDescriptor, UnsignedIntRegisterInputDescriptor, ) from custom_components.askoheat.const import ( DOMAIN, BinarySensorAttrKey, NumberAttrKey, + SelectAttrKey, SensorAttrKey, SwitchAttrKey, + TextAttrKey, + TimeAttrKey, ) if TYPE_CHECKING: @@ -142,3 +152,63 @@ class AskoheatNumberEntityDescription( def data_key(self) -> str: """Get data key.""" return f"number.{self.key}" + + +@dataclass(frozen=True) +class AskoheatTimeEntityDescription( + AskoheatEntityDescription[ + TimeAttrKey, + TimeRegisterInputDescriptor, + ], + TimeEntityDescription, +): + """Class describing Askoheat time entities.""" + + key: TimeAttrKey + platform = Platform.TIME + domain = DOMAIN + + @cached_property + def data_key(self) -> str: + """Get data key.""" + return f"time.{self.key}" + + +@dataclass(frozen=True) +class AskoheatTextEntityDescription( + AskoheatEntityDescription[ + TextAttrKey, + StringRegisterInputDescriptor, + ], + TextEntityDescription, +): + """Class describing Askoheat text entities.""" + + key: TextAttrKey + platform = Platform.TEXT + domain = DOMAIN + + @cached_property + def data_key(self) -> str: + """Get data key.""" + return f"text.{self.key}" + + +@dataclass(frozen=True) +class AskoheatSelectEntityDescription( + AskoheatEntityDescription[ + SelectAttrKey, + IntEnumInputDescriptor | StrEnumInputDescriptor, + ], + SelectEntityDescription, +): + """Class describing Askoheat select entities.""" + + key: SelectAttrKey + platform = Platform.SELECT + domain = DOMAIN + + @cached_property + def data_key(self) -> str: + """Get data key.""" + return f"select.{self.key}" diff --git a/custom_components/askoheat/select.py b/custom_components/askoheat/select.py new file mode 100644 index 0000000..b4c0040 --- /dev/null +++ b/custom_components/askoheat/select.py @@ -0,0 +1,106 @@ +"""Askoheat time entity.""" + +from functools import cached_property +import platform +from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from custom_components.askoheat.api_conf_desc import CONF_REGISTER_BLOCK_DESCRIPTOR +from custom_components.askoheat.api_ema_desc import EMA_REGISTER_BLOCK_DESCRIPTOR +from custom_components.askoheat.const import LOGGER +from custom_components.askoheat.coordinator import AskoheatDataUpdateCoordinator +from custom_components.askoheat.model import ( + AskoheatSelectEntityDescription, +) + +from .data import AskoheatConfigEntry +from .entity import AskoheatEntity + + +async def async_setup_entry( + hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` + entry: AskoheatConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the select platform.""" + async_add_entities( + AskoheatSelect( + coordinator=entry.runtime_data.ema_coordinator, + entity_description=entity_description, + ) + for entity_description in EMA_REGISTER_BLOCK_DESCRIPTOR.select_inputs + ) + async_add_entities( + AskoheatSelect( + coordinator=entry.runtime_data.config_coordinator, + entity_description=entity_description, + ) + for entity_description in CONF_REGISTER_BLOCK_DESCRIPTOR.select_inputs + ) + + +class AskoheatSelect(AskoheatEntity[AskoheatSelectEntityDescription], SelectEntity): + """Askoheat select entity.""" + + def __init__( + self, + coordinator: AskoheatDataUpdateCoordinator, + entity_description: AskoheatSelectEntityDescription, + ) -> None: + """Initialize the select class.""" + super().__init__(coordinator, entity_description) + self.entity_id = ENTITY_ID_FORMAT.format(entity_description.key) + self._attr_unique_id = self.entity_id + self.current_option = None + + @cached_property + def _entity_translation_key_base(self) -> str | None: + """Return translation key for entity name.""" + if self.translation_key is None: + return None + return ( + f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + f".{self.translation_key}" + ) + + @cached_property + def _options_to_enum(self) -> dict[str, object]: + return { + self.platform.object_id_platform_translations.get( + f"{self._entity_translation_key_base}.values.{e}" + ) + or str(e): e + for e in self.entity_description.api_descriptor.values # type: ignore # noqa: PD011, PGH003 + } + + @cached_property + def _enum_to_options(self) -> dict[object, str]: + return {e: v for v, e in self._options_to_enum.items()} + + @cached_property + def options(self) -> list[str]: + """Return a set of selectable options.""" + self.options = list(self._options_to_enum) + return self.options + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data + if data is None: + return + enum = data[self.entity_description.data_key] + self.current_option = self._enum_to_options[enum] + super()._handle_coordinator_update() + + async def async_select_option(self, value: str) -> None: + """Update the current value.""" + if self.entity_description.api_descriptor is None: + LOGGER.error( + "Cannot set value, missing api_descriptor on entity %s", self.entity_id + ) + return + enum = self._options_to_enum[value] + await self.coordinator.async_write(self.entity_description.api_descriptor, enum) + self._handle_coordinator_update() diff --git a/custom_components/askoheat/text.py b/custom_components/askoheat/text.py new file mode 100644 index 0000000..ff80a99 --- /dev/null +++ b/custom_components/askoheat/text.py @@ -0,0 +1,76 @@ +"""Askoheat time entity.""" + +from datetime import time + +from homeassistant.components.text import ENTITY_ID_FORMAT, TextEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from custom_components.askoheat.api_conf_desc import CONF_REGISTER_BLOCK_DESCRIPTOR +from custom_components.askoheat.api_ema_desc import EMA_REGISTER_BLOCK_DESCRIPTOR +from custom_components.askoheat.const import LOGGER +from custom_components.askoheat.coordinator import AskoheatDataUpdateCoordinator +from custom_components.askoheat.model import ( + AskoheatTextEntityDescription, + AskoheatTimeEntityDescription, +) + +from .data import AskoheatConfigEntry +from .entity import AskoheatEntity + + +async def async_setup_entry( + hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` + entry: AskoheatConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the text platform.""" + async_add_entities( + AskoheatText( + coordinator=entry.runtime_data.ema_coordinator, + entity_description=entity_description, + ) + for entity_description in EMA_REGISTER_BLOCK_DESCRIPTOR.text_inputs + ) + async_add_entities( + AskoheatText( + coordinator=entry.runtime_data.config_coordinator, + entity_description=entity_description, + ) + for entity_description in CONF_REGISTER_BLOCK_DESCRIPTOR.text_inputs + ) + + +class AskoheatText(AskoheatEntity[AskoheatTextEntityDescription], TextEntity): + """Askoheat text entity.""" + + def __init__( + self, + coordinator: AskoheatDataUpdateCoordinator, + entity_description: AskoheatTextEntityDescription, + ) -> None: + """Initialize the text class.""" + super().__init__(coordinator, entity_description) + self.entity_id = ENTITY_ID_FORMAT.format(entity_description.key) + self._attr_unique_id = self.entity_id + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data + if data is None: + return + self._attr_native_value = data[self.entity_description.data_key] + super()._handle_coordinator_update() + + async def async_set_value(self, value: str) -> None: + """Update the current value.""" + if self.entity_description.api_descriptor is None: + LOGGER.error( + "Cannot set value, missing api_descriptor on entity %s", self.entity_id + ) + return + await self.coordinator.async_write( + self.entity_description.api_descriptor, value + ) + self._handle_coordinator_update() diff --git a/custom_components/askoheat/time.py b/custom_components/askoheat/time.py new file mode 100644 index 0000000..99c871a --- /dev/null +++ b/custom_components/askoheat/time.py @@ -0,0 +1,73 @@ +"""Askoheat time entity.""" + +from datetime import time + +from homeassistant.components.time import ENTITY_ID_FORMAT, TimeEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from custom_components.askoheat.api_conf_desc import CONF_REGISTER_BLOCK_DESCRIPTOR +from custom_components.askoheat.api_ema_desc import EMA_REGISTER_BLOCK_DESCRIPTOR +from custom_components.askoheat.const import LOGGER +from custom_components.askoheat.coordinator import AskoheatDataUpdateCoordinator +from custom_components.askoheat.model import AskoheatTimeEntityDescription + +from .data import AskoheatConfigEntry +from .entity import AskoheatEntity + + +async def async_setup_entry( + hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` + entry: AskoheatConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the text platform.""" + async_add_entities( + AskoheatTime( + coordinator=entry.runtime_data.ema_coordinator, + entity_description=entity_description, + ) + for entity_description in EMA_REGISTER_BLOCK_DESCRIPTOR.time_inputs + ) + async_add_entities( + AskoheatTime( + coordinator=entry.runtime_data.config_coordinator, + entity_description=entity_description, + ) + for entity_description in CONF_REGISTER_BLOCK_DESCRIPTOR.time_inputs + ) + + +class AskoheatTime(AskoheatEntity[AskoheatTimeEntityDescription], TimeEntity): + """Askoheat time entity.""" + + def __init__( + self, + coordinator: AskoheatDataUpdateCoordinator, + entity_description: AskoheatTimeEntityDescription, + ) -> None: + """Initialize the time class.""" + super().__init__(coordinator, entity_description) + self.entity_id = ENTITY_ID_FORMAT.format(entity_description.key) + self._attr_unique_id = self.entity_id + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data + if data is None: + return + self.native_value = data[self.entity_description.data_key] + super()._handle_coordinator_update() + + async def async_set_value(self, value: time) -> None: + """Update the current value.""" + if self.entity_description.api_descriptor is None: + LOGGER.error( + "Cannot set value, missing api_descriptor on entity %s", self.entity_id + ) + return + await self.coordinator.async_write( + self.entity_description.api_descriptor, value + ) + self._handle_coordinator_update() diff --git a/custom_components/askoheat/translations/en.json b/custom_components/askoheat/translations/en.json index 5480578..1b20129 100644 --- a/custom_components/askoheat/translations/en.json +++ b/custom_components/askoheat/translations/en.json @@ -4,15 +4,26 @@ "user": { "description": "If you need help with the configuration have a look here: https://github.com/toggm/askoheat", "data": { - "username": "Username", - "password": "Password" + "host": "host", + "port": "port" } } }, "error": { - "auth": "Username/Password is wrong.", - "connection": "Unable to connect to the server.", + "connection": "Unable to connect to the askoheat instance.", "unknown": "Unknown error occurred." } + }, + "entity": { + "select": { + "energy_meter_type": { + "values": { + "0": "Not installed", + "1": "Automation One A1EM.BIDMOD", + "2": "Automation One A1EM.MOD", + "16": "Carlo Gavazzi EM300 / ET300 SERIES (e.g. EM340)" + } + } + } } } \ No newline at end of file