From c72021f70f4743c32119404b4a6d599c9feac91a Mon Sep 17 00:00:00 2001 From: "mike.toggweiler" Date: Thu, 7 Nov 2024 23:18:37 +0100 Subject: [PATCH] Added integration of EMA read registers using separate coordinator --- custom_components/askoheat/__init__.py | 35 +-- custom_components/askoheat/api.py | 234 ++++++++++++------ custom_components/askoheat/binary_sensor.py | 62 +++-- .../askoheat/binary_sensor_entities_ema.py | 83 +++++++ custom_components/askoheat/config_flow.py | 76 +++--- custom_components/askoheat/const.py | 58 +++++ custom_components/askoheat/coordinator.py | 63 +++-- custom_components/askoheat/data.py | 30 ++- custom_components/askoheat/entity.py | 8 +- custom_components/askoheat/model.py | 77 ++++++ custom_components/askoheat/sensor.py | 72 ++++-- .../askoheat/sensor_entities_ema.py | 90 +++++++ custom_components/askoheat/switch.py | 30 +-- 13 files changed, 690 insertions(+), 228 deletions(-) create mode 100644 custom_components/askoheat/binary_sensor_entities_ema.py create mode 100644 custom_components/askoheat/model.py create mode 100644 custom_components/askoheat/sensor_entities_ema.py diff --git a/custom_components/askoheat/__init__.py b/custom_components/askoheat/__init__.py index 4ab59e5..70929c0 100644 --- a/custom_components/askoheat/__init__.py +++ b/custom_components/askoheat/__init__.py @@ -9,18 +9,18 @@ from typing import TYPE_CHECKING -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.loader import async_get_loaded_integration -from .api import IntegrationBlueprintApiClient -from .coordinator import BlueprintDataUpdateCoordinator -from .data import IntegrationBlueprintData +from .api import AskoHeatModbusApiClient +from .coordinator import AskoheatEMADataUpdateCoordinator +from .data import AskoheatData if TYPE_CHECKING: from homeassistant.core import HomeAssistant - from .data import IntegrationBlueprintConfigEntry + from .data import AskoheatConfigEntry + PLATFORMS: list[Platform] = [ Platform.SENSOR, @@ -32,24 +32,24 @@ # https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry async def async_setup_entry( hass: HomeAssistant, - entry: IntegrationBlueprintConfigEntry, + entry: AskoheatConfigEntry, ) -> bool: """Set up this integration using UI.""" - coordinator = BlueprintDataUpdateCoordinator( + ema_coordinator = AskoheatEMADataUpdateCoordinator( hass=hass, ) - entry.runtime_data = IntegrationBlueprintData( - client=IntegrationBlueprintApiClient( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - session=async_get_clientsession(hass), + entry.runtime_data = AskoheatData( + client=AskoHeatModbusApiClient( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], ), integration=async_get_loaded_integration(hass, entry.domain), - coordinator=coordinator, + ema_coordinator=ema_coordinator, ) + await entry.runtime_data.client.connect() # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities - await coordinator.async_config_entry_first_refresh() + await ema_coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -59,15 +59,16 @@ async def async_setup_entry( async def async_unload_entry( hass: HomeAssistant, - entry: IntegrationBlueprintConfigEntry, + entry: AskoheatConfigEntry, ) -> bool: """Handle removal of an entry.""" + entry.runtime_data.client.close() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_reload_entry( hass: HomeAssistant, - entry: IntegrationBlueprintConfigEntry, + entry: AskoheatConfigEntry, ) -> None: """Reload config entry.""" await async_unload_entry(hass, entry) diff --git a/custom_components/askoheat/api.py b/custom_components/askoheat/api.py index 441e745..66bfcdd 100644 --- a/custom_components/askoheat/api.py +++ b/custom_components/askoheat/api.py @@ -2,100 +2,178 @@ from __future__ import annotations -import socket -from typing import Any +from typing import TYPE_CHECKING, Any -import aiohttp -import async_timeout +import numpy as np +from pymodbus.client import AsyncModbusTcpClient as ModbusClient +from custom_components.askoheat.const import ( + LOGGER, + BinarySensorEMAAttrKey, + SensorEMAAttrKey, + SwitchEMAAttrKey, +) +from custom_components.askoheat.data import AskoheatEMAData -class IntegrationBlueprintApiClientError(Exception): +if TYPE_CHECKING: + from pymodbus.pdu import ModbusPDU + + +class AskoheatModbusApiClientError(Exception): """Exception to indicate a general API error.""" -class IntegrationBlueprintApiClientCommunicationError( - IntegrationBlueprintApiClientError, +class AskoheatModbusApiClientCommunicationError( + AskoheatModbusApiClientError, ): """Exception to indicate a communication error.""" -class IntegrationBlueprintApiClientAuthenticationError( - IntegrationBlueprintApiClientError, -): - """Exception to indicate an authentication error.""" - +class AskoHeatModbusApiClient: + """Sample API Client.""" -def _verify_response_or_raise(response: aiohttp.ClientResponse) -> None: - """Verify that the response is valid.""" - if response.status in (401, 403): - msg = "Invalid credentials" - raise IntegrationBlueprintApiClientAuthenticationError( - msg, + def __init__(self, host: str, port: int) -> None: + """Askoheat Modbus API Client.""" + self._host = host + self._port = port + self._client = ModbusClient(host=host, port=port) + + async def connect(self) -> Any: + """Connect to modbus client.""" + return await self._client.connect() + + def close(self) -> None: + """Close comnection to modbus client.""" + self._client.close() + + async def async_read_ema_data(self) -> AskoheatEMAData: + """Read EMA states.""" + # http://www.download.askoma.com/askofamily_plus/modbus/askoheat-modbus.html#EM_Block + data = await self.async_read_input_registers_data(300, 37) + LOGGER.info("async_read_ema_data %s", data) + return self._map_ema_data(data) + + async def async_read_input_registers_data( + self, address: int, count: int + ) -> ModbusPDU: + """Read holding registers through modbus.""" + if not self._client.connected: + msg = "cannot read holding registers, not connected" + raise AskoheatModbusApiClientCommunicationError(msg) + + return await self._client.read_input_registers(address=address, count=count) + + async def async_read_holding_registers_data( + self, address: int, count: int + ) -> ModbusPDU: + """Read input registers through modbus.""" + if not self._client.connected: + msg = "cannot read input registers, not connected" + raise AskoheatModbusApiClientCommunicationError(msg) + + return await self._client.read_holding_registers(address=address, count=count) + + def _map_ema_data(self, data: ModbusPDU) -> AskoheatEMAData: + """Map modbus result to EMA data structure.""" + return AskoheatEMAData( + binary_sensors=self._map_register_to_status(data.registers[16]), + sensors={ + SensorEMAAttrKey.HEATER_LOAD: _read_uint16(data.registers[17]), + SensorEMAAttrKey.LOAD_SETPOINT_VALUE: _read_int16(data.registers[19]), + SensorEMAAttrKey.LOAD_FEEDIN_VALUE: _read_int16(data.registers[20]), + SensorEMAAttrKey.ANALOG_INPUT_VALUE: _read_float32( + data.registers[23:25] + ), + SensorEMAAttrKey.INTERNAL_TEMPERATUR_SENSOR_VALUE: _read_float32( + data.registers[25:27] + ), + SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR1_VALUE: _read_float32( + data.registers[27:29] + ), + SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR2_VALUE: _read_float32( + data.registers[29:31] + ), + SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR3_VALUE: _read_float32( + data.registers[31:33] + ), + SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR4_VALUE: _read_float32( + data.registers[33:35] + ), + }, + switches=self._map_register_to_heater_step(data.registers[18]), ) - response.raise_for_status() + def _map_register_to_status( + self, register_value: int + ) -> dict[BinarySensorEMAAttrKey, bool]: + """Map modbus register status.""" + return { + # low byte values + BinarySensorEMAAttrKey.HEATER1_ACTIVE: _read_flag(register_value, 0), + BinarySensorEMAAttrKey.HEATER2_ACTIVE: _read_flag(register_value, 1), + BinarySensorEMAAttrKey.HEATER3_ACTIVE: _read_flag(register_value, 2), + BinarySensorEMAAttrKey.PUMP_ACTIVE: _read_flag(register_value, 3), + BinarySensorEMAAttrKey.RELAY_BOARD_CONNECTED: _read_flag(register_value, 4), + # bit 5 ignored + BinarySensorEMAAttrKey.HEAT_PUMP_REQUEST_ACTIVE: _read_flag( + register_value, 6 + ), + BinarySensorEMAAttrKey.EMERGENCY_MODE_ACTIVE: _read_flag(register_value, 7), + # high byte values + BinarySensorEMAAttrKey.LEGIONELLA_PROTECTION_ACTIVE: _read_flag( + register_value, 8 + ), + BinarySensorEMAAttrKey.ANALOG_INPUT_ACTIVE: _read_flag(register_value, 9), + BinarySensorEMAAttrKey.SETPOINT_ACTIVE: _read_flag(register_value, 10), + BinarySensorEMAAttrKey.LOAD_FEEDIN_ACTIVE: _read_flag(register_value, 11), + BinarySensorEMAAttrKey.AUTOHEATER_OFF_ACTIVE: _read_flag( + register_value, 12 + ), + BinarySensorEMAAttrKey.PUMP_RELAY_FOLLOW_UP_TIME_ACTIVE: _read_flag( + register_value, 13 + ), + BinarySensorEMAAttrKey.TEMP_LIMIT_REACHED: _read_flag(register_value, 14), + BinarySensorEMAAttrKey.ERROR_OCCURED: _read_flag(register_value, 15), + } + + def _map_register_to_heater_step( + self, register_value: int + ) -> dict[SwitchEMAAttrKey, bool]: + """Map modbus register to status class.""" + return { + SwitchEMAAttrKey.SET_HEATER_STEP_HEATER1: _read_flag(register_value, 0), + SwitchEMAAttrKey.SET_HEATER_STEP_HEATER2: _read_flag(register_value, 1), + SwitchEMAAttrKey.SET_HEATER_STEP_HEATER3: _read_flag(register_value, 2), + } + + +def _read_int16(register_value: int) -> np.int16: + """Read register value as int16.""" + return np.int16( + ModbusClient.convert_from_registers( + [register_value], ModbusClient.DATATYPE.INT16 + ) + ) -class IntegrationBlueprintApiClient: - """Sample API Client.""" - def __init__( - self, - username: str, - password: str, - session: aiohttp.ClientSession, - ) -> None: - """Sample API Client.""" - self._username = username - self._password = password - self._session = session - - async def async_get_data(self) -> Any: - """Get data from the API.""" - return await self._api_wrapper( - method="get", - url="https://jsonplaceholder.typicode.com/posts/1", +def _read_uint16(register_value: int) -> np.uint16: + """Read register value as uint16.""" + return np.uint16( + ModbusClient.convert_from_registers( + [register_value], ModbusClient.DATATYPE.UINT16 ) + ) - async def async_set_title(self, value: str) -> Any: - """Get data from the API.""" - return await self._api_wrapper( - method="patch", - url="https://jsonplaceholder.typicode.com/posts/1", - data={"title": value}, - headers={"Content-type": "application/json; charset=UTF-8"}, + +def _read_float32(register_values: list[int]) -> np.float32: + """Read register value as uint16.""" + return np.float32( + ModbusClient.convert_from_registers( + register_values, ModbusClient.DATATYPE.FLOAT32 ) + ) + - async def _api_wrapper( - self, - method: str, - url: str, - data: dict | None = None, - headers: dict | None = None, - ) -> Any: - """Get information from the API.""" - try: - async with async_timeout.timeout(10): - response = await self._session.request( - method=method, - url=url, - headers=headers, - json=data, - ) - _verify_response_or_raise(response) - return await response.json() - - except TimeoutError as exception: - msg = f"Timeout error fetching information - {exception}" - raise IntegrationBlueprintApiClientCommunicationError( - msg, - ) from exception - except (aiohttp.ClientError, socket.gaierror) as exception: - msg = f"Error fetching information - {exception}" - raise IntegrationBlueprintApiClientCommunicationError( - msg, - ) from exception - except Exception as exception: # pylint: disable=broad-except - msg = f"Something really wrong happened! - {exception}" - raise IntegrationBlueprintApiClientError( - msg, - ) from exception +def _read_flag(register_value: int, index: int) -> bool: + """Validate if bit at provided index is set.""" + return (register_value >> index) & 0x01 == 0x01 diff --git a/custom_components/askoheat/binary_sensor.py b/custom_components/askoheat/binary_sensor.py index c3c1710..544e2df 100644 --- a/custom_components/askoheat/binary_sensor.py +++ b/custom_components/askoheat/binary_sensor.py @@ -5,57 +5,73 @@ from typing import TYPE_CHECKING from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, + ENTITY_ID_FORMAT, BinarySensorEntity, - BinarySensorEntityDescription, ) +from homeassistant.core import callback -from .entity import IntegrationBlueprintEntity +from custom_components.askoheat.binary_sensor_entities_ema import ( + EMA_BINARY_SENSOR_ENTITY_DESCRIPTIONS, +) +from custom_components.askoheat.const import LOGGER +from custom_components.askoheat.data import AskoheatEMAData + +from .entity import AskoheatEntity if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .coordinator import BlueprintDataUpdateCoordinator - from .data import IntegrationBlueprintConfigEntry + from custom_components.askoheat.model import AskoheatBinarySensorEntityDescription -ENTITY_DESCRIPTIONS = ( - BinarySensorEntityDescription( - key="askoheat", - name="Integration Blueprint Binary Sensor", - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), -) + from .coordinator import AskoheatDataUpdateCoordinator + from .data import AskoheatConfigEntry async def async_setup_entry( hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` - entry: IntegrationBlueprintConfigEntry, + entry: AskoheatConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the binary_sensor platform.""" async_add_entities( - IntegrationBlueprintBinarySensor( - coordinator=entry.runtime_data.coordinator, + AskoheatBinarySensor( + coordinator=entry.runtime_data.ema_coordinator, entity_description=entity_description, ) - for entity_description in ENTITY_DESCRIPTIONS + for entity_description in EMA_BINARY_SENSOR_ENTITY_DESCRIPTIONS ) -class IntegrationBlueprintBinarySensor(IntegrationBlueprintEntity, BinarySensorEntity): +class AskoheatBinarySensor(AskoheatEntity, BinarySensorEntity): """askoheat binary_sensor class.""" + entity_description: AskoheatBinarySensorEntityDescription + def __init__( self, - coordinator: BlueprintDataUpdateCoordinator, - entity_description: BinarySensorEntityDescription, + coordinator: AskoheatDataUpdateCoordinator, + entity_description: AskoheatBinarySensorEntityDescription, ) -> None: """Initialize the binary_sensor class.""" super().__init__(coordinator) self.entity_description = 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 - @property - def is_on(self) -> bool: - """Return true if the binary_sensor is on.""" - return self.coordinator.data.get("title", "") == "foo" + self._attr_state = data[self.entity_description.data_key] + if self.entity_description.inverted: + self._attr_is_on = self._attr_state != self.entity_description.on_state + else: + self._attr_is_on = self._attr_state == self.entity_description.on_state or ( + self.entity_description.on_states is not None + and self._attr_state in self.entity_description.on_states + ) + self.async_write_ha_state() diff --git a/custom_components/askoheat/binary_sensor_entities_ema.py b/custom_components/askoheat/binary_sensor_entities_ema.py new file mode 100644 index 0000000..6b24aee --- /dev/null +++ b/custom_components/askoheat/binary_sensor_entities_ema.py @@ -0,0 +1,83 @@ +"""Predefined energy managementt askoheat sensors.""" + +from custom_components.askoheat.const import BinarySensorEMAAttrKey +from custom_components.askoheat.model import AskoheatBinarySensorEntityDescription + +EMA_BINARY_SENSOR_ENTITY_DESCRIPTIONS = ( + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.HEATER1_ACTIVE, + translation_key=BinarySensorEMAAttrKey.HEATER1_ACTIVE, + icon="mdi:power-plug", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.HEATER2_ACTIVE, + translation_key=BinarySensorEMAAttrKey.HEATER2_ACTIVE, + icon="mdi:power-plug", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.HEATER3_ACTIVE, + translation_key=BinarySensorEMAAttrKey.HEATER3_ACTIVE, + icon="mdi:power-plug", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.PUMP_ACTIVE, + translation_key=BinarySensorEMAAttrKey.PUMP_ACTIVE, + icon="mdi:pump", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.RELAY_BOARD_CONNECTED, + translation_key=BinarySensorEMAAttrKey.RELAY_BOARD_CONNECTED, + icon="mdi:connection", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.HEAT_PUMP_REQUEST_ACTIVE, + translation_key=BinarySensorEMAAttrKey.HEAT_PUMP_REQUEST_ACTIVE, + icon="mdi:heat_pump", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.EMERGENCY_MODE_ACTIVE, + translation_key=BinarySensorEMAAttrKey.EMERGENCY_MODE_ACTIVE, + icon="mdi:car-emergency", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.LEGIONELLA_PROTECTION_ACTIVE, + translation_key=BinarySensorEMAAttrKey.LEGIONELLA_PROTECTION_ACTIVE, + icon="mdi:shield-sun", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.ANALOG_INPUT_ACTIVE, + translation_key=BinarySensorEMAAttrKey.ANALOG_INPUT_ACTIVE, + icon="mdi:sine-wave", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.SETPOINT_ACTIVE, + translation_key=BinarySensorEMAAttrKey.SETPOINT_ACTIVE, + icon="mdi:finance", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.LOAD_FEEDIN_ACTIVE, + translation_key=BinarySensorEMAAttrKey.LOAD_FEEDIN_ACTIVE, + icon="mdi:solar-power", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.AUTOHEATER_OFF_ACTIVE, + translation_key=BinarySensorEMAAttrKey.AUTOHEATER_OFF_ACTIVE, + icon="mdi:water-boiler-auto", + inverted=True, + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.PUMP_RELAY_FOLLOW_UP_TIME_ACTIVE, + translation_key=BinarySensorEMAAttrKey.PUMP_RELAY_FOLLOW_UP_TIME_ACTIVE, + icon="mdi:water-boiler-auto", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.TEMP_LIMIT_REACHED, + translation_key=BinarySensorEMAAttrKey.TEMP_LIMIT_REACHED, + icon="mdi:water-boiler-auto", + ), + AskoheatBinarySensorEntityDescription( + key=BinarySensorEMAAttrKey.ERROR_OCCURED, + translation_key=BinarySensorEMAAttrKey.ERROR_OCCURED, + icon="mdi:water-thermometer", + ), +) diff --git a/custom_components/askoheat/config_flow.py b/custom_components/askoheat/config_flow.py index 601089b..70f6890 100644 --- a/custom_components/askoheat/config_flow.py +++ b/custom_components/askoheat/config_flow.py @@ -4,20 +4,34 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers import selector -from homeassistant.helpers.aiohttp_client import async_create_clientsession from .api import ( - IntegrationBlueprintApiClient, - IntegrationBlueprintApiClientAuthenticationError, - IntegrationBlueprintApiClientCommunicationError, - IntegrationBlueprintApiClientError, + AskoHeatModbusApiClient, + AskoheatModbusApiClientCommunicationError, + AskoheatModbusApiClientError, +) +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN, LOGGER + +PORT_SELECTOR = vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, step=1, max=65535, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): PORT_SELECTOR, + } ) -from .const import DOMAIN, LOGGER -class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class AskoheatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Blueprint.""" VERSION = 1 @@ -30,52 +44,30 @@ async def async_step_user( _errors = {} if user_input is not None: try: - await self._test_credentials( - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], + await self._test_connection( + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], ) - except IntegrationBlueprintApiClientAuthenticationError as exception: - LOGGER.warning(exception) - _errors["base"] = "auth" - except IntegrationBlueprintApiClientCommunicationError as exception: + except AskoheatModbusApiClientCommunicationError as exception: LOGGER.error(exception) _errors["base"] = "connection" - except IntegrationBlueprintApiClientError as exception: + except AskoheatModbusApiClientError as exception: LOGGER.exception(exception) _errors["base"] = "unknown" else: return self.async_create_entry( - title=user_input[CONF_USERNAME], + title=user_input[CONF_HOST], data=user_input, ) return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_USERNAME, - default=(user_input or {}).get(CONF_USERNAME, vol.UNDEFINED), - ): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.TEXT, - ), - ), - vol.Required(CONF_PASSWORD): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.PASSWORD, - ), - ), - }, - ), + data_schema=STEP_USER_DATA_SCHEMA, errors=_errors, ) - async def _test_credentials(self, username: str, password: str) -> None: - """Validate credentials.""" - client = IntegrationBlueprintApiClient( - username=username, - password=password, - session=async_create_clientsession(self.hass), - ) - await client.async_get_data() + async def _test_connection(self, host: str, port: int) -> None: + """Validate connection settings.""" + client = AskoHeatModbusApiClient(host=host, port=port) + await client.connect() + client.close() diff --git a/custom_components/askoheat/const.py b/custom_components/askoheat/const.py index 9264441..38389f9 100644 --- a/custom_components/askoheat/const.py +++ b/custom_components/askoheat/const.py @@ -1,8 +1,66 @@ """Constants for askoheat.""" +from datetime import timedelta +from enum import StrEnum from logging import Logger, getLogger LOGGER: Logger = getLogger(__package__) DOMAIN = "askoheat" ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" + +DEFAULT_HOST = "askoheat.local" +DEFAULT_PORT = 502 +DEFAULT_SCAN_INTERVAL = 5 + +# per coordinator scan intervals +SCAN_INTERVAL_EMA = timedelta(seconds=5) +SCAN_INTERVAL_CONFIG = timedelta(hours=1) +SCAN_INTERVAL_DATA = timedelta(minutes=1) + + +class SwitchEMAAttrKey(StrEnum): + """Askoheat EMA binary switch attribute keys.""" + + SET_HEATER_STEP_HEATER1 = "set_heater_step_heater1" + SET_HEATER_STEP_HEATER2 = "set_heater_step_heater2" + SET_HEATER_STEP_HEATER3 = "set_heater_step_heater3" + + +class BinarySensorEMAAttrKey(StrEnum): + """Askoheat EMA binary sensor attribute keys.""" + + # from status register + HEATER1_ACTIVE = "status.heater1" + HEATER2_ACTIVE = "status.heater2" + HEATER3_ACTIVE = "status.heater3" + PUMP_ACTIVE = "status.pump" + RELAY_BOARD_CONNECTED = "status.relay_board_connected" + EMERGENCY_MODE_ACTIVE = "status.emergency_mode" + HEAT_PUMP_REQUEST_ACTIVE = "status.heat_pump_request" + LEGIONELLA_PROTECTION_ACTIVE = "status.legionella_protection" + ANALOG_INPUT_ACTIVE = "status.analog_input" + SETPOINT_ACTIVE = "status.setpoint" + LOAD_FEEDIN_ACTIVE = "status.load_feedin" + AUTOHEATER_OFF_ACTIVE = "status.autoheater_off" + PUMP_RELAY_FOLLOW_UP_TIME_ACTIVE = "status.pump_relay_follow_up_time_active" + TEMP_LIMIT_REACHED = "status.temp_limit_reached" + ERROR_OCCURED = "status.error" + + +class SensorEMAAttrKey(StrEnum): + """Askoheat EMA sensor attribute keys.""" + + # 250-30000 watt + HEATER_LOAD = "heater_load" + # 250-30000 watt + LOAD_SETPOINT_VALUE = "load_setpoint" + # -30000-30000 watt + LOAD_FEEDIN_VALUE = "load_feedin" + # 0-10V + ANALOG_INPUT_VALUE = "analog_input" + INTERNAL_TEMPERATUR_SENSOR_VALUE = "internal_temp_sensor" + EXTERNAL_TEMPERATUR_SENSOR1_VALUE = "external_temp_sensor1" + EXTERNAL_TEMPERATUR_SENSOR2_VALUE = "external_temp_sensor2" + EXTERNAL_TEMPERATUR_SENSOR3_VALUE = "external_temp_sensor3" + EXTERNAL_TEMPERATUR_SENSOR4_VALUE = "external_temp_sensor4" diff --git a/custom_components/askoheat/coordinator.py b/custom_components/askoheat/coordinator.py index b9cd908..1d961cb 100644 --- a/custom_components/askoheat/coordinator.py +++ b/custom_components/askoheat/coordinator.py @@ -2,47 +2,70 @@ from __future__ import annotations -from datetime import timedelta from typing import TYPE_CHECKING, Any -from homeassistant.exceptions import ConfigEntryAuthFailed +import async_timeout from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( - IntegrationBlueprintApiClientAuthenticationError, - IntegrationBlueprintApiClientError, + AskoheatModbusApiClientError, ) -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, SCAN_INTERVAL_EMA if TYPE_CHECKING: + from datetime import timedelta + from homeassistant.core import HomeAssistant - from .data import IntegrationBlueprintConfigEntry + from custom_components.askoheat.data import AskoheatEMAData + + from .data import AskoheatConfigEntry # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities -class BlueprintDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the API.""" +class AskoheatDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching state of askoheat through a single API call.""" - config_entry: IntegrationBlueprintConfigEntry + config_entry: AskoheatConfigEntry - def __init__( - self, - hass: HomeAssistant, - ) -> None: + def __init__(self, hass: HomeAssistant, scan_interval: timedelta) -> None: """Initialize.""" super().__init__( hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(hours=1), + update_interval=scan_interval, + # Set always_update to `False` if the data returned from the + # api can be compared via `__eq__` to avoid duplicate updates + # being dispatched to listeners + always_update=True, ) - async def _async_update_data(self) -> Any: - """Update data via library.""" + +class AskoheatEMADataUpdateCoordinator(AskoheatDataUpdateCoordinator): + """Class to manage fetching askoheat energymanager states.""" + + config_entry: AskoheatConfigEntry + data: AskoheatEMAData + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize.""" + super().__init__(hass=hass, scan_interval=SCAN_INTERVAL_EMA) + + async def _async_update_data(self) -> dict[str, Any]: + """Update ema data via library.""" try: - return await self.config_entry.runtime_data.client.async_get_data() - except IntegrationBlueprintApiClientAuthenticationError as exception: - raise ConfigEntryAuthFailed(exception) from exception - except IntegrationBlueprintApiClientError as exception: + async with async_timeout.timeout(10): + data = await self.config_entry.runtime_data.client.async_read_ema_data() + result: dict[str, Any] = {} + result.update( + { + f"binary_sensor.{k}": data.binary_sensors[k] + for k in data.binary_sensors + } + ) + result.update({f"sensor.{k}": data.sensors[k] for k in data.sensors}) + result.update({f"switch.{k}": data.switches[k] for k in data.switches}) + return result + except AskoheatModbusApiClientError as exception: raise UpdateFailed(exception) from exception diff --git a/custom_components/askoheat/data.py b/custom_components/askoheat/data.py index 304a8b0..a3644f2 100644 --- a/custom_components/askoheat/data.py +++ b/custom_components/askoheat/data.py @@ -5,21 +5,37 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from custom_components.askoheat.const import ( + BinarySensorEMAAttrKey, + SensorEMAAttrKey, + SwitchEMAAttrKey, +) + if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.loader import Integration - from .api import IntegrationBlueprintApiClient - from .coordinator import BlueprintDataUpdateCoordinator + from custom_components.askoheat.coordinator import AskoheatEMADataUpdateCoordinator + + from .api import AskoHeatModbusApiClient -type IntegrationBlueprintConfigEntry = ConfigEntry[IntegrationBlueprintData] +type AskoheatConfigEntry = ConfigEntry[AskoheatData] @dataclass -class IntegrationBlueprintData: - """Data for the Blueprint integration.""" +class AskoheatData: + """Data for the Askoheat integration.""" - client: IntegrationBlueprintApiClient - coordinator: BlueprintDataUpdateCoordinator + client: AskoHeatModbusApiClient + ema_coordinator: AskoheatEMADataUpdateCoordinator integration: Integration + + +@dataclass +class AskoheatEMAData: + """Data returnes when querying EMA attributes of askoheat.""" + + binary_sensors: dict[BinarySensorEMAAttrKey, bool] + sensors: dict[SensorEMAAttrKey, object] + switches: dict[SwitchEMAAttrKey, bool] diff --git a/custom_components/askoheat/entity.py b/custom_components/askoheat/entity.py index dd75c31..7098715 100644 --- a/custom_components/askoheat/entity.py +++ b/custom_components/askoheat/entity.py @@ -6,15 +6,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION -from .coordinator import BlueprintDataUpdateCoordinator +from .coordinator import AskoheatDataUpdateCoordinator -class IntegrationBlueprintEntity(CoordinatorEntity[BlueprintDataUpdateCoordinator]): - """BlueprintEntity class.""" +class AskoheatEntity(CoordinatorEntity[AskoheatDataUpdateCoordinator]): + """AskoheatEntity class.""" _attr_attribution = ATTRIBUTION - def __init__(self, coordinator: BlueprintDataUpdateCoordinator) -> None: + def __init__(self, coordinator: AskoheatDataUpdateCoordinator) -> None: """Initialize.""" super().__init__(coordinator) self._attr_unique_id = coordinator.config_entry.entry_id diff --git a/custom_components/askoheat/model.py b/custom_components/askoheat/model.py new file mode 100644 index 0000000..0c12758 --- /dev/null +++ b/custom_components/askoheat/model.py @@ -0,0 +1,77 @@ +"""The Askoheat models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import cached_property +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import BinarySensorEntityDescription +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.switch import SwitchEntityDescription +from homeassistant.const import Platform + +from custom_components.askoheat.const import DOMAIN + +if TYPE_CHECKING: + from custom_components.askoheat.const import ( + BinarySensorEMAAttrKey, + SensorEMAAttrKey, + SwitchEMAAttrKey, + ) + + +@dataclass(frozen=True) +class AskoheatBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Askoheat binary sensor entities.""" + + key: BinarySensorEMAAttrKey + platform = Platform.BINARY_SENSOR + on_state: str | bool = True + on_states: list[str] | None = None + off_state: str | bool = False + inverted: bool = False + domain = DOMAIN + + @cached_property + def data_key(self) -> str: + """Get data key.""" + return f"binary_sensor.{self.key}" + + +@dataclass(frozen=True) +class AskoheatSwitchEntityDescription( + SwitchEntityDescription, +): + """Class describing Askoheat switch entities.""" + + key: SwitchEMAAttrKey + platform = Platform.SWITCH + on_state: str | bool = True + on_states: list[str] | None = None + off_state: str | bool = False + inverted = False + domain = DOMAIN + + @cached_property + def data_key(self) -> str: + """Get data key.""" + return f"switch.{self.key}" + + +@dataclass(frozen=True) +class AskoheatSensorEntityDescription( + SensorEntityDescription, +): + """Class describing Askoheat sensor entities.""" + + key: SensorEMAAttrKey + platform = Platform.SENSOR + factor: float | None = None + native_precision: int | None = None + domain = DOMAIN + + @cached_property + def data_key(self) -> str: + """Get data key.""" + return f"sensor.{self.key}" diff --git a/custom_components/askoheat/sensor.py b/custom_components/askoheat/sensor.py index f6f0c95..b7d2e04 100644 --- a/custom_components/askoheat/sensor.py +++ b/custom_components/askoheat/sensor.py @@ -4,54 +4,82 @@ from typing import TYPE_CHECKING -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +import numpy as np +from homeassistant.components.sensor import ENTITY_ID_FORMAT, SensorEntity +from homeassistant.core import callback -from .entity import IntegrationBlueprintEntity +from custom_components.askoheat.sensor_entities_ema import ( + EMA_SENSOR_ENTITY_DESCRIPTIONS, +) + +from .entity import AskoheatEntity if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .coordinator import BlueprintDataUpdateCoordinator - from .data import IntegrationBlueprintConfigEntry + from custom_components.askoheat.model import AskoheatSensorEntityDescription -ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( - key="askoheat", - name="Integration Sensor", - icon="mdi:format-quote-close", - ), -) + from .coordinator import AskoheatDataUpdateCoordinator + from .data import AskoheatConfigEntry async def async_setup_entry( hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` - entry: IntegrationBlueprintConfigEntry, + entry: AskoheatConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" async_add_entities( - IntegrationBlueprintSensor( - coordinator=entry.runtime_data.coordinator, + AskoheatSensor( + coordinator=entry.runtime_data.ema_coordinator, entity_description=entity_description, ) - for entity_description in ENTITY_DESCRIPTIONS + for entity_description in EMA_SENSOR_ENTITY_DESCRIPTIONS ) -class IntegrationBlueprintSensor(IntegrationBlueprintEntity, SensorEntity): +class AskoheatSensor(AskoheatEntity, SensorEntity): """askoheat Sensor class.""" + entity_description: AskoheatSensorEntityDescription + def __init__( self, - coordinator: BlueprintDataUpdateCoordinator, - entity_description: SensorEntityDescription, + coordinator: AskoheatDataUpdateCoordinator, + entity_description: AskoheatSensorEntityDescription, ) -> None: """Initialize the sensor class.""" super().__init__(coordinator) self.entity_description = 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] + + if self._attr_native_value is None: + pass + + elif isinstance( + self._attr_native_value, float | int | np.floating | np.integer + ) and ( + self.entity_description.factor is not None + or self.entity_description.native_precision is not None + ): + float_value = float(self._attr_native_value) + if self.entity_description.factor is not None: + float_value *= self.entity_description.factor + if self.entity_description.native_precision is not None: + float_value = round( + float_value, self.entity_description.native_precision + ) + self._attr_native_value = float_value - @property - def native_value(self) -> str | None: - """Return the native value of the sensor.""" - return self.coordinator.data.get("body") + self.async_write_ha_state() diff --git a/custom_components/askoheat/sensor_entities_ema.py b/custom_components/askoheat/sensor_entities_ema.py new file mode 100644 index 0000000..27983f5 --- /dev/null +++ b/custom_components/askoheat/sensor_entities_ema.py @@ -0,0 +1,90 @@ +"""Predefined energy managementt askoheat sensors.""" + +from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfElectricPotential, UnitOfPower, UnitOfTemperature + +from custom_components.askoheat.const import SensorEMAAttrKey +from custom_components.askoheat.model import AskoheatSensorEntityDescription + +EMA_SENSOR_ENTITY_DESCRIPTIONS = ( + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.ANALOG_INPUT_VALUE, + translation_key=SensorEMAAttrKey.ANALOG_INPUT_VALUE, + icon="mdi:gauge", + native_precision=0, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.HEATER_LOAD, + translation_key=SensorEMAAttrKey.HEATER_LOAD, + icon="mdi:lightning-bold", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.LOAD_FEEDIN_VALUE, + translation_key=SensorEMAAttrKey.LOAD_FEEDIN_VALUE, + icon="mdi:solar-power", + native_precision=0, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.LOAD_SETPOINT_VALUE, + translation_key=SensorEMAAttrKey.LOAD_SETPOINT_VALUE, + icon="mdi:lightning-bolt", + native_precision=0, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.INTERNAL_TEMPERATUR_SENSOR_VALUE, + translation_key=SensorEMAAttrKey.INTERNAL_TEMPERATUR_SENSOR_VALUE, + icon="mdi:thermometer", + native_precision=1, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR1_VALUE, + translation_key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR1_VALUE, + icon="mdi:thermometer", + native_precision=1, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR2_VALUE, + translation_key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR2_VALUE, + icon="mdi:thermometer", + native_precision=1, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR3_VALUE, + translation_key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR3_VALUE, + icon="mdi:thermometer", + native_precision=1, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + AskoheatSensorEntityDescription( + key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR4_VALUE, + translation_key=SensorEMAAttrKey.EXTERNAL_TEMPERATUR_SENSOR4_VALUE, + icon="mdi:thermometer", + native_precision=1, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), +) diff --git a/custom_components/askoheat/switch.py b/custom_components/askoheat/switch.py index 22ad3b3..86da492 100644 --- a/custom_components/askoheat/switch.py +++ b/custom_components/askoheat/switch.py @@ -6,14 +6,14 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from .entity import IntegrationBlueprintEntity +from .entity import AskoheatEntity if TYPE_CHECKING: from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .coordinator import BlueprintDataUpdateCoordinator - from .data import IntegrationBlueprintConfigEntry + from .coordinator import AskoheatDataUpdateCoordinator + from .data import AskoheatConfigEntry ENTITY_DESCRIPTIONS = ( SwitchEntityDescription( @@ -26,25 +26,25 @@ async def async_setup_entry( hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` - entry: IntegrationBlueprintConfigEntry, + entry: AskoheatConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the switch platform.""" - async_add_entities( - IntegrationBlueprintSwitch( - coordinator=entry.runtime_data.coordinator, - entity_description=entity_description, - ) - for entity_description in ENTITY_DESCRIPTIONS - ) + # async_add_entities( + # IntegrationBlueprintSwitch( + # coordinator=entry.runtime_data.coordinator, + # entity_description=entity_description, + # ) + # for entity_description in ENTITY_DESCRIPTIONS + # ) -class IntegrationBlueprintSwitch(IntegrationBlueprintEntity, SwitchEntity): +class IntegrationBlueprintSwitch(AskoheatEntity, SwitchEntity): """askoheat switch class.""" def __init__( self, - coordinator: BlueprintDataUpdateCoordinator, + coordinator: AskoheatDataUpdateCoordinator, entity_description: SwitchEntityDescription, ) -> None: """Initialize the switch class.""" @@ -58,10 +58,10 @@ def is_on(self) -> bool: async def async_turn_on(self, **_: Any) -> None: """Turn on the switch.""" - await self.coordinator.config_entry.runtime_data.client.async_set_title("bar") + # await self.coordinator.config_entry.runtime_data.client.async_set_title("bar") await self.coordinator.async_request_refresh() async def async_turn_off(self, **_: Any) -> None: """Turn off the switch.""" - await self.coordinator.config_entry.runtime_data.client.async_set_title("foo") + # await self.coordinator.config_entry.runtime_data.client.async_set_title("foo") await self.coordinator.async_request_refresh()