diff --git a/CHANGELOG.md b/CHANGELOG.md index 9858bd1..df55052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +- add MQTT topic `dcu_power` for setting output power on DCUs - Update ghcr.io/hassio-addons/base Docker tag to v17.2.5 - fix a lot of pytest-asyncio problems in the unit tests - Cleanup startup code for Quart and the Proxy diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py old mode 100644 new mode 100755 index 38d9eb9..2cf7d16 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -247,6 +247,7 @@ class SolarmanBase(Message): class SolarmanV5(SolarmanBase): AT_CMD = 1 MB_RTU_CMD = 2 + DCU_CMD = 5 AT_CMD_RSP = 8 MB_CLIENT_DATA_UP = 30 '''Data up time in client mode''' @@ -532,6 +533,26 @@ class SolarmanV5(SolarmanBase): except Exception: self.ifc.tx_clear() + def send_dcu_cmd(self, pdu: bytearray): + if self.sensor_list != 0x3026: + logger.debug(f'[{self.node_id}] DCU CMD not allowed,' + f' for sensor: {self.sensor_list:#04x}') + return + + if self.state != State.up: + logger.warning(f'[{self.node_id}] ignore DCU CMD,' + ' cause the state is not UP anymore') + return + + self.inverter.forward_dcu_cmd_resp = False + self._build_header(0x4510) + self.ifc.tx_add(struct.pack(' 1024: - logger_mqtt.error('out_coeff: value must be in' - 'the range 0..100,' - f' got: {payload}') - else: - await self.modbus_cmd(message, - Modbus.WRITE_SINGLE_REG, - 0, 0x202c, val) - except Exception: - pass - - if message.topic.matches(self.mb_reads_topic): - await self.modbus_cmd(message, - Modbus.READ_REGS, 2) - - if message.topic.matches(self.mb_inputs_topic): - await self.modbus_cmd(message, - Modbus.READ_INPUTS, 2) - - if message.topic.matches(self.mb_at_cmd_topic): - await self.at_cmd(message) + async def _out_coeff(self, message): + payload = message.payload.decode("UTF-8") + try: + val = round(float(payload) * 1024/100) + if val < 0 or val > 1024: + logger_mqtt.error('out_coeff: value must be in' + 'the range 0..100,' + f' got: {payload}') + else: + await self._modbus_cmd(message, + Modbus.WRITE_SINGLE_REG, + 0, 0x202c, val) + except Exception: + pass def each_inverter(self, message, func_name: str): topic = str(message.topic) @@ -175,7 +182,7 @@ class Mqtt(metaclass=Singleton): else: logger_mqtt.warning(f'Node_id: {node_id} not found') - async def modbus_cmd(self, message, func, params=0, addr=0, val=0): + async def _modbus_cmd(self, message, func, params=0, addr=0, val=0): payload = message.payload.decode("UTF-8") for fnc in self.each_inverter(message, "send_modbus_cmd"): res = payload.split(',') @@ -190,7 +197,22 @@ class Mqtt(metaclass=Singleton): val = int(res[1]) # lenght await fnc(func, addr, val, logging.INFO) - async def at_cmd(self, message): + async def _at_cmd(self, message): payload = message.payload.decode("UTF-8") for fnc in self.each_inverter(message, "send_at_cmd"): await fnc(payload) + + def _dcu_cmd(self, message): + payload = message.payload.decode("UTF-8") + try: + val = round(float(payload) * 10) + if val < 1000 or val > 8000: + logger_mqtt.error('dcu_power: value must be in' + 'the range 100..800,' + f' got: {payload}') + else: + pdu = struct.pack('>BBBBBBH', 1, 1, 6, 1, 0, 1, val) + for fnc in self.each_inverter(message, "send_dcu_cmd"): + fnc(pdu) + except Exception: + pass diff --git a/app/tests/test_infos.py b/app/tests/test_infos.py index 43c0050..9977a67 100644 --- a/app/tests/test_infos.py +++ b/app/tests/test_infos.py @@ -17,13 +17,13 @@ def test_statistic_counter(): assert val == None or val == 0 i.static_init() # initialize counter - assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "DCU_Command": 0, "Modbus_Command": 0}}) val = i.dev_value(Register.INVERTER_CNT) # valid and initiliazed addr assert val == 0 i.inc_counter('Inverter_Cnt') - assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "DCU_Command": 0, "Modbus_Command": 0}}) val = i.dev_value(Register.INVERTER_CNT) assert val == 1 diff --git a/app/tests/test_mqtt.py b/app/tests/test_mqtt.py old mode 100644 new mode 100755 index c6f7f49..eb68796 --- a/app/tests/test_mqtt.py +++ b/app/tests/test_mqtt.py @@ -3,8 +3,9 @@ import pytest import asyncio import aiomqtt import logging - +from aiomqtt import MqttError from mock import patch, Mock + from async_stream import AsyncIfcImpl from singleton import Singleton from mqtt import Mqtt @@ -17,7 +18,7 @@ NO_MOSQUITTO_TEST = False pytest_plugins = ('pytest_asyncio',) -@pytest.fixture(scope="module", autouse=True) +@pytest.fixture(scope="function", autouse=True) def module_init(): Singleton._instances.clear() yield @@ -44,6 +45,14 @@ def config_no_conn(test_port): Config.act_config = {'mqtt':{'host': "", 'port': test_port, 'user': '', 'passwd': ''}, 'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'} } + Config.def_config = {} + +@pytest.fixture +def config_def_conn(test_port): + Config.act_config = {'mqtt':{'host': "unknown_url", 'port': test_port, 'user': '', 'passwd': ''}, + 'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'} + } + Config.def_config = Config.act_config @pytest.fixture def spy_at_cmd(): @@ -69,6 +78,14 @@ def spy_modbus_cmd_client(): yield wrapped_conn conn.close() +@pytest.fixture +def spy_dcu_cmd(): + conn = SolarmanV5(None, ('test.local', 1234), server_side=True, client_mode= False, ifc=AsyncIfcImpl()) + conn.node_id = 'inv_3/' + with patch.object(conn, 'send_dcu_cmd', wraps=conn.send_dcu_cmd) as wrapped_conn: + yield wrapped_conn + conn.close() + def test_native_client(test_hostname, test_port): """Sanity check: Make sure the paho-mqtt client can connect to the test MQTT server. Otherwise the test set NO_MOSQUITTO_TEST to True and disable @@ -167,12 +184,81 @@ async def test_mqtt_no_config(config_no_conn): finally: await m.close() +@pytest.mark.asyncio +async def test_mqtt_except_no_config(config_no_conn, monkeypatch, caplog): + _ = config_no_conn + + assert asyncio.get_running_loop() + + async def my_aenter(self): + raise MqttError('TestException') from None + + monkeypatch.setattr(aiomqtt.Client, "__aenter__", my_aenter) + + LOGGER = logging.getLogger("mqtt") + LOGGER.propagate = True + LOGGER.setLevel(logging.INFO) + + with caplog.at_level(logging.INFO): + m = Mqtt(None) + assert m.task + await asyncio.sleep(0) + try: + await m.publish('homeassistant/status', 'online') + assert False + except MqttError: + pass + except Exception: + assert False + finally: + await m.close() + assert 'Connection lost; Reconnecting in 5 seconds' in caplog.text + +@pytest.mark.asyncio +async def test_mqtt_except_def_config(config_def_conn, monkeypatch, caplog): + _ = config_def_conn + + assert asyncio.get_running_loop() + + on_connect = asyncio.Event() + async def cb(): + on_connect.set() + + async def my_aenter(self): + raise MqttError('TestException') from None + + monkeypatch.setattr(aiomqtt.Client, "__aenter__", my_aenter) + + LOGGER = logging.getLogger("mqtt") + LOGGER.propagate = True + LOGGER.setLevel(logging.INFO) + + with caplog.at_level(logging.INFO): + m = Mqtt(cb) + assert m.task + await asyncio.sleep(0) + assert not on_connect.is_set() + try: + await m.publish('homeassistant/status', 'online') + assert False + except MqttError: + pass + except Exception: + assert False + finally: + await m.close() + assert 'MQTT is unconfigured; Check your config.toml!' in caplog.text + @pytest.mark.asyncio async def test_msg_dispatch(config_mqtt_conn, spy_modbus_cmd): _ = config_mqtt_conn spy = spy_modbus_cmd try: m = Mqtt(None) + msg = aiomqtt.Message(topic= 'homeassistant/status', payload= b'online', qos= 0, retain = False, mid= 0, properties= None) + await m.dispatch_msg(msg) + assert m.ha_restarts == 1 + msg = aiomqtt.Message(topic= 'tsun/inv_1/rated_load', payload= b'2', qos= 0, retain = False, mid= 0, properties= None) await m.dispatch_msg(msg) spy.assert_awaited_once_with(Modbus.WRITE_SINGLE_REG, 0x2008, 2, logging.INFO) @@ -197,6 +283,23 @@ async def test_msg_dispatch(config_mqtt_conn, spy_modbus_cmd): await m.dispatch_msg(msg) spy.assert_awaited_once_with(Modbus.READ_INPUTS, 0x3000, 10, logging.INFO) + # test dispatching with empty mapping table + m.topic_defs.clear() + spy.reset_mock() + msg = aiomqtt.Message(topic= 'tsun/inv_1/modbus_read_inputs', payload= b'0x3000, 10', qos= 0, retain = False, mid= 0, properties= None) + await m.dispatch_msg(msg) + spy.assert_not_called() + + # test dispatching with incomplete mapping table - invalid fnc defined + m.topic_defs.append( + {'prefix': 'entity_prefix', 'topic': '/+/modbus_read_inputs', + 'full_topic': 'tsun/+/modbus_read_inputs', 'fnc': 'invalid'} + ) + spy.reset_mock() + msg = aiomqtt.Message(topic= 'tsun/inv_1/modbus_read_inputs', payload= b'0x3000, 10', qos= 0, retain = False, mid= 0, properties= None) + await m.dispatch_msg(msg) + spy.assert_not_called() + finally: await m.close() @@ -227,6 +330,12 @@ async def test_msg_dispatch_err(config_mqtt_conn, spy_modbus_cmd): msg = aiomqtt.Message(topic= 'tsun/inv_1/modbus_read_regs', payload= b'0x3000, 10, 7', qos= 0, retain = False, mid= 0, properties= None) await m.dispatch_msg(msg) spy.assert_not_called() + + spy.reset_mock() + msg = aiomqtt.Message(topic= 'tsun/inv_1/dcu_power', payload= b'100W', qos= 0, retain = False, mid= 0, properties= None) + await m.dispatch_msg(msg) + spy.assert_not_called() + finally: await m.close() @@ -267,3 +376,31 @@ async def test_at_cmd_dispatch(config_mqtt_conn, spy_at_cmd): finally: await m.close() + +@pytest.mark.asyncio +async def test_dcu_dispatch(config_mqtt_conn, spy_dcu_cmd): + _ = config_mqtt_conn + spy = spy_dcu_cmd + try: + m = Mqtt(None) + msg = aiomqtt.Message(topic= 'tsun/inv_3/dcu_power', payload= b'100.0', qos= 0, retain = False, mid= 0, properties= None) + await m.dispatch_msg(msg) + spy.assert_called_once_with(b'\x01\x01\x06\x01\x00\x01\x03\xe8') + finally: + await m.close() + +@pytest.mark.asyncio +async def test_dcu_inv_value(config_mqtt_conn, spy_dcu_cmd): + _ = config_mqtt_conn + spy = spy_dcu_cmd + try: + m = Mqtt(None) + msg = aiomqtt.Message(topic= 'tsun/inv_3/dcu_power', payload= b'99.9', qos= 0, retain = False, mid= 0, properties= None) + await m.dispatch_msg(msg) + spy.assert_not_called() + + msg = aiomqtt.Message(topic= 'tsun/inv_3/dcu_power', payload= b'800.1', qos= 0, retain = False, mid= 0, properties= None) + await m.dispatch_msg(msg) + spy.assert_not_called() + finally: + await m.close() diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py old mode 100644 new mode 100755 index 58da012..77866cf --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -812,6 +812,26 @@ def dcu_data_rsp_msg(): # 0x1210 msg += b'\x15' return msg +@pytest.fixture +def dcu_command_ind_msg(): # 0x4510 + msg = b'\xa5\x17\x00\x10\x45\x94\x02' +get_dcu_sn() +b'\x05\x26\x30' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x01\x01\x06\x01\x00\x01\x03\xe8' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def dcu_command_rsp_msg(): # 0x1510 + msg = b'\xa5\x11\x00\x10\x15\x94\x03' +get_dcu_sn() +b'\x05\x01' + msg += total() + msg += hb() + msg += b'\x00\x00\x00\x00' + msg += b'\x01\x01\x01' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def config_tsun_allow_all(): Config.act_config = { @@ -854,7 +874,17 @@ def config_tsun_scan_dcu(): @pytest.fixture def config_tsun_dcu1(): - Config.act_config = {'solarman':{'enabled': True},'batteries':{'4100000000000001':{'monitor_sn': 2070233888, 'node_id':'inv1/', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}} + Config.act_config = { + 'ha':{ + 'auto_conf_prefix': 'homeassistant', + 'discovery_prefix': 'homeassistant', + 'entity_prefix': 'tsun', + 'proxy_node_id': 'test_1', + 'proxy_unique_id': '' + }, + 'solarman':{'enabled': True, 'host': 'test_cloud.local', 'port': 1234},'batteries':{'4100000000000001':{'monitor_sn': 2070233888, 'node_id':'inv1/', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}} + Proxy.class_init() + Proxy.mqtt = Mqtt() @pytest.mark.asyncio async def test_read_message(device_ind_msg): @@ -2402,3 +2432,123 @@ async def test_proxy_at_blocked(my_loop, config_tsun_inv1, patch_open_connection assert Proxy.mqtt.key == 'tsun/inv1/at_resp' assert Proxy.mqtt.data == "+ok" + +@pytest.mark.asyncio +async def test_dcu_cmd(my_loop, config_tsun_allow_all, dcu_dev_ind_msg, dcu_dev_rsp_msg, dcu_data_ind_msg, dcu_data_rsp_msg, dcu_command_ind_msg, dcu_command_rsp_msg): + '''test dcu_power command fpr a DCU device with sensor 0x3026''' + _ = config_tsun_allow_all + m = MemoryStream(dcu_dev_ind_msg, (0,), True) + m.read() # read device ind + assert m.control == 0x4110 + assert str(m.seq) == '01:92' + assert m.ifc.tx_fifo.get()==dcu_dev_rsp_msg + assert m.ifc.fwd_fifo.get()==dcu_dev_ind_msg + + m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8') + assert m.ifc.tx_fifo.get()==b'' + assert m.ifc.fwd_fifo.get()==b'' + assert m.sent_pdu == b'' + assert str(m.seq) == '01:92' + assert Proxy.mqtt.key == '' + assert Proxy.mqtt.data == "" + + m.append_msg(dcu_data_ind_msg) + m.read() # read inverter ind + assert m.control == 0x4210 + assert str(m.seq) == '02:93' + assert m.ifc.tx_fifo.get()==dcu_data_rsp_msg + assert m.ifc.fwd_fifo.get()==dcu_data_ind_msg + + m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8') + assert m.ifc.fwd_fifo.get() == b'' + assert m.ifc.tx_fifo.get()== b'' + assert m.sent_pdu == dcu_command_ind_msg + m.sent_pdu = bytearray() + + assert str(m.seq) == '02:94' + assert Proxy.mqtt.key == '' + assert Proxy.mqtt.data == "" + + m.append_msg(dcu_command_rsp_msg) + m.read() # read at resp + assert m.control == 0x1510 + assert str(m.seq) == '03:94' + assert m.ifc.rx_get()==b'' + assert m.ifc.tx_fifo.get()==b'' + assert m.ifc.fwd_fifo.get()==b'' + assert Proxy.mqtt.key == 'tsun/dcu_resp' + assert Proxy.mqtt.data == "+ok" + Proxy.mqtt.clear() # clear last test result + +@pytest.mark.asyncio +async def test_dcu_cmd_not_supported(my_loop, config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg): + '''test that an inverter don't accept the dcu_power command''' + _ = config_tsun_allow_all + m = MemoryStream(device_ind_msg, (0,), True) + m.read() # read device ind + assert m.control == 0x4110 + assert str(m.seq) == '01:01' + assert m.ifc.tx_fifo.get()==device_rsp_msg + assert m.ifc.fwd_fifo.get()==device_ind_msg + + m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8') + assert m.ifc.tx_fifo.get()==b'' + assert m.ifc.fwd_fifo.get()==b'' + assert m.sent_pdu == b'' + assert str(m.seq) == '01:01' + assert Proxy.mqtt.key == '' + assert Proxy.mqtt.data == "" + + m.append_msg(inverter_ind_msg) + m.read() # read inverter ind + assert m.control == 0x4210 + assert str(m.seq) == '02:02' + assert m.ifc.tx_fifo.get()==inverter_rsp_msg + assert m.ifc.fwd_fifo.get()==inverter_ind_msg + + m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8') + assert m.ifc.fwd_fifo.get() == b'' + assert m.ifc.tx_fifo.get()== b'' + assert m.sent_pdu == b'' + Proxy.mqtt.clear() # clear last test result + +@pytest.mark.asyncio +async def test_proxy_dcu_cmd(my_loop, config_tsun_dcu1, patch_open_connection, dcu_command_ind_msg, dcu_command_rsp_msg): + _ = config_tsun_inv1 + _ = patch_open_connection + assert asyncio.get_running_loop() + + with InverterTest(FakeReader(), FakeWriter(), client_mode=False) as inverter: + await inverter.create_remote() + await asyncio.sleep(0) + r = inverter.remote.stream + l = inverter.local.stream + + l.db.stat['proxy']['DCU_Command'] = 0 + l.db.stat['proxy']['AT_Command'] = 0 + l.db.stat['proxy']['Unknown_Ctrl'] = 0 + l.db.stat['proxy']['AT_Command_Blocked'] = 0 + l.db.stat['proxy']['Modbus_Command'] = 0 + inverter.forward_dcu_cmd_resp = False + r.append_msg(dcu_command_ind_msg) + r.read() # read complete msg, and dispatch msg + assert inverter.forward_dcu_cmd_resp + inverter.forward(r,l) + + assert l.ifc.tx_fifo.get()==dcu_command_ind_msg + + assert l.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert l.db.stat['proxy']['DCU_Command'] == 1 + assert l.db.stat['proxy']['AT_Command'] == 0 + assert l.db.stat['proxy']['AT_Command_Blocked'] == 0 + assert l.db.stat['proxy']['Modbus_Command'] == 0 + + l.append_msg(dcu_command_rsp_msg) + l.read() # read at resp + assert l.ifc.fwd_fifo.peek()==dcu_command_rsp_msg + inverter.forward(l,r) + assert r.ifc.tx_fifo.get()==dcu_command_rsp_msg + + assert Proxy.mqtt.key == '' + assert Proxy.mqtt.data == "" +