Skip to content

Commit

Permalink
Added integration of EMA read registers using separate coordinator
Browse files Browse the repository at this point in the history
  • Loading branch information
toggm committed Nov 7, 2024
1 parent 5290b12 commit c72021f
Show file tree
Hide file tree
Showing 13 changed files with 690 additions and 228 deletions.
35 changes: 18 additions & 17 deletions custom_components/askoheat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -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)
Expand Down
234 changes: 156 additions & 78 deletions custom_components/askoheat/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit c72021f

Please sign in to comment.