From 743b6b36688f7e0ae5438eee4547cfa43d93f0d0 Mon Sep 17 00:00:00 2001 From: Ilia Sotnikov Date: Fri, 28 Apr 2023 08:49:50 +0300 Subject: [PATCH 1/2] * (Re)send the configuration payload once it changes (e.g. due to interpolation). The entities are tracked using hash of JSON formatted configuration payloads sent, and compared for any change to make a decision it should be sent again + `EnergomeraConfig`: introduced `_read_config` method dedicated to reading configuration file, for ease of mocks during testing * Better test organization by moving fixtures `mock_serial`, `mock_mqtt`, `mock_config` to `conftest.py` and making those configurable via `pytest` markers * `EnergomeraConfig` class no longer requires mocking `patch` built-in, since the class now has `_read_config()` method easily mocked * `test_energomera` no longer generates separate tests for each particular MQTT and serial call - those are now validates by single test as the whole flow + Added tests for handling HomeAssistant configuration payloads, especialy for resending those once the payload changes --- pyproject.toml | 3 + src/energomera_hass_mqtt/__init__.py | 35 +- src/energomera_hass_mqtt/config.py | 16 +- tests/test_config.py | 359 ++++---- tests/test_energomera.py | 1126 +------------------------- tests/test_online_sensor.py | 45 +- 6 files changed, 244 insertions(+), 1340 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index be02023..3a9c617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,9 @@ log_cli_level = "error" markers = [ "mqtt_broker_users", + "serial_simulate_timeout", + "serial_exchange", + "config_yaml" ] [tool.pylint.main] diff --git a/src/energomera_hass_mqtt/__init__.py b/src/energomera_hass_mqtt/__init__.py index b19c681..e75090f 100644 --- a/src/energomera_hass_mqtt/__init__.py +++ b/src/energomera_hass_mqtt/__init__.py @@ -54,9 +54,9 @@ class IecToHassSensor: # pylint: disable=too-many-instance-attributes # Class attribute to store HASS sensors having config payload sent, to # ensure it is done only once per multiple instantiations of the class - - # HASS needs sensor disocvery only once, otherwise logs a message re: + # HASS needs sensor discovery only once, otherwise logs a message re: # sensor has already been discovered - _hass_config_entities_published = {} + hass_config_payloads_published = {} # Class attribute defining MQTT topic base for HASS discovery _mqtt_topic_base = 'sensor' @@ -259,15 +259,17 @@ async def process(self, setup_only=False): if setup_only: continue - # Send payloads using MQTT - # Send config payload for HomeAssistant discovery only once - # per sensor - if (self._hass_unique_id not in - self._hass_config_entities_published): - - # Config payload for sensor discovery - config_payload = self.hass_config_payload() - json_config_payload = json.dumps(config_payload) + # Send configuration payloads using MQTT once per sensor + config_payload = self.hass_config_payload() + json_config_payload = json.dumps(config_payload) + config_payload_sent_hash = ( + self.hass_config_payloads_published.get( + self._hass_unique_id, None + ) + ) + # (re)send the configuration payload once it changes (e.g. due + # to interpolation) + if config_payload_sent_hash != hash(json_config_payload): _LOGGER.debug("MQTT config payload for HASS" " auto-discovery: '%s'", json_config_payload) @@ -277,10 +279,10 @@ async def process(self, setup_only=False): payload=json_config_payload, retain=True, ) - # Mark the config payload for the given sensor as sent - self._hass_config_entities_published[ + # Keep track of JSON formatted configuration payloads sent + self.hass_config_payloads_published[ self._hass_unique_id - ] = True + ] = hash(json_config_payload) _LOGGER.debug("Sent HASS config payload to MQTT topic" " '%s'", @@ -459,6 +461,11 @@ def __init__( self._serial_number = None self._sw_version = None + # (re)initialize what configuration payloads have been sent across all + # instances of `IecToHassSensor`. Mostly used by tests, as + # `async_main()` instantiates the class only once + IecToHassSensor.hass_config_payloads_published = {} + def iec_read_values(self, address, additional_data=None): """ Reads value(s) at selected address from the meter using IEC 62056-21 diff --git a/src/energomera_hass_mqtt/config.py b/src/energomera_hass_mqtt/config.py index fbecb83..fe3fd19 100644 --- a/src/energomera_hass_mqtt/config.py +++ b/src/energomera_hass_mqtt/config.py @@ -188,6 +188,19 @@ def _get_schema(self): ], }) + @staticmethod + def _read_config(config_file): + """ + Reads configuration file. + + :param str config_file: Name of configuration file + :return str: Configuration file contents + """ + with open(config_file, encoding='ascii') as file: + content = file.read() + + return content + def __init__(self, config_file=None, content=None): """ Initializes configuration state either from file or content string. @@ -202,8 +215,7 @@ def __init__(self, config_file=None, content=None): try: if not content: - with open(config_file, encoding='ascii') as file: - content = file.read() + content = self._read_config(config_file) config = yaml.safe_load(content) except (yaml.YAMLError, OSError) as exc: diff --git a/tests/test_config.py b/tests/test_config.py index e9f1656..6088eda 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -24,31 +24,33 @@ import logging import re -from unittest.mock import mock_open, patch from freezegun import freeze_time import pytest from energomera_hass_mqtt import EnergomeraConfig, EnergomeraConfigError +VALID_CONFIG_YAML = ''' + meter: + port: dummy_serial + password: dummy_password + mqtt: + host: a_mqtt_host + user: a_mqtt_user + password: mqtt_dummy_password + parameters: + - name: dummy_param + address: dummy_addr + device_class: dummy_class + state_class: dummy_state + unit: dummy +''' + +@pytest.mark.usefixtures('mock_config') +@pytest.mark.config_yaml(VALID_CONFIG_YAML) def test_valid_config_file(): ''' Tests for processing of valid configuration file. ''' - valid_config_yaml = ''' - meter: - port: dummy_serial - password: dummy_password - mqtt: - host: a_mqtt_host - user: a_mqtt_user - password: mqtt_dummy_password - parameters: - - name: dummy_param - address: dummy_addr - device_class: dummy_class - state_class: dummy_state - unit: dummy - ''' valid_config = { 'general': { 'oneshot': False, @@ -82,55 +84,58 @@ def test_valid_config_file(): ], } - with patch('builtins.open', mock_open(read_data=valid_config_yaml)): - config = EnergomeraConfig(config_file='dummy') - assert isinstance(config.of, dict) - assert config.of == valid_config - assert config.logging_level == logging.ERROR + config = EnergomeraConfig(config_file='dummy') + assert isinstance(config.of, dict) + assert config.of == valid_config + assert config.logging_level == logging.ERROR + + +VALID_CONFIG_DEFAULT_PARAMETERS_YAML = ''' + general: + include_default_parameters: true + meter: + port: dummy_serial + password: dummy_password + mqtt: + host: a_mqtt_host + user: a_mqtt_user + password: mqtt_dummy_password + parameters: + - name: dummy_param + address: dummy_addr + device_class: dummy_class + state_class: dummy_state + unit: dummy +''' +@pytest.mark.usefixtures('mock_config') +@pytest.mark.config_yaml(VALID_CONFIG_DEFAULT_PARAMETERS_YAML) def test_valid_config_file_with_default_parameters(): ''' Tests for processing of valid configuration file that allows including default parameters plus adds some custom ones. ''' - valid_config_yaml = ''' - general: - include_default_parameters: true - meter: - port: dummy_serial - password: dummy_password - mqtt: - host: a_mqtt_host - user: a_mqtt_user - password: mqtt_dummy_password - parameters: - - name: dummy_param - address: dummy_addr - device_class: dummy_class - state_class: dummy_state - unit: dummy - ''' - - with patch('builtins.open', mock_open(read_data=valid_config_yaml)): - config = EnergomeraConfig(config_file='dummy') - assert isinstance(config.of, dict) - # Resulting number of parameters should be combined across default and - # custom ones - assert len(config.of.parameters) == 12 - # Verify the last parameter is the custom one - assert config.of.parameters[-1].name == 'dummy_param' + config = EnergomeraConfig(config_file='dummy') + assert isinstance(config.of, dict) + # Resulting number of parameters should be combined across default and + # custom ones + assert len(config.of.parameters) == 12 + # Verify the last parameter is the custom one + assert config.of.parameters[-1].name == 'dummy_param' +@pytest.mark.usefixtures('mock_config') +@pytest.mark.config_yaml('') def test_empty_file(): ''' Tests for processing empty configuration file. ''' - with patch('builtins.open', mock_open(read_data='')): - with pytest.raises(EnergomeraConfigError): - EnergomeraConfig('dummy') + with pytest.raises(EnergomeraConfigError): + EnergomeraConfig('dummy') +@pytest.mark.config_yaml(None) def test_non_existing_file(): ''' Tests for processing non-existent configuration file. @@ -175,69 +180,60 @@ def test_config_invalid_logging_level(): EnergomeraConfig(content=invalid_config) -@pytest.fixture(scope='module') -def config_with_interpolations(): - ''' - Provides configuration object with interpolation expressions. To be - instantiated once per module to verify multiple calls to interpolate over - single instance of configuration object. - ''' - interpolated_config_yaml = ''' - meter: - port: dummy_serial - password: dummy_password - mqtt: - host: a_mqtt_host - user: a_mqtt_user - password: mqtt_dummy_password - parameters: - - name: dummy_param1 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_month }}' - - name: dummy_param2 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_day }}' - # Argument specifying 5 months ago - - name: dummy_param3 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_month (5) }}' - # Argument specifying 2 days ago - - name: dummy_param4 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_day (2) }}' - # Empty argument that falls back to 1 month ago - - name: dummy_param5 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_month () }}' - # Empty argument with whitespaces only that falls back to 1 day ago - - name: dummy_param6 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_day ( ) }}' - ''' - - with patch('builtins.open', mock_open(read_data=interpolated_config_yaml)): - config = EnergomeraConfig(config_file='dummy') - return config +INTERPOLATED_CONFIG_YAML = ''' + meter: + port: dummy_serial + password: dummy_password + mqtt: + host: a_mqtt_host + user: a_mqtt_user + password: mqtt_dummy_password + parameters: + - name: dummy_param1 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_month }}' + - name: dummy_param2 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_day }}' + # Argument specifying 5 months ago + - name: dummy_param3 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_month (5) }}' + # Argument specifying 2 days ago + - name: dummy_param4 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_day (2) }}' + # Empty argument that falls back to 1 month ago + - name: dummy_param5 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_month () }}' + # Empty argument with whitespaces only that falls back to 1 day ago + - name: dummy_param6 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_day ( ) }}' +''' +@pytest.mark.usefixtures('mock_config') +@pytest.mark.config_yaml(INTERPOLATED_CONFIG_YAML) @pytest.mark.parametrize( 'frozen_date,prev_month,prev_day,older_month,older_day', [ @@ -248,122 +244,111 @@ def config_with_interpolations(): ] ) def test_config_interpolation_date_change( - # `pylint` mistekenly treats fixture as re-definition - # pylint: disable=redefined-outer-name,too-many-arguments - config_with_interpolations, frozen_date, - prev_month, prev_day, older_month, older_day + frozen_date, prev_month, prev_day, older_month, older_day ): ''' Verifies for interpolated expressions properly processed when `interpolate` method is called repeatedly on single configuration object. ''' with freeze_time(frozen_date): - config_with_interpolations.interpolate() + config = EnergomeraConfig(config_file='dummy') + config.interpolate() assert ( - config_with_interpolations + config .of.parameters[0] .additional_data == prev_month ) assert ( - config_with_interpolations + config .of.parameters[1] .additional_data == prev_day ) assert ( - config_with_interpolations + config .of.parameters[2] .additional_data == older_month ) assert ( - config_with_interpolations + config .of.parameters[3] .additional_data == older_day ) assert ( - config_with_interpolations + config .of.parameters[4] .additional_data == prev_month ) assert ( - config_with_interpolations + config .of.parameters[5] .additional_data == prev_day ) -def config_with_invalid_interpolations(): +INVALID_INTERPOLATION_CONFIGS_YAML = [ ''' - Provides configuration object with interpolation expressions having invalid - argument specified. + meter: + port: dummy_serial + password: dummy_password + mqtt: + host: a_mqtt_host + user: a_mqtt_user + password: mqtt_dummy_password + parameters: + # Argument with closing bracket missing + - name: dummy_param1 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_day ( }}' + ''', ''' - interpolated_configs_yaml = [ - ''' - meter: - port: dummy_serial - password: dummy_password - mqtt: - host: a_mqtt_host - user: a_mqtt_user - password: mqtt_dummy_password - parameters: - # Argument with closing bracket missing - - name: dummy_param1 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_day ( }}' - ''', - ''' - meter: - port: dummy_serial - password: dummy_password - mqtt: - host: a_mqtt_host - user: a_mqtt_user - password: mqtt_dummy_password - parameters: - # Argument with opening bracket missing - - name: dummy_param2 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_day ) }}' - ''', - ''' - meter: - port: dummy_serial - password: dummy_password - mqtt: - host: a_mqtt_host - user: a_mqtt_user - password: mqtt_dummy_password - parameters: - # Non-numeric argument - - name: dummy_param3 - address: dummy_addr1 - device_class: dummy_class - state_class: dummy_state - unit: dummy - additional_data: '{{ energomera_prev_day (non-numeric) }}' - ''', - ] - for config_yaml in interpolated_configs_yaml: - with patch('builtins.open', mock_open(read_data=config_yaml)): - config = EnergomeraConfig(config_file='dummy') - yield config + meter: + port: dummy_serial + password: dummy_password + mqtt: + host: a_mqtt_host + user: a_mqtt_user + password: mqtt_dummy_password + parameters: + # Argument with opening bracket missing + - name: dummy_param2 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_day ) }}' + ''', + ''' + meter: + port: dummy_serial + password: dummy_password + mqtt: + host: a_mqtt_host + user: a_mqtt_user + password: mqtt_dummy_password + parameters: + # Non-numeric argument + - name: dummy_param3 + address: dummy_addr1 + device_class: dummy_class + state_class: dummy_state + unit: dummy + additional_data: '{{ energomera_prev_day (non-numeric) }}' + ''', +] -# `pylint` mistekenly treats fixture as re-definition -# pylint: disable=redefined-outer-name -@pytest.mark.parametrize('config', config_with_invalid_interpolations()) -def test_config_interpolation_invalid(config): +@pytest.mark.usefixtures('mock_config') +@pytest.mark.parametrize('config_yaml', INVALID_INTERPOLATION_CONFIGS_YAML) +def test_config_interpolation_invalid(config_yaml): ''' Verifies the expression interpolation raises exception processing the argument having invalid format. ''' with pytest.raises(EnergomeraConfigError): + config = EnergomeraConfig(content=config_yaml) config.interpolate() @@ -372,6 +357,7 @@ def test_config_interpolation_expr_param_re_no_groups(): Verifies processing interpolation expression with associated regexp having no capturing groups results in default value. ''' + # pylint:disable=protected-access res = EnergomeraConfig._energomera_re_expr_param_int( re.match('dummy pattern', 'dummy pattern'), 100 ) @@ -384,6 +370,7 @@ def test_config_interpolation_expr_param_re_no_match(): expression arguments directly with non-matching regex. ''' with pytest.raises(AssertionError): + # pylint:disable=protected-access EnergomeraConfig._energomera_re_expr_param_int( re.match('dummy pattern', 'dummy non-matching value'), 100 ) diff --git a/tests/test_energomera.py b/tests/test_energomera.py index 9a526be..e871f81 100644 --- a/tests/test_energomera.py +++ b/tests/test_energomera.py @@ -18,1129 +18,31 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -# pylint: disable=too-many-lines ''' Tests for 'energomera_hass_mqtt' package ''' -import sys -import json -try: - from unittest.mock import AsyncMock -except ImportError: - # AsyncMock introduced in Python 3.8, import from alternative package if - # older - from mock import AsyncMock -from unittest.mock import call, patch, mock_open, DEFAULT -from contextlib import contextmanager -from functools import reduce +from unittest.mock import call import pytest -import iec62056_21.transports -from energomera_hass_mqtt.mqtt_client import MqttClient +from conftest import MQTT_PUBLISH_CALLS_COMPLETE, SERIAL_EXCHANGE_COMPLETE from energomera_hass_mqtt.main import main -# Serial exchange to simulate - `send_bytes` will be simulated as if received -# from the device, `receive_bytes` is what expected to be sent by the package. -# The directional view here is from device standpoint. -# The data is taken from real device's traffic with only some parts obfuscated. -serial_exchange = [ - { - 'receive_bytes': b'/?!\r\n', - 'send_bytes': b'/EKT5CE301v12\r\n', - }, - { - 'receive_bytes': b'\x06051\r\n', - 'send_bytes': b'\x01P0\x02(777777)\x03\x20', - }, - { - 'receive_bytes': b'\x01P1\x02(dummy)\x03\x03', - 'send_bytes': b'\x06', - }, - { - 'receive_bytes': b'\x01R1\x02HELLO()\x03M', - 'send_bytes': b'\x02HELLO(2,CE301,12,00123456,dummy)\r\n\x03\x01', - }, - { - 'receive_bytes': b'\x01R1\x02ET0PE()\x037', - 'send_bytes': - b'\x02ET0PE(16907.9477764)\r\n' - b'ET0PE(11504.3875082)\r\n' - b'ET0PE(3628.2698795)\r\n' - b'ET0PE(1775.2903887)\r\n' - b'ET0PE(0.0)\r\n' - b'ET0PE(0.0)\r\n\x03\x04', - }, - { - 'receive_bytes': b'\x01R1\x02ECMPE()\x03C', - 'send_bytes': - b'\x02ECMPE(357.8505119)\r\n' - b'ECMPE(208.6539992)\r\n' - b'ECMPE(106.9769041)\r\n' - b'ECMPE(42.2196086)\r\n' - b'ECMPE(0.0)\r\n' - b'ECMPE(0.0)\r\n' - b'\x03E', - }, - { - 'receive_bytes': b'\x01R1\x02ENMPE(04.22)\x03D', - 'send_bytes': - b'\x02ENMPE(16550.0972645)\r\n' - b'ENMPE(11295.733509)\r\n' - b'ENMPE(3521.2929754)\r\n' - b'ENMPE(1733.0707801)\r\n' - b'ENMPE(0.0)\r\n' - b'ENMPE(0.0)\r\n' - b'\x03*', - }, - { - 'receive_bytes': b'\x01R1\x02EAMPE(04.22)\x037', - 'send_bytes': - b'\x02EAMPE(477.8955487)\r\n' - b'EAMPE(325.201782)\r\n' - b'EAMPE(103.4901674)\r\n' - b'EAMPE(49.2035993)\r\n' - b'EAMPE(0.0)\r\n' - b'EAMPE(0.0)\r\n' - b'\x03\x04', - }, - { - 'receive_bytes': b'\x01R1\x02ECDPE()\x03:', - 'send_bytes': - b'\x02ECDPE(13.7433546)\r\n' - b'ECDPE(5.5472398)\r\n' - b'ECDPE(5.7096121)\r\n' - b'ECDPE(2.4865027)\r\n' - b'ECDPE(0.0)\r\n' - b'ECDPE(0.0)\r\n' - b'\x03M', - }, - { - 'receive_bytes': b'\x01R1\x02POWPP()\x03o', - 'send_bytes': - b'\x02POWPP(0.0592)\r\n' - b'POWPP(0.4402)\r\n' - b'POWPP(0.054)\r\n' - b'\x03J', - }, - { - 'receive_bytes': b'\x01R1\x02POWEP()\x03d', - 'send_bytes': b'\x02POWEP(0.5266)\r\n\x03\'', - }, - { - 'receive_bytes': b'\x01R1\x02VOLTA()\x03_', - 'send_bytes': - b'\x02VOLTA(233.751)\r\n' - b'VOLTA(235.418)\r\n' - b'VOLTA(234.796)\r\n' - b'\x03\x02', - }, - { - 'receive_bytes': b'\x01R1\x02VNULL()\x03j', - 'send_bytes': b'\x02VNULL(0)\r\n\x03,', - }, - { - 'receive_bytes': b'\x01R1\x02CURRE()\x03Z', - 'send_bytes': - b'\x02CURRE(1.479)\r\n' - b'CURRE(2.8716)\r\n' - b'CURRE(0.782)\r\n' - b'\x03v', - }, - { - 'receive_bytes': b'\x01R1\x02FREQU()\x03\\', - 'send_bytes': b'\x02FREQU(49.96)\r\n\x03x', - }, - { - 'receive_bytes': b'\x01R1\x02ECDPE()\x03:', - 'send_bytes': - b'\x02ECDPE(13.7433546)\r\n' - b'ECDPE(5.5472398)\r\n' - b'ECDPE(5.7096121)\r\n' - b'ECDPE(2.4865027)\r\n' - b'ECDPE(0.0)\r\n' - b'ECDPE(0.0)\r\n' - b'\x03M', - }, - # Entry for HASS sensor with auto-indexed name - { - 'receive_bytes': b'\x01R1\x02CURRE()\x03Z', - 'send_bytes': - b'\x02CURRE(1.479)\r\n' - b'CURRE(2.8716)\r\n' - b'CURRE(0.782)\r\n' - b'\x03v', - }, - # Entry for HASS sensor with fallback names - { - 'receive_bytes': b'\x01R1\x02CURRE()\x03Z', - 'send_bytes': - b'\x02CURRE(1.479)\r\n' - b'CURRE(2.8716)\r\n' - b'CURRE(0.782)\r\n' - b'\x03v', - }, -] -# Expected MQTT publish calls of the sequence and contents corresponds to the -# serial exchange above -mqtt_publish_calls = [ - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ET0PE/config', - payload=json.dumps( - { - 'name': 'Cumulative energy', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'energy', - 'unique_id': 'CE301_00123456_ET0PE', - 'object_id': 'CE301_00123456_ET0PE', - 'unit_of_measurement': 'kWh', - 'state_class': 'total_increasing', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ET0PE/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456/CE301_00123456_ET0PE/state', - payload=json.dumps({'value': '16907.9477764'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ECMPE/config', - payload=json.dumps( - { - 'name': 'Monthly energy', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'energy', - 'unique_id': 'CE301_00123456_ECMPE', - 'object_id': 'CE301_00123456_ECMPE', - 'unit_of_measurement': 'kWh', - 'state_class': 'total', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ECMPE/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456/CE301_00123456_ECMPE/state', - payload=json.dumps({'value': '357.8505119'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ENMPE_PREV_MONTH/config', - payload=json.dumps( - { - 'name': 'Cumulative energy, previous month', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'energy', - 'unique_id': 'CE301_00123456_ENMPE_PREV_MONTH', - 'object_id': 'CE301_00123456_ENMPE_PREV_MONTH', - 'unit_of_measurement': 'kWh', - 'state_class': 'total_increasing', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ENMPE_PREV_MONTH/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ENMPE_PREV_MONTH/state', - payload=json.dumps({'value': '16550.0972645'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ECMPE_PREV_MONTH/config', - payload=json.dumps( - { - 'name': 'Previous month energy', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'energy', - 'unique_id': 'CE301_00123456_ECMPE_PREV_MONTH', - 'object_id': 'CE301_00123456_ECMPE_PREV_MONTH', - 'unit_of_measurement': 'kWh', - 'state_class': 'total', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ECMPE_PREV_MONTH/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ECMPE_PREV_MONTH/state', - payload=json.dumps({'value': '477.8955487'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ECDPE/config', - payload=json.dumps( - { - 'name': 'Daily energy', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'energy', - 'unique_id': 'CE301_00123456_ECDPE', - 'object_id': 'CE301_00123456_ECDPE', - 'unit_of_measurement': 'kWh', - 'state_class': 'total', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_ECDPE/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456/CE301_00123456_ECDPE/state', - payload=json.dumps({'value': '13.7433546'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_0/config', - payload=json.dumps( - { - 'name': 'Active energy, phase A', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'power', - 'unique_id': 'CE301_00123456_POWPP_0', - 'object_id': 'CE301_00123456_POWPP_0', - 'unit_of_measurement': 'kW', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_0/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_0/state', - payload=json.dumps({'value': '0.0592'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_1/config', - payload=json.dumps( - { - 'name': 'Active energy, phase B', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'power', - 'unique_id': 'CE301_00123456_POWPP_1', - 'object_id': 'CE301_00123456_POWPP_1', - 'unit_of_measurement': 'kW', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_1/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_1/state', - payload=json.dumps({'value': '0.4402'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_2/config', - payload=json.dumps( - { - 'name': 'Active energy, phase C', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'power', - 'unique_id': 'CE301_00123456_POWPP_2', - 'object_id': 'CE301_00123456_POWPP_2', - 'unit_of_measurement': 'kW', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_2/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWPP_2/state', - payload=json.dumps({'value': '0.054'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWEP/config', - payload=json.dumps( - { - 'name': 'Active energy', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'power', - 'unique_id': 'CE301_00123456_POWEP', - 'object_id': 'CE301_00123456_POWEP', - 'unit_of_measurement': 'kW', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_POWEP/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456/CE301_00123456_POWEP/state', - payload=json.dumps({'value': '0.5266'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_0/config', - payload=json.dumps( - { - 'name': 'Voltage, phase A', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'voltage', - 'unique_id': 'CE301_00123456_VOLTA_0', - 'object_id': 'CE301_00123456_VOLTA_0', - 'unit_of_measurement': 'V', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_0/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_0/state', - payload=json.dumps({'value': '233.751'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_1/config', - payload=json.dumps( - { - 'name': 'Voltage, phase B', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'voltage', - 'unique_id': 'CE301_00123456_VOLTA_1', - 'object_id': 'CE301_00123456_VOLTA_1', - 'unit_of_measurement': 'V', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_1/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_1/state', - payload=json.dumps({'value': '235.418'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_2/config', - payload=json.dumps( - { - 'name': 'Voltage, phase C', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'voltage', - 'unique_id': 'CE301_00123456_VOLTA_2', - 'object_id': 'CE301_00123456_VOLTA_2', - 'unit_of_measurement': 'V', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_2/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VOLTA_2/state', - payload=json.dumps({'value': '234.796'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VNULL/config', - payload=json.dumps( - { - 'name': 'Neutral voltage', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'voltage', - 'unique_id': 'CE301_00123456_VNULL', - 'object_id': 'CE301_00123456_VNULL', - 'unit_of_measurement': 'V', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_VNULL/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456/CE301_00123456_VNULL/state', - payload=json.dumps({'value': '0'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_0/config', - payload=json.dumps( - { - 'name': 'Current, phase A', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_0', - 'object_id': 'CE301_00123456_CURRE_0', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_0/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_0/state', - payload=json.dumps({'value': '1.479'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_1/config', - payload=json.dumps( - { - 'name': 'Current, phase B', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_1', - 'object_id': 'CE301_00123456_CURRE_1', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_1/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_1/state', - payload=json.dumps({'value': '2.8716'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_2/config', - payload=json.dumps( - { - 'name': 'Current, phase C', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_2', - 'object_id': 'CE301_00123456_CURRE_2', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_2/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_2/state', - payload=json.dumps({'value': '0.782'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_FREQU/config', - payload=json.dumps( - { - 'name': 'Frequency', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'frequency', - 'unique_id': 'CE301_00123456_FREQU', - 'object_id': 'CE301_00123456_FREQU', - 'unit_of_measurement': 'Hz', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_FREQU/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_FREQU/state', - payload=json.dumps({'value': '49.96'}), - ), - # MQTT calls for HASS entry with auto-indexed name - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_0/config', - payload=json.dumps( - { - 'name': 'Current 0', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_INDEXED_0', - 'object_id': 'CE301_00123456_CURRE_INDEXED_0', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_0/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_0/state', - payload=json.dumps({'value': '1.479'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_1/config', - payload=json.dumps( - { - 'name': 'Current 1', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_INDEXED_1', - 'object_id': 'CE301_00123456_CURRE_INDEXED_1', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_1/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_1/state', - payload=json.dumps({'value': '2.8716'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_2/config', - payload=json.dumps( - { - 'name': 'Current 2', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_INDEXED_2', - 'object_id': 'CE301_00123456_CURRE_INDEXED_2', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_2/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_2/state', - payload=json.dumps({'value': '0.782'}), - ), - # MQTT calls for HASS entry with fallback names - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_0/config', - payload=json.dumps( - { - 'name': 'Current, phase A', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_0', - 'object_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_0', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_0' - '/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_0/state', - payload=json.dumps({'value': '1.479'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_1/config', - payload=json.dumps( - { - 'name': 'CURRE 1', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_1', - 'object_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_1', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_1' - '/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_1/state', - payload=json.dumps({'value': '2.8716'}), - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_2/config', - payload=json.dumps( - { - 'name': 'CURRE 2', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'current', - 'unique_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_2', - 'object_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_2', - 'unit_of_measurement': 'A', - 'state_class': 'measurement', - 'state_topic': 'homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_2' - '/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/sensor/CE301_00123456' - '/CE301_00123456_CURRE_INDEXED_FALLBACK_2/state', - payload=json.dumps({'value': '0.782'}), - ), - call( - topic='homeassistant/binary_sensor/CE301_00123456' - '/CE301_00123456_IS_ONLINE/config', - payload=json.dumps( - { - 'name': 'Meter online status', - 'device': { - 'name': '00123456', - 'ids': 'CE301_00123456', - 'model': 'CE301', - 'sw_version': '12', - }, - 'device_class': 'connectivity', - 'unique_id': 'CE301_00123456_IS_ONLINE', - 'object_id': 'CE301_00123456_IS_ONLINE', - 'state_topic': 'homeassistant/binary_sensor/CE301_00123456' - '/CE301_00123456_IS_ONLINE' - '/state', - 'value_template': '{{ value_json.value }}', - } - ), - retain=True, - ), - call( - topic='homeassistant/binary_sensor/CE301_00123456' - '/CE301_00123456_IS_ONLINE/state', - payload=json.dumps({'value': 'ON'}), - ), -] - - -@contextmanager -def mock_config(): - ''' - Provides mocked configuration file, to be used as context manager. - ''' - - config_yaml = ''' - general: - oneshot: true - meter: - port: dummy_serial - password: dummy - timeout: 1 - mqtt: - # This is important as Docker-backed tests spawn the broker exposed - # on the localhost - host: 127.0.0.1 - user: mqtt_dummy_user - password: mqtt_dummy_password - # Leveraged by Docker-based tests since the broker is TLS-unaware - tls: false - parameters: - - address: ET0PE - device_class: energy - name: Cumulative energy - response_idx: 0 - state_class: total_increasing - unit: kWh - - address: ECMPE - device_class: energy - name: Monthly energy - response_idx: 0 - state_class: total - unit: kWh - - additional_data: '04.22' - address: ENMPE - device_class: energy - entity_name: ENMPE_PREV_MONTH - name: Cumulative energy, previous month - response_idx: 0 - state_class: total_increasing - unit: kWh - - additional_data: '04.22' - address: EAMPE - device_class: energy - entity_name: ECMPE_PREV_MONTH - name: Previous month energy - response_idx: 0 - state_class: total - unit: kWh - - address: ECDPE - device_class: energy - name: Daily energy - response_idx: 0 - state_class: total - unit: kWh - - address: POWPP - device_class: power - name: - - Active energy, phase A - - Active energy, phase B - - Active energy, phase C - state_class: measurement - unit: kW - - address: POWEP - device_class: power - name: Active energy - state_class: measurement - unit: kW - - address: VOLTA - device_class: voltage - name: - - Voltage, phase A - - Voltage, phase B - - Voltage, phase C - state_class: measurement - unit: V - - address: VNULL - device_class: voltage - name: Neutral voltage - state_class: measurement - unit: V - - address: CURRE - device_class: current - name: - - Current, phase A - - Current, phase B - - Current, phase C - state_class: measurement - unit: A - - address: FREQU - device_class: frequency - name: Frequency - state_class: measurement - unit: Hz - - address: ECDPE - device_class: energy - name: Daily energy - response_idx: 100 - state_class: total - unit: kWh - - address: CURRE - device_class: current - name: Current - state_class: measurement - entity_name: CURRE_INDEXED - unit: A - - address: CURRE - device_class: current - name: - - Current, phase A - state_class: measurement - unit: A - entity_name: CURRE_INDEXED_FALLBACK - ''' - - # Perform communication with the device and issue MQTT calls - with patch('builtins.open', mock_open(read_data=config_yaml)): - with patch.object(sys, 'argv', ['dummy']): - yield - - -@contextmanager -def mock_serial( - simulate_timeout=False -): - ''' - Provides necessary serial mocks, to be used as context manager. - ''' - - # Mock certain methods of 'iec62056_21' package (serial communications) to - # prevent serial calls - with patch.multiple(iec62056_21.transports.SerialTransport, - switch_baudrate=DEFAULT, disconnect=DEFAULT, - connect=DEFAULT): - # Mock the calls we interested in - with patch.multiple(iec62056_21.transports.SerialTransport, - _send=DEFAULT, _recv=DEFAULT) as mocks: - # Simulate data received from serial port. - mocked_serial_exchange = [ - # Accessing `bytes` by subscription or via iterator (what - # `side_effect` internally does for iterable) will result in - # integer, so to retain the type the nested arrays each - # containing single `bytes` is used - bytes([y]) for y in - # Produces array of bytes of all serial exchange fragments - reduce( - lambda x, y: x + y, - [x['send_bytes'] for x in serial_exchange] - ) - ] - - if simulate_timeout: - # Simulate timeout occured at the end of mocked serial - # exchange. Some initial packets are needed to grab meter - # identification so we can test online sensor (it depends on - # those) - mocked_serial_exchange[-1] = TimeoutError - - mocks['_recv'].side_effect = mocked_serial_exchange - - yield mocks - - -@contextmanager -def mock_mqtt(): - ''' - Provides necessary MQTT mocks, to be used as context manager. +@pytest.mark.usefixtures('mock_config') +def test_normal_run(mock_serial, mock_mqtt): ''' - # Mock the calls we interested in - with patch.multiple(MqttClient, - publish=DEFAULT, connect=DEFAULT, - new_callable=AsyncMock) as mocks: - yield mocks - - -serial_send_call_args = [] -mqtt_publish_call_args = [] - - -def generate_call_lists(): - ''' - Generates MQTT and serial call lists out of `main` function being executed - with relevant calls mocked. + Tests for normal program execution validating serial and MQTT exchanges. ''' - with mock_serial() as serial_mocks: - with mock_mqtt() as mqtt_mocks: - with mock_config(): - main() - mqtt_call_args = mqtt_mocks['publish'].call_args_list - serial_call_args = serial_mocks['_send'].call_args_list - - return mqtt_call_args, serial_call_args - - -# Execute the `main` function generating call lists only when the module is -# invoked by `pytest`, but not when being imported (to avoid excessive `main` -# execution) -if __name__ == 'test_energomera': - mqtt_publish_call_args, serial_send_call_args = generate_call_lists() - - -def generate_mqtt_tests(call_args): - ''' - Generates data for `@pytest.mark.parametrize` to instantiate multiple tests - for MQTT publishes, each test per specific MQTT call - makes easier to see - particular call differs from expected one. - ''' - values = [] - ids = [] - for (exp, arg) in zip(mqtt_publish_calls, call_args): - values.append((arg, exp)) - ids.append(str(exp.kwargs.get('topic', exp))) - - return {'argvalues': values, 'ids': ids} - - -def generate_serial_tests(call_args): - ''' - Generates data for `@pytest.mark.parametrize` to instantiate multiple tests - for serial sends, each test per specific MQTT call - makes easier to see - particular call differs from expected one. - ''' - - values = [] - ids = [] - - for (exp, arg) in zip([x['receive_bytes'] for x in serial_exchange], - call_args): - values.append((arg, call(exp))) - ids.append(str(exp)) - - return {'argvalues': values, 'ids': ids} - - -@pytest.mark.parametrize('serial_call,serial_expected', - **generate_serial_tests(serial_send_call_args)) -def test_serial_send(serial_call, serial_expected): - ''' - Tests the data sent over serial. - ''' - assert serial_call == serial_expected - - -@pytest.mark.parametrize('mqtt_call,mqtt_expected', - **generate_mqtt_tests(mqtt_publish_call_args)) -def test_mqtt_publish(mqtt_call, mqtt_expected): - ''' - Tests the data published to MQTT. - ''' - assert mqtt_call == mqtt_expected + main() + mock_mqtt['publish'].assert_has_calls(MQTT_PUBLISH_CALLS_COMPLETE) + mock_serial['_send'].assert_has_calls( + [call(x['receive_bytes']) for x in SERIAL_EXCHANGE_COMPLETE] + ) +@pytest.mark.usefixtures('mock_serial', 'mock_config') +@pytest.mark.serial_simulate_timeout(True) def test_timeout(): ''' - Tests for timeout handling. + Tests for timeout handling, no unhandled exceptions should be raised. ''' - - with mock_serial(simulate_timeout=True) as serial_mocks: - with mock_mqtt(): - with mock_config(): - serial_mocks['_recv'].return_value = None - main() + main() diff --git a/tests/test_online_sensor.py b/tests/test_online_sensor.py index 3837aee..87d67c4 100644 --- a/tests/test_online_sensor.py +++ b/tests/test_online_sensor.py @@ -30,7 +30,6 @@ from unittest.mock import patch, call import docker import pytest -from test_energomera import mock_serial, mock_mqtt, mock_config from energomera_hass_mqtt.mqtt_client import MqttClient from energomera_hass_mqtt.main import async_main, main @@ -160,11 +159,12 @@ async def listen(sensor_states): return online_sensor_states +@pytest.mark.usefixtures('mock_config', 'mock_serial') @pytest.mark.asyncio async def test_online_sensor_last_will( # `pylint` mistekenly treats fixture as re-definition # pylint: disable=redefined-outer-name, unused-argument - mqtt_broker + mqtt_broker, ): ''' Tests online sensor for properly utilizing MQTT last will to set the state @@ -182,27 +182,26 @@ async def mqtt_client_destroy(self): # pylint: disable=protected-access self._client = MqttClient(hostname='dummy')._client - with mock_config(): - with mock_serial(): - # Replace MQTT disconnect with the function to forcibly shutdown - # MQTT client sockets, simulating unclean disconnect - with patch.object(MqttClient, 'disconnect', mqtt_client_destroy): - # Setup MQTT keep-alive to a minimal value, so that unclean - # disconnect will result in last will message being sent upon - # keep-alive elapses - with patch.object(MqttClient, '_keepalive', 1): - await async_main() + # Replace MQTT disconnect with the function to forcibly shutdown + # MQTT client sockets, simulating unclean disconnect + with patch.object(MqttClient, 'disconnect', mqtt_client_destroy): + # Setup MQTT keep-alive to a minimal value, so that unclean + # disconnect will result in last will message being sent upon + # keep-alive elapses + with patch.object(MqttClient, '_keepalive', 1): + await async_main() online_sensor_states = await read_online_sensor_states() # Verify the last sensor state should be OFF during unclean shutdown assert online_sensor_states.pop() == json.dumps({'value': 'OFF'}) +@pytest.mark.usefixtures('mock_config', 'mock_serial') @pytest.mark.asyncio async def test_online_sensor_normal_run( # `pylint` mistekenly treats fixture as re-definition # pylint: disable=redefined-outer-name, unused-argument - mqtt_broker + mqtt_broker, ): ''' Tests online sensor for properly reflecting online sensors state during @@ -211,9 +210,7 @@ async def test_online_sensor_normal_run( # Attempt to receive online sensor state upon normal program run async def normal_run(): - with mock_config(): - with mock_serial(): - await async_main() + await async_main() online_sensor_states = await read_online_sensor_states(normal_run) # There should be two messages for online sensor - first with 'ON' @@ -226,22 +223,18 @@ async def normal_run(): ] -def test_online_sensor(): +@pytest.mark.usefixtures('mock_config', 'mock_serial') +@pytest.mark.serial_simulate_timeout(True) +def test_online_sensor(mock_mqtt): ''' Tests for handling pseudo online sensor under timeout condition. ''' - mqtt_publish_call_args_for_timeout = [] - with mock_serial(simulate_timeout=True): - with mock_mqtt() as mqtt_mocks: - with mock_config(): - main() - mqtt_publish_call_args_for_timeout = ( - mqtt_mocks['publish'].call_args_list - ) + main() + # Verify the last call to MQTT contains online sensor being OFF assert call( topic='homeassistant/binary_sensor/CE301_00123456' '/CE301_00123456_IS_ONLINE/state', payload=json.dumps({'value': 'OFF'}), - ) in mqtt_publish_call_args_for_timeout + ) == mock_mqtt['publish'].call_args_list[-1] From d6ff1df6080230cfc5818ce41598a82e22be1763 Mon Sep 17 00:00:00 2001 From: Ilia Sotnikov Date: Fri, 28 Apr 2023 09:14:33 +0300 Subject: [PATCH 2/2] + Added missing files --- tests/conftest.py | 1051 +++++++++++++++++++++++++++ tests/test_config_payload_update.py | 142 ++++ 2 files changed, 1193 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_config_payload_update.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..80ad518 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,1051 @@ +''' +Shared data structures and fixtures. +''' +# pylint: disable=too-many-lines + +import json +import sys +from functools import reduce +from unittest.mock import patch, call, DEFAULT +import pytest +import iec62056_21.transports +from energomera_hass_mqtt.mqtt_client import MqttClient +try: + from unittest.mock import AsyncMock +except ImportError: + # AsyncMock introduced in Python 3.8, import from alternative package if + # older + from mock import AsyncMock + + +SERIAL_EXCHANGE_COMPLETE = [ + { + 'receive_bytes': b'/?!\r\n', + 'send_bytes': b'/EKT5CE301v12\r\n', + }, + { + 'receive_bytes': b'\x06051\r\n', + 'send_bytes': b'\x01P0\x02(777777)\x03\x20', + }, + { + 'receive_bytes': b'\x01P1\x02(dummy)\x03\x03', + 'send_bytes': b'\x06', + }, + { + 'receive_bytes': b'\x01R1\x02HELLO()\x03M', + 'send_bytes': b'\x02HELLO(2,CE301,12,00123456,dummy)\r\n\x03\x01', + }, + { + 'receive_bytes': b'\x01R1\x02ET0PE()\x037', + 'send_bytes': + b'\x02ET0PE(16907.9477764)\r\n' + b'ET0PE(11504.3875082)\r\n' + b'ET0PE(3628.2698795)\r\n' + b'ET0PE(1775.2903887)\r\n' + b'ET0PE(0.0)\r\n' + b'ET0PE(0.0)\r\n\x03\x04', + }, + { + 'receive_bytes': b'\x01R1\x02ECMPE()\x03C', + 'send_bytes': + b'\x02ECMPE(357.8505119)\r\n' + b'ECMPE(208.6539992)\r\n' + b'ECMPE(106.9769041)\r\n' + b'ECMPE(42.2196086)\r\n' + b'ECMPE(0.0)\r\n' + b'ECMPE(0.0)\r\n' + b'\x03E', + }, + { + 'receive_bytes': b'\x01R1\x02ENMPE(04.22)\x03D', + 'send_bytes': + b'\x02ENMPE(16550.0972645)\r\n' + b'ENMPE(11295.733509)\r\n' + b'ENMPE(3521.2929754)\r\n' + b'ENMPE(1733.0707801)\r\n' + b'ENMPE(0.0)\r\n' + b'ENMPE(0.0)\r\n' + b'\x03*', + }, + { + 'receive_bytes': b'\x01R1\x02EAMPE(04.22)\x037', + 'send_bytes': + b'\x02EAMPE(477.8955487)\r\n' + b'EAMPE(325.201782)\r\n' + b'EAMPE(103.4901674)\r\n' + b'EAMPE(49.2035993)\r\n' + b'EAMPE(0.0)\r\n' + b'EAMPE(0.0)\r\n' + b'\x03\x04', + }, + { + 'receive_bytes': b'\x01R1\x02ECDPE()\x03:', + 'send_bytes': + b'\x02ECDPE(13.7433546)\r\n' + b'ECDPE(5.5472398)\r\n' + b'ECDPE(5.7096121)\r\n' + b'ECDPE(2.4865027)\r\n' + b'ECDPE(0.0)\r\n' + b'ECDPE(0.0)\r\n' + b'\x03M', + }, + { + 'receive_bytes': b'\x01R1\x02POWPP()\x03o', + 'send_bytes': + b'\x02POWPP(0.0592)\r\n' + b'POWPP(0.4402)\r\n' + b'POWPP(0.054)\r\n' + b'\x03J', + }, + { + 'receive_bytes': b'\x01R1\x02POWEP()\x03d', + 'send_bytes': b'\x02POWEP(0.5266)\r\n\x03\'', + }, + { + 'receive_bytes': b'\x01R1\x02VOLTA()\x03_', + 'send_bytes': + b'\x02VOLTA(233.751)\r\n' + b'VOLTA(235.418)\r\n' + b'VOLTA(234.796)\r\n' + b'\x03\x02', + }, + { + 'receive_bytes': b'\x01R1\x02VNULL()\x03j', + 'send_bytes': b'\x02VNULL(0)\r\n\x03,', + }, + { + 'receive_bytes': b'\x01R1\x02CURRE()\x03Z', + 'send_bytes': + b'\x02CURRE(1.479)\r\n' + b'CURRE(2.8716)\r\n' + b'CURRE(0.782)\r\n' + b'\x03v', + }, + { + 'receive_bytes': b'\x01R1\x02FREQU()\x03\\', + 'send_bytes': b'\x02FREQU(49.96)\r\n\x03x', + }, + { + 'receive_bytes': b'\x01R1\x02ECDPE()\x03:', + 'send_bytes': + b'\x02ECDPE(13.7433546)\r\n' + b'ECDPE(5.5472398)\r\n' + b'ECDPE(5.7096121)\r\n' + b'ECDPE(2.4865027)\r\n' + b'ECDPE(0.0)\r\n' + b'ECDPE(0.0)\r\n' + b'\x03M', + }, + # Entry for HASS sensor with auto-indexed name + { + 'receive_bytes': b'\x01R1\x02CURRE()\x03Z', + 'send_bytes': + b'\x02CURRE(1.479)\r\n' + b'CURRE(2.8716)\r\n' + b'CURRE(0.782)\r\n' + b'\x03v', + }, + # Entry for HASS sensor with fallback names + { + 'receive_bytes': b'\x01R1\x02CURRE()\x03Z', + 'send_bytes': + b'\x02CURRE(1.479)\r\n' + b'CURRE(2.8716)\r\n' + b'CURRE(0.782)\r\n' + b'\x03v', + }, +] + +# Expected MQTT publish calls of the sequence and contents corresponds to the +# serial exchange above +MQTT_PUBLISH_CALLS_COMPLETE = [ + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ET0PE/config', + payload=json.dumps( + { + 'name': 'Cumulative energy', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'energy', + 'unique_id': 'CE301_00123456_ET0PE', + 'object_id': 'CE301_00123456_ET0PE', + 'unit_of_measurement': 'kWh', + 'state_class': 'total_increasing', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ET0PE/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456/CE301_00123456_ET0PE/state', + payload=json.dumps({'value': '16907.9477764'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ECMPE/config', + payload=json.dumps( + { + 'name': 'Monthly energy', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'energy', + 'unique_id': 'CE301_00123456_ECMPE', + 'object_id': 'CE301_00123456_ECMPE', + 'unit_of_measurement': 'kWh', + 'state_class': 'total', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ECMPE/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456/CE301_00123456_ECMPE/state', + payload=json.dumps({'value': '357.8505119'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ENMPE_PREV_MONTH/config', + payload=json.dumps( + { + 'name': 'Cumulative energy, previous month', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'energy', + 'unique_id': 'CE301_00123456_ENMPE_PREV_MONTH', + 'object_id': 'CE301_00123456_ENMPE_PREV_MONTH', + 'unit_of_measurement': 'kWh', + 'state_class': 'total_increasing', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ENMPE_PREV_MONTH/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ENMPE_PREV_MONTH/state', + payload=json.dumps({'value': '16550.0972645'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ECMPE_PREV_MONTH/config', + payload=json.dumps( + { + 'name': 'Previous month energy', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'energy', + 'unique_id': 'CE301_00123456_ECMPE_PREV_MONTH', + 'object_id': 'CE301_00123456_ECMPE_PREV_MONTH', + 'unit_of_measurement': 'kWh', + 'state_class': 'total', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ECMPE_PREV_MONTH/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ECMPE_PREV_MONTH/state', + payload=json.dumps({'value': '477.8955487'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ECDPE/config', + payload=json.dumps( + { + 'name': 'Daily energy', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'energy', + 'unique_id': 'CE301_00123456_ECDPE', + 'object_id': 'CE301_00123456_ECDPE', + 'unit_of_measurement': 'kWh', + 'state_class': 'total', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ECDPE/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456/CE301_00123456_ECDPE/state', + payload=json.dumps({'value': '13.7433546'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_0/config', + payload=json.dumps( + { + 'name': 'Active energy, phase A', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'power', + 'unique_id': 'CE301_00123456_POWPP_0', + 'object_id': 'CE301_00123456_POWPP_0', + 'unit_of_measurement': 'kW', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_0/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_0/state', + payload=json.dumps({'value': '0.0592'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_1/config', + payload=json.dumps( + { + 'name': 'Active energy, phase B', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'power', + 'unique_id': 'CE301_00123456_POWPP_1', + 'object_id': 'CE301_00123456_POWPP_1', + 'unit_of_measurement': 'kW', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_1/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_1/state', + payload=json.dumps({'value': '0.4402'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_2/config', + payload=json.dumps( + { + 'name': 'Active energy, phase C', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'power', + 'unique_id': 'CE301_00123456_POWPP_2', + 'object_id': 'CE301_00123456_POWPP_2', + 'unit_of_measurement': 'kW', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_2/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWPP_2/state', + payload=json.dumps({'value': '0.054'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWEP/config', + payload=json.dumps( + { + 'name': 'Active energy', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'power', + 'unique_id': 'CE301_00123456_POWEP', + 'object_id': 'CE301_00123456_POWEP', + 'unit_of_measurement': 'kW', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_POWEP/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456/CE301_00123456_POWEP/state', + payload=json.dumps({'value': '0.5266'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_0/config', + payload=json.dumps( + { + 'name': 'Voltage, phase A', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'voltage', + 'unique_id': 'CE301_00123456_VOLTA_0', + 'object_id': 'CE301_00123456_VOLTA_0', + 'unit_of_measurement': 'V', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_0/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_0/state', + payload=json.dumps({'value': '233.751'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_1/config', + payload=json.dumps( + { + 'name': 'Voltage, phase B', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'voltage', + 'unique_id': 'CE301_00123456_VOLTA_1', + 'object_id': 'CE301_00123456_VOLTA_1', + 'unit_of_measurement': 'V', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_1/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_1/state', + payload=json.dumps({'value': '235.418'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_2/config', + payload=json.dumps( + { + 'name': 'Voltage, phase C', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'voltage', + 'unique_id': 'CE301_00123456_VOLTA_2', + 'object_id': 'CE301_00123456_VOLTA_2', + 'unit_of_measurement': 'V', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_2/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VOLTA_2/state', + payload=json.dumps({'value': '234.796'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VNULL/config', + payload=json.dumps( + { + 'name': 'Neutral voltage', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'voltage', + 'unique_id': 'CE301_00123456_VNULL', + 'object_id': 'CE301_00123456_VNULL', + 'unit_of_measurement': 'V', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_VNULL/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456/CE301_00123456_VNULL/state', + payload=json.dumps({'value': '0'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_0/config', + payload=json.dumps( + { + 'name': 'Current, phase A', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_0', + 'object_id': 'CE301_00123456_CURRE_0', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_0/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_0/state', + payload=json.dumps({'value': '1.479'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_1/config', + payload=json.dumps( + { + 'name': 'Current, phase B', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_1', + 'object_id': 'CE301_00123456_CURRE_1', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_1/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_1/state', + payload=json.dumps({'value': '2.8716'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_2/config', + payload=json.dumps( + { + 'name': 'Current, phase C', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_2', + 'object_id': 'CE301_00123456_CURRE_2', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_2/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_2/state', + payload=json.dumps({'value': '0.782'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_FREQU/config', + payload=json.dumps( + { + 'name': 'Frequency', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'frequency', + 'unique_id': 'CE301_00123456_FREQU', + 'object_id': 'CE301_00123456_FREQU', + 'unit_of_measurement': 'Hz', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_FREQU/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_FREQU/state', + payload=json.dumps({'value': '49.96'}), + ), + # MQTT calls for HASS entry with auto-indexed name + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_0/config', + payload=json.dumps( + { + 'name': 'Current 0', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_INDEXED_0', + 'object_id': 'CE301_00123456_CURRE_INDEXED_0', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_0/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_0/state', + payload=json.dumps({'value': '1.479'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_1/config', + payload=json.dumps( + { + 'name': 'Current 1', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_INDEXED_1', + 'object_id': 'CE301_00123456_CURRE_INDEXED_1', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_1/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_1/state', + payload=json.dumps({'value': '2.8716'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_2/config', + payload=json.dumps( + { + 'name': 'Current 2', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_INDEXED_2', + 'object_id': 'CE301_00123456_CURRE_INDEXED_2', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_2/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_2/state', + payload=json.dumps({'value': '0.782'}), + ), + # MQTT calls for HASS entry with fallback names + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_0/config', + payload=json.dumps( + { + 'name': 'Current, phase A', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_0', + 'object_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_0', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_0' + '/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_0/state', + payload=json.dumps({'value': '1.479'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_1/config', + payload=json.dumps( + { + 'name': 'CURRE 1', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_1', + 'object_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_1', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_1' + '/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_1/state', + payload=json.dumps({'value': '2.8716'}), + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_2/config', + payload=json.dumps( + { + 'name': 'CURRE 2', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'current', + 'unique_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_2', + 'object_id': 'CE301_00123456_CURRE_INDEXED_FALLBACK_2', + 'unit_of_measurement': 'A', + 'state_class': 'measurement', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_2' + '/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_CURRE_INDEXED_FALLBACK_2/state', + payload=json.dumps({'value': '0.782'}), + ), + call( + topic='homeassistant/binary_sensor/CE301_00123456' + '/CE301_00123456_IS_ONLINE/config', + payload=json.dumps( + { + 'name': 'Meter online status', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'connectivity', + 'unique_id': 'CE301_00123456_IS_ONLINE', + 'object_id': 'CE301_00123456_IS_ONLINE', + 'state_topic': 'homeassistant/binary_sensor/CE301_00123456' + '/CE301_00123456_IS_ONLINE' + '/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + call( + topic='homeassistant/binary_sensor/CE301_00123456' + '/CE301_00123456_IS_ONLINE/state', + payload=json.dumps({'value': 'ON'}), + ), + call( + topic='homeassistant/binary_sensor/CE301_00123456' + '/CE301_00123456_IS_ONLINE/state', + payload=json.dumps({'value': 'OFF'}), + ), +] + +CONFIG_YAML = ''' + general: + oneshot: true + meter: + port: dummy_serial + password: dummy + timeout: 1 + mqtt: + # This is important as Docker-backed tests spawn the broker exposed + # on the localhost + host: 127.0.0.1 + user: mqtt_dummy_user + password: mqtt_dummy_password + # Leveraged by Docker-based tests since the broker is TLS-unaware + tls: false + parameters: + - address: ET0PE + device_class: energy + name: Cumulative energy + response_idx: 0 + state_class: total_increasing + unit: kWh + - address: ECMPE + device_class: energy + name: Monthly energy + response_idx: 0 + state_class: total + unit: kWh + - additional_data: '04.22' + address: ENMPE + device_class: energy + entity_name: ENMPE_PREV_MONTH + name: Cumulative energy, previous month + response_idx: 0 + state_class: total_increasing + unit: kWh + - additional_data: '04.22' + address: EAMPE + device_class: energy + entity_name: ECMPE_PREV_MONTH + name: Previous month energy + response_idx: 0 + state_class: total + unit: kWh + - address: ECDPE + device_class: energy + name: Daily energy + response_idx: 0 + state_class: total + unit: kWh + - address: POWPP + device_class: power + name: + - Active energy, phase A + - Active energy, phase B + - Active energy, phase C + state_class: measurement + unit: kW + - address: POWEP + device_class: power + name: Active energy + state_class: measurement + unit: kW + - address: VOLTA + device_class: voltage + name: + - Voltage, phase A + - Voltage, phase B + - Voltage, phase C + state_class: measurement + unit: V + - address: VNULL + device_class: voltage + name: Neutral voltage + state_class: measurement + unit: V + - address: CURRE + device_class: current + name: + - Current, phase A + - Current, phase B + - Current, phase C + state_class: measurement + unit: A + - address: FREQU + device_class: frequency + name: Frequency + state_class: measurement + unit: Hz + - address: ECDPE + device_class: energy + name: Daily energy + response_idx: 100 + state_class: total + unit: kWh + - address: CURRE + device_class: current + name: Current + state_class: measurement + entity_name: CURRE_INDEXED + unit: A + - address: CURRE + device_class: current + name: + - Current, phase A + state_class: measurement + unit: A + entity_name: CURRE_INDEXED_FALLBACK +''' + + +@pytest.fixture +def mock_config(request): + ''' + Provides mocked configuration file, to be used as context manager. + ''' + config_yaml = getattr( + request.node.get_closest_marker("config_yaml"), + 'args', [CONFIG_YAML] + )[0] + + # Perform communication with the device and issue MQTT calls + with patch( + 'energomera_hass_mqtt.config.EnergomeraConfig._read_config', + return_value=config_yaml + ): + with patch.object(sys, 'argv', ['dummy']): + yield + + +@pytest.fixture +def mock_serial(request): + ''' + Provides necessary serial mocks, to be used as context manager. + ''' + serial_exchange = getattr( + request.node.get_closest_marker("serial_exchange"), + 'args', [SERIAL_EXCHANGE_COMPLETE] + )[0] + simulate_timeout = getattr( + request.node.get_closest_marker("serial_simulate_timeout"), + 'args', [False] + )[0] + + # Mock certain methods of 'iec62056_21' package (serial communications) to + # prevent serial calls + with patch.multiple(iec62056_21.transports.SerialTransport, + switch_baudrate=DEFAULT, disconnect=DEFAULT, + connect=DEFAULT): + # Mock the calls we interested in + with patch.multiple(iec62056_21.transports.SerialTransport, + _send=DEFAULT, _recv=DEFAULT) as mocks: + # Simulate data received from serial port. + mocked_serial_exchange = [ + # Accessing `bytes` by subscription or via iterator (what + # `side_effect` internally does for iterable) will result in + # integer, so to retain the type the nested arrays each + # containing single `bytes` is used + bytes([y]) for y in + # Produces array of bytes of all serial exchange fragments + reduce( + lambda x, y: x + y, + [x['send_bytes'] for x in serial_exchange] + ) + ] + + if simulate_timeout: + # Simulate timeout occured at the end of mocked serial + # exchange. Some initial packets are needed to grab meter + # identification so we can test online sensor (it depends on + # those) + mocked_serial_exchange[-1] = TimeoutError + + mocks['_recv'].side_effect = mocked_serial_exchange + + yield mocks + + +@pytest.fixture +def mock_mqtt(): + ''' + Provides necessary MQTT mocks, to be used as context manager. + ''' + # Mock the calls we interested in + with patch.multiple(MqttClient, + publish=DEFAULT, connect=DEFAULT, + new_callable=AsyncMock) as mocks: + yield mocks diff --git a/tests/test_config_payload_update.py b/tests/test_config_payload_update.py new file mode 100644 index 0000000..5c6f034 --- /dev/null +++ b/tests/test_config_payload_update.py @@ -0,0 +1,142 @@ +''' +Tests for handling HomeAssistant configuration payloads. +''' +import json +from unittest.mock import call +import pytest +from energomera_hass_mqtt.main import main + +serial_exchange = [ + { + 'receive_bytes': b'/?!\r\n', + 'send_bytes': b'/EKT5CE301v12\r\n', + }, + { + 'receive_bytes': b'\x06051\r\n', + 'send_bytes': b'\x01P0\x02(777777)\x03\x20', + }, + { + 'receive_bytes': b'\x01P1\x02(dummy)\x03\x03', + 'send_bytes': b'\x06', + }, + { + 'receive_bytes': b'\x01R1\x02HELLO()\x03M', + 'send_bytes': b'\x02HELLO(2,CE301,12,00123456,dummy)\r\n\x03\x01', + }, + { + 'receive_bytes': b'\x01R1\x02ET0PE()\x037', + 'send_bytes': + b'\x02ET0PE(16907.9477764)\r\n' + b'ET0PE(11504.3875082)\r\n' + b'ET0PE(3628.2698795)\r\n' + b'ET0PE(1775.2903887)\r\n' + b'ET0PE(0.0)\r\n' + b'ET0PE(0.0)\r\n\x03\x04', + }, + { + 'receive_bytes': b'\x01R1\x02ET0PE()\x037', + 'send_bytes': + b'\x02ET0PE(16907.9477764)\r\n' + b'ET0PE(11504.3875082)\r\n' + b'ET0PE(3628.2698795)\r\n' + b'ET0PE(1775.2903887)\r\n' + b'ET0PE(0.0)\r\n' + b'ET0PE(0.0)\r\n\x03\x04', + }, +] + +CONFIG_YAML = ''' + general: + oneshot: true + meter: + port: dummy_serial + password: dummy + timeout: 1 + mqtt: + # This is important as Docker-backed tests spawn the broker exposed + # on the localhost + host: 127.0.0.1 + user: mqtt_dummy_user + password: mqtt_dummy_password + parameters: + - address: ET0PE + device_class: energy + name: Cumulative energy (updated) + response_idx: 0 + state_class: total_increasing + unit: kWh + - address: ET0PE + device_class: energy + name: Cumulative energy (updated 1) + response_idx: 0 + state_class: total_increasing + unit: kWh +''' + + +@pytest.mark.usefixtures('mock_config') +@pytest.mark.serial_exchange(serial_exchange) +@pytest.mark.config_yaml(CONFIG_YAML) +def test_config_payload_update(mock_serial, mock_mqtt): + ''' + Tests for configuration payload to be sent again once it changes (e.g. + through interpolation). + ''' + main() + mock_serial['_send'].assert_has_calls( + [call(x['receive_bytes']) for x in serial_exchange] + ) + + mock_mqtt['publish'].assert_has_calls([ + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ET0PE/config', + payload=json.dumps( + { + 'name': 'Cumulative energy (updated)', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'energy', + 'unique_id': 'CE301_00123456_ET0PE', + 'object_id': 'CE301_00123456_ET0PE', + 'unit_of_measurement': 'kWh', + 'state_class': 'total_increasing', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ET0PE/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + ]) + + mock_mqtt['publish'].assert_has_calls([ + call( + topic='homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ET0PE/config', + payload=json.dumps( + { + 'name': 'Cumulative energy (updated 1)', + 'device': { + 'name': '00123456', + 'ids': 'CE301_00123456', + 'model': 'CE301', + 'sw_version': '12', + }, + 'device_class': 'energy', + 'unique_id': 'CE301_00123456_ET0PE', + 'object_id': 'CE301_00123456_ET0PE', + 'unit_of_measurement': 'kWh', + 'state_class': 'total_increasing', + 'state_topic': 'homeassistant/sensor/CE301_00123456' + '/CE301_00123456_ET0PE/state', + 'value_template': '{{ value_json.value }}', + } + ), + retain=True, + ), + ])