From b768fa48d2db1cd1983453186c31261114328a89 Mon Sep 17 00:00:00 2001 From: Ilia Sotnikov Date: Mon, 9 Sep 2024 01:59:28 +0300 Subject: [PATCH 1/4] feat: Pseudo-sensor reflecting the duration of the meter cycle * Added `CYCLE_DURATION` pseudo-sensor (under `diagnostic` category) that reflects the duration in milliseconds of the meter cycle - that is, how long it took to process all parameters configured * Added `entity_category` field to items under `parameters` section of the configuration file --- README.rst | 6 +- pyproject.toml | 5 ++ requirements_dev.txt | 1 + src/energomera_hass_mqtt/extra_sensors.py | 79 ++++++++++----------- src/energomera_hass_mqtt/hass_mqtt.py | 30 +++++++- src/energomera_hass_mqtt/iec_hass_sensor.py | 15 ++-- src/energomera_hass_mqtt/schema.py | 3 +- tests/conftest.py | 37 ++++++++++ 8 files changed, 121 insertions(+), 55 deletions(-) diff --git a/README.rst b/README.rst index 4aa60a3..d119a81 100644 --- a/README.rst +++ b/README.rst @@ -124,6 +124,8 @@ Configuration file is in YAML format and supports following elements: # (number) - optional: Zero-based index to pick an entry from # multi-value response to meter's parameter response_idx: + # (string) - optional: Category of the HASS sensor entity + entity_category: Interpolation expressions @@ -167,7 +169,7 @@ directory. Docker support ============== -There are Docker images available if you would like to run it as Docker container - you could use +There are Docker images available if you would like to run it as Docker container - you could use ``ghcr.io/hostcc/energomera-hass-mqtt:latest`` or ``ghcr.io/hostcc/energomera-hass-mqtt:``. @@ -187,7 +189,7 @@ Then, assuming the directory is called ``config`` and resides relative to current directory, and the serial port the meter is connected to is ``/dev/ttyUSB0`` the following command will run it -.. code:: +.. code:: $ docker run --device /dev/ttyUSB0 -v `pwd`/config:/etc/energomera/ \ ghcr.io/hostcc/energomera-hass-mqtt:latest diff --git a/pyproject.toml b/pyproject.toml index fa27538..bacc44c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,3 +36,8 @@ signature-mutators = [ # No typing support in the module module = "iec62056_21.*" ignore_missing_imports = true + +[[tool.mypy.overrides]] +# No typing support in the module +module = "callee.*" +ignore_missing_imports = true diff --git a/requirements_dev.txt b/requirements_dev.txt index 716befe..9c10ec6 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -9,3 +9,4 @@ freezegun==1.5.1 mypy==1.11.2 types-python-dateutil==2.8.19.14 types-PyYAML==6.0.12 +callee==0.3.1 diff --git a/src/energomera_hass_mqtt/extra_sensors.py b/src/energomera_hass_mqtt/extra_sensors.py index 780d61e..34bb433 100644 --- a/src/energomera_hass_mqtt/extra_sensors.py +++ b/src/energomera_hass_mqtt/extra_sensors.py @@ -22,61 +22,58 @@ Package to provide additional sensors on top of `IecToHassSensor`. """ from __future__ import annotations -from typing import Dict, Union, Any +from typing import Any, Generic, TypeVar, Optional +from iec62056_21.messages import DataSet as IecDataSet from .iec_hass_sensor import IecToHassSensor +T = TypeVar('T') -class IecToHassBinarySensor(IecToHassSensor): + +class PseudoSensor(IecToHassSensor, Generic[T]): """ - Represents HASS binary sensor. + Represents a pseudo-sensor, i.e. one doesn't exist on meter. """ - _mqtt_topic_base = 'binary_sensor' + def __init__(self, value: T, *args: Any, **kwargs: Any): + self._state_last_will_payload: Optional[T] = None + # Invoke the parent constructor providing `iec_item` from the + # pseudo-sensor value + kwargs['iec_item'] = [ + IecDataSet(value=PseudoSensor._format_value(value)) + ] + super().__init__(*args, **kwargs) - def hass_state_payload( - self, value: Union[str, str] - ) -> Dict[str, str]: # pylint: disable=no-self-use + @staticmethod + def _format_value(value: T) -> Optional[str]: """ - Transforms the binary sensor payload to the format understood by HASS - MQTT discovery for the sensor type. + Formats the sensor's value according to its type. :param value: The sensor value + :return: The formatted value """ + result = None if isinstance(value, bool): - b_value = value - elif isinstance(value, str): - b_value = value.lower() == 'true' + result = 'ON' if value else 'OFF' else: - raise TypeError( - f'Unsupported argument type to {__name__}: {type(value)}' - ) - - return dict( - value='ON' if b_value else 'OFF' - ) - + result = str(value) + return result -class PseudoBinarySensor(IecToHassBinarySensor): - """ - Represents a pseudo-sensor, i.e. one doesn't exist on meter. + @property + def state_last_will_payload(self) -> Optional[str]: + """ + Stores the value of the last will payload for the item, i.e. sent by + the MQTT broker if the client disconnects uncleanly. - :param value: The sensor value - :param args: Parameters passed through to parent constructor - :param kwargs: Keyword parameters passed through to parent constructor - """ - def __init__(self, value: bool, *args: Any, **kwargs: Any): - class PseudoValue: # pylint: disable=too-few-public-methods - """ - Mimics `class`:iec62056_21.messages.AnswerDataMessage to store the - sensor value in a conmpatible manner. + :param value: Value for last will payload + """ + return PseudoSensor._format_value(self._state_last_will_payload) - :param value: Sensor value - """ - value = None + @state_last_will_payload.setter + def state_last_will_payload(self, value: T) -> None: + self._state_last_will_payload = value - def __init__(self, value: bool) -> None: - self.value = value - # Invoke the parent constructor providing `iec_item` from the - # pseudo-sensor value - kwargs['iec_item'] = [PseudoValue(value)] - super().__init__(*args, **kwargs) +class PseudoBinarySensor(PseudoSensor[bool]): + """ + Represents a binary pseudo-sensor. + """ + _mqtt_topic_base = 'binary_sensor' diff --git a/src/energomera_hass_mqtt/hass_mqtt.py b/src/energomera_hass_mqtt/hass_mqtt.py index c37cf84..60e8358 100644 --- a/src/energomera_hass_mqtt/hass_mqtt.py +++ b/src/energomera_hass_mqtt/hass_mqtt.py @@ -27,6 +27,7 @@ import logging import ssl from os import getenv +from time import time from iec62056_21.messages import CommandMessage, DataSet as IecDataSet from iec62056_21.client import Iec6205621Client @@ -34,7 +35,7 @@ from iec62056_21 import utils from .mqtt_client import MqttClient from .iec_hass_sensor import IecToHassSensor -from .extra_sensors import PseudoBinarySensor +from .extra_sensors import PseudoBinarySensor, PseudoSensor from .schema import ConfigParameterSchema from .exceptions import EnergomeraMeterError if TYPE_CHECKING: @@ -177,6 +178,7 @@ async def iec_read_admin(self) -> None: Primary method to loop over the parameters requested and process them. """ + start = time() try: _LOGGER.debug('Opening connection with meter') self._client.connect() @@ -221,12 +223,14 @@ async def iec_read_admin(self) -> None: # End the session _LOGGER.debug('Closing session with meter') self._client.send_break() - except TimeoutError as exc: await self.set_online_sensor(False) raise exc else: await self.set_online_sensor(True) + duration = time() - start + _LOGGER.debug('Cycle duration: %s ms', duration) + await self.set_duration_sensor(duration) finally: # Disconnect serial client ignoring possible # exceptions - it might have not been connected yet @@ -278,5 +282,25 @@ async def set_online_sensor( serial_number=self._serial_number ) # Set the last will of the MQTT client to the `state=False` - hass_item.set_state_last_will_payload(value=False) + hass_item.state_last_will_payload = False await hass_item.process(setup_only) + + async def set_duration_sensor(self, value: float) -> None: + """ + Adds a pseudo-sensor to HASS reflecting the duration of the meter + cycle. + """ + # Add a pseudo-sensor + param = ConfigParameterSchema( + address='CYCLE_DURATION', + name='Meter cycle duration', + unit='ms', + entity_category='diagnostic', + ) + hass_item = PseudoSensor[float]( + mqtt_config=self._config.of.mqtt, + mqtt_client=self._mqtt_client, config_param=param, + value=value, model=self._model, sw_version=self._sw_version, + serial_number=self._serial_number + ) + await hass_item.process() diff --git a/src/energomera_hass_mqtt/iec_hass_sensor.py b/src/energomera_hass_mqtt/iec_hass_sensor.py index 565fe95..15aa8e6 100644 --- a/src/energomera_hass_mqtt/iec_hass_sensor.py +++ b/src/energomera_hass_mqtt/iec_hass_sensor.py @@ -75,16 +75,15 @@ def __init__( # pylint: disable=too-many-arguments self._model = model self._sw_version = sw_version self._serial_number = serial_number - self._state_last_will_payload: Optional[bool] = None - def set_state_last_will_payload(self, value: bool) -> None: + @property + def state_last_will_payload(self) -> Optional[str]: """ - Stores there value of the last will payload for the item, i.e. sent by - the MQTT broker if the client disconnects uncleanly. + Stores value of last will payload to be set for MQTT client. - :param value: Value for last will payload + Should be implemented by subclasses. """ - self._state_last_will_payload = value + return None @property def iec_item(self) -> List[IecDataSet]: @@ -245,9 +244,9 @@ async def process(self, setup_only: bool = False) -> None: " at '%s' address", self._hass_item_name, self._config_param.address) # Set last will for MQTT if specified for the item - if self._state_last_will_payload is not None: + if self.state_last_will_payload is not None: will_payload = self.hass_state_payload( - value=str(self._state_last_will_payload) + value=self.state_last_will_payload ) json_will_payload = json.dumps(will_payload) diff --git a/src/energomera_hass_mqtt/schema.py b/src/energomera_hass_mqtt/schema.py index d4ca3e7..5a97a75 100644 --- a/src/energomera_hass_mqtt/schema.py +++ b/src/energomera_hass_mqtt/schema.py @@ -89,12 +89,13 @@ class ConfigParameterSchema(BaseModel): """ address: str name: Union[str, List[str]] - device_class: str + device_class: Optional[str] = None state_class: Optional[str] = None unit: Optional[str] = None additional_data: Optional[str] = None entity_name: Optional[str] = None response_idx: Optional[int] = None + entity_category: Optional[str] = None class ConfigSchema(BaseModel): diff --git a/tests/conftest.py b/tests/conftest.py index f8f67b3..540c06f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,7 @@ import sys from functools import reduce from unittest.mock import patch, call, DEFAULT, AsyncMock, Mock +from callee import Regex as CallRegexMatcher import pytest from pytest import FixtureRequest import iec62056_21.transports @@ -36,6 +37,15 @@ MockMqttT = Dict[str, Mock] MockSerialT = Dict[str, Mock] + +def re_match(pattern: str) -> str: + ''' + Helper function to match regular expressions in mock calls. + ''' + print(f'called: {str}') + return pattern + + SERIAL_EXCHANGE_BASE = [ { 'receive_bytes': b'/?!\r\n', @@ -875,6 +885,33 @@ '/CE301_00123456_IS_ONLINE/state', payload=json.dumps({'value': 'ON'}), ), + call( + topic='homeassistant/sensor/CE301_00123456/' + 'CE301_00123456_CYCLE_DURATION/config', + payload=json.dumps( + { + 'name': 'Meter cycle duration', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12' + }, + 'unique_id': 'CE301_00123456_CYCLE_DURATION', + 'object_id': 'CE301_00123456_CYCLE_DURATION', + 'unit_of_measurement': 'ms', + 'state_topic': 'homeassistant/sensor/CE301_00123456/' + 'CE301_00123456_CYCLE_DURATION/state', + 'value_template': '{{ value_json.value }}' + } + ), + retain=True + ), + call( + topic='homeassistant/sensor/CE301_00123456/' + 'CE301_00123456_CYCLE_DURATION/state', + payload=CallRegexMatcher('{"value": "[0-9.]+"}'), + ), call( topic='homeassistant/binary_sensor/CE301_00123456' '/CE301_00123456_IS_ONLINE/state', From bfe155bdbf41743cc329afaa5389e65564739b1a Mon Sep 17 00:00:00 2001 From: Ilia Sotnikov Date: Mon, 9 Sep 2024 02:04:29 +0300 Subject: [PATCH 2/4] * `conftest.py`: Removed unused function `re_match` left from some testing. --- tests/conftest.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 540c06f..f1b57d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,14 +38,6 @@ MockSerialT = Dict[str, Mock] -def re_match(pattern: str) -> str: - ''' - Helper function to match regular expressions in mock calls. - ''' - print(f'called: {str}') - return pattern - - SERIAL_EXCHANGE_BASE = [ { 'receive_bytes': b'/?!\r\n', From 0db85f4d5d869d1f3aacb3448f50e9ee876fceb2 Mon Sep 17 00:00:00 2001 From: Ilia Sotnikov Date: Mon, 9 Sep 2024 09:52:28 +0300 Subject: [PATCH 3/4] * Fixed UOM and entity category for cycle duration sensor --- src/energomera_hass_mqtt/hass_mqtt.py | 2 +- src/energomera_hass_mqtt/iec_hass_sensor.py | 1 + tests/conftest.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/energomera_hass_mqtt/hass_mqtt.py b/src/energomera_hass_mqtt/hass_mqtt.py index 60e8358..51bc62a 100644 --- a/src/energomera_hass_mqtt/hass_mqtt.py +++ b/src/energomera_hass_mqtt/hass_mqtt.py @@ -294,7 +294,7 @@ async def set_duration_sensor(self, value: float) -> None: param = ConfigParameterSchema( address='CYCLE_DURATION', name='Meter cycle duration', - unit='ms', + unit='s', entity_category='diagnostic', ) hass_item = PseudoSensor[float]( diff --git a/src/energomera_hass_mqtt/iec_hass_sensor.py b/src/energomera_hass_mqtt/iec_hass_sensor.py index 15aa8e6..2610492 100644 --- a/src/energomera_hass_mqtt/iec_hass_sensor.py +++ b/src/energomera_hass_mqtt/iec_hass_sensor.py @@ -201,6 +201,7 @@ def hass_config_payload( unit_of_measurement=self._config_param.unit, state_class=self._config_param.state_class, state_topic=self._hass_state_topic, + entity_category=self._config_param.entity_category, value_template='{{ value_json.value }}', ) # Skip empty values diff --git a/tests/conftest.py b/tests/conftest.py index f1b57d4..0339af5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -891,9 +891,10 @@ }, 'unique_id': 'CE301_00123456_CYCLE_DURATION', 'object_id': 'CE301_00123456_CYCLE_DURATION', - 'unit_of_measurement': 'ms', + 'unit_of_measurement': 's', 'state_topic': 'homeassistant/sensor/CE301_00123456/' 'CE301_00123456_CYCLE_DURATION/state', + 'entity_category': 'diagnostic', 'value_template': '{{ value_json.value }}' } ), From 02b4011c26578239fa15b50009d6e24944e345b8 Mon Sep 17 00:00:00 2001 From: Ilia Sotnikov Date: Mon, 9 Sep 2024 12:07:51 +0300 Subject: [PATCH 4/4] * Cycle duration sensor: fixed UOM in debug message, which is moved to `set_duration_sensor` method --- src/energomera_hass_mqtt/hass_mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/energomera_hass_mqtt/hass_mqtt.py b/src/energomera_hass_mqtt/hass_mqtt.py index 51bc62a..9ca6280 100644 --- a/src/energomera_hass_mqtt/hass_mqtt.py +++ b/src/energomera_hass_mqtt/hass_mqtt.py @@ -229,7 +229,6 @@ async def iec_read_admin(self) -> None: else: await self.set_online_sensor(True) duration = time() - start - _LOGGER.debug('Cycle duration: %s ms', duration) await self.set_duration_sensor(duration) finally: # Disconnect serial client ignoring possible @@ -290,6 +289,7 @@ async def set_duration_sensor(self, value: float) -> None: Adds a pseudo-sensor to HASS reflecting the duration of the meter cycle. """ + _LOGGER.debug('Cycle duration: %s s', value) # Add a pseudo-sensor param = ConfigParameterSchema( address='CYCLE_DURATION',