From 7b4ed406a1a937e9141248b272090d16b113298a Mon Sep 17 00:00:00 2001 From: Stefan Allius <122395479+s-allius@users.noreply.github.com> Date: Tue, 23 Apr 2024 22:26:01 +0200 Subject: [PATCH 001/118] Update README.md Exchange logger fw version with the real inverter fw version in the compatibility table --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a26179..b3ef585 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ In the following table you will find an overview of which inverter model has bee A combination with a red question mark should work, but I have not checked it in detail. - + From 5d0c95d6e66f9a2779eaffaa9eec1ca5387d392f Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 1 May 2024 11:57:02 +0200 Subject: [PATCH 002/118] fix typo --- app/src/gen3plus/infos_g3p.py | 2 +- app/src/infos.py | 6 ++++-- app/tests/test_infos_g3p.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index 0191d04..b0adc0a 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -19,7 +19,7 @@ class RegisterMap: 0x4102001a: {'reg': Register.HEARTBEAT_INTERVAL, 'fmt': ' Date: Wed, 1 May 2024 11:57:32 +0200 Subject: [PATCH 003/118] Add Modbus_Command counter --- app/tests/test_infos.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/tests/test_infos.py b/app/tests/test_infos.py index d3b542e..c3e6ddf 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, "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}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_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, "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, "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}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "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, "Modbus_Command": 0}}) val = i.dev_value(Register.INVERTER_CNT) assert val == 1 From 58c3333fcc85a1686594306c97e697998015d339 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 2 May 2024 23:55:59 +0200 Subject: [PATCH 004/118] initial checkin --- app/src/modbus.py | 56 ++++++++++++++++++++++++++++++++++++++++ app/src/singleton.py | 9 +++++++ app/tests/test_modbus.py | 21 +++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 app/src/modbus.py create mode 100644 app/src/singleton.py create mode 100644 app/tests/test_modbus.py diff --git a/app/src/modbus.py b/app/src/modbus.py new file mode 100644 index 0000000..9745f23 --- /dev/null +++ b/app/src/modbus.py @@ -0,0 +1,56 @@ +import struct + +if __name__ == "app.src.modbus": + from app.src.singleton import Singleton +else: # pragma: no cover + from singleton import Singleton + +####### +# TSUN uses the Modbus in the RTU transmission mode. +# see: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf +# +# A Modbus PDU consists of: 'Function-Code' + 'Data' +# A Modbus RTU message consists of: 'Addr' + 'Modbus-PDU' + 'CRC-16' +# +# The 16-bit CRC is known as CRC-16-ANSI(reverse) +# see: https://en.wikipedia.org/wiki/Computation_of_cyclic_redundancy_checks +####### + +CRC_POLY = 0xA001 # (LSBF/reverse) +CRC_INIT = 0xFFFF + + +class Modbus(metaclass=Singleton): + MB_WRITE_SINGLE_REG = 6 + MB_READ_SINGLE_REG = 3 + __crc_tab = [] + + def __init__(self): + self.__build_crc_tab(CRC_POLY) + + def build_msg(self, addr, func, reg, val): + msg = struct.pack('>BBHH', addr, func, reg, val) + msg += struct.pack(' bool: + return 0 == self.__calc_crc(msg) + + def __calc_crc(self, buffer: bytes) -> int: + crc = CRC_INIT + + for cur in buffer: + crc = (crc >> 8) ^ self.__crc_tab[(crc ^ cur) & 0xFF] + return crc + + def __build_crc_tab(self, poly) -> None: + for index in range(256): + data = index << 1 + crc = 0 + for _ in range(8, 0, -1): + data >>= 1 + if (data ^ crc) & 1: + crc = (crc >> 1) ^ poly + else: + crc >>= 1 + self.__crc_tab.append(crc) diff --git a/app/src/singleton.py b/app/src/singleton.py new file mode 100644 index 0000000..48778b9 --- /dev/null +++ b/app/src/singleton.py @@ -0,0 +1,9 @@ +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + # logger_mqtt.debug('singleton: __call__') + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, + cls).__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py new file mode 100644 index 0000000..0e9cf5b --- /dev/null +++ b/app/tests/test_modbus.py @@ -0,0 +1,21 @@ +# test_with_pytest.py +# import pytest, logging +from app.src.modbus import Modbus + + +def test_modbus_crc(): + mb = Modbus() + assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04') + assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') + assert mb.check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') + + assert 0xc803 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00') + assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') + assert mb.check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') + +def test_build_modbus_pdu(): + mb = Modbus() + pdu = mb.build_msg(1,6,0x2000,0x12) + assert pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' + assert mb.check_crc(pdu) + From 1d9cbf314e96d0bd61d383ee3eba8fd03c11e37e Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 2 May 2024 23:56:42 +0200 Subject: [PATCH 005/118] add Modbus tests --- app/tests/test_talent.py | 122 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 89fd420..fc4ed4e 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -3,6 +3,10 @@ import pytest, logging from app.src.gen3.talent import Talent, Control from app.src.config import Config from app.src.infos import Infos +from app.src.modbus import Modbus + + +pytest_plugins = ('pytest_asyncio',) # initialize the proxy statistics Infos.static_init() @@ -19,6 +23,7 @@ class MemoryStream(Talent): self.__chunk_idx = 0 self.msg_count = 0 self.addr = 'Test: SrvSide' + self.send_msg_ofs = 0 def append_msg(self, msg): self.__msg += msg @@ -50,6 +55,10 @@ class MemoryStream(Talent): self.msg_count += 1 return + async def flush_send_msg(self): + pass + + @pytest.fixture def MsgContactInfo(): # Contact Info message @@ -170,6 +179,26 @@ def MsgOtaAck(): # Over the air update rewuest from tsun cloud def MsgOtaInvalid(): # Get Time Request message return b'\x00\x00\x00\x14\x10R170000000000001\x99\x13\x01' +@pytest.fixture +def MsgModbusCmd(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x70\x77\x00\x01\xa3\x28\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + return msg + +@pytest.fixture +def MsgModbusRsp(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x91\x77\x17\x18\x19\x1a\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + return msg + +@pytest.fixture +def MsgModbusInv(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x99\x77\x17\x18\x19\x1a\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + return msg def test_read_message(MsgContactInfo): m = MemoryStream(MsgContactInfo, (0,)) @@ -740,3 +769,96 @@ def test_proxy_counter(): assert Infos.new_stat_data == {'proxy': True} assert 0 == m.db.stat['proxy']['Unknown_Msg'] m.close() + +def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(MsgModbusCmd, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==112 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==13 + assert m._forward_buffer==MsgModbusCmd + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 1 + m.close() + +def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_modbus_rep = False + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==145 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==13 + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_modbus_rep = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==145 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==13 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInv): + ConfigTsunInv1 + m = MemoryStream(MsgModbusInv, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==153 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==13 + assert m._forward_buffer==MsgModbusInv + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 1 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +@pytest.mark.asyncio +async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(b'', (0,), False) + m.id_str = b"R170000000000001" + await m.send_modbus_cmd(Modbus.MB_WRITE_SINGLE_REG, 0x2008, 0) + assert 0==m.send_msg_ofs + assert m._forward_buffer==b'' + assert m._send_buffer==MsgModbusCmd + m.close() From dba3b458ba34d787c5afae755da22ea9d542c7d5 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 2 May 2024 23:59:55 +0200 Subject: [PATCH 006/118] add Modbus support --- app/src/gen3/talent.py | 58 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 46302ac..f983c79 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -3,12 +3,15 @@ import logging import time from datetime import datetime + if __name__ == "app.src.gen3.talent": from app.src.messages import hex_dump_memory, Message + from app.src.modbus import Modbus from app.src.config import Config from app.src.gen3.infos_g3 import InfosG3 else: # pragma: no cover from messages import hex_dump_memory, Message + from modbus import Modbus from config import Config from gen3.infos_g3 import InfosG3 @@ -41,13 +44,18 @@ class Talent(Message): self.contact_name = b'' self.contact_mail = b'' self.db = InfosG3() + self.forward_modbus_rep = False self.switch = { 0x00: self.msg_contact_info, 0x13: self.msg_ota_update, 0x22: self.msg_get_time, 0x71: self.msg_collector_data, + # 0x76: + 0x77: self.msg_modbus, + # 0x78: 0x04: self.msg_inverter_data, } + self.mb = Modbus() ''' Our puplic methods @@ -115,6 +123,18 @@ class Talent(Message): f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') return + async def send_modbus_cmd(self, func, addr, val) -> None: + self.forward_modbus_rep = False + self.__build_header(0x70, 0x77) + self._send_buffer += b'\x00\x01\xa3\x28' + modbus_msg = self.mb.build_msg(1, func, addr, val) + self._send_buffer += struct.pack('!B', len(modbus_msg)) + self._send_buffer += modbus_msg + _len = self.__finish_send_msg() + hex_dump_memory(logging.INFO, 'Send Modbus Command:', + self._send_buffer[self.send_msg_ofs:], _len) + await self.flush_send_msg() + def _init_new_client_conn(self) -> bool: contact_name = self.contact_name contact_mail = self.contact_mail @@ -190,17 +210,20 @@ class Talent(Message): self.header_valid = True return - def __build_header(self, ctrl) -> None: + def __build_header(self, ctrl, msg_id=None) -> None: + if not msg_id: + msg_id = self.msg_id self.send_msg_ofs = len(self._send_buffer) self._send_buffer += struct.pack(f'!l{len(self.id_str)+1}pBB', - 0, self.id_str, ctrl, self.msg_id) - fnc = self.switch.get(self.msg_id, self.msg_unknown) + 0, self.id_str, ctrl, msg_id) + fnc = self.switch.get(msg_id, self.msg_unknown) logger.info(self.__flow_str(self.server_side, 'tx') + f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}') - def __finish_send_msg(self) -> None: + def __finish_send_msg(self) -> int: _len = len(self._send_buffer) - self.send_msg_ofs struct.pack_into('!l', self._send_buffer, self.send_msg_ofs, _len-4) + return _len def __dispatch_msg(self) -> None: fnc = self.switch.get(self.msg_id, self.msg_unknown) @@ -348,6 +371,33 @@ class Talent(Message): self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) + def parse_modbus_header(self): + + msg_hdr_len = 5 + + result = struct.unpack_from('!lB', self._recv_buffer, + self.header_len + 4) + modbus_len = result[1] + logger.debug(f'Ref: {result[0]}') + logger.debug(f'Modbus Len: {modbus_len}') + # logger.info(f'time: {datetime.utcfromtimestamp(result[2]).strftime( + # "%Y-%m-%d %H:%M:%S")}') + return msg_hdr_len, modbus_len + + def msg_modbus(self): + hdr_len, modbus_len = self.parse_modbus_header() + + if self.ctrl.is_req(): + self.forward_modbus_rep = True + self.inc_counter('Modbus_Command') + elif self.ctrl.is_ind(): + if not self.forward_modbus_rep: + return + else: + logger.warning('Unknown Ctrl') + self.inc_counter('Unknown_Ctrl') + self.forward(self._recv_buffer, self.header_len+self.data_len) + def msg_unknown(self): logger.warning(f"Unknow Msg: ID:{self.msg_id}") self.inc_counter('Unknown_Msg') From 5fdad484f480860bc96899b54fb078454eee5a83 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 00:03:02 +0200 Subject: [PATCH 007/118] add flush_send_msg() implementation --- app/src/gen3/connection_g3.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/gen3/connection_g3.py b/app/src/gen3/connection_g3.py index c93156e..3ac1e7c 100644 --- a/app/src/gen3/connection_g3.py +++ b/app/src/gen3/connection_g3.py @@ -29,6 +29,11 @@ class ConnectionG3(AsyncStream, Talent): async def async_publ_mqtt(self) -> None: pass + async def flush_send_msg(self) -> None: + self.writer.write(self._send_buffer) + await self.writer.drain() + self._send_buffer = bytearray(0) + ''' Our private methods ''' From 30dc802fb23cdae858cf362c8df26cd9f0c14adc Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 00:05:34 +0200 Subject: [PATCH 008/118] Add MQTT subscrition for modbus experiences --- app/src/mqtt.py | 57 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/app/src/mqtt.py b/app/src/mqtt.py index 5b2de02..6a69c95 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -1,22 +1,15 @@ import asyncio import logging import aiomqtt +import traceback +from modbus import Modbus +from messages import Message from config import Config +from singleton import Singleton logger_mqtt = logging.getLogger('mqtt') -class Singleton(type): - _instances = {} - - def __call__(cls, *args, **kwargs): - logger_mqtt.debug('singleton: __call__') - if cls not in cls._instances: - cls._instances[cls] = super(Singleton, - cls).__call__(*args, **kwargs) - return cls._instances[cls] - - class Mqtt(metaclass=Singleton): __client = None __cb_MqttIsUp = None @@ -65,6 +58,9 @@ class Mqtt(metaclass=Singleton): password=mqtt['passwd']) interval = 5 # Seconds + ha_status_topic = f"{ha['auto_conf_prefix']}/status" + inv_cnf_topic = "tsun/+/test" + while True: try: async with self.__client: @@ -74,16 +70,32 @@ class Mqtt(metaclass=Singleton): await self.__cb_MqttIsUp() # async with self.__client.messages() as messages: - await self.__client.subscribe( - f"{ha['auto_conf_prefix']}" - "/status") + await self.__client.subscribe(ha_status_topic) + await self.__client.subscribe(inv_cnf_topic) + async for message in self.__client.messages: - status = message.payload.decode("UTF-8") - logger_mqtt.info('Home-Assistant Status:' - f' {status}') - if status == 'online': - self.ha_restarts += 1 - await self.__cb_MqttIsUp() + if message.topic.matches(ha_status_topic): + status = message.payload.decode("UTF-8") + logger_mqtt.info('Home-Assistant Status:' + f' {status}') + if status == 'online': + self.ha_restarts += 1 + await self.__cb_MqttIsUp() + + if message.topic.matches(inv_cnf_topic): + topic = str(message.topic) + node_id = topic.split('/')[1] + '/' + payload = message.payload.decode("UTF-8") + logger_mqtt.info(f'InvCnf: {node_id}:{payload}') + for m in Message: + if m.server_side and m.node_id == node_id: + logger_mqtt.info(f'Found: {node_id}') + fnc = getattr(m, "send_modbus_cmd", None) + if callable(fnc): + # await fnc(Modbus.MB_WRITE_SINGLE_REG, + # 0x2008, 2) + await fnc(Modbus.MB_READ_SINGLE_REG, + 0x2008, 1) except aiomqtt.MqttError: if Config.is_default('mqtt'): @@ -101,3 +113,8 @@ class Mqtt(metaclass=Singleton): logger_mqtt.debug("MQTT task cancelled") self.__client = None return + except Exception: + # self.inc_counter('SW_Exception') + logger_mqtt.error( + f"Exception:\n" + f"{traceback.format_exc()}") From 494c30e4898b67ffb602a0ca5df947e4159fef16 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 18:21:15 +0200 Subject: [PATCH 009/118] renme __async_write() into async_write() --- app/proxy.svg | 562 ++++++++++++++++++++-------------------- app/proxy.yuml | 4 +- app/src/async_stream.py | 8 +- 3 files changed, 293 insertions(+), 281 deletions(-) diff --git a/app/proxy.svg b/app/proxy.svg index 588835e..dfbdd46 100644 --- a/app/proxy.svg +++ b/app/proxy.svg @@ -4,340 +4,352 @@ - + G - + A0 - - - -You can stick notes -on diagrams too! + + + +You can stick notes +on diagrams too! A1 - -Singleton + +Singleton A2 - -Mqtt - -<static>ha_restarts -<static>__client -<static>__cb_MqttIsUp - -<async>publish() -<async>close() + +Mqtt + +<static>ha_restarts +<static>__client +<static>__cb_MqttIsUp + +<async>publish() +<async>close() A1->A2 - - - - - -A10 - -Inverter - -cls.db_stat -cls.entity_prfx -cls.discovery_prfx -cls.proxy_node_id -cls.proxy_unique_id -cls.mqtt:Mqtt - - - - -A2->A10 - + + A3 - -IterRegistry - - -__iter__ + +Modbus - - -A4 - -Message - -server_side:bool -header_valid:bool -header_len:unsigned -data_len:unsigned -unique_id -node_id -sug_area -_recv_buffer:bytearray -_send_buffer:bytearray -_forward_buffer:bytearray -db:Infos -new_data:list - -_read():void<abstract> -close():void -inc_counter():void -dec_counter():void - - + -A3->A4 - - - - - -A5 - -Talent - -await_conn_resp_cnt -id_str -contact_name -contact_mail -switch - -msg_contact_info() -msg_ota_update() -msg_get_time() -msg_collector_data() -msg_inverter_data() -msg_unknown() -close() - - - -A4->A5 - - - - - -A6 - -SolarmanV5 - -control -serial -snr -switch - -msg_unknown() -close() - - - -A4->A6 - - - - - -A7 - -ConnectionG3 - -remoteStream:ConnectionG3 - -close() - - - -A5->A7 - - - - - -A8 - -ConnectionG3P - -remoteStream:ConnectionG3P - -close() - - - -A6->A8 - - - - - -A7->A7 - - -0..1 -has +A1->A3 + + A11 - -InverterG3 - -__ha_restarts - -async_create_remote() -close() + +Inverter + +cls.db_stat +cls.entity_prfx +cls.discovery_prfx +cls.proxy_node_id +cls.proxy_unique_id +cls.mqtt:Mqtt + - + -A7->A11 - - +A2->A11 + - - -A8->A8 - - -0..1 -has + + +A4 + +IterRegistry + + +__iter__ - - -A12 - -InverterG3P - -__ha_restarts - -async_create_remote() -close() + + +A5 + +Message + +server_side:bool +header_valid:bool +header_len:unsigned +data_len:unsigned +unique_id +node_id +sug_area +_recv_buffer:bytearray +_send_buffer:bytearray +_forward_buffer:bytearray +db:Infos +new_data:list + +_read():void<abstract> +close():void +inc_counter():void +dec_counter():void - - -A8->A12 - - + + +A4->A5 + + + + + +A6 + +Talent + +await_conn_resp_cnt +id_str +contact_name +contact_mail +switch + +msg_contact_info() +msg_ota_update() +msg_get_time() +msg_collector_data() +msg_inverter_data() +msg_unknown() +close() + + + +A5->A6 + + + + + +A7 + +SolarmanV5 + +control +serial +snr +switch + +msg_unknown() +close() + + + +A5->A7 + + + + + +A8 + +ConnectionG3 + +remoteStream:ConnectionG3 + +close() + + + +A6->A8 + + A9 - -AsyncStream - -reader -writer -addr -r_addr -l_addr - -<async>server_loop() -<async>client_loop() -<async>loop -disc() -close() -__async_read() -__async_write() -__async_forward() + +ConnectionG3P + +remoteStream:ConnectionG3P + +close() - + -A9->A7 - - +A7->A9 + + - - -A9->A8 - - + + +A8->A8 + + +0..1 +has - - -A10->A11 - - + + +A12 + +InverterG3 + +__ha_restarts + +async_create_remote() +close() - - -A10->A12 - - + + +A8->A12 + + + + + +A9->A9 + + +0..1 +has A13 - -Infos - -stat -new_stat_data -info_dev - -static_init() -dev_value() -inc_counter() -dec_counter() -ha_proxy_conf -ha_conf -update_db -set_db_def_value -get_db_value -ignore_this_device + +InverterG3P + +__ha_restarts + +async_create_remote() +close() + + + +A9->A13 + + + + + +A10 + +AsyncStream + +reader +writer +addr +r_addr +l_addr + +<async>server_loop() +<async>client_loop() +<async>loop +disc() +close() +__async_read() +async_write() +__async_forward() + + + +A10->A8 + + + + + +A10->A9 + + + + + +A11->A12 + + + + + +A11->A13 + + A14 - -InfosG3 - - -ha_confs() -parse() - - - -A13->A14 - - + +Infos + +stat +new_stat_data +info_dev + +static_init() +dev_value() +inc_counter() +dec_counter() +ha_proxy_conf +ha_conf +update_db +set_db_def_value +get_db_value +ignore_this_device A15 - -InfosG3P - - -ha_confs() -parse() + +InfosG3 + + +ha_confs() +parse() - + -A13->A15 - - +A14->A15 + + - - -A14->A5 - - + + +A16 + +InfosG3P + + +ha_confs() +parse() + + + +A14->A16 + + - + A15->A6 - - + + + + + +A16->A7 + + diff --git a/app/proxy.yuml b/app/proxy.yuml index 7f5be21..daf5d3c 100644 --- a/app/proxy.yuml +++ b/app/proxy.yuml @@ -4,13 +4,13 @@ [note: You can stick notes on diagrams too!{bg:cornsilk}] [Singleton]^[Mqtt|ha_restarts;__client;__cb_MqttIsUp|publish();close()] - +[Singleton]^[Modbus] [IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list|_read():void;close():void;inc_counter():void;dec_counter():void] [Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()] [Message]^[SolarmanV5|control;serial;snr;switch|msg_unknown();;close()] [Talent]^[ConnectionG3|remoteStream:ConnectionG3|close()] [SolarmanV5]^[ConnectionG3P|remoteStream:ConnectionG3P|close()] -[AsyncStream|reader;writer;addr;r_addr;l_addr|server_loop();client_loop();loop;disc();close();;__async_read();__async_write();__async_forward()]^[ConnectionG3] +[AsyncStream|reader;writer;addr;r_addr;l_addr|server_loop();client_loop();loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3] [AsyncStream]^[ConnectionG3P] [Inverter|cls.db_stat;cls.entity_prfx;cls.discovery_prfx;cls.proxy_node_id;cls.proxy_unique_id;cls.mqtt:Mqtt|]^[InverterG3|__ha_restarts|async_create_remote();;close()] [Inverter]^[InverterG3P|__ha_restarts|async_create_remote();;close()] diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 6c1136c..28873e8 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -61,7 +61,7 @@ class AsyncStream(): await self.__async_read() if self.unique_id: - await self.__async_write() + await self.async_write() await self.__async_forward() await self.async_publ_mqtt() @@ -100,9 +100,9 @@ class AsyncStream(): else: raise RuntimeError("Peer closed.") - async def __async_write(self) -> None: + async def async_write(self, headline='Transmit to ') -> None: if self._send_buffer: - hex_dump_memory(logging.INFO, f'Transmit to {self.addr}:', + hex_dump_memory(logging.INFO, f'{headline}{self.addr}:', self._send_buffer, len(self._send_buffer)) self.writer.write(self._send_buffer) await self.writer.drain() @@ -114,7 +114,7 @@ class AsyncStream(): await self.async_create_remote() if self.remoteStream: if self.remoteStream._init_new_client_conn(): - await self.remoteStream.__async_write() + await self.remoteStream.async_write() if self.remoteStream: self.remoteStream._update_header(self._forward_buffer) From fdedfcbf8e438f0ff8c897018b4d7438797d63e1 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 18:21:59 +0200 Subject: [PATCH 010/118] reneme Modbus constants --- app/src/modbus.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index 9745f23..ebaa365 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -21,8 +21,12 @@ CRC_INIT = 0xFFFF class Modbus(metaclass=Singleton): - MB_WRITE_SINGLE_REG = 6 - MB_READ_SINGLE_REG = 3 + + READ_REGS = 3 + READ_INPUTS = 4 + WRITE_SINGLE_REG = 6 + '''Modbus function codes''' + __crc_tab = [] def __init__(self): From f78d4ac31075cba554be4c3e8638bde54076e136 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 18:22:31 +0200 Subject: [PATCH 011/118] remove flush_send_msg() --- app/src/gen3/connection_g3.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/gen3/connection_g3.py b/app/src/gen3/connection_g3.py index 3ac1e7c..c93156e 100644 --- a/app/src/gen3/connection_g3.py +++ b/app/src/gen3/connection_g3.py @@ -29,11 +29,6 @@ class ConnectionG3(AsyncStream, Talent): async def async_publ_mqtt(self) -> None: pass - async def flush_send_msg(self) -> None: - self.writer.write(self._send_buffer) - await self.writer.drain() - self._send_buffer = bytearray(0) - ''' Our private methods ''' From a2f67e7d3e58f2c17ac2a1a2eebef49a37bf306f Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 18:23:08 +0200 Subject: [PATCH 012/118] use async_write() instead of flush_send_msg() --- app/src/gen3/talent.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index f983c79..ac63cef 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -3,7 +3,6 @@ import logging import time from datetime import datetime - if __name__ == "app.src.gen3.talent": from app.src.messages import hex_dump_memory, Message from app.src.modbus import Modbus @@ -44,6 +43,7 @@ class Talent(Message): self.contact_name = b'' self.contact_mail = b'' self.db = InfosG3() + self.mb = Modbus() self.forward_modbus_rep = False self.switch = { 0x00: self.msg_contact_info, @@ -55,7 +55,6 @@ class Talent(Message): # 0x78: 0x04: self.msg_inverter_data, } - self.mb = Modbus() ''' Our puplic methods @@ -126,14 +125,12 @@ class Talent(Message): async def send_modbus_cmd(self, func, addr, val) -> None: self.forward_modbus_rep = False self.__build_header(0x70, 0x77) - self._send_buffer += b'\x00\x01\xa3\x28' + self._send_buffer += b'\x00\x01\xa3\x28' # fixme modbus_msg = self.mb.build_msg(1, func, addr, val) self._send_buffer += struct.pack('!B', len(modbus_msg)) self._send_buffer += modbus_msg - _len = self.__finish_send_msg() - hex_dump_memory(logging.INFO, 'Send Modbus Command:', - self._send_buffer[self.send_msg_ofs:], _len) - await self.flush_send_msg() + self.__finish_send_msg() + await self.async_write('Send Modbus Command:') def _init_new_client_conn(self) -> bool: contact_name = self.contact_name @@ -220,10 +217,9 @@ class Talent(Message): logger.info(self.__flow_str(self.server_side, 'tx') + f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}') - def __finish_send_msg(self) -> int: + def __finish_send_msg(self) -> None: _len = len(self._send_buffer) - self.send_msg_ofs struct.pack_into('!l', self._send_buffer, self.send_msg_ofs, _len-4) - return _len def __dispatch_msg(self) -> None: fnc = self.switch.get(self.msg_id, self.msg_unknown) @@ -375,11 +371,11 @@ class Talent(Message): msg_hdr_len = 5 - result = struct.unpack_from('!lB', self._recv_buffer, - self.header_len + 4) + result = struct.unpack_from('!lBB', self._recv_buffer, + self.header_len) modbus_len = result[1] logger.debug(f'Ref: {result[0]}') - logger.debug(f'Modbus Len: {modbus_len}') + logger.debug(f'Modbus MsgLen: {modbus_len} Func:{result[2]}') # logger.info(f'time: {datetime.utcfromtimestamp(result[2]).strftime( # "%Y-%m-%d %H:%M:%S")}') return msg_hdr_len, modbus_len From 763af8b4cfcff38441fdff78b89843c44f6d3a56 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 18:24:06 +0200 Subject: [PATCH 013/118] add send_modbus_cmd() --- app/src/gen3plus/solarman_v5.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index d23efa3..83f4d49 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -6,12 +6,14 @@ from datetime import datetime if __name__ == "app.src.gen3plus.solarman_v5": from app.src.messages import hex_dump_memory, Message + from app.src.modbus import Modbus from app.src.config import Config from app.src.gen3plus.infos_g3p import InfosG3P from app.src.infos import Register else: # pragma: no cover from messages import hex_dump_memory, Message from config import Config + from modbus import Modbus from gen3plus.infos_g3p import InfosG3P from infos import Register # import traceback @@ -56,6 +58,8 @@ class SolarmanV5(Message): self.snr = 0 self.db = InfosG3P() self.time_ofs = 0 + self.mb = Modbus() + self.forward_modbus_rep = False self.switch = { 0x4210: self.msg_data_ind, # real time data @@ -293,6 +297,14 @@ class SolarmanV5(Message): self._heartbeat()) self.__finish_send_msg() + async def send_modbus_cmd(self, func, addr, val) -> None: + self.forward_modbus_rep = False + self.__build_header(0x4510) + self._send_buffer += struct.pack(' None: self.__build_header(0x4510) self._send_buffer += struct.pack(f' Date: Fri, 3 May 2024 18:24:48 +0200 Subject: [PATCH 014/118] use async_write instead of flush_send_msg() --- app/tests/test_talent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index fc4ed4e..7c996ab 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -55,7 +55,7 @@ class MemoryStream(Talent): self.msg_count += 1 return - async def flush_send_msg(self): + async def async_write(self, headline=''): pass @@ -857,7 +857,7 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 m = MemoryStream(b'', (0,), False) m.id_str = b"R170000000000001" - await m.send_modbus_cmd(Modbus.MB_WRITE_SINGLE_REG, 0x2008, 0) + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) assert 0==m.send_msg_ofs assert m._forward_buffer==b'' assert m._send_buffer==MsgModbusCmd From 3dbcee63f6907ac08bd6e95fba55d6d1e68198ec Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 3 May 2024 18:25:37 +0200 Subject: [PATCH 015/118] add Modbus topics --- app/src/mqtt.py | 60 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/app/src/mqtt.py b/app/src/mqtt.py index 6a69c95..9d6e83e 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -59,7 +59,9 @@ class Mqtt(metaclass=Singleton): interval = 5 # Seconds ha_status_topic = f"{ha['auto_conf_prefix']}/status" - inv_cnf_topic = "tsun/+/test" + mb_rated_topic = "tsun/+/rated_load" # fixme + mb_reads_topic = "tsun/+/modbus_read_regs" # fixme + mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme while True: try: @@ -71,7 +73,9 @@ class Mqtt(metaclass=Singleton): # async with self.__client.messages() as messages: await self.__client.subscribe(ha_status_topic) - await self.__client.subscribe(inv_cnf_topic) + await self.__client.subscribe(mb_rated_topic) + await self.__client.subscribe(mb_reads_topic) + await self.__client.subscribe(mb_inputs_topic) async for message in self.__client.messages: if message.topic.matches(ha_status_topic): @@ -82,20 +86,18 @@ class Mqtt(metaclass=Singleton): self.ha_restarts += 1 await self.__cb_MqttIsUp() - if message.topic.matches(inv_cnf_topic): - topic = str(message.topic) - node_id = topic.split('/')[1] + '/' - payload = message.payload.decode("UTF-8") - logger_mqtt.info(f'InvCnf: {node_id}:{payload}') - for m in Message: - if m.server_side and m.node_id == node_id: - logger_mqtt.info(f'Found: {node_id}') - fnc = getattr(m, "send_modbus_cmd", None) - if callable(fnc): - # await fnc(Modbus.MB_WRITE_SINGLE_REG, - # 0x2008, 2) - await fnc(Modbus.MB_READ_SINGLE_REG, - 0x2008, 1) + if message.topic.matches(mb_rated_topic): + await self.modbus_cmd(message, + Modbus.WRITE_SINGLE_REG, + 1, 0x2008) + + if message.topic.matches(mb_reads_topic): + await self.modbus_cmd(message, + Modbus.READ_REGS, 2) + + if message.topic.matches(mb_inputs_topic): + await self.modbus_cmd(message, + Modbus.READ_INPUTS, 2) except aiomqtt.MqttError: if Config.is_default('mqtt'): @@ -114,7 +116,31 @@ class Mqtt(metaclass=Singleton): self.__client = None return except Exception: - # self.inc_counter('SW_Exception') + # self.inc_counter('SW_Exception') # fixme logger_mqtt.error( f"Exception:\n" f"{traceback.format_exc()}") + + async def modbus_cmd(self, message, func, params=0, addr=0, val=0): + topic = str(message.topic) + node_id = topic.split('/')[1] + '/' + # refactor into a loop over a table + payload = message.payload.decode("UTF-8") + logger_mqtt.info(f'InvCnf: {node_id}:{payload}') + for m in Message: + if m.server_side and m.node_id == node_id: + logger_mqtt.info(f'Found: {node_id}') + fnc = getattr(m, "send_modbus_cmd", None) + res = payload.split(',') + if params != len(res): + logger_mqtt.error(f'Parameter expected: {params}, ' + f'got: {len(res)}') + return + + if callable(fnc): + if params == 1: + val = int(payload) + elif params == 2: + addr = int(res[0], base=16) + val = int(res[1]) # lenght + await fnc(func, addr, val) From eda8ef1db634f030c2a3a8ab3d0d730f9e0e14e1 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 5 May 2024 20:13:51 +0200 Subject: [PATCH 016/118] add Modbus and AT command handler --- app/src/gen3plus/solarman_v5.py | 101 +++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 28 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 83f4d49..a459e89 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -48,6 +48,8 @@ class Sequence(): class SolarmanV5(Message): + AT_CMD = 1 + MB_RTU_CMD = 2 def __init__(self, server_side: bool): super().__init__(server_side) @@ -59,7 +61,7 @@ class SolarmanV5(Message): self.db = InfosG3P() self.time_ofs = 0 self.mb = Modbus() - self.forward_modbus_rep = False + self.forward_modbus_resp = False self.switch = { 0x4210: self.msg_data_ind, # real time data @@ -88,7 +90,7 @@ class SolarmanV5(Message): # # MODbus or AT cmd 0x4510: self.msg_command_req, # from server - 0x1510: self.msg_response, # from inverter + 0x1510: self.msg_command_rsp, # from inverter } ''' @@ -298,23 +300,48 @@ class SolarmanV5(Message): self.__finish_send_msg() async def send_modbus_cmd(self, func, addr, val) -> None: - self.forward_modbus_rep = False + self.forward_modbus_resp = False self.__build_header(0x4510) - self._send_buffer += struct.pack(' None: + async def send_at_cmd(self, AT_cmd: str) -> None: self.__build_header(0x4510) - self._send_buffer += struct.pack(f'> 8 @@ -325,21 +352,7 @@ class SolarmanV5(Message): self.new_data[key] = True if inv_update: - db = self.db - MaxPow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0) - Rated = db.get_db_value(Register.RATED_POWER, 0) - Model = None - if MaxPow == 2000: - if Rated == 800 or Rated == 600: - Model = f'TSOL-MS{MaxPow}({Rated})' - else: - Model = f'TSOL-MS{MaxPow}' - elif MaxPow == 1800 or MaxPow == 1600: - Model = f'TSOL-MS{MaxPow}' - if Model: - logger.info(f'Model: {Model}') - self.db.set_db_def_value(Register.EQUIPMENT_MODEL, Model) - + self.__build_model_name() ''' Message handler methods ''' @@ -402,11 +415,42 @@ class SolarmanV5(Message): data = self._recv_buffer[self.header_len:] result = struct.unpack_from(' 4: + logger.info(f'first byte modbus:{data[14]}') + inv_update = False + for key, update in self.mb.recv_resp(self.db, data[14:-2]): + if update: + if key == 'inverter': + inv_update = True + self.new_data[key] = True + + if inv_update: + self.__build_model_name() + + if not self.forward_modbus_resp: + return + self.__forward_msg() + def msg_hbeat_ind(self): data = self._recv_buffer[self.header_len:] result = struct.unpack_from(' Date: Sun, 5 May 2024 20:14:51 +0200 Subject: [PATCH 017/118] add modbus resp handler --- app/src/gen3/talent.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index ac63cef..6225aa0 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -44,7 +44,7 @@ class Talent(Message): self.contact_mail = b'' self.db = InfosG3() self.mb = Modbus() - self.forward_modbus_rep = False + self.forward_modbus_resp = False self.switch = { 0x00: self.msg_contact_info, 0x13: self.msg_ota_update, @@ -123,14 +123,17 @@ class Talent(Message): return async def send_modbus_cmd(self, func, addr, val) -> None: - self.forward_modbus_rep = False + self.forward_modbus_resp = False self.__build_header(0x70, 0x77) - self._send_buffer += b'\x00\x01\xa3\x28' # fixme - modbus_msg = self.mb.build_msg(1, func, addr, val) + self._send_buffer += b'\x00\x01\xa3\x28' # fixme + modbus_msg = self.mb.build_msg(Modbus.INV_ADDR, func, addr, val) self._send_buffer += struct.pack('!B', len(modbus_msg)) self._send_buffer += modbus_msg self.__finish_send_msg() - await self.async_write('Send Modbus Command:') + try: + await self.async_write('Send Modbus Command:') + except Exception: + self._send_buffer = bytearray(0) def _init_new_client_conn(self) -> bool: contact_name = self.contact_name @@ -384,10 +387,16 @@ class Talent(Message): hdr_len, modbus_len = self.parse_modbus_header() if self.ctrl.is_req(): - self.forward_modbus_rep = True + self.forward_modbus_resp = True self.inc_counter('Modbus_Command') elif self.ctrl.is_ind(): - if not self.forward_modbus_rep: + logger.debug(f'Modbus Ind MsgLen: {modbus_len}') + for key, update in self.mb.recv_resp(self.db, self._recv_buffer[ + self.header_len + hdr_len:]): + if update: + self.new_data[key] = True + + if not self.forward_modbus_resp: return else: logger.warning('Unknown Ctrl') From 808bf2fe873642149c7d187e07211b0b8381c20a Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 5 May 2024 20:15:36 +0200 Subject: [PATCH 018/118] add MQTT topic for AT commands --- app/src/mqtt.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/mqtt.py b/app/src/mqtt.py index 9d6e83e..7257038 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -62,6 +62,7 @@ class Mqtt(metaclass=Singleton): mb_rated_topic = "tsun/+/rated_load" # fixme mb_reads_topic = "tsun/+/modbus_read_regs" # fixme mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme + mb_at_cmd_topic = "tsun/+/at_cmd" # fixme while True: try: @@ -76,6 +77,7 @@ class Mqtt(metaclass=Singleton): await self.__client.subscribe(mb_rated_topic) await self.__client.subscribe(mb_reads_topic) await self.__client.subscribe(mb_inputs_topic) + await self.__client.subscribe(mb_at_cmd_topic) async for message in self.__client.messages: if message.topic.matches(ha_status_topic): @@ -99,6 +101,9 @@ class Mqtt(metaclass=Singleton): await self.modbus_cmd(message, Modbus.READ_INPUTS, 2) + if message.topic.matches(mb_at_cmd_topic): + await self.at_cmd(message) + except aiomqtt.MqttError: if Config.is_default('mqtt'): logger_mqtt.info( @@ -116,11 +121,26 @@ class Mqtt(metaclass=Singleton): self.__client = None return except Exception: - # self.inc_counter('SW_Exception') # fixme + # self.inc_counter('SW_Exception') # fixme logger_mqtt.error( f"Exception:\n" f"{traceback.format_exc()}") + def each_inverter(self, message, func_name: str): + topic = str(message.topic) + node_id = topic.split('/')[1] + '/' + for m in Message: + if m.server_side and m.node_id == node_id: + logger_mqtt.debug(f'Found: {node_id}') + fnc = getattr(m, func_name, None) + if callable(fnc): + yield fnc + else: + logger_mqtt.warning(f'Cmd not supported by: {node_id}') + + else: + logger_mqtt.warning(f'Node_id: {node_id} not found') + async def modbus_cmd(self, message, func, params=0, addr=0, val=0): topic = str(message.topic) node_id = topic.split('/')[1] + '/' @@ -144,3 +164,8 @@ class Mqtt(metaclass=Singleton): addr = int(res[0], base=16) val = int(res[1]) # lenght await fnc(func, addr, val) + + 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) From 283ae31af2f4e4c55b162ffa5b18041352031fdf Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 5 May 2024 20:16:28 +0200 Subject: [PATCH 019/118] parse modbus message and store values in db --- app/src/modbus.py | 112 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index ebaa365..0f16705 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -1,9 +1,11 @@ import struct +import logging +from typing import Generator if __name__ == "app.src.modbus": - from app.src.singleton import Singleton + from app.src.infos import Register else: # pragma: no cover - from singleton import Singleton + from infos import Register ####### # TSUN uses the Modbus in the RTU transmission mode. @@ -20,23 +22,123 @@ CRC_POLY = 0xA001 # (LSBF/reverse) CRC_INIT = 0xFFFF -class Modbus(metaclass=Singleton): - +class Modbus(): + INV_ADDR = 1 READ_REGS = 3 READ_INPUTS = 4 WRITE_SINGLE_REG = 6 '''Modbus function codes''' __crc_tab = [] + map = { + 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 + 0x420100c0: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 + 0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'v{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 + 0x3009: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x300a: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x300c: {'reg': Register.INVERTER_TEMP, 'fmt': '!H', 'eval': 'result-40'}, # noqa: E501 + 0x300e: {'reg': Register.RATED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 + 0x300f: {'reg': Register.OUTPUT_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3010: {'reg': Register.PV1_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3011: {'reg': Register.PV1_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3012: {'reg': Register.PV1_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3013: {'reg': Register.PV2_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3014: {'reg': Register.PV2_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3015: {'reg': Register.PV2_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3016: {'reg': Register.PV3_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3017: {'reg': Register.PV3_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x3018: {'reg': Register.PV3_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x3019: {'reg': Register.PV4_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x301a: {'reg': Register.PV4_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x301b: {'reg': Register.PV4_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 + 0x301c: {'reg': Register.DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x420100fa: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x301f: {'reg': Register.PV1_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x42010100: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3022: {'reg': Register.PV2_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x42010106: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3025: {'reg': Register.PV3_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x4201010c: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3028: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 + 0x42010112: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + } def __init__(self): - self.__build_crc_tab(CRC_POLY) + if not len(self.__crc_tab): + self.__build_crc_tab(CRC_POLY) + self.last_fcode = 0 + self.last_len = 0 + self.last_reg = 0 def build_msg(self, addr, func, reg, val): msg = struct.pack('>BBHH', addr, func, reg, val) msg += struct.pack(' bool: + logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') + if not self.check_crc(buf): + logging.error('Modbus: CRC error') + return False + if buf[0] != self.INV_ADDR: + logging.info(f'Modbus: Wrong addr{buf[0]}') + return False + res = struct.unpack_from('>BHH', buf, 1) + self.last_fcode = res[0] + self.last_reg = res[1] + self.last_len = res[2] + return True + + def recv_resp(self, info_db, buf: bytearray) -> Generator[tuple[str, bool], + None, None]: + logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') + if not self.check_crc(buf): + logging.error('Modbus: CRC error') + return + if buf[0] != self.INV_ADDR: + logging.info(f'Modbus: Wrong addr {buf[0]}') + return + if buf[1] != self.last_fcode: + logging.info(f'Modbus: Wrong fcode {buf[1]} != {self.last_fcode}') + return + elmlen = buf[2] >> 1 + if elmlen != self.last_len: + logging.info(f'Modbus: len error {elmlen} != {self.last_len}') + return + + for i in range(0, elmlen): + val = struct.unpack_from('>H', buf, 3+2*i) + addr = self.last_reg+i + # logging.info(f'Modbus: 0x{addr:04x}: {val[0]}') + if addr in self.map: + row = self.map[addr] + info_id = row['reg'] + result = val[0] + # fmt = row['fmt'] + # res = struct.unpack_from(fmt, buf, addr) + # result = res[0] + + if 'eval' in row: + result = eval(row['eval']) + if 'ratio' in row: + result = round(result * row['ratio'], 2) + + keys, level, unit, must_incr = info_db._key_obj(info_id) + + if keys: + name, update = info_db.update_db(keys, must_incr, result) + yield keys[0], update + else: + name = str(f'info-id.0x{addr:x}') + update = False + + info_db.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}' + f' update: {update}') + def check_crc(self, msg) -> bool: return 0 == self.__calc_crc(msg) From 5822f5de50feb8aa564b7ca43a8669a8409d6ccc Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 5 May 2024 20:18:19 +0200 Subject: [PATCH 020/118] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddf9b0..0eede6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- parse Modbus values and store them in the database +- add cron task to request the output power every minute +- GEN3PLUS: add MQTT topics to send AT commands to the inverter +- add MQTT topics to send Modbus commands to the inverter - convert data collect interval to minutes - add postfix for rc and dev versions to the version number - change logging level to DEBUG for some logs From 29ee540a19c3b00814e796a7e7225a71dcff0499 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 5 May 2024 20:18:45 +0200 Subject: [PATCH 021/118] add cron tasks for modbus requests every minute --- app/src/scheduler.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/scheduler.py b/app/src/scheduler.py index b5d238d..dc45890 100644 --- a/app/src/scheduler.py +++ b/app/src/scheduler.py @@ -3,6 +3,8 @@ import json from mqtt import Mqtt from aiocron import crontab from infos import ClrAtMidnight +from modbus import Modbus +from messages import Message logger_mqtt = logging.getLogger('mqtt') @@ -17,7 +19,9 @@ class Schedule: cls.mqtt = Mqtt(None) crontab('0 0 * * *', func=cls.atmidnight, start=True) - # crontab('*/5 * * * *', func=cls.atmidnight, start=True) + + # every minute + crontab('* * * * *', func=cls.regular_modbus_cmds, start=True) @classmethod async def atmidnight(cls) -> None: @@ -28,3 +32,12 @@ class Schedule: logger_mqtt.debug(f'{key}: {data}') data_json = json.dumps(data) await cls.mqtt.publish(f"{key}", data_json) + + @classmethod + async def regular_modbus_cmds(cls): + # logging.info("Regular Modbus requests") + for m in Message: + if m.server_side: + fnc = getattr(m, "send_modbus_cmd", None) + if callable(fnc): + await fnc(Modbus.READ_REGS, 0x300e, 2) From bf0f152d5a26ac79e8549fb50d136fd8a0955ce1 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 5 May 2024 20:20:19 +0200 Subject: [PATCH 022/118] add unit tests for modbus --- app/tests/test_modbus.py | 20 ++++++++++++++++++++ app/tests/test_solarman.py | 21 ++++++++++++++------- app/tests/test_talent.py | 4 ++-- system_tests/test_tcp_socket.py | 2 +- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index 0e9cf5b..fcec232 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -1,7 +1,12 @@ # test_with_pytest.py # import pytest, logging from app.src.modbus import Modbus +from app.src.infos import Infos +class TestHelper(Modbus): + def __init__(self): + super().__init__() + self.db = Infos() def test_modbus_crc(): mb = Modbus() @@ -19,3 +24,18 @@ def test_build_modbus_pdu(): assert pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' assert mb.check_crc(pdu) +def test_build_recv(): + mb = TestHelper() + pdu = mb.build_msg(1,3,0x300e,0x2) + assert pdu == b'\x01\x03\x30\x0e\x00\x02\xaa\xc8' + assert mb.check_crc(pdu) + call = 0 + for key, update in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4'): + if key == 'grid': + assert update == True + elif key == 'inverter': + assert update == True + else: + assert False + call += 1 + assert 2 == call diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 48f5509..e699ae7 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -6,6 +6,9 @@ from app.src.gen3plus.solarman_v5 import SolarmanV5 from app.src.config import Config from app.src.infos import Infos, Register + +pytest_plugins = ('pytest_asyncio',) + # initialize the proxy statistics Infos.static_init() @@ -54,6 +57,9 @@ class MemoryStream(SolarmanV5): pass return copied_bytes + async def async_write(self, headline=''): + pass + def _SolarmanV5__flush_recv_msg(self) -> None: super()._SolarmanV5__flush_recv_msg() self.msg_count += 1 @@ -725,7 +731,7 @@ def test_device_rsp(ConfigTsunInv1, DeviceRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # DeviceRspMsg + assert m._forward_buffer==DeviceRspMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -743,7 +749,7 @@ def test_inverter_rsp(ConfigTsunInv1, InverterRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # InverterRspMsg + assert m._forward_buffer==InverterRspMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -779,7 +785,7 @@ def test_heartbeat_rsp(ConfigTsunInv1, HeartbeatRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # HeartbeatRspMsg + assert m._forward_buffer==HeartbeatRspMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -820,7 +826,7 @@ def test_sync_start_rsp(ConfigTsunInv1, SyncStartRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # HeartbeatRspMsg + assert m._forward_buffer==SyncStartRspMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -856,7 +862,7 @@ def test_sync_end_rsp(ConfigTsunInv1, SyncEndRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==b'' # HeartbeatRspMsg + assert m._forward_buffer==SyncEndRspMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -942,7 +948,8 @@ def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg): assert 'V1.1.00.0B' == m.db.get_db_value(Register.COLLECTOR_FW_VERSION, 0).rstrip('\00') m.close() -def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, AtCommandIndMsg): +@pytest.mark.asyncio +async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, AtCommandIndMsg): ConfigTsunAllowAll m = MemoryStream(DeviceIndMsg, (0,), True) m.read() @@ -954,7 +961,7 @@ def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, AtCommandIndMsg) m._send_buffer = bytearray(0) # clear send buffer for next test m._forward_buffer = bytearray(0) # clear send buffer for next test - m.send_at_cmd('AT+TIME=214028,1,60,120') + await m.send_at_cmd('AT+TIME=214028,1,60,120') assert m._recv_buffer==b'' assert m._send_buffer==AtCommandIndMsg assert m._forward_buffer==b'' diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 7c996ab..4b1de2f 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -795,7 +795,7 @@ def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): m = MemoryStream(MsgModbusRsp, (0,), False) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_rep = False + m.forward_modbus_resp = False m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 @@ -816,7 +816,7 @@ def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): m = MemoryStream(MsgModbusRsp, (0,), False) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_rep = True + m.forward_modbus_resp = True m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 diff --git a/system_tests/test_tcp_socket.py b/system_tests/test_tcp_socket.py index 606ea68..f01a0a0 100644 --- a/system_tests/test_tcp_socket.py +++ b/system_tests/test_tcp_socket.py @@ -224,7 +224,7 @@ def test_send_inv_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgI data = s.recv(1024) except TimeoutError: pass - # time.sleep(32.5) + time.sleep(32.5) # assert data == MsgTimeStampResp try: s.sendall(MsgInvData) From f804b755a4752732e5a5753002d000707356bfe0 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 6 May 2024 23:18:47 +0200 Subject: [PATCH 023/118] improve modbus trace --- app/src/gen3/talent.py | 2 +- app/src/gen3plus/solarman_v5.py | 5 +++-- app/src/modbus.py | 9 +++++---- app/tests/test_modbus.py | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 6225aa0..2c70062 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -392,7 +392,7 @@ class Talent(Message): elif self.ctrl.is_ind(): logger.debug(f'Modbus Ind MsgLen: {modbus_len}') for key, update in self.mb.recv_resp(self.db, self._recv_buffer[ - self.header_len + hdr_len:]): + self.header_len + hdr_len:], self.new_data): if update: self.new_data[key] = True diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index a459e89..5805361 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -434,11 +434,12 @@ class SolarmanV5(Message): elif ftype == self.MB_RTU_CMD: valid = data[1] modbus_msg_len = self.data_len - 14 - logger.info(f'modbus_len:{modbus_msg_len} accepted:{valid}') + logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}') if valid == 1 and modbus_msg_len > 4: logger.info(f'first byte modbus:{data[14]}') inv_update = False - for key, update in self.mb.recv_resp(self.db, data[14:-2]): + for key, update in self.mb.recv_resp(self.db, data[14:-2], + self.node_id): if update: if key == 'inverter': inv_update = True diff --git a/app/src/modbus.py b/app/src/modbus.py index 0f16705..37eadff 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -93,8 +93,8 @@ class Modbus(): self.last_len = res[2] return True - def recv_resp(self, info_db, buf: bytearray) -> Generator[tuple[str, bool], - None, None]: + def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \ + Generator[tuple[str, bool], None, None]: logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') if not self.check_crc(buf): logging.error('Modbus: CRC error') @@ -136,8 +136,9 @@ class Modbus(): name = str(f'info-id.0x{addr:x}') update = False - info_db.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}' - f' update: {update}') + info_db.tracer.log(level, + f'MODBUS({node_id}): {name} : {result}' + f'{unit} update: {update}') def check_crc(self, msg) -> bool: return 0 == self.__calc_crc(msg) diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index fcec232..b1764e9 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -30,7 +30,7 @@ def test_build_recv(): assert pdu == b'\x01\x03\x30\x0e\x00\x02\xaa\xc8' assert mb.check_crc(pdu) call = 0 - for key, update in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4'): + for key, update in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): if key == 'grid': assert update == True elif key == 'inverter': From 54d2bf4439eb5b9ae9e5df12397cfdd520ae560f Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 17:52:51 +0200 Subject: [PATCH 024/118] set err value for unit tests --- app/src/modbus.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index 37eadff..e8a8d0e 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -32,12 +32,13 @@ class Modbus(): __crc_tab = [] map = { 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 - 0x420100c0: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 + # 0x????: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'v{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 0x3009: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x300a: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x300c: {'reg': Register.INVERTER_TEMP, 'fmt': '!H', 'eval': 'result-40'}, # noqa: E501 + # 0x300d 0x300e: {'reg': Register.RATED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 0x300f: {'reg': Register.OUTPUT_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x3010: {'reg': Register.PV1_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 @@ -53,15 +54,15 @@ class Modbus(): 0x301a: {'reg': Register.PV4_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x301b: {'reg': Register.PV4_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x301c: {'reg': Register.DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - 0x420100fa: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + # 0x301d: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x301f: {'reg': Register.PV1_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - 0x42010100: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + # 0x3020: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x3022: {'reg': Register.PV2_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - 0x42010106: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + # 0x3023: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x3025: {'reg': Register.PV3_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - 0x4201010c: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + # 0x3026: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x3028: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - 0x42010112: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + # 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 } def __init__(self): @@ -70,6 +71,7 @@ class Modbus(): self.last_fcode = 0 self.last_len = 0 self.last_reg = 0 + self.err = 0 def build_msg(self, addr, func, reg, val): msg = struct.pack('>BBHH', addr, func, reg, val) @@ -77,38 +79,47 @@ class Modbus(): self.last_fcode = func self.last_reg = reg self.last_len = val + self.err = 0 return msg def recv_req(self, buf: bytearray) -> bool: - logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') + # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') if not self.check_crc(buf): + self.err = 1 logging.error('Modbus: CRC error') return False if buf[0] != self.INV_ADDR: + self.err = 2 logging.info(f'Modbus: Wrong addr{buf[0]}') return False res = struct.unpack_from('>BHH', buf, 1) self.last_fcode = res[0] self.last_reg = res[1] self.last_len = res[2] + self.err = 0 return True def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \ Generator[tuple[str, bool], None, None]: - logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') + # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') if not self.check_crc(buf): logging.error('Modbus: CRC error') + self.err = 1 return if buf[0] != self.INV_ADDR: logging.info(f'Modbus: Wrong addr {buf[0]}') + self.err = 2 return if buf[1] != self.last_fcode: logging.info(f'Modbus: Wrong fcode {buf[1]} != {self.last_fcode}') + self.err = 3 return elmlen = buf[2] >> 1 if elmlen != self.last_len: logging.info(f'Modbus: len error {elmlen} != {self.last_len}') + self.err = 4 return + self.err = 0 for i in range(0, elmlen): val = struct.unpack_from('>H', buf, 3+2*i) From d5010fe053894f55ed903eaa6e10593fe81124f2 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 17:56:54 +0200 Subject: [PATCH 025/118] parse modbus corect if we have received more than one message --- app/src/gen3/talent.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 2c70062..e6d3b67 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -55,6 +55,7 @@ class Talent(Message): # 0x78: 0x04: self.msg_inverter_data, } + self.modbus_elms = 0 # for unit tests ''' Our puplic methods @@ -377,10 +378,8 @@ class Talent(Message): result = struct.unpack_from('!lBB', self._recv_buffer, self.header_len) modbus_len = result[1] - logger.debug(f'Ref: {result[0]}') - logger.debug(f'Modbus MsgLen: {modbus_len} Func:{result[2]}') - # logger.info(f'time: {datetime.utcfromtimestamp(result[2]).strftime( - # "%Y-%m-%d %H:%M:%S")}') + # logger.debug(f'Ref: {result[0]}') + # logger.debug(f'Modbus MsgLen: {modbus_len} Func:{result[2]}') return msg_hdr_len, modbus_len def msg_modbus(self): @@ -390,11 +389,14 @@ class Talent(Message): self.forward_modbus_resp = True self.inc_counter('Modbus_Command') elif self.ctrl.is_ind(): - logger.debug(f'Modbus Ind MsgLen: {modbus_len}') + # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') + self.modbus_elms = 0 for key, update in self.mb.recv_resp(self.db, self._recv_buffer[ - self.header_len + hdr_len:], self.new_data): + self.header_len + hdr_len:self.header_len+self.data_len], + self.new_data): if update: self.new_data[key] = True + self.modbus_elms += 1 if not self.forward_modbus_resp: return From 39beb0cb44136694c37c114cb270eba2a37e5a9d Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 18:02:09 +0200 Subject: [PATCH 026/118] add more modbus tests --- app/tests/test_talent.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 4b1de2f..cc9ab85 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -200,6 +200,15 @@ def MsgModbusInv(): msg += b'\x00\x00\x03\xc8' return msg +@pytest.fixture +def MsgModbusResp20(): + msg = b'\x00\x00\x00\x45\x10R170000000000001' + msg += b'\x91\x77\x17\x18\x19\x1a\x2d\x01\x03\x28\x51' + msg += b'\x09\x08\xd3\x00\x29\x13\x87\x00\x3e\x00\x00\x01\x2c\x03\xb4\x00' + msg += b'\x08\x00\x00\x00\x00\x01\x59\x01\x21\x03\xe6\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\xdb\x6b' + return msg + def test_read_message(MsgContactInfo): m = MemoryStream(MsgContactInfo, (0,)) m.read() # read complete msg, and dispatch msg @@ -852,6 +861,33 @@ def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInv): assert m.db.stat['proxy']['Modbus_Command'] == 0 m.close() +def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): + ConfigTsunInv1 + # receive more bytes than expected (7 bytes from the next msg) + m = MemoryStream(MsgModbusResp20+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_modbus_resp = True + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==0x91 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==50 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.mb.err == 0 + assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + @pytest.mark.asyncio async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 From 02d9f01947f4eea7fbfe2054bb3da8ce1bb08f4c Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 18:32:56 +0200 Subject: [PATCH 027/118] don't send AT or Modbus cmds on closed connections --- app/src/gen3/talent.py | 2 ++ app/src/gen3plus/solarman_v5.py | 6 ++++-- app/src/mqtt.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index e6d3b67..1534446 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -45,6 +45,7 @@ class Talent(Message): self.db = InfosG3() self.mb = Modbus() self.forward_modbus_resp = False + self.closed = False self.switch = { 0x00: self.msg_contact_info, 0x13: self.msg_ota_update, @@ -66,6 +67,7 @@ class Talent(Message): # so we have to erase self.switch, otherwise this instance can't be # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() + self.closed = True def __set_serial_no(self, serial_no: str): diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 5805361..126e06e 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -62,6 +62,7 @@ class SolarmanV5(Message): self.time_ofs = 0 self.mb = Modbus() self.forward_modbus_resp = False + self.closed = False self.switch = { 0x4210: self.msg_data_ind, # real time data @@ -102,6 +103,7 @@ class SolarmanV5(Message): # so we have to erase self.switch, otherwise this instance can't be # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() + self.closed = True def __set_serial_no(self, snr: int): serial_no = str(snr) @@ -434,9 +436,9 @@ class SolarmanV5(Message): elif ftype == self.MB_RTU_CMD: valid = data[1] modbus_msg_len = self.data_len - 14 - logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}') + # logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}') if valid == 1 and modbus_msg_len > 4: - logger.info(f'first byte modbus:{data[14]}') + # logger.info(f'first byte modbus:{data[14]}') inv_update = False for key, update in self.mb.recv_resp(self.db, data[14:-2], self.node_id): diff --git a/app/src/mqtt.py b/app/src/mqtt.py index 7257038..3469201 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -130,7 +130,7 @@ class Mqtt(metaclass=Singleton): topic = str(message.topic) node_id = topic.split('/')[1] + '/' for m in Message: - if m.server_side and m.node_id == node_id: + if m.server_side and not m.closed and (m.node_id == node_id): logger_mqtt.debug(f'Found: {node_id}') fnc = getattr(m, func_name, None) if callable(fnc): @@ -148,7 +148,7 @@ class Mqtt(metaclass=Singleton): payload = message.payload.decode("UTF-8") logger_mqtt.info(f'InvCnf: {node_id}:{payload}') for m in Message: - if m.server_side and m.node_id == node_id: + if m.server_side and not m.closed and (m.node_id == node_id): logger_mqtt.info(f'Found: {node_id}') fnc = getattr(m, "send_modbus_cmd", None) res = payload.split(',') From e15387b1ff7dfff1afce02532fa7377b2f52ab91 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 19:41:07 +0200 Subject: [PATCH 028/118] fix modbus trace --- app/src/gen3/talent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 1534446..45eb081 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -395,7 +395,7 @@ class Talent(Message): self.modbus_elms = 0 for key, update in self.mb.recv_resp(self.db, self._recv_buffer[ self.header_len + hdr_len:self.header_len+self.data_len], - self.new_data): + self.node_id): if update: self.new_data[key] = True self.modbus_elms += 1 From 3fd528bdbee1fd63b6c80a8524e28b5237c438a7 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 21:20:12 +0200 Subject: [PATCH 029/118] improve logging --- app/src/gen3/infos_g3.py | 4 ++-- app/src/gen3plus/infos_g3p.py | 4 ++-- app/src/infos.py | 2 +- app/src/modbus.py | 8 ++++---- app/src/scheduler.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/gen3/infos_g3.py b/app/src/gen3/infos_g3.py index 7e45634..d3fb987 100644 --- a/app/src/gen3/infos_g3.py +++ b/app/src/gen3/infos_g3.py @@ -161,7 +161,7 @@ class InfosG3(Infos): update = False name = str(f'info-id.0x{addr:x}') - self.tracer.log(level, f'GEN3: {name} : {result}{unit}' - f' update: {update}') + if update: + self.tracer.log(level, f'GEN3: {name} : {result}{unit}') i += 1 diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index b0adc0a..ed8d9bd 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -122,5 +122,5 @@ class InfosG3P(Infos): name = str(f'info-id.0x{addr:x}') update = False - self.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}' - f' update: {update}') + if update: + self.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}') diff --git a/app/src/infos.py b/app/src/infos.py index e9e8ebe..dadacd7 100644 --- a/app/src/infos.py +++ b/app/src/infos.py @@ -193,7 +193,7 @@ class Infos: Register.SERIAL_NUMBER: {'name': ['inverter', 'Serial_Number'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EQUIPMENT_MODEL: {'name': ['inverter', 'Equipment_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.NO_INPUTS: {'name': ['inverter', 'No_Inputs'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'fmt': '| string + " W"', 'name': 'Max Designed Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501 + Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'fmt': '| string + " W"', 'name': 'Max Designed Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.RATED_POWER: {'name': ['inverter', 'Rated_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'rated_power_', 'fmt': '| string + " W"', 'name': 'Rated Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV1_MANUFACTURER: {'name': ['inverter', 'PV1_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 diff --git a/app/src/modbus.py b/app/src/modbus.py index e8a8d0e..25c5734 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -146,10 +146,10 @@ class Modbus(): else: name = str(f'info-id.0x{addr:x}') update = False - - info_db.tracer.log(level, - f'MODBUS({node_id}): {name} : {result}' - f'{unit} update: {update}') + if update: + info_db.tracer.log(level, + f'MODBUS[{node_id}]: {name} : {result}' + f'{unit}') def check_crc(self, msg) -> bool: return 0 == self.__calc_crc(msg) diff --git a/app/src/scheduler.py b/app/src/scheduler.py index dc45890..a1e763b 100644 --- a/app/src/scheduler.py +++ b/app/src/scheduler.py @@ -40,4 +40,4 @@ class Schedule: if m.server_side: fnc = getattr(m, "send_modbus_cmd", None) if callable(fnc): - await fnc(Modbus.READ_REGS, 0x300e, 2) + await fnc(Modbus.READ_REGS, 0x3008, 20) From 2301511242380ea365494d0c351dab760abc6a4e Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 22:11:55 +0200 Subject: [PATCH 030/118] update documentation --- CHANGELOG.md | 1 + README.md | 9 +- app/proxy.svg | 466 ++++++++++++++++++++++++++----------------------- app/proxy.yuml | 8 +- 4 files changed, 255 insertions(+), 229 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eede6e..0ec1d47 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 timeout monitoring for received packets - parse Modbus values and store them in the database - add cron task to request the output power every minute - GEN3PLUS: add MQTT topics to send AT commands to the inverter diff --git a/README.md b/README.md index 1a26179..342a643 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,15 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole. ## Features -- supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600 -- supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300 +- Supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600 +- Supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300 - `MQTT` support - `Home-Assistant` auto-discovery support +- `MODBUS` support via MQTT topics +- `AT Command` support via MQTT topics (GEN3PLUS only) +- Faster DataUp interval sends measurement data to the MQTT broker every minute - Self-sufficient island operation without internet -- runs in a non-root Docker Container +- Runs in a non-root Docker Container ## Home Assistant Screenshots diff --git a/app/proxy.svg b/app/proxy.svg index dfbdd46..cef1e69 100644 --- a/app/proxy.svg +++ b/app/proxy.svg @@ -4,352 +4,372 @@ - - + + G - + A0 - - - -You can stick notes -on diagrams too! + + + +You can stick notes +on diagrams too! A1 - -Singleton + +Singleton A2 - -Mqtt - -<static>ha_restarts -<static>__client -<static>__cb_MqttIsUp - -<async>publish() -<async>close() + +Mqtt + +<static>ha_restarts +<static>__client +<static>__cb_MqttIsUp + +<async>publish() +<async>close() A1->A2 - - - - - -A3 - -Modbus - - - -A1->A3 - - + + A11 - -Inverter - -cls.db_stat -cls.entity_prfx -cls.discovery_prfx -cls.proxy_node_id -cls.proxy_unique_id -cls.mqtt:Mqtt - + +Inverter + +cls.db_stat +cls.entity_prfx +cls.discovery_prfx +cls.proxy_node_id +cls.proxy_unique_id +cls.mqtt:Mqtt + - + A2->A11 - + + + + +A3 + +Modbus + + +build_msg() +recv_req() +recv_resp() +check_crc() A4 - -IterRegistry - - -__iter__ + +IterRegistry + + +__iter__ A5 - -Message - -server_side:bool -header_valid:bool -header_len:unsigned -data_len:unsigned -unique_id -node_id -sug_area -_recv_buffer:bytearray -_send_buffer:bytearray -_forward_buffer:bytearray -db:Infos -new_data:list - -_read():void<abstract> -close():void -inc_counter():void -dec_counter():void + +Message + +server_side:bool +header_valid:bool +header_len:unsigned +data_len:unsigned +unique_id +node_id +sug_area +_recv_buffer:bytearray +_send_buffer:bytearray +_forward_buffer:bytearray +db:Infos +new_data:list + +_read():void<abstract> +close():void +inc_counter():void +dec_counter():void - + A4->A5 - - + + A6 - -Talent - -await_conn_resp_cnt -id_str -contact_name -contact_mail -switch - -msg_contact_info() -msg_ota_update() -msg_get_time() -msg_collector_data() -msg_inverter_data() -msg_unknown() -close() + +Talent + +await_conn_resp_cnt +id_str +contact_name +contact_mail +db:InfosG3 +mb:Modbus +switch + +msg_contact_info() +msg_ota_update() +msg_get_time() +msg_collector_data() +msg_inverter_data() +msg_unknown() +close() - + A5->A6 - - + + A7 - -SolarmanV5 - -control -serial -snr -switch - -msg_unknown() -close() + +SolarmanV5 + +control +serial +snr +db:InfosG3P +mb:Modbus +switch + +msg_unknown() +close() - + A5->A7 - - + + + + + +A6->A3 + + +1 +has A8 - -ConnectionG3 - -remoteStream:ConnectionG3 - -close() + +ConnectionG3 + +remoteStream:ConnectionG3 + +close() - + A6->A8 - - + + + + + +A7->A3 + + +1 +has A9 - -ConnectionG3P - -remoteStream:ConnectionG3P - -close() + +ConnectionG3P + +remoteStream:ConnectionG3P + +close() A7->A9 - - + + - + A8->A8 - - -0..1 -has + + +0..1 +has A12 - -InverterG3 - -__ha_restarts - -async_create_remote() -close() + +InverterG3 + +__ha_restarts + +async_create_remote() +close() - + A8->A12 - - + + - + A9->A9 - - -0..1 -has + + +0..1 +has A13 - -InverterG3P - -__ha_restarts - -async_create_remote() -close() + +InverterG3P + +__ha_restarts + +async_create_remote() +close() - + A9->A13 - - + + A10 - -AsyncStream - -reader -writer -addr -r_addr -l_addr - -<async>server_loop() -<async>client_loop() -<async>loop -disc() -close() -__async_read() -async_write() -__async_forward() + +AsyncStream + +reader +writer +addr +r_addr +l_addr + +<async>server_loop() +<async>client_loop() +<async>loop +disc() +close() +__async_read() +async_write() +__async_forward() - + A10->A8 - - + + - + A10->A9 - - + + - + A11->A12 - - + + - + A11->A13 - - + + A14 - -Infos - -stat -new_stat_data -info_dev - -static_init() -dev_value() -inc_counter() -dec_counter() -ha_proxy_conf -ha_conf -update_db -set_db_def_value -get_db_value -ignore_this_device + +Infos + +stat +new_stat_data +info_dev + +static_init() +dev_value() +inc_counter() +dec_counter() +ha_proxy_conf +ha_conf +update_db +set_db_def_value +get_db_value +ignore_this_device A15 - -InfosG3 - - -ha_confs() -parse() + +InfosG3 + + +ha_confs() +parse() - + A14->A15 - - + + A16 - -InfosG3P - - -ha_confs() -parse() + +InfosG3P + + +ha_confs() +parse() - + A14->A16 - - + + - + A15->A6 - - + + - + A16->A7 - - + + diff --git a/app/proxy.yuml b/app/proxy.yuml index daf5d3c..7514a93 100644 --- a/app/proxy.yuml +++ b/app/proxy.yuml @@ -4,12 +4,14 @@ [note: You can stick notes on diagrams too!{bg:cornsilk}] [Singleton]^[Mqtt|ha_restarts;__client;__cb_MqttIsUp|publish();close()] -[Singleton]^[Modbus] +[Modbus||build_msg();recv_req();recv_resp();check_crc()] [IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list|_read():void;close():void;inc_counter():void;dec_counter():void] -[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()] -[Message]^[SolarmanV5|control;serial;snr;switch|msg_unknown();;close()] +[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()] +[Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()] [Talent]^[ConnectionG3|remoteStream:ConnectionG3|close()] +[Talent]has-1>[Modbus] [SolarmanV5]^[ConnectionG3P|remoteStream:ConnectionG3P|close()] +[SolarmanV5]has-1>[Modbus] [AsyncStream|reader;writer;addr;r_addr;l_addr|server_loop();client_loop();loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3] [AsyncStream]^[ConnectionG3P] [Inverter|cls.db_stat;cls.entity_prfx;cls.discovery_prfx;cls.proxy_node_id;cls.proxy_unique_id;cls.mqtt:Mqtt|]^[InverterG3|__ha_restarts|async_create_remote();;close()] From eab109ddab4d51668266358ef514e1882a0705ad Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 7 May 2024 22:37:17 +0200 Subject: [PATCH 031/118] install pytest-asyncio --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8f4f09a..518a148 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest + pip install flake8 pytest pytest-asyncio if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | From 5fc1b16627facdb2ff225c4a72ee1f5896e904cb Mon Sep 17 00:00:00 2001 From: Stefan Allius <122395479+s-allius@users.noreply.github.com> Date: Tue, 7 May 2024 22:52:20 +0200 Subject: [PATCH 032/118] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 342a643..27b84c7 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole. - `MQTT` support - `Home-Assistant` auto-discovery support - `MODBUS` support via MQTT topics -- `AT Command` support via MQTT topics (GEN3PLUS only) +- `AT-Command` support via MQTT topics (GEN3PLUS only) - Faster DataUp interval sends measurement data to the MQTT broker every minute - Self-sufficient island operation without internet - Runs in a non-root Docker Container From 0ae6dffc6bd0c0a99545dcefacc8a5ead3d14000 Mon Sep 17 00:00:00 2001 From: Stefan Allius <122395479+s-allius@users.noreply.github.com> Date: Tue, 7 May 2024 22:54:23 +0200 Subject: [PATCH 033/118] Update test_talent.py --- app/tests/test_talent.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index cc9ab85..a94df4f 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -876,12 +876,12 @@ def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): assert m.msg_count == 1 assert m.id_str == b"R170000000000001" assert m.unique_id == 'R170000000000001' - assert int(m.ctrl)==0x91 - assert m.msg_id==119 - assert m.header_len==23 - assert m.data_len==50 + assert int(m.ctrl) == 0x91 + assert m.msg_id == 119 + assert m.header_len == 23 + assert m.data_len == 50 assert m._forward_buffer==MsgModbusResp20 - assert m._send_buffer==b'' + assert m._send_buffer == b'' assert m.mb.err == 0 assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 @@ -894,7 +894,7 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): m = MemoryStream(b'', (0,), False) m.id_str = b"R170000000000001" await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) - assert 0==m.send_msg_ofs - assert m._forward_buffer==b'' - assert m._send_buffer==MsgModbusCmd + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == MsgModbusCmd m.close() From 2d176894d356bbf3cb428ca34a248b518a324a87 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 8 May 2024 23:46:24 +0200 Subject: [PATCH 034/118] remove unneeded sleep() call --- system_tests/test_tcp_socket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system_tests/test_tcp_socket.py b/system_tests/test_tcp_socket.py index f01a0a0..e2b64f8 100644 --- a/system_tests/test_tcp_socket.py +++ b/system_tests/test_tcp_socket.py @@ -224,7 +224,7 @@ def test_send_inv_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgI data = s.recv(1024) except TimeoutError: pass - time.sleep(32.5) + # time.sleep(32.5) # assert data == MsgTimeStampResp try: s.sendall(MsgInvData) From 2ec0a59cd3f0738cf863cd66fc9cadb1c124e9ff Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 8 May 2024 23:48:41 +0200 Subject: [PATCH 035/118] add modbus long int support --- app/src/gen3/talent.py | 2 +- app/src/gen3plus/solarman_v5.py | 4 ++-- app/src/modbus.py | 30 ++++++++++++------------------ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 45eb081..33d5d8d 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -393,7 +393,7 @@ class Talent(Message): elif self.ctrl.is_ind(): # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') self.modbus_elms = 0 - for key, update in self.mb.recv_resp(self.db, self._recv_buffer[ + for key, update, _ in self.mb.recv_resp(self.db, self._recv_buffer[ self.header_len + hdr_len:self.header_len+self.data_len], self.node_id): if update: diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 126e06e..b175cb3 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -440,8 +440,8 @@ class SolarmanV5(Message): if valid == 1 and modbus_msg_len > 4: # logger.info(f'first byte modbus:{data[14]}') inv_update = False - for key, update in self.mb.recv_resp(self.db, data[14:-2], - self.node_id): + for key, update, _ in self.mb.recv_resp(self.db, data[14:-2], + self.node_id): if update: if key == 'inverter': inv_update = True diff --git a/app/src/modbus.py b/app/src/modbus.py index 25c5734..c69dbb9 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -54,15 +54,15 @@ class Modbus(): 0x301a: {'reg': Register.PV4_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x301b: {'reg': Register.PV4_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x301c: {'reg': Register.DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - # 0x301d: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x301d: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x301f: {'reg': Register.PV1_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - # 0x3020: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3020: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x3022: {'reg': Register.PV2_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - # 0x3023: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3023: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x3025: {'reg': Register.PV3_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - # 0x3026: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3026: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x3028: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 - # 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 + 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 } def __init__(self): @@ -122,16 +122,13 @@ class Modbus(): self.err = 0 for i in range(0, elmlen): - val = struct.unpack_from('>H', buf, 3+2*i) addr = self.last_reg+i - # logging.info(f'Modbus: 0x{addr:04x}: {val[0]}') if addr in self.map: row = self.map[addr] info_id = row['reg'] + fmt = row['fmt'] + val = struct.unpack_from(fmt, buf, 3+2*i) result = val[0] - # fmt = row['fmt'] - # res = struct.unpack_from(fmt, buf, addr) - # result = res[0] if 'eval' in row: result = eval(row['eval']) @@ -142,14 +139,11 @@ class Modbus(): if keys: name, update = info_db.update_db(keys, must_incr, result) - yield keys[0], update - else: - name = str(f'info-id.0x{addr:x}') - update = False - if update: - info_db.tracer.log(level, - f'MODBUS[{node_id}]: {name} : {result}' - f'{unit}') + yield keys[0], update, result + if update: + info_db.tracer.log(level, + f'MODBUS[{node_id}]: {name}' + f' : {result}{unit}') def check_crc(self, msg) -> bool: return 0 == self.__calc_crc(msg) From 0ac4b1f5715980a21a2c210cff2e7f023329e018 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 8 May 2024 23:50:04 +0200 Subject: [PATCH 036/118] add more unit tests --- app/tests/test_modbus.py | 121 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index b1764e9..a3d24ac 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -18,24 +18,137 @@ def test_modbus_crc(): assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') assert mb.check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') + assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46') def test_build_modbus_pdu(): mb = Modbus() pdu = mb.build_msg(1,6,0x2000,0x12) assert pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' assert mb.check_crc(pdu) +def test_recv_req_crc(): + mb = Modbus() + res = mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') + assert not res + assert mb.last_fcode == 0 + assert mb.last_reg == 0 + assert mb.last_len == 0 + assert mb.err == 1 + +def test_recv_req_addr(): + mb = Modbus() + res = mb.recv_req(b'\x02\x06\x20\x00\x00\x12\x02\x34') + assert not res + assert mb.last_fcode == 0 + assert mb.last_reg == 0 + assert mb.last_len == 0 + assert mb.err == 2 + +def test_recv_req(): + mb = Modbus() + res = mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') + assert res + assert mb.last_fcode == 6 + assert mb.last_reg == 0x2000 + assert mb.last_len == 0x12 + assert mb.err == 0 + +def test_recv_recv_crc(): + mb = TestHelper() + mb.last_fcode = 3 + mb.last_reg == 0x300e + mb.last_len == 2 + + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'): + call += 1 + assert mb.err == 1 + assert 0 == call + +def test_recv_recv_addr(): + mb = TestHelper() + mb.last_fcode = 3 + mb.last_reg == 0x300e + mb.last_len == 2 + + call = 0 + for key, update in mb.recv_resp(mb.db, b'\x02\x03\x04\x01\x2c\x00\x46\x88\xf4', 'test'): + call += 1 + assert mb.err == 2 + assert 0 == call + +def test_recv_recv_fcode(): + mb = TestHelper() + mb.last_fcode = 4 + mb.last_reg == 0x300e + mb.last_len == 2 + + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + assert mb.err == 3 + assert 0 == call + +def test_recv_recv_len(): + mb = TestHelper() + mb.last_fcode = 3 + mb.last_reg == 0x300e + mb.last_len == 2 + + call = 0 + for key, update, _ in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + assert mb.err == 4 + assert 0 == call + def test_build_recv(): mb = TestHelper() - pdu = mb.build_msg(1,3,0x300e,0x2) - assert pdu == b'\x01\x03\x30\x0e\x00\x02\xaa\xc8' + pdu = mb.build_msg(1,3,0x3007,6) assert mb.check_crc(pdu) + assert mb.err == 0 call = 0 - for key, update in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + exp_result = ['v0.0.212', 4.4, 0.7, 0.7, 30] + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): if key == 'grid': assert update == True elif key == 'inverter': assert update == True + elif key == 'env': + assert update == True + else: + assert False + assert exp_result[call] == val + call += 1 + assert 0 == mb.err + assert 5 == call + + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + if key == 'grid': + assert update == False + elif key == 'inverter': + assert update == False + elif key == 'env': + assert update == False + else: + assert False + assert exp_result[call] == val + call += 1 + assert 0 == mb.err + assert 5 == call + +def test_build_long(): + mb = TestHelper() + pdu = mb.build_msg(1,3,0x3022,4) + assert mb.check_crc(pdu) + assert mb.err == 0 + call = 0 + exp_result = [3.0, 28841.4, 113.34] + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46\x75\x5c', 'test'): + if key == 'input': + assert update == True + assert exp_result[call] == val else: assert False call += 1 - assert 2 == call + assert 0 == mb.err + assert 3 == call From 91873d0c340fa7e70491a36a50788124d4b480ba Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 8 May 2024 23:52:31 +0200 Subject: [PATCH 037/118] await wait_closed() on disconnects --- app/src/async_stream.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 28873e8..ac6c54f 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -1,5 +1,6 @@ import logging import traceback +import asyncio from messages import hex_dump_memory logger = logging.getLogger('conn') @@ -27,7 +28,7 @@ class AsyncStream(): # the connection to te TSUN cloud if self.remoteStream: logging.debug("disconnect client connection") - self.remoteStream.disc() + await self.remoteStream.disc() try: await self._async_publ_mqtt_proxy_stat('proxy') except Exception: @@ -58,6 +59,7 @@ class AsyncStream(): while True: try: + # await asyncio.wait_for(self.__async_read(), 0.3) await self.__async_read() if self.unique_id: @@ -65,25 +67,32 @@ class AsyncStream(): await self.__async_forward() await self.async_publ_mqtt() + except asyncio.TimeoutError: + pass + except (ConnectionResetError, ConnectionAbortedError, - BrokenPipeError, - RuntimeError) as error: - logger.warning(f'In loop for l{self.l_addr} | ' - f'r{self.r_addr}: {error}') - self.close() + BrokenPipeError) as error: + logger.error(f'{error} for l{self.l_addr} | ' + f'r{self.r_addr}') + await self.disc() return self + + except RuntimeError as error: + logger.warning(f"{error} for {self.l_addr}") + await self.disc() + return self + except Exception: self.inc_counter('SW_Exception') logger.error( f"Exception for {self.addr}:\n" f"{traceback.format_exc()}") - self.close() - return self - def disc(self) -> None: + async def disc(self) -> None: logger.debug(f'AsyncStream.disc() l{self.l_addr} | r{self.r_addr}') self.writer.close() + await self.writer.wait_closed() def close(self): logger.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}') From a869ead89a38598de6181606d108c27e7bcddade Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 14:16:15 +0200 Subject: [PATCH 038/118] add MAX_DESIGNED_POWER (only readable by Modbus) --- app/src/gen3/infos_g3.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/gen3/infos_g3.py b/app/src/gen3/infos_g3.py index d3fb987..1de9bf0 100644 --- a/app/src/gen3/infos_g3.py +++ b/app/src/gen3/infos_g3.py @@ -30,6 +30,7 @@ class RegisterMap: 0xffffff05: Register.UNKNOWN_CTRL, 0xffffff06: Register.OTA_START_MSG, 0xffffff07: Register.SW_EXCEPTION, + 0xffffff08: Register.MAX_DESIGNED_POWER, 0xfffffffe: Register.TEST_REG1, 0xffffffff: Register.TEST_REG2, 0x00000640: Register.OUTPUT_POWER, @@ -104,7 +105,8 @@ class InfosG3(Infos): if res: yield res - def parse(self, buf, ind=0) -> Generator[tuple[str, bool], None, None]: + def parse(self, buf, ind=0, node_id: str = '') -> \ + Generator[tuple[str, bool], None, None]: '''parse a data sequence received from the inverter and stores the values in Infos.db @@ -162,6 +164,7 @@ class InfosG3(Infos): name = str(f'info-id.0x{addr:x}') if update: - self.tracer.log(level, f'GEN3: {name} : {result}{unit}') + self.tracer.log(level, f'[\'{node_id}\']GEN3: {name} :' + f' {result}{unit}') i += 1 From 41d9a2a1ef3040b4805b053db625a1a37007865f Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 14:19:37 +0200 Subject: [PATCH 039/118] improve logger --- app/src/gen3plus/infos_g3p.py | 5 +++-- app/src/modbus.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index ed8d9bd..a436dbf 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -88,7 +88,7 @@ class InfosG3P(Infos): if res: yield res - def parse(self, buf, msg_type: int, rcv_ftype: int) \ + def parse(self, buf, msg_type: int, rcv_ftype: int, node_id: str = '') \ -> Generator[tuple[str, bool], None, None]: '''parse a data sequence received from the inverter and stores the values in Infos.db @@ -123,4 +123,5 @@ class InfosG3P(Infos): update = False if update: - self.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}') + self.tracer.log(level, f'[\'{node_id}\']GEN3PLUS: {name}' + f' : {result}{unit}') diff --git a/app/src/modbus.py b/app/src/modbus.py index c69dbb9..0047afc 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -142,7 +142,7 @@ class Modbus(): yield keys[0], update, result if update: info_db.tracer.log(level, - f'MODBUS[{node_id}]: {name}' + f'[\'{node_id}\']MODBUS: {name}' f' : {result}{unit}') def check_crc(self, msg) -> bool: From 5a0456650f6144f2330335efb7e89e9b22e61793 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 14:20:57 +0200 Subject: [PATCH 040/118] avoid sending modbus cmds in critical states --- app/src/gen3/talent.py | 12 +++++++++--- app/src/gen3plus/solarman_v5.py | 7 ++++++- app/src/mqtt.py | 4 ++-- app/tests/test_talent.py | 9 ++++++++- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 33d5d8d..daf700f 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -35,6 +35,9 @@ class Control: class Talent(Message): + STATE_INIT = 0 + STATE_UP = 2 + STATE_CLOSED = 3 def __init__(self, server_side: bool, id_str=b''): super().__init__(server_side) @@ -45,7 +48,7 @@ class Talent(Message): self.db = InfosG3() self.mb = Modbus() self.forward_modbus_resp = False - self.closed = False + self.state = self.STATE_INIT self.switch = { 0x00: self.msg_contact_info, 0x13: self.msg_ota_update, @@ -67,7 +70,7 @@ class Talent(Message): # so we have to erase self.switch, otherwise this instance can't be # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() - self.closed = True + self.state = self.STATE_CLOSED def __set_serial_no(self, serial_no: str): @@ -126,6 +129,8 @@ class Talent(Message): return async def send_modbus_cmd(self, func, addr, val) -> None: + if self.state != self.STATE_UP: + return self.forward_modbus_resp = False self.__build_header(0x70, 0x77) self._send_buffer += b'\x00\x01\xa3\x28' # fixme @@ -331,6 +336,7 @@ class Talent(Message): self._send_buffer += b'\x01' self.__finish_send_msg() self.__process_data() + self.state = self.STATE_UP elif self.ctrl.is_resp(): return # ignore received response @@ -359,7 +365,7 @@ class Talent(Message): msg_hdr_len = self.parse_msg_header() for key, update in self.db.parse(self._recv_buffer, self.header_len - + msg_hdr_len): + + msg_hdr_len, self.node_id): if update: self.new_data[key] = True diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index b175cb3..823e5ab 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -302,6 +302,8 @@ class SolarmanV5(Message): self.__finish_send_msg() async def send_modbus_cmd(self, func, addr, val) -> None: + if self.closed: + return self.forward_modbus_resp = False self.__build_header(0x4510) self._send_buffer += struct.pack(' None: + if self.closed: + return self.__build_header(0x4510) self._send_buffer += struct.pack(f'> 8 - for key, update in self.db.parse(self._recv_buffer, msg_type, ftype): + for key, update in self.db.parse(self._recv_buffer, msg_type, ftype, + self.node_id): if update: if key == 'inverter': inv_update = True diff --git a/app/src/mqtt.py b/app/src/mqtt.py index 3469201..484d7df 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -130,7 +130,7 @@ class Mqtt(metaclass=Singleton): topic = str(message.topic) node_id = topic.split('/')[1] + '/' for m in Message: - if m.server_side and not m.closed and (m.node_id == node_id): + if m.server_side and (m.node_id == node_id): logger_mqtt.debug(f'Found: {node_id}') fnc = getattr(m, func_name, None) if callable(fnc): @@ -148,7 +148,7 @@ class Mqtt(metaclass=Singleton): payload = message.payload.decode("UTF-8") logger_mqtt.info(f'InvCnf: {node_id}:{payload}') for m in Message: - if m.server_side and not m.closed and (m.node_id == node_id): + if m.server_side and (m.node_id == node_id): logger_mqtt.info(f'Found: {node_id}') fnc = getattr(m, "send_modbus_cmd", None) res = payload.split(',') diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index a94df4f..202cd96 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -733,10 +733,16 @@ def test_msg_unknown(ConfigTsunInv1, MsgUnknown): m.close() def test_ctrl_byte(): + c = Control(0x70) + assert not c.is_ind() + assert not c.is_resp() + assert c.is_req() c = Control(0x91) + assert not c.is_req() assert c.is_ind() assert not c.is_resp() c = Control(0x99) + assert not c.is_req() assert not c.is_ind() assert c.is_resp() @@ -891,8 +897,9 @@ def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): @pytest.mark.asyncio async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 - m = MemoryStream(b'', (0,), False) + m = MemoryStream(b'', (0,), True) m.id_str = b"R170000000000001" + m.state = m.STATE_UP await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' From 5fe455e42fadeeb93e6ab33fa364634b1e804a8e Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 16:46:59 +0200 Subject: [PATCH 041/118] fix typo --- app/src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/config.py b/app/src/config.py index 73a633c..eef0c86 100644 --- a/app/src/config.py +++ b/app/src/config.py @@ -80,7 +80,7 @@ class Config(): try: # make the default config transparaent by copying it # in the config.example file - logging.debug('Copy Defaul Config to config.example.toml') + logging.debug('Copy Default Config to config.example.toml') shutil.copy2("default_config.toml", "config/config.example.toml") From 537d81fa19a5dbf85f790b9d9208625b9a7ff4f7 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 16:49:59 +0200 Subject: [PATCH 042/118] add graceful shutdown --- CHANGELOG.md | 1 + app/src/async_stream.py | 4 ++++ app/src/server.py | 18 ++++++++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec1d47..e4365c7 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 graceful shutdown - add timeout monitoring for received packets - parse Modbus values and store them in the database - add cron task to request the output power every minute diff --git a/app/src/async_stream.py b/app/src/async_stream.py index ac6c54f..7c99373 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -90,11 +90,15 @@ class AsyncStream(): f"{traceback.format_exc()}") async def disc(self) -> None: + if self.writer.is_closing(): + return logger.debug(f'AsyncStream.disc() l{self.l_addr} | r{self.r_addr}') self.writer.close() await self.writer.wait_closed() def close(self): + if self.writer.is_closing(): + return logger.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}') self.writer.close() diff --git a/app/src/server.py b/app/src/server.py index 7151cf2..c7ee03e 100644 --- a/app/src/server.py +++ b/app/src/server.py @@ -1,7 +1,6 @@ import logging import asyncio import signal -import functools import os from logging import config # noqa F401 from messages import Message @@ -26,13 +25,23 @@ async def handle_client_v2(reader, writer): await InverterG3P(reader, writer, addr).server_loop(addr) -def handle_SIGTERM(loop): +async def handle_shutdown(loop): '''Close all TCP connections and stop the event loop''' logging.info('Shutdown due to SIGTERM') # - # first, close all open TCP connections + # first, disc all open TCP connections gracefully + # + for stream in Message: + try: + await asyncio.wait_for(stream.disc(), 2) + except Exception: + pass + logging.info('Disconnecting done') + + # + # second, close all open TCP connections # for stream in Message: stream.close() @@ -91,7 +100,8 @@ if __name__ == "__main__": # for signame in ('SIGINT', 'SIGTERM'): loop.add_signal_handler(getattr(signal, signame), - functools.partial(handle_SIGTERM, loop)) + lambda loop=loop: asyncio.create_task( + handle_shutdown(loop))) # # Create taska for our listening servera. These must be tasks! If we call From 93e82a22842a16ebc43fb1844aa8e09b513dac3f Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 18:22:08 +0200 Subject: [PATCH 043/118] move state variable to the parent class --- app/src/gen3/talent.py | 5 ----- app/src/messages.py | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index daf700f..44c7d0f 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -35,10 +35,6 @@ class Control: class Talent(Message): - STATE_INIT = 0 - STATE_UP = 2 - STATE_CLOSED = 3 - def __init__(self, server_side: bool, id_str=b''): super().__init__(server_side) self.await_conn_resp_cnt = 0 @@ -48,7 +44,6 @@ class Talent(Message): self.db = InfosG3() self.mb = Modbus() self.forward_modbus_resp = False - self.state = self.STATE_INIT self.switch = { 0x00: self.msg_contact_info, 0x13: self.msg_ota_update, diff --git a/app/src/messages.py b/app/src/messages.py index 5bcf711..615c054 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -50,6 +50,9 @@ class IterRegistry(type): class Message(metaclass=IterRegistry): _registry = [] + STATE_INIT = 0 + STATE_UP = 2 + STATE_CLOSED = 3 def __init__(self, server_side: bool): self._registry.append(weakref.ref(self)) @@ -65,6 +68,7 @@ class Message(metaclass=IterRegistry): self._send_buffer = bytearray(0) self._forward_buffer = bytearray(0) self.new_data = {} + self.state = self.STATE_INIT ''' Empty methods, that have to be implemented in any child class which From b240b74994e8903c04420a61d4674464c956f196 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 18:22:43 +0200 Subject: [PATCH 044/118] avoid sending AT/Modbus commands too early - wait until we have received the first data from the inverter --- app/src/gen3plus/solarman_v5.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 823e5ab..2bc2b87 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -62,7 +62,6 @@ class SolarmanV5(Message): self.time_ofs = 0 self.mb = Modbus() self.forward_modbus_resp = False - self.closed = False self.switch = { 0x4210: self.msg_data_ind, # real time data @@ -103,7 +102,7 @@ class SolarmanV5(Message): # so we have to erase self.switch, otherwise this instance can't be # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() - self.closed = True + self.state = self.STATE_CLOSED def __set_serial_no(self, snr: int): serial_no = str(snr) @@ -302,7 +301,7 @@ class SolarmanV5(Message): self.__finish_send_msg() async def send_modbus_cmd(self, func, addr, val) -> None: - if self.closed: + if self.state != self.STATE_UP: return self.forward_modbus_resp = False self.__build_header(0x4510) @@ -317,7 +316,7 @@ class SolarmanV5(Message): self._send_buffer = bytearray(0) async def send_at_cmd(self, AT_cmd: str) -> None: - if self.closed: + if self.state != self.STATE_UP: return self.__build_header(0x4510) self._send_buffer += struct.pack(f' Date: Thu, 9 May 2024 18:48:59 +0200 Subject: [PATCH 045/118] fix unit tests --- app/tests/test_solarman.py | 58 +++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index e699ae7..2b9f39f 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -5,6 +5,7 @@ from datetime import datetime from app.src.gen3plus.solarman_v5 import SolarmanV5 from app.src.config import Config from app.src.infos import Infos, Register +from app.src.modbus import Modbus pytest_plugins = ('pytest_asyncio',) @@ -363,7 +364,7 @@ def SyncStartFwdMsg(): # 0x4310 @pytest.fixture def AtCommandIndMsg(): # 0x4510 - msg = b'\xa5\x27\x00\x10\x45\x02\x01' +get_sn() +b'\x01\x02\x00' + msg = b'\xa5\x27\x00\x10\x45\x03\x02' +get_sn() +b'\x01\x02\x00' msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'AT+TIME=214028,1,60,120\r' msg += correct_checksum(msg) @@ -372,7 +373,7 @@ def AtCommandIndMsg(): # 0x4510 @pytest.fixture def AtCommandRspMsg(): # 0x1510 - msg = b'\xa5\x0a\x00\x10\x15\x02\x02' +get_sn() +b'\x01\x01' + msg = b'\xa5\x0a\x00\x10\x15\x03\x03' +get_sn() +b'\x01\x01' msg += total() msg += hb() msg += correct_checksum(msg) @@ -416,6 +417,15 @@ def SyncEndRspMsg(): # 0x1810 msg += b'\x15' return msg +@pytest.fixture +def MsgModbusCmd(): + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x02\xb0\x02' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def ConfigTsunAllowAll(): Config.config = {'solarman':{'enabled': True}, 'inverters':{'allow_all':True}} @@ -876,7 +886,7 @@ def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg, AtCommandRspMsg): assert m.snr == 2070233889 # assert m.unique_id == '2070233889' assert m.control == 0x4510 - assert str(m.seq) == '02:02' + assert str(m.seq) == '03:03' assert m.data_len == 39 assert m._recv_buffer==b'' assert m._send_buffer==AtCommandRspMsg @@ -949,15 +959,49 @@ def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg): m.close() @pytest.mark.asyncio -async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, AtCommandIndMsg): - ConfigTsunAllowAll +async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, MsgModbusCmd): + ConfigTsunInv1 m = MemoryStream(DeviceIndMsg, (0,), True) + m.append_msg(InverterIndMsg) m.read() assert m.control == 0x4110 assert str(m.seq) == '01:01' - assert m._recv_buffer==b'' + assert m._recv_buffer==InverterIndMsg # unhandled next message assert m._send_buffer==DeviceRspMsg assert m._forward_buffer==DeviceIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' # modbus command must be ignore, cause connection is still not up + + m.read() + assert m.control == 0x4210 + assert str(m.seq) == '02:02' + assert m._recv_buffer==b'' + assert m._send_buffer==InverterRspMsg + assert m._forward_buffer==InverterIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == MsgModbusCmd + m.close() + +@pytest.mark.asyncio +async def test_AT_cmd(ConfigTsunAllowAll, InverterIndMsg, InverterRspMsg, AtCommandIndMsg): + ConfigTsunAllowAll + m = MemoryStream(InverterIndMsg, (0,), True) + m.read() + assert m.control == 0x4210 + assert str(m.seq) == '02:02' + assert m._recv_buffer==b'' + assert m._send_buffer==InverterRspMsg + assert m._forward_buffer==InverterIndMsg m._send_buffer = bytearray(0) # clear send buffer for next test m._forward_buffer = bytearray(0) # clear send buffer for next test @@ -965,5 +1009,5 @@ async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, AtCommandI assert m._recv_buffer==b'' assert m._send_buffer==AtCommandIndMsg assert m._forward_buffer==b'' - assert str(m.seq) == '01:02' + assert str(m.seq) == '02:03' m.close() From b3f0fc97d79c634655097b1e05dadad6805ab6b2 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 23:23:33 +0200 Subject: [PATCH 046/118] add more unit tests --- app/tests/test_solarman.py | 86 ++++++++++++++++++++++++++++++++++++-- app/tests/test_talent.py | 16 ++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 2b9f39f..3092e45 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -28,6 +28,7 @@ class MemoryStream(SolarmanV5): self.addr = 'Test: SrvSide' self.db.stat['proxy']['Invalid_Msg_Format'] = 0 self.db.stat['proxy']['AT_Command'] = 0 + self.test_exception_async_write = False def _timestamp(self): return timestamp @@ -59,7 +60,8 @@ class MemoryStream(SolarmanV5): return copied_bytes async def async_write(self, headline=''): - pass + if self.test_exception_async_write: + raise RuntimeError("Peer closed.") def _SolarmanV5__flush_recv_msg(self) -> None: super()._SolarmanV5__flush_recv_msg() @@ -315,6 +317,39 @@ def InverterIndMsg2000(): # 0x4210 rated Power 2000W msg += b'\x15' return msg +@pytest.fixture +def InverterIndMsg800(): # 0x4210 rated Power 800W + msg = b'\xa5\x99\x01\x10\x42\xe6\x9e' +get_sn() +b'\x01\xb0\x02\xbc\xc8' + msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x47\xe4\x33\x01\x00\x03\x08\x00\x00' + msg += b'\x59\x31\x37\x45\x37\x41\x30\x46\x30\x31\x30\x42\x30\x31\x33\x45' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x40\x10\x08\xc8\x00\x49\x13\x8d\x00\x36\x00\x00\x03\x20\x06\x7a' + msg += b'\x01\x61\x00\xa8\x02\x54\x01\x5a\x00\x8a\x01\xe4\x01\x5a\x00\xbd' + msg += b'\x02\x8f\x00\x11\x00\x01\x00\x00\x00\x0b\x00\x00\x27\x98\x00\x04' + msg += b'\x00\x00\x0c\x04\x00\x03\x00\x00\x0a\xe7\x00\x05\x00\x00\x0c\x75' + msg += b'\x00\x00\x00\x00\x06\x16\x02\x00\x00\x00\x55\xaa\x00\x01\x00\x00' + msg += b'\x00\x00\x00\x00\xff\xff\x03\x20\x00\x03\x04\x00\x04\x00\x04\x00' + msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00' + msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41' + msg += b'\x00\x0f\x0a\x64\x0a\x64\x00\x06\x00\x06\x09\xf6\x12\x8c\x12\x8c' + msg += b'\x00\x10\x00\x10\x14\x52\x14\x52\x00\x10\x00\x10\x01\x51\x00\x05' + msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00' + msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00' + msg += b'\x00\x00\x00\x00' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def InverterRspMsg(): # 0x1210 msg = b'\xa5\x0a\x00\x10\x12\x02\02' +get_sn() +b'\x01\x01' @@ -947,6 +982,18 @@ def test_build_modell_2000(ConfigTsunAllowAll, InverterIndMsg2000): assert 'TSOL-MS2000' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) m.close() +def test_build_modell_800(ConfigTsunAllowAll, InverterIndMsg800): + ConfigTsunAllowAll + m = MemoryStream(InverterIndMsg800, (0,)) + assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) + assert None == m.db.get_db_value(Register.RATED_POWER, None) + assert None == m.db.get_db_value(Register.INVERTER_TEMP, None) + m.read() # read complete msg, and dispatch msg + assert 800 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) + assert 800 == m.db.get_db_value(Register.RATED_POWER, 0) + assert 'TSOL-MSxx00' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) + m.close() + def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg): ConfigTsunAllowAll m = MemoryStream(DeviceIndMsg, (0,)) @@ -973,6 +1020,7 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, m._send_buffer = bytearray(0) # clear send buffer for next test m._forward_buffer = bytearray(0) # clear send buffer for next test await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert m._recv_buffer==InverterIndMsg # unhandled next message assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' assert m._send_buffer == b'' # modbus command must be ignore, cause connection is still not up @@ -990,12 +1038,35 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' assert m._send_buffer == MsgModbusCmd + + m._send_buffer = bytearray(0) # clear send buffer for next test + m.test_exception_async_write = True + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' m.close() @pytest.mark.asyncio -async def test_AT_cmd(ConfigTsunAllowAll, InverterIndMsg, InverterRspMsg, AtCommandIndMsg): +async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg): ConfigTsunAllowAll - m = MemoryStream(InverterIndMsg, (0,), True) + m = MemoryStream(DeviceIndMsg, (0,), True) + m.append_msg(InverterIndMsg) + m.read() + assert m.control == 0x4110 + assert str(m.seq) == '01:01' + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==DeviceRspMsg + assert m._forward_buffer==DeviceIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_at_cmd('AT+TIME=214028,1,60,120') + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '01:01' + m.read() assert m.control == 0x4210 assert str(m.seq) == '02:02' @@ -1010,4 +1081,13 @@ async def test_AT_cmd(ConfigTsunAllowAll, InverterIndMsg, InverterRspMsg, AtComm assert m._send_buffer==AtCommandIndMsg assert m._forward_buffer==b'' assert str(m.seq) == '02:03' + + m._send_buffer = bytearray(0) # clear send buffer for next test + m.test_exception_async_write = True + await m.send_at_cmd('AT+TIME=214028,1,60,120') + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '02:04' + m.close() diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 202cd96..9e6e8b5 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -24,6 +24,7 @@ class MemoryStream(Talent): self.msg_count = 0 self.addr = 'Test: SrvSide' self.send_msg_ofs = 0 + self.test_exception_async_write = False def append_msg(self, msg): self.__msg += msg @@ -56,7 +57,8 @@ class MemoryStream(Talent): return async def async_write(self, headline=''): - pass + if self.test_exception_async_write: + raise RuntimeError("Peer closed.") @@ -899,9 +901,21 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 m = MemoryStream(b'', (0,), True) m.id_str = b"R170000000000001" + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' + m.state = m.STATE_UP await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' assert m._send_buffer == MsgModbusCmd + + m._send_buffer = bytearray(0) # clear send buffer for next test + m.test_exception_async_write = True + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' m.close() From def57024157a150343557ed369262d438248d718 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 23:31:22 +0200 Subject: [PATCH 047/118] upgrade version fron v3 to v4 --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 518a148..72ce456 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -29,9 +29,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.12 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.12" - name: Install dependencies From 6a644841745e13c903ac92a33721e6e5ee85f280 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 23:34:29 +0200 Subject: [PATCH 048/118] read `Designed Power' with Modbus --- app/src/scheduler.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/scheduler.py b/app/src/scheduler.py index a1e763b..1342643 100644 --- a/app/src/scheduler.py +++ b/app/src/scheduler.py @@ -11,6 +11,7 @@ logger_mqtt = logging.getLogger('mqtt') class Schedule: mqtt = None + count = 0 @classmethod def start(cls) -> None: @@ -36,8 +37,15 @@ class Schedule: @classmethod async def regular_modbus_cmds(cls): # logging.info("Regular Modbus requests") + if 0 == (cls.count % 30): + # logging.info("Regular Modbus Status request") + addr, len = 0x2007, 2 + else: + addr, len = 0x3008, 20 + cls.count += 1 + for m in Message: if m.server_side: fnc = getattr(m, "send_modbus_cmd", None) if callable(fnc): - await fnc(Modbus.READ_REGS, 0x3008, 20) + await fnc(Modbus.READ_REGS, addr, len) From f48596a512c6cef85445e3c04791716c90997649 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 23:38:02 +0200 Subject: [PATCH 049/118] use actions/setup-python@v5 --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 72ce456..2c7031b 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies From dd438bf2014d909b12f25e9ff681391d0c477c25 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 9 May 2024 23:38:34 +0200 Subject: [PATCH 050/118] add comment --- app/src/gen3/talent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 44c7d0f..d0cc123 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -399,7 +399,7 @@ class Talent(Message): self.node_id): if update: self.new_data[key] = True - self.modbus_elms += 1 + self.modbus_elms += 1 # count for unit tests if not self.forward_modbus_resp: return From 26f108cc5172df28f83963a2ea6169d945d465d4 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 10 May 2024 20:50:37 +0200 Subject: [PATCH 051/118] build version string in the same format as TSUN --- CHANGELOG.md | 1 + app/src/gen3plus/infos_g3p.py | 2 +- app/src/modbus.py | 2 +- app/tests/test_infos_g3p.py | 2 +- app/tests/test_modbus.py | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4365c7..425d189 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] +- build version string in the same format as TSUN for GEN3 invterts - add graceful shutdown - add timeout monitoring for received packets - parse Modbus values and store them in the database diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index a436dbf..213bcf2 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -25,7 +25,7 @@ class RegisterMap: 0x4201001c: {'reg': Register.POWER_ON_TIME, 'fmt': '>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 + 0x420100d0: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 0x420100d2: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x420100d4: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x420100d6: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 diff --git a/app/src/modbus.py b/app/src/modbus.py index 0047afc..148b3f6 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -33,7 +33,7 @@ class Modbus(): map = { 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 # 0x????: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 - 0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'v{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 + 0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 0x3009: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x300a: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 diff --git a/app/tests/test_infos_g3p.py b/app/tests/test_infos_g3p.py index 4af9cb2..a127d13 100644 --- a/app/tests/test_infos_g3p.py +++ b/app/tests/test_infos_g3p.py @@ -83,7 +83,7 @@ def test_parse_4210(InverterData: bytes): assert json.dumps(i.db) == json.dumps({ "controller": {"Power_On_Time": 2051}, - "inverter": {"Serial_Number": "Y17E00000000000E", "Version": "v4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000, "No_Inputs": 4}, + "inverter": {"Serial_Number": "Y17E00000000000E", "Version": "V4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000, "No_Inputs": 4}, "env": {"Inverter_Status": 1, "Inverter_Temp": 14}, "grid": {"Voltage": 224.8, "Current": 0.73, "Frequency": 50.05, "Output_Power": 165.8}, "input": {"pv1": {"Voltage": 35.3, "Current": 1.68, "Power": 59.6, "Daily_Generation": 0.04, "Total_Generation": 30.76}, diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index a3d24ac..ed6a68a 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -106,7 +106,7 @@ def test_build_recv(): assert mb.check_crc(pdu) assert mb.err == 0 call = 0 - exp_result = ['v0.0.212', 4.4, 0.7, 0.7, 30] + exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): if key == 'grid': assert update == True From 0e7fbc7820a1a3e5781cc2bb487b4355881bfa7a Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 11 May 2024 20:46:36 +0200 Subject: [PATCH 052/118] fix Modbus CRC errors - parse Modbus messages well if another msg follows in the receive buffer --- app/src/gen3plus/solarman_v5.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 2bc2b87..1989225 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -419,22 +419,24 @@ class SolarmanV5(Message): self.__send_ack_rsp(0x1310, ftype) def msg_command_req(self): - data = self._recv_buffer[self.header_len:] + data = self._recv_buffer[self.header_len: + self.header_len+self.data_len] result = struct.unpack_from(' 4: # logger.info(f'first byte modbus:{data[14]}') inv_update = False - for key, update, _ in self.mb.recv_resp(self.db, data[14:-2], + for key, update, _ in self.mb.recv_resp(self.db, data[14:], self.node_id): if update: if key == 'inverter': From 3fda08bd25d756cd36f00626af3d2a3049b4de70 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 11 May 2024 20:48:57 +0200 Subject: [PATCH 053/118] add more unit tests --- app/tests/test_solarman.py | 245 ++++++++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 3 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 3092e45..befb159 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -461,6 +461,19 @@ def MsgModbusCmd(): msg += b'\x15' return msg +@pytest.fixture +def MsgModbusRsp(): # 0x1510 + msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01' + msg += total() + msg += hb() + msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8' + msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02' + msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04' + msg += b'\x00\x01\x00\x00\x6c\x68' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def ConfigTsunAllowAll(): Config.config = {'solarman':{'enabled': True}, 'inverters':{'allow_all':True}} @@ -911,7 +924,7 @@ def test_sync_end_rsp(ConfigTsunInv1, SyncEndRspMsg): assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() -def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg, AtCommandRspMsg): +def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): ConfigTsunInv1 m = MemoryStream(AtCommandIndMsg, (0,), False) m.read() # read complete msg, and dispatch msg @@ -921,10 +934,10 @@ def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg, AtCommandRspMsg): assert m.snr == 2070233889 # assert m.unique_id == '2070233889' assert m.control == 0x4510 - assert str(m.seq) == '03:03' + assert str(m.seq) == '03:02' assert m.data_len == 39 assert m._recv_buffer==b'' - assert m._send_buffer==AtCommandRspMsg + assert m._send_buffer==b'' assert m._forward_buffer==AtCommandIndMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 assert m.db.stat['proxy']['AT_Command'] == 1 @@ -1005,6 +1018,44 @@ def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg): assert 'V1.1.00.0B' == m.db.get_db_value(Register.COLLECTOR_FW_VERSION, 0).rstrip('\00') m.close() +def test_msg_iterator(): + m1 = SolarmanV5(server_side=True) + m2 = SolarmanV5(server_side=True) + m3 = SolarmanV5(server_side=True) + m3.close() + del m3 + test1 = 0 + test2 = 0 + for key in SolarmanV5: + if key == m1: + test1+=1 + elif key == m2: + test2+=1 + elif type(key) != SolarmanV5: + continue + else: + assert False + assert test1 == 1 + assert test2 == 1 + +def test_proxy_counter(): + m = SolarmanV5(server_side=True) + assert m.new_data == {} + m.db.stat['proxy']['Unknown_Msg'] = 0 + Infos.new_stat_data['proxy'] = False + + m.inc_counter('Unknown_Msg') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert 1 == m.db.stat['proxy']['Unknown_Msg'] + + Infos.new_stat_data['proxy'] = False + m.dec_counter('Unknown_Msg') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert 0 == m.db.stat['proxy']['Unknown_Msg'] + m.close() + @pytest.mark.asyncio async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, MsgModbusCmd): ConfigTsunInv1 @@ -1091,3 +1142,191 @@ async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIn assert str(m.seq) == '02:04' m.close() + +def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(MsgModbusCmd, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x4510 + assert str(m.seq) == '03:02' + assert m.header_len==11 + assert m.data_len==23 + assert m._forward_buffer==MsgModbusCmd + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 1 + m.close() + +def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_modbus_resp = False + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==59 + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_modbus_resp = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==59 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp, (0,), False) + m.append_msg(MsgModbusRsp) + + m.forward_modbus_resp = True + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + # assert m.db.db == {'inverter': {'Manufacturer': 'TSUN', 'Equipment_Model': 'TSOL-MSxx00'}} + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == True + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == False + + m.close() +''' +def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInv): + ConfigTsunInv1 + m = MemoryStream(MsgModbusInv, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==153 + assert m.msg_id==119 + assert m.header_len==23 + assert m.data_len==13 + assert m._forward_buffer==MsgModbusInv + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 1 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): + ConfigTsunInv1 + # receive more bytes than expected (7 bytes from the next msg) + m = MemoryStream(MsgModbusResp20+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_modbus_resp = True + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl) == 0x91 + assert m.msg_id == 119 + assert m.header_len == 23 + assert m.data_len == 50 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer == b'' + assert m.mb.err == 0 + assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +@pytest.mark.asyncio +async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(b'', (0,), True) + m.id_str = b"R170000000000001" + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' + + m.state = m.STATE_UP + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == MsgModbusCmd + + m._send_buffer = bytearray(0) # clear send buffer for next test + m.test_exception_async_write = True + await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) + assert 0 == m.send_msg_ofs + assert m._forward_buffer == b'' + assert m._send_buffer == b'' + m.close() + +def test_zombie_conn(ConfigTsunInv1, MsgInverterInd): + ConfigTsunInv1 + tracer.setLevel(logging.DEBUG) + m1 = MemoryStream(MsgInverterInd, (0,)) + m2 = MemoryStream(MsgInverterInd, (0,)) + m3 = MemoryStream(MsgInverterInd, (0,)) + assert m1.state == m1.STATE_INIT + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m1.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_INIT + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m2.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m3.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_CLOSED + assert m3.state == m3.STATE_INIT + m1.close() + m2.close() + m3.close() +''' \ No newline at end of file From 73baffe9e08b71c4fbc0715f271fa9d9706a2519 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 11 May 2024 20:50:26 +0200 Subject: [PATCH 054/118] also get the 'Daily Generation' every minute --- app/src/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/scheduler.py b/app/src/scheduler.py index 1342643..610f586 100644 --- a/app/src/scheduler.py +++ b/app/src/scheduler.py @@ -41,7 +41,7 @@ class Schedule: # logging.info("Regular Modbus Status request") addr, len = 0x2007, 2 else: - addr, len = 0x3008, 20 + addr, len = 0x3008, 21 cls.count += 1 for m in Message: From 6fcf4f47c2ae4cacc17f8a13bc700021d124f062 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 11 May 2024 20:53:39 +0200 Subject: [PATCH 055/118] add more unit tests --- app/tests/test_talent.py | 71 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 9e6e8b5..96feaf1 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -2,7 +2,7 @@ import pytest, logging from app.src.gen3.talent import Talent, Control from app.src.config import Config -from app.src.infos import Infos +from app.src.infos import Infos, Register from app.src.modbus import Modbus @@ -849,6 +849,41 @@ def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): assert m.db.stat['proxy']['Modbus_Command'] == 0 m.close() +def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20): + ConfigTsunInv1 + m = MemoryStream(MsgModbusResp20, (0,), False) + m.append_msg(MsgModbusResp20) + + m.forward_modbus_resp = True + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + assert m.db.db == {} + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == True + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == False + + m.close() + def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInv): ConfigTsunInv1 m = MemoryStream(MsgModbusInv, (0,), False) @@ -919,3 +954,37 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert m._forward_buffer == b'' assert m._send_buffer == b'' m.close() +''' +def test_zombie_conn(ConfigTsunInv1, MsgInverterInd): + ConfigTsunInv1 + tracer.setLevel(logging.DEBUG) + start_val = MemoryStream._RefNo + + m1 = MemoryStream(MsgInverterInd, (0,)) + assert MemoryStream._RefNo == 1 + start_val + assert m1.RefNo == 1 + start_val + m2 = MemoryStream(MsgInverterInd, (0,)) + assert MemoryStream._RefNo == 2 + start_val + assert m2.RefNo == 2 + start_val + m3 = MemoryStream(MsgInverterInd, (0,)) + assert MemoryStream._RefNo == 3 + start_val + assert m3.RefNo == 3 + start_val + assert m1.state == m1.STATE_INIT + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m1.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_UP + assert m2.state == m2.STATE_INIT + assert m3.state == m3.STATE_INIT + m2.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_UP + assert m3.state == m3.STATE_INIT + m3.read() # read complete msg, and set unique_id + assert m1.state == m1.STATE_CLOSED + assert m2.state == m2.STATE_CLOSED + assert m3.state == m3.STATE_UP + m1.close() + m2.close() + m3.close() +''' \ No newline at end of file From 4ea70dee64a6f3e4b5476ab9f9b1419d79133d1a Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 11 May 2024 20:55:31 +0200 Subject: [PATCH 056/118] improve connection handling - insure close() call after graceful disconnect, to release proxy internal resources - timeout handler disconnect inverter connection if no message was received for longer than 2.5 minutes --- app/src/async_stream.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 7c99373..56f475d 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -59,28 +59,35 @@ class AsyncStream(): while True: try: - # await asyncio.wait_for(self.__async_read(), 0.3) - await self.__async_read() + if self.state == self.STATE_UP and self.server_side: + await asyncio.wait_for(self.__async_read(), 150) + else: + await self.__async_read() if self.unique_id: await self.async_write() await self.__async_forward() await self.async_publ_mqtt() - except asyncio.TimeoutError: - pass - except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as error: logger.error(f'{error} for l{self.l_addr} | ' f'r{self.r_addr}') await self.disc() + self.close() return self except RuntimeError as error: logger.warning(f"{error} for {self.l_addr}") await self.disc() + self.close() + return self + + except asyncio.TimeoutError: + logger.warning(f"Timeout for {self.l_addr}") + await self.disc() + self.close() return self except Exception: From e43a02c508068e94fe370238d2d5ee8d4ef25c62 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 11 May 2024 23:40:46 +0200 Subject: [PATCH 057/118] improve modbus parsing - parse Modbus messages well if another msg follows in the receive buffer --- app/src/gen3/talent.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index d0cc123..f82f47c 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -347,6 +347,7 @@ class Talent(Message): self._send_buffer += b'\x01' self.__finish_send_msg() self.__process_data() + self.state = self.STATE_UP elif self.ctrl.is_resp(): return # ignore received response @@ -387,15 +388,20 @@ class Talent(Message): def msg_modbus(self): hdr_len, modbus_len = self.parse_modbus_header() + data = self._recv_buffer[self.header_len: + self.header_len+self.data_len] if self.ctrl.is_req(): + if not self.mb.recv_req(data[hdr_len:]): + return + self.forward_modbus_resp = True self.inc_counter('Modbus_Command') elif self.ctrl.is_ind(): # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') self.modbus_elms = 0 - for key, update, _ in self.mb.recv_resp(self.db, self._recv_buffer[ - self.header_len + hdr_len:self.header_len+self.data_len], + for key, update, _ in self.mb.recv_resp(self.db, data[ + hdr_len:], self.node_id): if update: self.new_data[key] = True From 1ae7784bee83898d2b370ec98cd3de16d60357b0 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 11 May 2024 23:41:40 +0200 Subject: [PATCH 058/118] add more unit tests --- app/src/gen3plus/solarman_v5.py | 4 + app/tests/test_solarman.py | 149 ++++++++++++++++++-------------- 2 files changed, 88 insertions(+), 65 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 1989225..a766ce8 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -92,6 +92,7 @@ class SolarmanV5(Message): 0x4510: self.msg_command_req, # from server 0x1510: self.msg_command_rsp, # from inverter } + self.modbus_elms = 0 # for unit tests ''' Our puplic methods @@ -447,8 +448,11 @@ class SolarmanV5(Message): if valid == 1 and modbus_msg_len > 4: # logger.info(f'first byte modbus:{data[14]}') inv_update = False + self.modbus_elms = 0 + for key, update, _ in self.mb.recv_resp(self.db, data[14:], self.node_id): + self.modbus_elms += 1 if update: if key == 'inverter': inv_update = True diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index befb159..f7f4a21 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -388,7 +388,7 @@ def SyncStartRspMsg(): # 0x1310 @pytest.fixture def SyncStartFwdMsg(): # 0x4310 - msg = b'\xa5\x2f\x00\x10\x43\x0e\x0d' +get_sn() +b'\x81\x7a\x0b\x2e\x32' + msg = b'\xa5\x2f\x00\x10\x43\x0d\x0e' +get_sn() +b'\x81\x7a\x0b\x2e\x32' msg += b'\x39\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x41\x6c\x6c\x69\x75\x73' msg += b'\x2d\x48\x6f\x6d\x65\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x61\x01' @@ -474,6 +474,28 @@ def MsgModbusRsp(): # 0x1510 msg += b'\x15' return msg +@pytest.fixture +def MsgModbusInvalid(): # 0x1510 + msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x00' + msg += total() + msg += hb() + msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8' + msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02' + msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04' + msg += b'\x00\x01\x00\x00\x6c\x68' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + +@pytest.fixture +def MsgUnknownCmd(): + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x03\xb0\x02' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08' + msg += b'\x00\x00\x03\xc8' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def ConfigTsunAllowAll(): Config.config = {'solarman':{'enabled': True}, 'inverters':{'allow_all':True}} @@ -864,6 +886,7 @@ def test_sync_start_ind(ConfigTsunInv1, SyncStartIndMsg, SyncStartRspMsg, SyncSt assert m._forward_buffer==SyncStartIndMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.seq.server_side = False # simulate forawding to TSUN cloud m._update_header(m._forward_buffer) assert str(m.seq) == '0d:0e' # value after forwarding indication assert m._forward_buffer==SyncStartFwdMsg @@ -924,25 +947,6 @@ def test_sync_end_rsp(ConfigTsunInv1, SyncEndRspMsg): assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() -def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): - ConfigTsunInv1 - m = MemoryStream(AtCommandIndMsg, (0,), False) - m.read() # read complete msg, and dispatch msg - assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.msg_count == 1 - assert m.header_len==11 - assert m.snr == 2070233889 - # assert m.unique_id == '2070233889' - assert m.control == 0x4510 - assert str(m.seq) == '03:02' - assert m.data_len == 39 - assert m._recv_buffer==b'' - assert m._send_buffer==b'' - assert m._forward_buffer==AtCommandIndMsg - assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 - assert m.db.stat['proxy']['AT_Command'] == 1 - m.close() - def test_build_modell_600(ConfigTsunAllowAll, InverterIndMsg): ConfigTsunAllowAll m = MemoryStream(InverterIndMsg, (0,)) @@ -1143,10 +1147,37 @@ async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIn m.close() +def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): + ConfigTsunInv1 + m = MemoryStream(AtCommandIndMsg, (0,), False) + m.forward_modbus_resp = False + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.header_len==11 + assert m.snr == 2070233889 + # assert m.unique_id == '2070233889' + assert m.control == 0x4510 + assert str(m.seq) == '03:02' + assert m.data_len == 39 + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==AtCommandIndMsg + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 1 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.forward_modbus_resp == False + m.close() + def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 m = MemoryStream(MsgModbusCmd, (0,), False) + m.forward_modbus_resp = False m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed @@ -1158,7 +1189,31 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert m._forward_buffer==MsgModbusCmd assert m._send_buffer==b'' assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 1 + assert m.forward_modbus_resp == True + m.close() + +def test_msg_unknown_cmd_req(ConfigTsunInv1, MsgUnknownCmd): + ConfigTsunInv1 + m = MemoryStream(MsgUnknownCmd, (0,), False) + m.forward_modbus_resp = False + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x4510 + assert str(m.seq) == '03:02' + assert m.header_len==11 + assert m.data_len==23 + assert m._forward_buffer==MsgUnknownCmd + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.forward_modbus_resp == False m.close() def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): @@ -1233,31 +1288,25 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): assert m.new_data['inverter'] == False m.close() -''' -def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInv): + +def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInvalid): ConfigTsunInv1 - m = MemoryStream(MsgModbusInv, (0,), False) + m = MemoryStream(MsgModbusInvalid, (0,), False) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 - assert m.id_str == b"R170000000000001" - assert m.unique_id == 'R170000000000001' - assert int(m.ctrl)==153 - assert m.msg_id==119 - assert m.header_len==23 - assert m.data_len==13 - assert m._forward_buffer==MsgModbusInv + assert m._forward_buffer==MsgModbusInvalid assert m._send_buffer==b'' - assert m.db.stat['proxy']['Unknown_Ctrl'] == 1 + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 0 m.close() -def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): +def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusRsp): ConfigTsunInv1 # receive more bytes than expected (7 bytes from the next msg) - m = MemoryStream(MsgModbusResp20+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) + m = MemoryStream(MsgModbusRsp+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.forward_modbus_resp = True @@ -1267,44 +1316,14 @@ def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 - assert m.id_str == b"R170000000000001" - assert m.unique_id == 'R170000000000001' - assert int(m.ctrl) == 0x91 - assert m.msg_id == 119 - assert m.header_len == 23 - assert m.data_len == 50 - assert m._forward_buffer==MsgModbusResp20 + assert m._forward_buffer==MsgModbusRsp assert m._send_buffer == b'' assert m.mb.err == 0 assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 0 m.close() - -@pytest.mark.asyncio -async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): - ConfigTsunInv1 - m = MemoryStream(b'', (0,), True) - m.id_str = b"R170000000000001" - await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) - assert 0 == m.send_msg_ofs - assert m._forward_buffer == b'' - assert m._send_buffer == b'' - - m.state = m.STATE_UP - await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) - assert 0 == m.send_msg_ofs - assert m._forward_buffer == b'' - assert m._send_buffer == MsgModbusCmd - - m._send_buffer = bytearray(0) # clear send buffer for next test - m.test_exception_async_write = True - await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) - assert 0 == m.send_msg_ofs - assert m._forward_buffer == b'' - assert m._send_buffer == b'' - m.close() - +''' def test_zombie_conn(ConfigTsunInv1, MsgInverterInd): ConfigTsunInv1 tracer.setLevel(logging.DEBUG) From 1658036a261781225b3a72e8397fba0a4e538e36 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 12 May 2024 23:09:51 +0200 Subject: [PATCH 059/118] store modbus params always on the server side --- app/src/gen3/talent.py | 10 +++++----- app/src/gen3plus/solarman_v5.py | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index f82f47c..c007295 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -392,11 +392,11 @@ class Talent(Message): self.header_len+self.data_len] if self.ctrl.is_req(): - if not self.mb.recv_req(data[hdr_len:]): - return - - self.forward_modbus_resp = True - self.inc_counter('Modbus_Command') + if not self.remoteStream.mb.recv_req(data[hdr_len:]): + self.inc_counter('Invalid_Msg_Format') + else: + self.inc_counter('Modbus_Command') + self.remoteStream.forward_modbus_resp = True elif self.ctrl.is_ind(): # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') self.modbus_elms = 0 diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index a766ce8..8e926ac 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -427,10 +427,11 @@ class SolarmanV5(Message): if ftype == self.AT_CMD: self.inc_counter('AT_Command') elif ftype == self.MB_RTU_CMD: - if not self.mb.recv_req(data[15:]): - return - self.forward_modbus_resp = True - self.inc_counter('Modbus_Command') + if not self.remoteStream.mb.recv_req(data[15:]): + self.inc_counter('Invalid_Msg_Format') + else: + self.inc_counter('Modbus_Command') + self.remoteStream.forward_modbus_resp = True self.__forward_msg() # self.__send_ack_rsp(0x1510, ftype) From 92469456b75817252b24b36428ca0c761e0ecb13 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 12 May 2024 23:11:55 +0200 Subject: [PATCH 060/118] fix unit tests --- app/tests/test_solarman.py | 28 ++++++++++++++++++---------- app/tests/test_talent.py | 32 ++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index f7f4a21..7c02ed1 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -63,6 +63,12 @@ class MemoryStream(SolarmanV5): if self.test_exception_async_write: raise RuntimeError("Peer closed.") + def createClientStream(self, msg, chunks = (0,)): + c = MemoryStream(msg, chunks, False) + self.remoteStream = c + c. remoteStream = self + return c + def _SolarmanV5__flush_recv_msg(self) -> None: super()._SolarmanV5__flush_recv_msg() self.msg_count += 1 @@ -1174,20 +1180,22 @@ def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 - m = MemoryStream(MsgModbusCmd, (0,), False) + m = MemoryStream(b'') + c = m.createClientStream(MsgModbusCmd) + m.forward_modbus_resp = False m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.read() # read complete msg, and dispatch msg - assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.msg_count == 1 - assert m.control == 0x4510 - assert str(m.seq) == '03:02' - assert m.header_len==11 - assert m.data_len==23 - assert m._forward_buffer==MsgModbusCmd - assert m._send_buffer==b'' + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.control == 0x4510 + assert str(c.seq) == '03:02' + assert c.header_len==11 + assert c.data_len==23 + assert c._forward_buffer==MsgModbusCmd + assert c._send_buffer==b'' assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['AT_Command'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 1 diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 96feaf1..4c9ec65 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -51,6 +51,12 @@ class MemoryStream(Talent): def _timestamp(self): return 1700260990000 + def createClientStream(self, msg, chunks = (0,)): + c = MemoryStream(msg, chunks, False) + self.remoteStream = c + c. remoteStream = self + return c + def _Talent__flush_recv_msg(self) -> None: super()._Talent__flush_recv_msg() self.msg_count += 1 @@ -789,20 +795,22 @@ def test_proxy_counter(): def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 - m = MemoryStream(MsgModbusCmd, (0,), False) + m = MemoryStream(b'') + c = m.createClientStream(MsgModbusCmd) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.read() # read complete msg, and dispatch msg - assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.msg_count == 1 - assert m.id_str == b"R170000000000001" - assert m.unique_id == 'R170000000000001' - assert int(m.ctrl)==112 - assert m.msg_id==119 - assert m.header_len==23 - assert m.data_len==13 - assert m._forward_buffer==MsgModbusCmd - assert m._send_buffer==b'' + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.id_str == b"R170000000000001" + assert c.unique_id == 'R170000000000001' + assert int(c.ctrl)==112 + assert c.msg_id==119 + assert c.header_len==23 + assert c.data_len==13 + assert c._forward_buffer==MsgModbusCmd + assert c._send_buffer==b'' assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 1 m.close() From 036af8e127f8fbfd2743551d7cadf9fead36d3be Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 13 May 2024 19:49:00 +0200 Subject: [PATCH 061/118] move the Modbus instance to the parent class --- app/src/gen3/talent.py | 1 - app/src/gen3plus/solarman_v5.py | 1 - app/src/messages.py | 5 +++++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index c007295..976d468 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -42,7 +42,6 @@ class Talent(Message): self.contact_name = b'' self.contact_mail = b'' self.db = InfosG3() - self.mb = Modbus() self.forward_modbus_resp = False self.switch = { 0x00: self.msg_contact_info, diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 8e926ac..cd1e5d7 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -60,7 +60,6 @@ class SolarmanV5(Message): self.snr = 0 self.db = InfosG3P() self.time_ofs = 0 - self.mb = Modbus() self.forward_modbus_resp = False self.switch = { diff --git a/app/src/messages.py b/app/src/messages.py index 615c054..01e3429 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -3,8 +3,10 @@ import weakref if __name__ == "app.src.messages": from app.src.infos import Infos + from app.src.modbus import Modbus else: # pragma: no cover from infos import Infos + from modbus import Modbus logger = logging.getLogger('msg') @@ -58,6 +60,9 @@ class Message(metaclass=IterRegistry): self._registry.append(weakref.ref(self)) self.server_side = server_side + if server_side: + self.mb = Modbus() + self.header_valid = False self.header_len = 0 self.data_len = 0 From 2e214b1e7197f949d90517c4aafe61cfdcf05e60 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 13 May 2024 22:46:23 +0200 Subject: [PATCH 062/118] avoid sending responses to TSUN for local at commands --- app/src/gen3plus/solarman_v5.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index cd1e5d7..91d2bd0 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -61,6 +61,7 @@ class SolarmanV5(Message): self.db = InfosG3P() self.time_ofs = 0 self.forward_modbus_resp = False + self.forward_at_cmd_resp = False self.switch = { 0x4210: self.msg_data_ind, # real time data @@ -318,6 +319,7 @@ class SolarmanV5(Message): async def send_at_cmd(self, AT_cmd: str) -> None: if self.state != self.STATE_UP: return + self.forward_at_cmd_resp = False self.__build_header(0x4510) self._send_buffer += struct.pack(f' Date: Mon, 13 May 2024 22:47:52 +0200 Subject: [PATCH 063/118] add more unit tests --- app/tests/test_solarman.py | 205 ++++++++++++++++++++++++++++++++++++- app/tests/test_talent.py | 39 ++++++- 2 files changed, 237 insertions(+), 7 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 7c02ed1..46df675 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -356,6 +356,39 @@ def InverterIndMsg800(): # 0x4210 rated Power 800W msg += b'\x15' return msg +@pytest.fixture +def InverterIndMsg_81(): # 0x4210 fcode 0x81 + msg = b'\xa5\x99\x01\x10\x42\x02\x03' +get_sn() +b'\x81\xb0\x02\xbc\xc8' + msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x07\x04\x03\x01\x00\x03\x08\x00\x00' + msg += b'\x59\x31\x37\x45\x37\x41\x30\x46\x30\x31\x30\x42\x30\x31\x33\x45' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x40\x10\x08\xc8\x00\x49\x13\x8d\x00\x36\x00\x00\x02\x58\x06\x7a' + msg += b'\x01\x61\x00\xa8\x02\x54\x01\x5a\x00\x8a\x01\xe4\x01\x5a\x00\xbd' + msg += b'\x02\x8f\x00\x11\x00\x01\x00\x00\x00\x0b\x00\x00\x27\x98\x00\x04' + msg += b'\x00\x00\x0c\x04\x00\x03\x00\x00\x0a\xe7\x00\x05\x00\x00\x0c\x75' + msg += b'\x00\x00\x00\x00\x06\x16\x02\x00\x00\x00\x55\xaa\x00\x01\x00\x00' + msg += b'\x00\x00\x00\x00\xff\xff\x07\xd0\x00\x03\x04\x00\x04\x00\x04\x00' + msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00' + msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41' + msg += b'\x00\x0f\x0a\x64\x0a\x64\x00\x06\x00\x06\x09\xf6\x12\x8c\x12\x8c' + msg += b'\x00\x10\x00\x10\x14\x52\x14\x52\x00\x10\x00\x10\x01\x51\x00\x05' + msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00' + msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00' + msg += b'\x00\x00\x00\x00' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def InverterRspMsg(): # 0x1210 msg = b'\xa5\x0a\x00\x10\x12\x02\02' +get_sn() +b'\x01\x01' @@ -365,6 +398,15 @@ def InverterRspMsg(): # 0x1210 msg += b'\x15' return msg +@pytest.fixture +def InverterRspMsg_81(): # 0x1210 fcode 0x81 + msg = b'\xa5\x0a\x00\x10\x12\x03\03' +get_sn() +b'\x81\x01' + msg += total() + msg += hb() + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def UnknownMsg(): # 0x5110 msg = b'\xa5\x0a\x00\x10\x51\x10\x84' +get_sn() +b'\x01\x01\x69\x6f\x09' @@ -467,6 +509,15 @@ def MsgModbusCmd(): msg += b'\x15' return msg +@pytest.fixture +def MsgModbusCmdCrcErr(): + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x02\xb0\x02' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08' + msg += b'\x00\x00\x04\xc8' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def MsgModbusRsp(): # 0x1510 msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01' @@ -502,6 +553,19 @@ def MsgUnknownCmd(): msg += b'\x15' return msg +@pytest.fixture +def MsgUnknownCmdRsp(): # 0x1510 + msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x03\x01' + msg += total() + msg += hb() + msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8' + msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02' + msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04' + msg += b'\x00\x01\x00\x00\x6c\x68' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def ConfigTsunAllowAll(): Config.config = {'solarman':{'enabled': True}, 'inverters':{'allow_all':True}} @@ -785,6 +849,52 @@ def test_read_two_messages(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, Inver assert m._send_buffer==b'' m.close() +def test_read_two_messages2(ConfigTsunAllowAll, InverterIndMsg, InverterIndMsg_81, InverterRspMsg, InverterRspMsg_81): + ConfigTsunAllowAll + m = MemoryStream(InverterIndMsg, (0,)) + m.append_msg(InverterIndMsg_81) + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.header_len==11 + assert m.snr == 2070233889 + assert m.unique_id == '2070233889' + assert m.control == 0x4210 + assert m.time_ofs == 0x33e447a0 + assert str(m.seq) == '02:02' + assert m.data_len == 0x199 + assert m.msg_count == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m._forward_buffer==InverterIndMsg + assert m._send_buffer==InverterRspMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._init_new_client_conn() + assert m._send_buffer==b'' + assert m._recv_buffer==InverterIndMsg_81 + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear forward buffer for next test + m.read() # read complete msg, and dispatch msg + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 2 + assert m.header_len==11 + assert m.snr == 2070233889 + assert m.unique_id == '2070233889' + assert m.control == 0x4210 + assert m.time_ofs == 0x33e447a0 + assert str(m.seq) == '03:03' + assert m.data_len == 0x199 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m._forward_buffer==InverterIndMsg_81 + assert m._send_buffer==InverterRspMsg_81 + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._init_new_client_conn() + assert m._send_buffer==b'' + m.close() + def test_unkown_message(ConfigTsunInv1, UnknownMsg): ConfigTsunInv1 m = MemoryStream(UnknownMsg, (0,)) @@ -1150,7 +1260,7 @@ async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIn assert m._send_buffer==b'' assert m._forward_buffer==b'' assert str(m.seq) == '02:04' - + assert m.forward_at_cmd_resp == False m.close() def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): @@ -1178,6 +1288,44 @@ def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): assert m.forward_modbus_resp == False m.close() +def test_msg_at_command_rsp1(ConfigTsunInv1, AtCommandRspMsg): + ConfigTsunInv1 + m = MemoryStream(AtCommandRspMsg) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_at_cmd_resp = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==10 + assert m._forward_buffer==AtCommandRspMsg + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_msg_at_command_rsp2(ConfigTsunInv1, AtCommandRspMsg): + ConfigTsunInv1 + m = MemoryStream(AtCommandRspMsg) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_at_cmd_resp = False + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==10 + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 m = MemoryStream(b'') @@ -1187,6 +1335,7 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 c.read() # read complete msg, and dispatch msg assert not c.header_valid # must be invalid, since msg was handled and buffer flushed assert c.msg_count == 1 @@ -1199,6 +1348,33 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['AT_Command'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m.forward_modbus_resp == True + m.close() + +def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): + ConfigTsunInv1 + m = MemoryStream(b'') + c = m.createClientStream(MsgModbusCmdCrcErr) + + m.forward_modbus_resp = False + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.control == 0x4510 + assert str(c.seq) == '03:02' + assert c.header_len==11 + assert c.data_len==23 + assert c._forward_buffer==MsgModbusCmdCrcErr + assert c._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1 assert m.forward_modbus_resp == True m.close() @@ -1209,6 +1385,7 @@ def test_msg_unknown_cmd_req(ConfigTsunInv1, MsgUnknownCmd): m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 @@ -1221,12 +1398,13 @@ def test_msg_unknown_cmd_req(ConfigTsunInv1, MsgUnknownCmd): assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['AT_Command'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 assert m.forward_modbus_resp == False m.close() def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): ConfigTsunInv1 - m = MemoryStream(MsgModbusRsp, (0,), False) + m = MemoryStream(MsgModbusRsp) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.forward_modbus_resp = False @@ -1245,7 +1423,7 @@ def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): ConfigTsunInv1 - m = MemoryStream(MsgModbusRsp, (0,), False) + m = MemoryStream(MsgModbusRsp) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.forward_modbus_resp = True @@ -1264,7 +1442,7 @@ def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): ConfigTsunInv1 - m = MemoryStream(MsgModbusRsp, (0,), False) + m = MemoryStream(MsgModbusRsp) m.append_msg(MsgModbusRsp) m.forward_modbus_resp = True @@ -1297,6 +1475,25 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): m.close() +def test_msg_unknown_rsp(ConfigTsunInv1, MsgUnknownCmdRsp): + ConfigTsunInv1 + m = MemoryStream(MsgUnknownCmdRsp) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.forward_modbus_resp = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.control == 0x1510 + assert str(m.seq) == '03:03' + assert m.header_len==11 + assert m.data_len==59 + assert m._forward_buffer==MsgUnknownCmdRsp + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInvalid): ConfigTsunInv1 m = MemoryStream(MsgModbusInvalid, (0,), False) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 4c9ec65..ed39111 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -194,6 +194,13 @@ def MsgModbusCmd(): msg += b'\x00\x00\x03\xc8' return msg +@pytest.fixture +def MsgModbusCmdCrcErr(): + msg = b'\x00\x00\x00\x20\x10R170000000000001' + msg += b'\x70\x77\x00\x01\xa3\x28\x08\x01\x06\x20\x08' + msg += b'\x00\x00\x04\xc8' + return msg + @pytest.fixture def MsgModbusRsp(): msg = b'\x00\x00\x00\x20\x10R170000000000001' @@ -800,6 +807,7 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 c.read() # read complete msg, and dispatch msg assert not c.header_valid # must be invalid, since msg was handled and buffer flushed assert c.msg_count == 1 @@ -813,11 +821,36 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert c._send_buffer==b'' assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.close() + +def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): + ConfigTsunInv1 + m = MemoryStream(b'') + c = m.createClientStream(MsgModbusCmdCrcErr) + + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.id_str == b"R170000000000001" + assert c.unique_id == 'R170000000000001' + assert int(c.ctrl)==112 + assert c.msg_id==119 + assert c.header_len==23 + assert c.data_len==13 + assert c._forward_buffer==MsgModbusCmdCrcErr + assert c._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1 m.close() def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): ConfigTsunInv1 - m = MemoryStream(MsgModbusRsp, (0,), False) + m = MemoryStream(MsgModbusRsp) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.forward_modbus_resp = False @@ -838,7 +871,7 @@ def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): ConfigTsunInv1 - m = MemoryStream(MsgModbusRsp, (0,), False) + m = MemoryStream(MsgModbusRsp) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.forward_modbus_resp = True @@ -859,7 +892,7 @@ def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20): ConfigTsunInv1 - m = MemoryStream(MsgModbusResp20, (0,), False) + m = MemoryStream(MsgModbusResp20) m.append_msg(MsgModbusResp20) m.forward_modbus_resp = True From 14425da5fae43398a8e614fbf664be5ee4686198 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 13 May 2024 22:48:44 +0200 Subject: [PATCH 064/118] improve Modbus logging --- app/src/modbus.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index 148b3f6..3ef0f5f 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -86,11 +86,11 @@ class Modbus(): # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') if not self.check_crc(buf): self.err = 1 - logging.error('Modbus: CRC error') + logging.error('Modbus recv: CRC error') return False if buf[0] != self.INV_ADDR: self.err = 2 - logging.info(f'Modbus: Wrong addr{buf[0]}') + logging.info(f'Modbus recv: Wrong addr{buf[0]}') return False res = struct.unpack_from('>BHH', buf, 1) self.last_fcode = res[0] @@ -103,11 +103,11 @@ class Modbus(): Generator[tuple[str, bool], None, None]: # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') if not self.check_crc(buf): - logging.error('Modbus: CRC error') + logging.error('Modbus resp: CRC error') self.err = 1 return if buf[0] != self.INV_ADDR: - logging.info(f'Modbus: Wrong addr {buf[0]}') + logging.info(f'Modbus resp: Wrong addr {buf[0]}') self.err = 2 return if buf[1] != self.last_fcode: From 841877305d46956a860e2c818a459d410fbddda8 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 15 May 2024 23:15:20 +0200 Subject: [PATCH 065/118] timeout handler removed again, as it has no positive effect --- CHANGELOG.md | 1 - app/src/async_stream.py | 12 +----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 425d189..ed901bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - build version string in the same format as TSUN for GEN3 invterts - add graceful shutdown -- add timeout monitoring for received packets - parse Modbus values and store them in the database - add cron task to request the output power every minute - GEN3PLUS: add MQTT topics to send AT commands to the inverter diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 56f475d..196a01f 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -1,6 +1,5 @@ import logging import traceback -import asyncio from messages import hex_dump_memory logger = logging.getLogger('conn') @@ -59,10 +58,7 @@ class AsyncStream(): while True: try: - if self.state == self.STATE_UP and self.server_side: - await asyncio.wait_for(self.__async_read(), 150) - else: - await self.__async_read() + await self.__async_read() if self.unique_id: await self.async_write() @@ -84,12 +80,6 @@ class AsyncStream(): self.close() return self - except asyncio.TimeoutError: - logger.warning(f"Timeout for {self.l_addr}") - await self.disc() - self.close() - return self - except Exception: self.inc_counter('SW_Exception') logger.error( From f4da16987f5084042db709c6ce9f33564f8e3f5a Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 18 May 2024 20:18:15 +0200 Subject: [PATCH 066/118] add fifo and timeout handler for modbus --- app/src/gen3/talent.py | 25 +++--- app/src/gen3plus/solarman_v5.py | 23 +++--- app/src/messages.py | 9 +- app/src/modbus.py | 141 +++++++++++++++++++++----------- 4 files changed, 129 insertions(+), 69 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 976d468..12fcf6c 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -36,7 +36,7 @@ class Control: class Talent(Message): def __init__(self, server_side: bool, id_str=b''): - super().__init__(server_side) + super().__init__(server_side, self.send_modbus_cb, 15) self.await_conn_resp_cnt = 0 self.id_str = id_str self.contact_name = b'' @@ -65,6 +65,7 @@ class Talent(Message): # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() self.state = self.STATE_CLOSED + super().close() def __set_serial_no(self, serial_no: str): @@ -122,20 +123,22 @@ class Talent(Message): f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') return - async def send_modbus_cmd(self, func, addr, val) -> None: - if self.state != self.STATE_UP: - return + def send_modbus_cb(self, modbus_pdu: bytearray): self.forward_modbus_resp = False self.__build_header(0x70, 0x77) self._send_buffer += b'\x00\x01\xa3\x28' # fixme - modbus_msg = self.mb.build_msg(Modbus.INV_ADDR, func, addr, val) - self._send_buffer += struct.pack('!B', len(modbus_msg)) - self._send_buffer += modbus_msg + self._send_buffer += struct.pack('!B', len(modbus_pdu)) + self._send_buffer += modbus_pdu self.__finish_send_msg() - try: - await self.async_write('Send Modbus Command:') - except Exception: - self._send_buffer = bytearray(0) + hex_dump_memory(logging.INFO, f'Send Modbus Command:{self.addr}:', + self._send_buffer, len(self._send_buffer)) + self.writer.write(self._send_buffer) + self._send_buffer = bytearray(0) # self._send_buffer[sent:] + + async def send_modbus_cmd(self, func, addr, val) -> None: + if self.state != self.STATE_UP: + return + self.mb.build_msg(Modbus.INV_ADDR, func, addr, val) def _init_new_client_conn(self) -> bool: contact_name = self.contact_name diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 91d2bd0..f744951 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -52,7 +52,7 @@ class SolarmanV5(Message): MB_RTU_CMD = 2 def __init__(self, server_side: bool): - super().__init__(server_side) + super().__init__(server_side, self.send_modbus_cb, 5) self.header_len = 11 # overwrite construcor in class Message self.control = 0 @@ -104,6 +104,7 @@ class SolarmanV5(Message): # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() self.state = self.STATE_CLOSED + super().close() def __set_serial_no(self, snr: int): serial_no = str(snr) @@ -301,20 +302,22 @@ class SolarmanV5(Message): self._heartbeat()) self.__finish_send_msg() - async def send_modbus_cmd(self, func, addr, val) -> None: - if self.state != self.STATE_UP: - return + def send_modbus_cb(self, pdu: bytearray): self.forward_modbus_resp = False self.__build_header(0x4510) self._send_buffer += struct.pack(' None: + if self.state != self.STATE_UP: + return + self.mb.build_msg(Modbus.INV_ADDR, func, addr, val) async def send_at_cmd(self, AT_cmd: str) -> None: if self.state != self.STATE_UP: diff --git a/app/src/messages.py b/app/src/messages.py index 01e3429..4968609 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -56,12 +56,14 @@ class Message(metaclass=IterRegistry): STATE_UP = 2 STATE_CLOSED = 3 - def __init__(self, server_side: bool): + def __init__(self, server_side: bool, send_modbus_cb, mb_timeout): self._registry.append(weakref.ref(self)) self.server_side = server_side if server_side: - self.mb = Modbus() + self.mb = Modbus(send_modbus_cb, mb_timeout) + else: + self.mb = None self.header_valid = False self.header_len = 0 @@ -91,6 +93,9 @@ class Message(metaclass=IterRegistry): Our puplic methods ''' def close(self) -> None: + if self.mb: + del self.mb + self.mb = None pass # pragma: no cover def inc_counter(self, counter: str) -> None: diff --git a/app/src/modbus.py b/app/src/modbus.py index 3ef0f5f..9302876 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -1,5 +1,6 @@ import struct import logging +import asyncio from typing import Generator if __name__ == "app.src.modbus": @@ -65,85 +66,133 @@ class Modbus(): 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 } - def __init__(self): + def __init__(self, snd_handler, timeout: int = 1): if not len(self.__crc_tab): self.__build_crc_tab(CRC_POLY) + self.que = asyncio.Queue(100) + self.snd_handler = snd_handler + self.timeout = timeout + self.last_addr = 0 self.last_fcode = 0 self.last_len = 0 self.last_reg = 0 self.err = 0 + self.loop = asyncio.get_event_loop() + self.req_pend = False + self.tim = None - def build_msg(self, addr, func, reg, val): + def start_timer(self): + if self.req_pend: + return + self.req_pend = True + self.tim = self.loop.call_later(self.timeout, self.timeout_cb) + # logging.debug(f'Modbus start timer {self}') + + def stop_timer(self): + self.req_pend = False + # logging.debug(f'Modbus stop timer {self}') + if self.tim: + self.tim.cancel() + self.get_next_req() + + def timeout_cb(self): + self.req_pend = False + logging.info(f'Modbus timeout {self}') + self.get_next_req() + + def get_next_req(self) -> None: + if self.req_pend: + return + try: + item = self.que.get_nowait() + req = item['req'] + self.rsp_handler = item['rsp_hdl'] + self.last_addr = req[0] + self.last_fcode = req[1] + + res = struct.unpack_from('>HH', req, 2) + self.last_reg = res[0] + self.last_len = res[1] + self.start_timer() + self.snd_handler(req) + except asyncio.QueueEmpty: + pass + + def build_msg(self, addr, func, reg, val) -> None: msg = struct.pack('>BBHH', addr, func, reg, val) msg += struct.pack(' bool: + def recv_req(self, buf: bytearray, rsp_handler=None) -> bool: # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') if not self.check_crc(buf): self.err = 1 logging.error('Modbus recv: CRC error') return False - if buf[0] != self.INV_ADDR: - self.err = 2 - logging.info(f'Modbus recv: Wrong addr{buf[0]}') - return False - res = struct.unpack_from('>BHH', buf, 1) - self.last_fcode = res[0] - self.last_reg = res[1] - self.last_len = res[2] - self.err = 0 + self.que.put_nowait({'req': buf, + 'rsp_hdl': rsp_handler}) + if self.que.qsize() == 1: + self.get_next_req() + return True def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \ Generator[tuple[str, bool], None, None]: # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') + if not self.req_pend: + self.err = 5 + return if not self.check_crc(buf): logging.error('Modbus resp: CRC error') self.err = 1 return - if buf[0] != self.INV_ADDR: + if buf[0] != self.last_addr: logging.info(f'Modbus resp: Wrong addr {buf[0]}') self.err = 2 return - if buf[1] != self.last_fcode: - logging.info(f'Modbus: Wrong fcode {buf[1]} != {self.last_fcode}') + fcode = buf[1] + if fcode != self.last_fcode: + logging.info(f'Modbus: Wrong fcode {fcode} != {self.last_fcode}') self.err = 3 return - elmlen = buf[2] >> 1 - if elmlen != self.last_len: - logging.info(f'Modbus: len error {elmlen} != {self.last_len}') - self.err = 4 - return - self.err = 0 + if self.last_addr == self.INV_ADDR and \ + (fcode == 3 or fcode == 4): + elmlen = buf[2] >> 1 + if elmlen != self.last_len: + logging.info(f'Modbus: len error {elmlen} != {self.last_len}') + self.err = 4 + return + self.stop_timer() - for i in range(0, elmlen): - addr = self.last_reg+i - if addr in self.map: - row = self.map[addr] - info_id = row['reg'] - fmt = row['fmt'] - val = struct.unpack_from(fmt, buf, 3+2*i) - result = val[0] + for i in range(0, elmlen): + addr = self.last_reg+i + if addr in self.map: + row = self.map[addr] + info_id = row['reg'] + fmt = row['fmt'] + val = struct.unpack_from(fmt, buf, 3+2*i) + result = val[0] - if 'eval' in row: - result = eval(row['eval']) - if 'ratio' in row: - result = round(result * row['ratio'], 2) + if 'eval' in row: + result = eval(row['eval']) + if 'ratio' in row: + result = round(result * row['ratio'], 2) - keys, level, unit, must_incr = info_db._key_obj(info_id) + keys, level, unit, must_incr = info_db._key_obj(info_id) - if keys: - name, update = info_db.update_db(keys, must_incr, result) - yield keys[0], update, result - if update: - info_db.tracer.log(level, - f'[\'{node_id}\']MODBUS: {name}' - f' : {result}{unit}') + if keys: + name, update = info_db.update_db(keys, must_incr, + result) + yield keys[0], update, result + if update: + info_db.tracer.log(level, + f'[\'{node_id}\']MODBUS: {name}' + f' : {result}{unit}') + else: + self.stop_timer() def check_crc(self, msg) -> bool: return 0 == self.__calc_crc(msg) From 766774224bbff23c0e7e36c9f252af6a66de4b1c Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 18 May 2024 21:46:28 +0200 Subject: [PATCH 067/118] adapt unit tests --- app/tests/test_modbus.py | 177 ++++++++++++++++++++++++++++++------- app/tests/test_solarman.py | 17 +++- app/tests/test_talent.py | 20 ++++- 3 files changed, 177 insertions(+), 37 deletions(-) diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index ed6a68a..8ea4637 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -1,15 +1,24 @@ # test_with_pytest.py -# import pytest, logging +import pytest +import asyncio from app.src.modbus import Modbus from app.src.infos import Infos +pytest_plugins = ('pytest_asyncio',) +pytestmark = pytest.mark.asyncio(scope="module") + class TestHelper(Modbus): def __init__(self): - super().__init__() + super().__init__(self.send_cb) self.db = Infos() + self.pdu = None + self.send_calls = 0 + def send_cb(self, pdu: bytearray): + self.pdu = pdu + self.send_calls += 1 def test_modbus_crc(): - mb = Modbus() + mb = Modbus(None) assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04') assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') assert mb.check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') @@ -20,33 +29,34 @@ def test_modbus_crc(): assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46') def test_build_modbus_pdu(): - mb = Modbus() - pdu = mb.build_msg(1,6,0x2000,0x12) - assert pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' - assert mb.check_crc(pdu) + mb = TestHelper() + mb.build_msg(1,6,0x2000,0x12) + mb.get_next_req() + assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' + assert mb.check_crc(mb.pdu) def test_recv_req_crc(): - mb = Modbus() - res = mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') - assert not res + mb = TestHelper() + mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') + mb.get_next_req() assert mb.last_fcode == 0 assert mb.last_reg == 0 assert mb.last_len == 0 assert mb.err == 1 def test_recv_req_addr(): - mb = Modbus() - res = mb.recv_req(b'\x02\x06\x20\x00\x00\x12\x02\x34') - assert not res - assert mb.last_fcode == 0 - assert mb.last_reg == 0 - assert mb.last_len == 0 - assert mb.err == 2 + mb = TestHelper() + mb.recv_req(b'\x02\x06\x20\x00\x00\x12\x02\x34') + mb.get_next_req() + assert mb.last_addr == 2 + assert mb.last_fcode == 6 + assert mb.last_reg == 0x2000 + assert mb.last_len == 18 def test_recv_req(): - mb = Modbus() - res = mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') - assert res + mb = TestHelper() + mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') + mb.get_next_req() assert mb.last_fcode == 6 assert mb.last_reg == 0x2000 assert mb.last_len == 0x12 @@ -54,6 +64,7 @@ def test_recv_req(): def test_recv_recv_crc(): mb = TestHelper() + mb.req_pend = True mb.last_fcode = 3 mb.last_reg == 0x300e mb.last_len == 2 @@ -66,6 +77,7 @@ def test_recv_recv_crc(): def test_recv_recv_addr(): mb = TestHelper() + mb.req_pend = True mb.last_fcode = 3 mb.last_reg == 0x300e mb.last_len == 2 @@ -75,36 +87,48 @@ def test_recv_recv_addr(): call += 1 assert mb.err == 2 assert 0 == call + assert mb.que.qsize() == 0 + mb.stop_timer() + assert not mb.req_pend def test_recv_recv_fcode(): mb = TestHelper() - mb.last_fcode = 4 - mb.last_reg == 0x300e - mb.last_len == 2 - + mb.build_msg(1,4,0x300e,2) + assert mb.que.qsize() == 0 + assert mb.req_pend + call = 0 for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): call += 1 + assert mb.err == 3 assert 0 == call + assert mb.que.qsize() == 0 + mb.stop_timer() + assert not mb.req_pend def test_recv_recv_len(): mb = TestHelper() - mb.last_fcode = 3 - mb.last_reg == 0x300e - mb.last_len == 2 - + mb.build_msg(1,3,0x300e,3) + assert mb.que.qsize() == 0 + assert mb.req_pend + assert mb.last_len == 3 call = 0 for key, update, _ in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): call += 1 + assert mb.err == 4 assert 0 == call + assert mb.que.qsize() == 0 + mb.stop_timer() + assert not mb.req_pend def test_build_recv(): mb = TestHelper() - pdu = mb.build_msg(1,3,0x3007,6) - assert mb.check_crc(pdu) - assert mb.err == 0 + mb.build_msg(1,3,0x3007,6) + assert mb.que.qsize() == 0 + assert mb.req_pend + call = 0 exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): @@ -121,6 +145,7 @@ def test_build_recv(): assert 0 == mb.err assert 5 == call + mb.req_pend = True call = 0 for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): if key == 'grid': @@ -133,13 +158,20 @@ def test_build_recv(): assert False assert exp_result[call] == val call += 1 + assert 0 == mb.err assert 5 == call + assert mb.que.qsize() == 0 + mb.stop_timer() + assert not mb.req_pend def test_build_long(): mb = TestHelper() - pdu = mb.build_msg(1,3,0x3022,4) - assert mb.check_crc(pdu) + mb.build_msg(1,3,0x3022,4) + assert mb.que.qsize() == 0 + assert mb.req_pend + assert mb.last_addr == 1 + assert mb.last_fcode == 3 assert mb.err == 0 call = 0 exp_result = [3.0, 28841.4, 113.34] @@ -150,5 +182,84 @@ def test_build_long(): else: assert False call += 1 + assert 0 == mb.err assert 3 == call + assert mb.que.qsize() == 0 + mb.stop_timer() + assert not mb.req_pend + +def test_queue(): + mb = TestHelper() + mb.build_msg(1,3,0x3022,4) + assert mb.que.qsize() == 0 + assert mb.req_pend + + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03' + mb.pdu = None + mb.get_next_req() + assert mb.send_calls == 1 + assert mb.pdu == None + + assert mb.que.qsize() == 0 + mb.stop_timer() + assert not mb.req_pend + +def test_queue2(): + mb = TestHelper() + mb.build_msg(1,3,0x3007,6) + mb.build_msg(1,6,0x2008,4) + assert mb.que.qsize() == 1 + assert mb.req_pend + + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + mb.get_next_req() + assert mb.send_calls == 1 + call = 0 + exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + if key == 'grid': + assert update == True + elif key == 'inverter': + assert update == True + elif key == 'env': + assert update == True + else: + assert False + assert exp_result[call] == val + call += 1 + assert 0 == mb.err + assert 5 == call + + assert mb.send_calls == 2 + assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' + + for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'): + pass + + assert mb.que.qsize() == 0 + assert not mb.req_pend + + +@pytest.mark.asyncio +async def test_timeout(): + assert asyncio.get_running_loop() + mb = TestHelper() + assert asyncio.get_running_loop() == mb.loop + mb.build_msg(1,3,0x3007,6) + mb.build_msg(1,6,0x2008,4) + assert mb.que.qsize() == 1 + assert mb.req_pend + + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + await asyncio.sleep(1.1) # wait for first timeout and next pdu + assert mb.req_pend + assert mb.send_calls == 2 + assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' + await asyncio.sleep(1.1) # wait for second timout + + assert not mb.req_pend + assert mb.que.qsize() == 0 diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 46df675..19132d7 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -16,9 +16,16 @@ Infos.static_init() timestamp = int(time.time()) # 1712861197 heartbeat = 60 +class Writer(): + def write(self, pdu: bytearray): + pass + class MemoryStream(SolarmanV5): def __init__(self, msg, chunks = (0,), server_side: bool = True): super().__init__(server_side) + if server_side: + self.mb.timeout = 1 # overwrite for faster testing + self.writer = Writer() self.__msg = msg self.__msg_len = len(msg) self.__chunks = chunks @@ -35,7 +42,6 @@ class MemoryStream(SolarmanV5): def _heartbeat(self) -> int: return heartbeat - def append_msg(self, msg): self.__msg += msg @@ -1446,9 +1452,12 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): m.append_msg(MsgModbusRsp) m.forward_modbus_resp = True + m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 # assert m.db.db == {'inverter': {'Manufacturer': 'TSUN', 'Equipment_Model': 'TSOL-MSxx00'}} m.new_data['inverter'] = False @@ -1465,7 +1474,7 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.mb.err == 0 + assert m.mb.err == 5 assert m.msg_count == 2 assert m._forward_buffer==MsgModbusRsp assert m._send_buffer==b'' @@ -1515,9 +1524,13 @@ def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusRsp): m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.forward_modbus_resp = True + m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index ed39111..c0b8e1e 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -12,10 +12,18 @@ pytest_plugins = ('pytest_asyncio',) Infos.static_init() tracer = logging.getLogger('tracer') - + + +class Writer(): + def write(self, pdu: bytearray): + pass + class MemoryStream(Talent): def __init__(self, msg, chunks = (0,), server_side: bool = True): super().__init__(server_side) + if server_side: + self.mb.timeout = 1 # overwrite for faster testing + self.writer = Writer() self.__msg = msg self.__msg_len = len(msg) self.__chunks = chunks @@ -896,9 +904,13 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20): m.append_msg(MsgModbusResp20) m.forward_modbus_resp = True + m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + assert m.db.db == {} m.new_data['inverter'] = False @@ -915,7 +927,7 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20): m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.mb.err == 0 + assert m.mb.err == 5 assert m.msg_count == 2 assert m._forward_buffer==MsgModbusResp20 assert m._send_buffer==b'' @@ -952,9 +964,13 @@ def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.forward_modbus_resp = True + m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 From 9c39ea27f7153ababdf7e48f86b773cc0e0799a3 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 18 May 2024 23:10:47 +0200 Subject: [PATCH 068/118] fix unit tests --- app/tests/test_solarman.py | 11 ++++++++--- app/tests/test_talent.py | 12 +++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 19132d7..b1d0a04 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -17,8 +17,11 @@ timestamp = int(time.time()) # 1712861197 heartbeat = 60 class Writer(): + def __init__(self): + self.sent_pdu = b'' + def write(self, pdu: bytearray): - pass + self.sent_pdu = pdu class MemoryStream(SolarmanV5): def __init__(self, msg, chunks = (0,), server_side: bool = True): @@ -1200,7 +1203,8 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, assert m._recv_buffer==InverterIndMsg # unhandled next message assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' - assert m._send_buffer == b'' # modbus command must be ignore, cause connection is still not up + assert m.writer.sent_pdu == b'' # modbus command must be ignore, cause connection is still not up + assert m._send_buffer == b'' # modbus command must be ignore, cause connection is still not up m.read() assert m.control == 0x4210 @@ -1214,7 +1218,8 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' - assert m._send_buffer == MsgModbusCmd + assert m.writer.sent_pdu == MsgModbusCmd + assert m._send_buffer == b'' m._send_buffer = bytearray(0) # clear send buffer for next test m.test_exception_async_write = True diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index c0b8e1e..01149f9 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -15,8 +15,11 @@ tracer = logging.getLogger('tracer') class Writer(): + def __init__(self): + self.sent_pdu = b'' + def write(self, pdu: bytearray): - pass + self.sent_pdu = pdu class MemoryStream(Talent): def __init__(self, msg, chunks = (0,), server_side: bool = True): @@ -997,19 +1000,22 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' assert m._send_buffer == b'' + assert m.writer.sent_pdu == b'' m.state = m.STATE_UP await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' - assert m._send_buffer == MsgModbusCmd + assert m._send_buffer == b'' + assert m.writer.sent_pdu == MsgModbusCmd - m._send_buffer = bytearray(0) # clear send buffer for next test + m.writer.sent_pdu = bytearray(0) # clear send buffer for next test m.test_exception_async_write = True await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0) assert 0 == m.send_msg_ofs assert m._forward_buffer == b'' assert m._send_buffer == b'' + assert m.writer.sent_pdu == b'' m.close() ''' def test_zombie_conn(ConfigTsunInv1, MsgInverterInd): From d25173e591c8357783df53ff9579b3ee2e949a13 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sat, 18 May 2024 23:11:49 +0200 Subject: [PATCH 069/118] fix sending next pdu before we have parsed the last response --- app/src/modbus.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index 9302876..930df86 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -165,10 +165,10 @@ class Modbus(): logging.info(f'Modbus: len error {elmlen} != {self.last_len}') self.err = 4 return - self.stop_timer() - + first_reg = self.last_reg # save last_reg before sending next pdu + self.stop_timer() # stop timer and send next pdu for i in range(0, elmlen): - addr = self.last_reg+i + addr = first_reg+i if addr in self.map: row = self.map[addr] info_id = row['reg'] From 282a459ef0b2d9f9019fb462058e824ce04fa104 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 12:23:58 +0200 Subject: [PATCH 070/118] add Modbus response forwarding --- app/src/gen3/talent.py | 13 ++++++------- app/src/gen3plus/solarman_v5.py | 12 ++++-------- app/src/modbus.py | 7 ++++++- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 12fcf6c..c76c775 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -42,7 +42,6 @@ class Talent(Message): self.contact_name = b'' self.contact_mail = b'' self.db = InfosG3() - self.forward_modbus_resp = False self.switch = { 0x00: self.msg_contact_info, 0x13: self.msg_ota_update, @@ -124,7 +123,6 @@ class Talent(Message): return def send_modbus_cb(self, modbus_pdu: bytearray): - self.forward_modbus_resp = False self.__build_header(0x70, 0x77) self._send_buffer += b'\x00\x01\xa3\x28' # fixme self._send_buffer += struct.pack('!B', len(modbus_pdu)) @@ -394,11 +392,11 @@ class Talent(Message): self.header_len+self.data_len] if self.ctrl.is_req(): - if not self.remoteStream.mb.recv_req(data[hdr_len:]): + if not self.remoteStream.mb.recv_req(data[hdr_len:], + self.msg_forward): self.inc_counter('Invalid_Msg_Format') else: self.inc_counter('Modbus_Command') - self.remoteStream.forward_modbus_resp = True elif self.ctrl.is_ind(): # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') self.modbus_elms = 0 @@ -408,14 +406,15 @@ class Talent(Message): if update: self.new_data[key] = True self.modbus_elms += 1 # count for unit tests - - if not self.forward_modbus_resp: - return + return else: logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) + def msg_forward(self): + self.forward(self._recv_buffer, self.header_len+self.data_len) + def msg_unknown(self): logger.warning(f"Unknow Msg: ID:{self.msg_id}") self.inc_counter('Unknown_Msg') diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index f744951..2d61b68 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -60,7 +60,6 @@ class SolarmanV5(Message): self.snr = 0 self.db = InfosG3P() self.time_ofs = 0 - self.forward_modbus_resp = False self.forward_at_cmd_resp = False self.switch = { @@ -303,7 +302,6 @@ class SolarmanV5(Message): self.__finish_send_msg() def send_modbus_cb(self, pdu: bytearray): - self.forward_modbus_resp = False self.__build_header(0x4510) self._send_buffer += struct.pack(' bool: return 0 == self.__calc_crc(msg) From 476c5f000673a5fcac3f9fbfe2e4f88c655909ff Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 12:24:35 +0200 Subject: [PATCH 071/118] adapt unit tests --- app/tests/test_solarman.py | 35 ++++------------------------------- app/tests/test_talent.py | 30 +++++------------------------- 2 files changed, 9 insertions(+), 56 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index b1d0a04..e339412 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -1277,7 +1277,6 @@ async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIn def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): ConfigTsunInv1 m = MemoryStream(AtCommandIndMsg, (0,), False) - m.forward_modbus_resp = False m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 @@ -1296,7 +1295,6 @@ def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 assert m.db.stat['proxy']['AT_Command'] == 1 assert m.db.stat['proxy']['Modbus_Command'] == 0 - assert m.forward_modbus_resp == False m.close() def test_msg_at_command_rsp1(ConfigTsunInv1, AtCommandRspMsg): @@ -1342,7 +1340,6 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): m = MemoryStream(b'') c = m.createClientStream(MsgModbusCmd) - m.forward_modbus_resp = False m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 @@ -1360,7 +1357,6 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert m.db.stat['proxy']['AT_Command'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 1 assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 - assert m.forward_modbus_resp == True m.close() def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): @@ -1368,7 +1364,6 @@ def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): m = MemoryStream(b'') c = m.createClientStream(MsgModbusCmdCrcErr) - m.forward_modbus_resp = False m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 @@ -1386,13 +1381,11 @@ def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): assert m.db.stat['proxy']['AT_Command'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 0 assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1 - assert m.forward_modbus_resp == True m.close() def test_msg_unknown_cmd_req(ConfigTsunInv1, MsgUnknownCmd): ConfigTsunInv1 m = MemoryStream(MsgUnknownCmd, (0,), False) - m.forward_modbus_resp = False m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 @@ -1410,15 +1403,14 @@ def test_msg_unknown_cmd_req(ConfigTsunInv1, MsgUnknownCmd): assert m.db.stat['proxy']['AT_Command'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 0 assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 - assert m.forward_modbus_resp == False m.close() def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response without a valid Modbus request must be dropped''' ConfigTsunInv1 m = MemoryStream(MsgModbusRsp) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_resp = False m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 @@ -1433,30 +1425,12 @@ def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): m.close() def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): - ConfigTsunInv1 - m = MemoryStream(MsgModbusRsp) - m.db.stat['proxy']['Unknown_Ctrl'] = 0 - m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_resp = True - m.read() # read complete msg, and dispatch msg - assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.msg_count == 1 - assert m.control == 0x1510 - assert str(m.seq) == '03:03' - assert m.header_len==11 - assert m.data_len==59 - assert m._forward_buffer==MsgModbusRsp - assert m._send_buffer==b'' - assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 - assert m.db.stat['proxy']['Modbus_Command'] == 0 - m.close() - -def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response with a valid Modbus request must be forwarded''' ConfigTsunInv1 m = MemoryStream(MsgModbusRsp) m.append_msg(MsgModbusRsp) - m.forward_modbus_resp = True + m.mb.rsp_handler = m._SolarmanV5__forward_msg m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 @@ -1494,7 +1468,6 @@ def test_msg_unknown_rsp(ConfigTsunInv1, MsgUnknownCmdRsp): m = MemoryStream(MsgUnknownCmdRsp) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_resp = True m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 @@ -1528,7 +1501,7 @@ def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusRsp): m = MemoryStream(MsgModbusRsp+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_resp = True + m.mb.rsp_handler = m._SolarmanV5__forward_msg m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 01149f9..1f53c8c 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -860,11 +860,11 @@ def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): m.close() def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response without a valid Modbus request must be dropped''' ConfigTsunInv1 m = MemoryStream(MsgModbusRsp) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_resp = False m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 @@ -880,33 +880,13 @@ def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp): assert m.db.stat['proxy']['Modbus_Command'] == 0 m.close() -def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): - ConfigTsunInv1 - m = MemoryStream(MsgModbusRsp) - m.db.stat['proxy']['Unknown_Ctrl'] = 0 - m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_resp = True - m.read() # read complete msg, and dispatch msg - assert not m.header_valid # must be invalid, since msg was handled and buffer flushed - assert m.msg_count == 1 - assert m.id_str == b"R170000000000001" - assert m.unique_id == 'R170000000000001' - assert int(m.ctrl)==145 - assert m.msg_id==119 - assert m.header_len==23 - assert m.data_len==13 - assert m._forward_buffer==MsgModbusRsp - assert m._send_buffer==b'' - assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 - assert m.db.stat['proxy']['Modbus_Command'] == 0 - m.close() - -def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20): +def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusResp20): + '''Modbus response with a valid Modbus request must be forwarded''' ConfigTsunInv1 m = MemoryStream(MsgModbusResp20) m.append_msg(MsgModbusResp20) - m.forward_modbus_resp = True + m.mb.rsp_handler = m.msg_forward m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 @@ -966,7 +946,7 @@ def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20): m = MemoryStream(MsgModbusResp20+b'\x00\x00\x00\x45\x10\x52\x31', (0,)) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 - m.forward_modbus_resp = True + m.mb.rsp_handler = m.msg_forward m.mb.last_addr = 1 m.mb.last_fcode = 3 m.mb.last_len = 20 From f30aa07431dfa65696de718df78d858ca0ea141e Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 13:42:29 +0200 Subject: [PATCH 072/118] don't frwd received modbus req directly - use always the fifoto sent valid req to the inverter - code cleanup --- app/src/gen3/talent.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index c76c775..c9cf89d 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -392,11 +392,11 @@ class Talent(Message): self.header_len+self.data_len] if self.ctrl.is_req(): - if not self.remoteStream.mb.recv_req(data[hdr_len:], - self.msg_forward): - self.inc_counter('Invalid_Msg_Format') + if self.remoteStream.mb.recv_req(data[hdr_len:], + self.msg_forward): + self.remoteStream.inc_counter('Modbus_Command') else: - self.inc_counter('Modbus_Command') + self.remoteStream.inc_counter('Invalid_Msg_Format') elif self.ctrl.is_ind(): # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') self.modbus_elms = 0 @@ -406,11 +406,10 @@ class Talent(Message): if update: self.new_data[key] = True self.modbus_elms += 1 # count for unit tests - return else: logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') - self.forward(self._recv_buffer, self.header_len+self.data_len) + self.forward(self._recv_buffer, self.header_len+self.data_len) def msg_forward(self): self.forward(self._recv_buffer, self.header_len+self.data_len) From c761446c11084e2848be5c940cc5916bf4453f86 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 13:43:51 +0200 Subject: [PATCH 073/118] code cleanup --- app/src/gen3plus/solarman_v5.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 2d61b68..80bcc8a 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -430,11 +430,11 @@ class SolarmanV5(Message): self.inc_counter('AT_Command') self.forward_at_cmd_resp = True elif ftype == self.MB_RTU_CMD: - if not self.remoteStream.mb.recv_req(data[15:], - self.__forward_msg()): - self.inc_counter('Invalid_Msg_Format') - else: + if self.remoteStream.mb.recv_req(data[15:], + self.__forward_msg()): self.inc_counter('Modbus_Command') + else: + self.inc_counter('Invalid_Msg_Format') return self.__forward_msg() From 23ff2bb05cc2ee040f7303d882ae39edb6b90dac Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 13:44:16 +0200 Subject: [PATCH 074/118] fix unit tests --- app/tests/test_talent.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 1f53c8c..ad7b6cb 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -814,6 +814,7 @@ def test_proxy_counter(): def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 m = MemoryStream(b'') + m.id_str = b"R170000000000001" c = m.createClientStream(MsgModbusCmd) m.db.stat['proxy']['Unknown_Ctrl'] = 0 @@ -828,8 +829,12 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert c.msg_id==119 assert c.header_len==23 assert c.data_len==13 - assert c._forward_buffer==MsgModbusCmd + assert c._forward_buffer==b'' assert c._send_buffer==b'' + assert m.id_str == b"R170000000000001" + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.writer.sent_pdu == MsgModbusCmd assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 1 assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 @@ -838,6 +843,7 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): ConfigTsunInv1 m = MemoryStream(b'') + m.id_str = b"R170000000000001" c = m.createClientStream(MsgModbusCmdCrcErr) m.db.stat['proxy']['Unknown_Ctrl'] = 0 @@ -852,8 +858,11 @@ def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): assert c.msg_id==119 assert c.header_len==23 assert c.data_len==13 - assert c._forward_buffer==MsgModbusCmdCrcErr + assert c._forward_buffer==b'' assert c._send_buffer==b'' + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.writer.sent_pdu ==b'' assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 0 assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1 From 3cc5f3ec530c602b0d75e0de1715a2806b115cf2 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 13:45:52 +0200 Subject: [PATCH 075/118] - add Modbus fifo and timeout handler --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed901bc..8412be2 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 Modbus fifo and timeout handler - build version string in the same format as TSUN for GEN3 invterts - add graceful shutdown - parse Modbus values and store them in the database From 9ac1f6f46d5ddefa526e3a39dc363c4a2a51a729 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 21:17:16 +0200 Subject: [PATCH 076/118] add Modbus retrasmissions --- app/src/gen3/talent.py | 21 ++++++++++++++++----- app/src/gen3plus/solarman_v5.py | 10 ++++++++-- app/src/modbus.py | 32 +++++++++++++++++++++++++++----- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index c9cf89d..94eec89 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -36,7 +36,7 @@ class Control: class Talent(Message): def __init__(self, server_side: bool, id_str=b''): - super().__init__(server_side, self.send_modbus_cb, 15) + super().__init__(server_side, self.send_modbus_cb, 11) self.await_conn_resp_cnt = 0 self.id_str = id_str self.contact_name = b'' @@ -122,13 +122,21 @@ class Talent(Message): f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') return - def send_modbus_cb(self, modbus_pdu: bytearray): + def send_modbus_cb(self, modbus_pdu: bytearray, retrans: bool): + if self.state != self.STATE_UP: + return + self.__build_header(0x70, 0x77) self._send_buffer += b'\x00\x01\xa3\x28' # fixme self._send_buffer += struct.pack('!B', len(modbus_pdu)) self._send_buffer += modbus_pdu self.__finish_send_msg() - hex_dump_memory(logging.INFO, f'Send Modbus Command:{self.addr}:', + if retrans: + cmd = 'Retrans' + else: + cmd = 'Command' + + hex_dump_memory(logging.INFO, f'Send Modbus {cmd}:{self.addr}:', self._send_buffer, len(self._send_buffer)) self.writer.write(self._send_buffer) self._send_buffer = bytearray(0) # self._send_buffer[sent:] @@ -392,11 +400,14 @@ class Talent(Message): self.header_len+self.data_len] if self.ctrl.is_req(): + # if (self.remoteStream.state != self.STATE_UP): + # logger.info('ignore Modbus Request in wrong state') + # return if self.remoteStream.mb.recv_req(data[hdr_len:], self.msg_forward): - self.remoteStream.inc_counter('Modbus_Command') + self.inc_counter('Modbus_Command') else: - self.remoteStream.inc_counter('Invalid_Msg_Format') + self.inc_counter('Invalid_Msg_Format') elif self.ctrl.is_ind(): # logger.debug(f'Modbus Ind MsgLen: {modbus_len}') self.modbus_elms = 0 diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 80bcc8a..7ac8310 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -301,13 +301,19 @@ class SolarmanV5(Message): self._heartbeat()) self.__finish_send_msg() - def send_modbus_cb(self, pdu: bytearray): + def send_modbus_cb(self, pdu: bytearray, retrans: bool): + if self.state != self.STATE_UP: + return self.__build_header(0x4510) self._send_buffer += struct.pack(' None: if self.req_pend: @@ -106,6 +126,7 @@ class Modbus(): try: item = self.que.get_nowait() req = item['req'] + self.last_req = req self.rsp_handler = item['rsp_hdl'] self.last_addr = req[0] self.last_fcode = req[1] @@ -113,8 +134,9 @@ class Modbus(): res = struct.unpack_from('>HH', req, 2) self.last_reg = res[0] self.last_len = res[1] + self.retry_cnt = 0 self.start_timer() - self.snd_handler(req) + self.snd_handler(self.last_req, retrans=False) except asyncio.QueueEmpty: pass @@ -140,7 +162,7 @@ class Modbus(): return True def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \ - Generator[tuple[str, bool], None, None]: + Generator[tuple[str, bool, any], None, None]: # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') if not self.req_pend: self.err = 5 @@ -194,7 +216,7 @@ class Modbus(): f' : {result}{unit}') else: self.stop_timer() - + self.counter['retries'][self.retry_cnt] += 1 if self.rsp_handler: self.rsp_handler() self.get_next_req() From 177706c3e6fb0905ecaa0074e4697ed1a75c47af Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 19 May 2024 21:17:56 +0200 Subject: [PATCH 077/118] test Modbus retries --- app/tests/test_modbus.py | 43 ++++++++++++++++++++++++++++++++-------- app/tests/test_talent.py | 2 ++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index 8ea4637..fed9c09 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -13,7 +13,7 @@ class TestHelper(Modbus): self.db = Infos() self.pdu = None self.send_calls = 0 - def send_cb(self, pdu: bytearray): + def send_cb(self, pdu: bytearray, retrans: bool): self.pdu = pdu self.send_calls += 1 @@ -247,19 +247,46 @@ def test_queue2(): async def test_timeout(): assert asyncio.get_running_loop() mb = TestHelper() + mb.max_retries = 2 assert asyncio.get_running_loop() == mb.loop mb.build_msg(1,3,0x3007,6) mb.build_msg(1,6,0x2008,4) + assert mb.que.qsize() == 1 assert mb.req_pend - + assert mb.retry_cnt == 0 assert mb.send_calls == 1 assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' - await asyncio.sleep(1.1) # wait for first timeout and next pdu - assert mb.req_pend - assert mb.send_calls == 2 - assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' - await asyncio.sleep(1.1) # wait for second timout - assert not mb.req_pend + mb.pdu = None + await asyncio.sleep(1.1) # wait for first timeout and retransmittion + assert mb.que.qsize() == 1 + assert mb.req_pend + assert mb.retry_cnt == 1 + assert mb.send_calls == 2 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + + mb.pdu = None + await asyncio.sleep(1.1) # wait for second timeout and retransmittion + assert mb.que.qsize() == 1 + assert mb.req_pend + assert mb.retry_cnt == 2 + assert mb.send_calls == 3 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + + mb.pdu = None + await asyncio.sleep(1.1) # wait for third timeout and next pdu assert mb.que.qsize() == 0 + assert mb.req_pend + assert mb.retry_cnt == 0 + assert mb.send_calls == 4 + assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' + + mb.max_retries = 0 # next pdu without retranmsission + await asyncio.sleep(1.1) # wait for fourth timout + assert mb.que.qsize() == 0 + assert not mb.req_pend + assert mb.retry_cnt == 0 + assert mb.send_calls == 4 + + # assert mb.counter == {} diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index ad7b6cb..3387d99 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -815,6 +815,8 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): ConfigTsunInv1 m = MemoryStream(b'') m.id_str = b"R170000000000001" + m.state = m.STATE_UP + c = m.createClientStream(MsgModbusCmd) m.db.stat['proxy']['Unknown_Ctrl'] = 0 From 6ef6f4cd3439a3ab3c9e28671bcc08023e414145 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 20 May 2024 00:48:23 +0200 Subject: [PATCH 078/118] cleanup --- app/src/modbus.py | 181 +++++++++++++++++++++++++-------------- app/tests/test_modbus.py | 31 +++---- 2 files changed, 133 insertions(+), 79 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index 717909c..82c2f01 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -1,7 +1,7 @@ import struct import logging import asyncio -from typing import Generator +from typing import Generator, Callable if __name__ == "app.src.modbus": from app.src.infos import Register @@ -25,10 +25,13 @@ CRC_INIT = 0xFFFF class Modbus(): INV_ADDR = 1 + '''MODBUS slave address of the TSUN inverter''' READ_REGS = 3 + '''MODBUS function code: Read Holding Register''' READ_INPUTS = 4 + '''MODBUS function code: Read Input Register''' WRITE_SINGLE_REG = 6 - '''Modbus function codes''' + '''Modbus function code: Write Single Register''' __crc_tab = [] map = { @@ -66,21 +69,26 @@ class Modbus(): 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 } - def __init__(self, snd_handler, timeout: int = 1): + def __init__(self, snd_handler: Callable[[bool], None], timeout: int = 1): if not len(self.__crc_tab): self.__build_crc_tab(CRC_POLY) self.que = asyncio.Queue(100) self.snd_handler = snd_handler + '''Send handler to transmit a MODBUS request''' self.rsp_handler = None + '''Response handler to forward the response''' self.timeout = timeout + '''MODBUS response timeout in seconds''' self.max_retries = 3 + '''Max retransmit for MODBUS requests''' self.retry_cnt = 0 self.last_req = b'' self.counter = {} + '''Dictenary with statistic counter''' self.counter['timeouts'] = 0 self.counter['retries'] = {} - for i in range(0, self.max_retries): - self.counter['retries'][i] = 0 + for i in range(0, self.max_retries+1): + self.counter['retries'][f'{i}'] = 0 self.last_addr = 0 self.last_fcode = 0 self.last_len = 0 @@ -94,80 +102,67 @@ class Modbus(): if type(self.counter) is not None: logging.info(f'Modbus __del__:\n {self.counter}') - def start_timer(self): - if self.req_pend: - return - self.req_pend = True - self.tim = self.loop.call_later(self.timeout, self.timeout_cb) - # logging.debug(f'Modbus start timer {self}') + def build_msg(self, addr: int, func: int, reg: int, val: int) -> None: + """Build MODBUS RTU message frame and add it to the tx queue - def stop_timer(self): - self.req_pend = False - # logging.debug(f'Modbus stop timer {self}') - if self.tim: - self.tim.cancel() - - def timeout_cb(self): - self.req_pend = False - - if self.retry_cnt < self.max_retries: - logging.debug(f'Modbus retrans {self}') - self.retry_cnt += 1 - self.start_timer() - self.snd_handler(self.last_req, retrans=True) - else: - logging.info(f'Modbus timeout {self}') - self.counter['timeouts'] += 1 - self.get_next_req() - - def get_next_req(self) -> None: - if self.req_pend: - return - try: - item = self.que.get_nowait() - req = item['req'] - self.last_req = req - self.rsp_handler = item['rsp_hdl'] - self.last_addr = req[0] - self.last_fcode = req[1] - - res = struct.unpack_from('>HH', req, 2) - self.last_reg = res[0] - self.last_len = res[1] - self.retry_cnt = 0 - self.start_timer() - self.snd_handler(self.last_req, retrans=False) - except asyncio.QueueEmpty: - pass - - def build_msg(self, addr, func, reg, val) -> None: + Keyword arguments: + addr: RTU slave address + func: MODBUS function code + reg: 16-bit register number + val: 16 bit value + """ msg = struct.pack('>BBHH', addr, func, reg, val) msg += struct.pack(' bool: + def recv_req(self, buf: bytearray, + rsp_handler: Callable[[None], None] = None) -> bool: + """Add the received Modbus request to the tx queue + + Keyword arguments: + buf: Modbus RTU pdu incl ADDR byte and trailing CRC + rsp_handler: Callback, if the received pdu is valid + + Returns: + True: PDU was added to the queue + False: PDU was ignored, due to an error + """ # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') - if not self.check_crc(buf): + if not self.__check_crc(buf): self.err = 1 logging.error('Modbus recv: CRC error') return False self.que.put_nowait({'req': buf, 'rsp_hdl': rsp_handler}) if self.que.qsize() == 1: - self.get_next_req() + self.__send_next_from_que() return True def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \ - Generator[tuple[str, bool, any], None, None]: + Generator[tuple[str, bool, int | float | str], None, None]: + """Generator which check and parse a received MODBUS response. + + Keyword arguments: + info_db: database for info lockups + buf: received Modbus RTU response frame + node_id: string for logging which identifies the slave + + Returns on error and set Self.err to: + 1: CRC error + 2: Wrong server address + 3: Unexpected function code + 4: Unexpected data length + 5: No MODBUS request pending + """ # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') if not self.req_pend: self.err = 5 return - if not self.check_crc(buf): + if not self.__check_crc(buf): logging.error('Modbus resp: CRC error') self.err = 1 return @@ -188,7 +183,7 @@ class Modbus(): self.err = 4 return first_reg = self.last_reg # save last_reg before sending next pdu - self.stop_timer() # stop timer and send next pdu + self.__stop_timer() # stop timer and send next pdu for i in range(0, elmlen): addr = first_reg+i @@ -215,23 +210,81 @@ class Modbus(): f'[\'{node_id}\']MODBUS: {name}' f' : {result}{unit}') else: - self.stop_timer() - self.counter['retries'][self.retry_cnt] += 1 + self.__stop_timer() + + self.counter['retries'][f'{self.retry_cnt}'] += 1 if self.rsp_handler: self.rsp_handler() - self.get_next_req() + self.__send_next_from_que() - def check_crc(self, msg) -> bool: + ''' + MODBUS response timer + ''' + def __start_timer(self) -> None: + '''Start response timer and set `req_pend` to True''' + self.req_pend = True + self.tim = self.loop.call_later(self.timeout, self.__timeout_cb) + # logging.debug(f'Modbus start timer {self}') + + def __stop_timer(self) -> None: + '''Stop response timer and set `req_pend` to False''' + self.req_pend = False + # logging.debug(f'Modbus stop timer {self}') + if self.tim: + self.tim.cancel() + + def __timeout_cb(self) -> None: + '''Rsponse timeout handler retransmit pdu or send next pdu''' + self.req_pend = False + + if self.retry_cnt < self.max_retries: + logging.debug(f'Modbus retrans {self}') + self.retry_cnt += 1 + self.__start_timer() + self.snd_handler(self.last_req, retrans=True) + else: + logging.info(f'Modbus timeout {self}') + self.counter['timeouts'] += 1 + self.__send_next_from_que() + + def __send_next_from_que(self) -> None: + '''Get next MODBUS pdu from queue and transmit it''' + if self.req_pend: + return + try: + item = self.que.get_nowait() + req = item['req'] + self.last_req = req + self.rsp_handler = item['rsp_hdl'] + self.last_addr = req[0] + self.last_fcode = req[1] + + res = struct.unpack_from('>HH', req, 2) + self.last_reg = res[0] + self.last_len = res[1] + self.retry_cnt = 0 + self.__start_timer() + self.snd_handler(self.last_req, retrans=False) + except asyncio.QueueEmpty: + pass + + ''' + Helper function for CRC-16 handling + ''' + def __check_crc(self, msg: bytearray) -> bool: + '''Check CRC-16 and returns True if valid''' return 0 == self.__calc_crc(msg) - def __calc_crc(self, buffer: bytes) -> int: + def __calc_crc(self, buffer: bytearray) -> int: + '''Build CRC-16 for buffer and returns it''' crc = CRC_INIT for cur in buffer: crc = (crc >> 8) ^ self.__crc_tab[(crc ^ cur) & 0xFF] return crc - def __build_crc_tab(self, poly) -> None: + def __build_crc_tab(self, poly: int) -> None: + '''Build CRC-16 helper table, must be called exactly one time''' for index in range(256): data = index << 1 crc = 0 diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index fed9c09..1985ee4 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -21,24 +21,25 @@ def test_modbus_crc(): mb = Modbus(None) assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04') assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') - assert mb.check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') + assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') assert 0xc803 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00') assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') - assert mb.check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') + assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46') + def test_build_modbus_pdu(): mb = TestHelper() mb.build_msg(1,6,0x2000,0x12) - mb.get_next_req() + mb._Modbus__send_next_from_que() assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' - assert mb.check_crc(mb.pdu) + assert mb._Modbus__check_crc(mb.pdu) def test_recv_req_crc(): mb = TestHelper() mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') - mb.get_next_req() + mb._Modbus__send_next_from_que() assert mb.last_fcode == 0 assert mb.last_reg == 0 assert mb.last_len == 0 @@ -47,7 +48,7 @@ def test_recv_req_crc(): def test_recv_req_addr(): mb = TestHelper() mb.recv_req(b'\x02\x06\x20\x00\x00\x12\x02\x34') - mb.get_next_req() + mb._Modbus__send_next_from_que() assert mb.last_addr == 2 assert mb.last_fcode == 6 assert mb.last_reg == 0x2000 @@ -56,7 +57,7 @@ def test_recv_req_addr(): def test_recv_req(): mb = TestHelper() mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') - mb.get_next_req() + mb._Modbus__send_next_from_que() assert mb.last_fcode == 6 assert mb.last_reg == 0x2000 assert mb.last_len == 0x12 @@ -88,7 +89,7 @@ def test_recv_recv_addr(): assert mb.err == 2 assert 0 == call assert mb.que.qsize() == 0 - mb.stop_timer() + mb._Modbus__stop_timer() assert not mb.req_pend def test_recv_recv_fcode(): @@ -104,7 +105,7 @@ def test_recv_recv_fcode(): assert mb.err == 3 assert 0 == call assert mb.que.qsize() == 0 - mb.stop_timer() + mb._Modbus__stop_timer() assert not mb.req_pend def test_recv_recv_len(): @@ -120,7 +121,7 @@ def test_recv_recv_len(): assert mb.err == 4 assert 0 == call assert mb.que.qsize() == 0 - mb.stop_timer() + mb._Modbus__stop_timer() assert not mb.req_pend def test_build_recv(): @@ -162,7 +163,7 @@ def test_build_recv(): assert 0 == mb.err assert 5 == call assert mb.que.qsize() == 0 - mb.stop_timer() + mb._Modbus__stop_timer() assert not mb.req_pend def test_build_long(): @@ -186,7 +187,7 @@ def test_build_long(): assert 0 == mb.err assert 3 == call assert mb.que.qsize() == 0 - mb.stop_timer() + mb._Modbus__stop_timer() assert not mb.req_pend def test_queue(): @@ -198,12 +199,12 @@ def test_queue(): assert mb.send_calls == 1 assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03' mb.pdu = None - mb.get_next_req() + mb._Modbus__send_next_from_que() assert mb.send_calls == 1 assert mb.pdu == None assert mb.que.qsize() == 0 - mb.stop_timer() + mb._Modbus__stop_timer() assert not mb.req_pend def test_queue2(): @@ -215,7 +216,7 @@ def test_queue2(): assert mb.send_calls == 1 assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' - mb.get_next_req() + mb._Modbus__send_next_from_que() assert mb.send_calls == 1 call = 0 exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] From eff3e7558b8ae6bf42f016b8a5948c7d9b897d70 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 20 May 2024 16:53:26 +0200 Subject: [PATCH 079/118] increase test coverage --- app/src/gen3/talent.py | 8 +- app/src/gen3plus/solarman_v5.py | 8 +- app/src/modbus.py | 45 +++--- app/tests/test_modbus.py | 237 ++++++++++++++++++++++---------- 4 files changed, 190 insertions(+), 108 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 94eec89..c1ba2dd 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -122,7 +122,7 @@ class Talent(Message): f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') return - def send_modbus_cb(self, modbus_pdu: bytearray, retrans: bool): + def send_modbus_cb(self, modbus_pdu: bytearray, state: str): if self.state != self.STATE_UP: return @@ -131,12 +131,8 @@ class Talent(Message): self._send_buffer += struct.pack('!B', len(modbus_pdu)) self._send_buffer += modbus_pdu self.__finish_send_msg() - if retrans: - cmd = 'Retrans' - else: - cmd = 'Command' - hex_dump_memory(logging.INFO, f'Send Modbus {cmd}:{self.addr}:', + hex_dump_memory(logging.INFO, f'Send Modbus {state}:{self.addr}:', self._send_buffer, len(self._send_buffer)) self.writer.write(self._send_buffer) self._send_buffer = bytearray(0) # self._send_buffer[sent:] diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 7ac8310..a53769a 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -301,7 +301,7 @@ class SolarmanV5(Message): self._heartbeat()) self.__finish_send_msg() - def send_modbus_cb(self, pdu: bytearray, retrans: bool): + def send_modbus_cb(self, pdu: bytearray, state: str): if self.state != self.STATE_UP: return self.__build_header(0x4510) @@ -309,11 +309,7 @@ class SolarmanV5(Message): 0x2b0, 0, 0, 0) self._send_buffer += pdu self.__finish_send_msg() - if retrans: - cmd = 'Retrans' - else: - cmd = 'Command' - hex_dump_memory(logging.INFO, f'Send Modbus {cmd}:{self.addr}:', + hex_dump_memory(logging.INFO, f'Send Modbus {state}:{self.addr}:', self._send_buffer, len(self._send_buffer)) self.writer.write(self._send_buffer) self._send_buffer = bytearray(0) # self._send_buffer[sent:] diff --git a/app/src/modbus.py b/app/src/modbus.py index 82c2f01..9cfeacd 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -1,3 +1,16 @@ +'''MODBUS module for TSUN inverter support + +TSUN uses the MODBUS in the RTU transmission mode over serial line. +see: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf +see: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf + +A Modbus PDU consists of: 'Function-Code' + 'Data' +A Modbus RTU message consists of: 'Addr' + 'Modbus-PDU' + 'CRC-16' +The inverter is a MODBUS server and the proxy the MODBUS client. + +The 16-bit CRC is known as CRC-16-ANSI(reverse) +see: https://en.wikipedia.org/wiki/Computation_of_cyclic_redundancy_checks +''' import struct import logging import asyncio @@ -8,30 +21,21 @@ if __name__ == "app.src.modbus": else: # pragma: no cover from infos import Register -####### -# TSUN uses the Modbus in the RTU transmission mode. -# see: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf -# -# A Modbus PDU consists of: 'Function-Code' + 'Data' -# A Modbus RTU message consists of: 'Addr' + 'Modbus-PDU' + 'CRC-16' -# -# The 16-bit CRC is known as CRC-16-ANSI(reverse) -# see: https://en.wikipedia.org/wiki/Computation_of_cyclic_redundancy_checks -####### CRC_POLY = 0xA001 # (LSBF/reverse) CRC_INIT = 0xFFFF class Modbus(): + '''Simple MODBUS implementation with TX queue and retransmit timer''' INV_ADDR = 1 - '''MODBUS slave address of the TSUN inverter''' + '''MODBUS server address of the TSUN inverter''' READ_REGS = 3 '''MODBUS function code: Read Holding Register''' READ_INPUTS = 4 '''MODBUS function code: Read Input Register''' WRITE_SINGLE_REG = 6 - '''Modbus function code: Write Single Register''' + '''Modbus function code: Write Single Register''' __crc_tab = [] map = { @@ -69,12 +73,12 @@ class Modbus(): 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 } - def __init__(self, snd_handler: Callable[[bool], None], timeout: int = 1): + def __init__(self, snd_handler: Callable[[str], None], timeout: int = 1): if not len(self.__crc_tab): self.__build_crc_tab(CRC_POLY) self.que = asyncio.Queue(100) self.snd_handler = snd_handler - '''Send handler to transmit a MODBUS request''' + '''Send handler to transmit a MODBUS RTU request''' self.rsp_handler = None '''Response handler to forward the response''' self.timeout = timeout @@ -99,14 +103,13 @@ class Modbus(): self.tim = None def __del__(self): - if type(self.counter) is not None: - logging.info(f'Modbus __del__:\n {self.counter}') + logging.info(f'Modbus __del__:\n {self.counter}') def build_msg(self, addr: int, func: int, reg: int, val: int) -> None: - """Build MODBUS RTU message frame and add it to the tx queue + """Build MODBUS RTU request frame and add it to the tx queue Keyword arguments: - addr: RTU slave address + addr: RTU server address (inverter) func: MODBUS function code reg: 16-bit register number val: 16 bit value @@ -120,7 +123,7 @@ class Modbus(): def recv_req(self, buf: bytearray, rsp_handler: Callable[[None], None] = None) -> bool: - """Add the received Modbus request to the tx queue + """Add the received Modbus RTU request to the tx queue Keyword arguments: buf: Modbus RTU pdu incl ADDR byte and trailing CRC @@ -241,7 +244,7 @@ class Modbus(): logging.debug(f'Modbus retrans {self}') self.retry_cnt += 1 self.__start_timer() - self.snd_handler(self.last_req, retrans=True) + self.snd_handler(self.last_req, state='Retrans') else: logging.info(f'Modbus timeout {self}') self.counter['timeouts'] += 1 @@ -264,7 +267,7 @@ class Modbus(): self.last_len = res[1] self.retry_cnt = 0 self.__start_timer() - self.snd_handler(self.last_req, retrans=False) + self.snd_handler(self.last_req, state='Command') except asyncio.QueueEmpty: pass diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py index 1985ee4..051401f 100644 --- a/app/tests/test_modbus.py +++ b/app/tests/test_modbus.py @@ -2,7 +2,7 @@ import pytest import asyncio from app.src.modbus import Modbus -from app.src.infos import Infos +from app.src.infos import Infos, Register pytest_plugins = ('pytest_asyncio',) pytestmark = pytest.mark.asyncio(scope="module") @@ -13,11 +13,15 @@ class TestHelper(Modbus): self.db = Infos() self.pdu = None self.send_calls = 0 - def send_cb(self, pdu: bytearray, retrans: bool): + self.recv_responses = 0 + def send_cb(self, pdu: bytearray, state: str): self.pdu = pdu self.send_calls += 1 + def resp_handler(self): + self.recv_responses += 1 def test_modbus_crc(): + '''Check CRC-16 calculation''' mb = Modbus(None) assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04') assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') @@ -30,101 +34,139 @@ def test_modbus_crc(): assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46') def test_build_modbus_pdu(): + '''Check building and sending a MODBUS RTU''' mb = TestHelper() mb.build_msg(1,6,0x2000,0x12) - mb._Modbus__send_next_from_que() assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' assert mb._Modbus__check_crc(mb.pdu) - -def test_recv_req_crc(): - mb = TestHelper() - mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') - mb._Modbus__send_next_from_que() - assert mb.last_fcode == 0 - assert mb.last_reg == 0 - assert mb.last_len == 0 - assert mb.err == 1 - -def test_recv_req_addr(): - mb = TestHelper() - mb.recv_req(b'\x02\x06\x20\x00\x00\x12\x02\x34') - mb._Modbus__send_next_from_que() - assert mb.last_addr == 2 - assert mb.last_fcode == 6 + assert mb.last_addr == 1 + assert mb.last_fcode == 6 assert mb.last_reg == 0x2000 assert mb.last_len == 18 + assert mb.err == 0 def test_recv_req(): + '''Receive a valid request, which must transmitted''' mb = TestHelper() - mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') - mb._Modbus__send_next_from_que() + assert mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') assert mb.last_fcode == 6 assert mb.last_reg == 0x2000 assert mb.last_len == 0x12 assert mb.err == 0 -def test_recv_recv_crc(): +def test_recv_req_crc_err(): + '''Receive a request with invalid CRC, which must be dropped''' mb = TestHelper() + assert not mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') + assert mb.pdu == None + assert mb.last_fcode == 0 + assert mb.last_reg == 0 + assert mb.last_len == 0 + assert mb.err == 1 + +def test_recv_resp_crc_err(): + '''Receive a response with invalid CRC, which must be dropped''' + mb = TestHelper() + # simulate a transmitted request mb.req_pend = True + mb.last_addr = 1 mb.last_fcode = 3 mb.last_reg == 0x300e mb.last_len == 2 - + # check matching response, but with CRC error call = 0 for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'): call += 1 assert mb.err == 1 assert 0 == call + assert mb.req_pend == True + # cleanup queue + mb._Modbus__stop_timer() + assert not mb.req_pend -def test_recv_recv_addr(): +def test_recv_resp_invalid_addr(): + '''Receive a response with wrong server addr, which must be dropped''' mb = TestHelper() mb.req_pend = True + # simulate a transmitted request + mb.last_addr = 1 mb.last_fcode = 3 mb.last_reg == 0x300e mb.last_len == 2 + # check not matching response, with wrong server addr call = 0 for key, update in mb.recv_resp(mb.db, b'\x02\x03\x04\x01\x2c\x00\x46\x88\xf4', 'test'): call += 1 assert mb.err == 2 assert 0 == call + assert mb.req_pend == True assert mb.que.qsize() == 0 + + # cleanup queue mb._Modbus__stop_timer() assert not mb.req_pend def test_recv_recv_fcode(): + '''Receive a response with wrong function code, which must be dropped''' mb = TestHelper() mb.build_msg(1,4,0x300e,2) assert mb.que.qsize() == 0 assert mb.req_pend + # check not matching response, with wrong function code call = 0 for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): call += 1 assert mb.err == 3 assert 0 == call + assert mb.req_pend == True assert mb.que.qsize() == 0 + + # cleanup queue mb._Modbus__stop_timer() assert not mb.req_pend -def test_recv_recv_len(): +def test_recv_resp_len(): + '''Receive a response with wrong data length, which must be dropped''' mb = TestHelper() mb.build_msg(1,3,0x300e,3) assert mb.que.qsize() == 0 assert mb.req_pend assert mb.last_len == 3 + + # check not matching response, with wrong data length call = 0 for key, update, _ in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): call += 1 assert mb.err == 4 assert 0 == call + assert mb.req_pend == True assert mb.que.qsize() == 0 + + # cleanup queue mb._Modbus__stop_timer() assert not mb.req_pend -def test_build_recv(): +def test_recv_unexpect_resp(): + '''Receive a response when we havb't sent a request''' + mb = TestHelper() + assert not mb.req_pend + + # check unexpected response, which must be dropped + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + + assert mb.err == 5 + assert 0 == call + assert mb.req_pend == False + assert mb.que.qsize() == 0 + +def test_parse_resp(): + '''Receive matching response and parse the values''' mb = TestHelper() mb.build_msg(1,3,0x3007,6) assert mb.que.qsize() == 0 @@ -145,49 +187,7 @@ def test_build_recv(): call += 1 assert 0 == mb.err assert 5 == call - - mb.req_pend = True - call = 0 - for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): - if key == 'grid': - assert update == False - elif key == 'inverter': - assert update == False - elif key == 'env': - assert update == False - else: - assert False - assert exp_result[call] == val - call += 1 - - assert 0 == mb.err - assert 5 == call assert mb.que.qsize() == 0 - mb._Modbus__stop_timer() - assert not mb.req_pend - -def test_build_long(): - mb = TestHelper() - mb.build_msg(1,3,0x3022,4) - assert mb.que.qsize() == 0 - assert mb.req_pend - assert mb.last_addr == 1 - assert mb.last_fcode == 3 - assert mb.err == 0 - call = 0 - exp_result = [3.0, 28841.4, 113.34] - for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46\x75\x5c', 'test'): - if key == 'input': - assert update == True - assert exp_result[call] == val - else: - assert False - call += 1 - - assert 0 == mb.err - assert 3 == call - assert mb.que.qsize() == 0 - mb._Modbus__stop_timer() assert not mb.req_pend def test_queue(): @@ -199,25 +199,28 @@ def test_queue(): assert mb.send_calls == 1 assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03' mb.pdu = None - mb._Modbus__send_next_from_que() assert mb.send_calls == 1 assert mb.pdu == None assert mb.que.qsize() == 0 + + # cleanup queue mb._Modbus__stop_timer() assert not mb.req_pend def test_queue2(): + '''Check queue handling for build_msg() calls''' mb = TestHelper() mb.build_msg(1,3,0x3007,6) mb.build_msg(1,6,0x2008,4) assert mb.que.qsize() == 1 assert mb.req_pend + mb.build_msg(1,3,0x3007,6) + assert mb.que.qsize() == 2 + assert mb.req_pend assert mb.send_calls == 1 assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' - mb._Modbus__send_next_from_que() - assert mb.send_calls == 1 call = 0 exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): @@ -234,21 +237,87 @@ def test_queue2(): assert 0 == mb.err assert 5 == call + assert mb.que.qsize() == 1 assert mb.send_calls == 2 assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'): pass + assert mb.que.qsize() == 0 + assert mb.send_calls == 3 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + call += 1 + assert 0 == mb.err + assert 5 == call + assert mb.que.qsize() == 0 assert not mb.req_pend +def test_queue3(): + '''Check queue handling for recv_req() calls''' + mb = TestHelper() + assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t', mb.resp_handler) + assert mb.recv_req(b'\x01\x06\x20\x08\x00\x04\x02\x0b', mb.resp_handler) + assert mb.que.qsize() == 1 + assert mb.req_pend + assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t') + assert mb.que.qsize() == 2 + assert mb.req_pend + + assert mb.send_calls == 1 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + assert mb.recv_responses == 0 + + call = 0 + exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + if key == 'grid': + assert update == True + elif key == 'inverter': + assert update == True + elif key == 'env': + assert update == True + else: + assert False + assert exp_result[call] == val + call += 1 + assert 0 == mb.err + assert 5 == call + assert mb.recv_responses == 1 + + assert mb.que.qsize() == 1 + assert mb.send_calls == 2 + assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' + + for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'): + pass + assert 0 == mb.err + assert mb.recv_responses == 2 + + assert mb.que.qsize() == 0 + assert mb.send_calls == 3 + assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'): + call += 1 + assert 0 == mb.err + assert mb.recv_responses == 2 + assert 5 == call + + + assert mb.que.qsize() == 0 + assert not mb.req_pend @pytest.mark.asyncio async def test_timeout(): + '''Test MODBUS response timeout and RTU retransmitting''' assert asyncio.get_running_loop() mb = TestHelper() mb.max_retries = 2 + mb.timeout = 0.1 # 100ms timeout for fast testing, expect a time resolution of at least 10ms assert asyncio.get_running_loop() == mb.loop mb.build_msg(1,3,0x3007,6) mb.build_msg(1,6,0x2008,4) @@ -260,7 +329,7 @@ async def test_timeout(): assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' mb.pdu = None - await asyncio.sleep(1.1) # wait for first timeout and retransmittion + await asyncio.sleep(0.11) # wait for first timeout and retransmittion assert mb.que.qsize() == 1 assert mb.req_pend assert mb.retry_cnt == 1 @@ -268,7 +337,7 @@ async def test_timeout(): assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' mb.pdu = None - await asyncio.sleep(1.1) # wait for second timeout and retransmittion + await asyncio.sleep(0.11) # wait for second timeout and retransmittion assert mb.que.qsize() == 1 assert mb.req_pend assert mb.retry_cnt == 2 @@ -276,7 +345,7 @@ async def test_timeout(): assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' mb.pdu = None - await asyncio.sleep(1.1) # wait for third timeout and next pdu + await asyncio.sleep(0.11) # wait for third timeout and next pdu assert mb.que.qsize() == 0 assert mb.req_pend assert mb.retry_cnt == 0 @@ -284,10 +353,28 @@ async def test_timeout(): assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' mb.max_retries = 0 # next pdu without retranmsission - await asyncio.sleep(1.1) # wait for fourth timout + await asyncio.sleep(0.11) # wait for fourth timout assert mb.que.qsize() == 0 assert not mb.req_pend assert mb.retry_cnt == 0 assert mb.send_calls == 4 # assert mb.counter == {} + +def test_recv_unknown_data(): + '''Receive a response with an unknwon register''' + mb = TestHelper() + assert 0x9000 not in mb.map + mb.map[0x9000] = {'reg': Register.TEST_REG1, 'fmt': '!H', 'ratio': 1} + + mb.build_msg(1,3,0x9000,2) + + # check matching response, but with CRC error + call = 0 + for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'): + call += 1 + assert mb.err == 0 + assert 0 == call + assert not mb.req_pend + + del mb.map[0x9000] From 3ac48dad1f877e5ab1821218d6a2f25ac783e817 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 20 May 2024 18:33:01 +0200 Subject: [PATCH 080/118] cleanup --- app/proxy.svg | 23 ++++++++++++----------- app/proxy.yuml | 2 +- app/src/gen3/talent.py | 3 --- app/src/infos.py | 32 ++++++++++++++++---------------- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/app/proxy.svg b/app/proxy.svg index cef1e69..9ee1aba 100644 --- a/app/proxy.svg +++ b/app/proxy.svg @@ -65,14 +65,15 @@ A3 - -Modbus - - -build_msg() -recv_req() -recv_resp() -check_crc() + +Modbus + +err +retry_cnt + +build_msg() +recv_req() +recv_resp() @@ -166,9 +167,9 @@ A6->A3 - - -1 + + +1 has diff --git a/app/proxy.yuml b/app/proxy.yuml index 7514a93..e2ce12b 100644 --- a/app/proxy.yuml +++ b/app/proxy.yuml @@ -4,7 +4,7 @@ [note: You can stick notes on diagrams too!{bg:cornsilk}] [Singleton]^[Mqtt|ha_restarts;__client;__cb_MqttIsUp|publish();close()] -[Modbus||build_msg();recv_req();recv_resp();check_crc()] +[Modbus|err;retry_cnt|build_msg();recv_req();recv_resp()] [IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list|_read():void;close():void;inc_counter():void;dec_counter():void] [Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()] [Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()] diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index c1ba2dd..def26fc 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -396,9 +396,6 @@ class Talent(Message): self.header_len+self.data_len] if self.ctrl.is_req(): - # if (self.remoteStream.state != self.STATE_UP): - # logger.info('ignore Modbus Request in wrong state') - # return if self.remoteStream.mb.recv_req(data[hdr_len:], self.msg_forward): self.inc_counter('Modbus_Command') diff --git a/app/src/infos.py b/app/src/infos.py index dadacd7..eaf7062 100644 --- a/app/src/infos.py +++ b/app/src/infos.py @@ -224,22 +224,22 @@ class Infos: # 0xffffff03: {'name':['proxy', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha':{'dev':'proxy', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id':'proxy_volt_', 'fmt':'| float','name': 'Grid Voltage'}}, # noqa: E501 # events - Register.EVENT_401: {'name': ['events', '401_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_402: {'name': ['events', '402_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_403: {'name': ['events', '403_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_404: {'name': ['events', '404_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_405: {'name': ['events', '405_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_406: {'name': ['events', '406_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_407: {'name': ['events', '407_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_408: {'name': ['events', '408_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_409: {'name': ['events', '409_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_410: {'name': ['events', '410_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_411: {'name': ['events', '411_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_412: {'name': ['events', '412_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_413: {'name': ['events', '413_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_414: {'name': ['events', '414_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_415: {'name': ['events', '415_GridFreqOverRating'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 - Register.EVENT_416: {'name': ['events', '416_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_401: {'name': ['events', '401_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_402: {'name': ['events', '402_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_403: {'name': ['events', '403_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_404: {'name': ['events', '404_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_405: {'name': ['events', '405_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_406: {'name': ['events', '406_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_407: {'name': ['events', '407_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_408: {'name': ['events', '408_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_409: {'name': ['events', '409_No_Utility'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_410: {'name': ['events', '410_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_411: {'name': ['events', '411_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_412: {'name': ['events', '412_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_413: {'name': ['events', '413_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_414: {'name': ['events', '414_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_415: {'name': ['events', '415_GridFreqOverRating'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_416: {'name': ['events', '416_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 # grid measures: Register.GRID_VOLTAGE: {'name': ['grid', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'inverter', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'out_volt_', 'fmt': '| float', 'name': 'Grid Voltage', 'ent_cat': 'diagnostic'}}, # noqa: E501 From 98ef252bb0167c7eec4db6e83194d7371bf52e2b Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 20 May 2024 18:51:55 +0200 Subject: [PATCH 081/118] don't forward invalid MODBUS responses --- app/src/gen3plus/solarman_v5.py | 2 +- app/tests/test_solarman.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index a53769a..f9231eb 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -467,7 +467,7 @@ class SolarmanV5(Message): if inv_update: self.__build_model_name() - return + return self.__forward_msg() def msg_hbeat_ind(self): diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index e339412..1092520 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -1489,7 +1489,7 @@ def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInvalid): m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.msg_count == 1 - assert m._forward_buffer==MsgModbusInvalid + assert m._forward_buffer==b'' assert m._send_buffer==b'' assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 assert m.db.stat['proxy']['Modbus_Command'] == 0 From e43244113434065b7c7732cc1aacb2b0e9fb7fd1 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 21 May 2024 18:56:52 +0200 Subject: [PATCH 082/118] don't log Events as Infos --- app/src/infos.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/infos.py b/app/src/infos.py index eaf7062..4eadce0 100644 --- a/app/src/infos.py +++ b/app/src/infos.py @@ -224,22 +224,22 @@ class Infos: # 0xffffff03: {'name':['proxy', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha':{'dev':'proxy', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id':'proxy_volt_', 'fmt':'| float','name': 'Grid Voltage'}}, # noqa: E501 # events - Register.EVENT_401: {'name': ['events', '401_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_402: {'name': ['events', '402_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_403: {'name': ['events', '403_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_404: {'name': ['events', '404_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_405: {'name': ['events', '405_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_406: {'name': ['events', '406_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_407: {'name': ['events', '407_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_408: {'name': ['events', '408_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_409: {'name': ['events', '409_No_Utility'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_410: {'name': ['events', '410_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_411: {'name': ['events', '411_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_412: {'name': ['events', '412_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_413: {'name': ['events', '413_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_414: {'name': ['events', '414_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_415: {'name': ['events', '415_GridFreqOverRating'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 - Register.EVENT_416: {'name': ['events', '416_'], 'level': logging.WARNING, 'unit': ''}, # noqa: E501 + Register.EVENT_401: {'name': ['events', '401_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_402: {'name': ['events', '402_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_403: {'name': ['events', '403_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_404: {'name': ['events', '404_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_405: {'name': ['events', '405_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_406: {'name': ['events', '406_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_407: {'name': ['events', '407_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_408: {'name': ['events', '408_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_409: {'name': ['events', '409_No_Utility'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_410: {'name': ['events', '410_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_411: {'name': ['events', '411_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_412: {'name': ['events', '412_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_413: {'name': ['events', '413_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_414: {'name': ['events', '414_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_415: {'name': ['events', '415_GridFreqOverRating'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 + Register.EVENT_416: {'name': ['events', '416_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 # grid measures: Register.GRID_VOLTAGE: {'name': ['grid', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'inverter', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'out_volt_', 'fmt': '| float', 'name': 'Grid Voltage', 'ent_cat': 'diagnostic'}}, # noqa: E501 From de1c48fa62a512d99d3a992e8f9c06e5538ea34c Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 21 May 2024 18:58:10 +0200 Subject: [PATCH 083/118] add keyword for timeout to argument list --- app/src/gen3/talent.py | 2 +- app/src/gen3plus/solarman_v5.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index def26fc..7e54d92 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -36,7 +36,7 @@ class Control: class Talent(Message): def __init__(self, server_side: bool, id_str=b''): - super().__init__(server_side, self.send_modbus_cb, 11) + super().__init__(server_side, self.send_modbus_cb, mb_timeout=11) self.await_conn_resp_cnt = 0 self.id_str = id_str self.contact_name = b'' diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index f9231eb..fb5f968 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -52,7 +52,7 @@ class SolarmanV5(Message): MB_RTU_CMD = 2 def __init__(self, server_side: bool): - super().__init__(server_side, self.send_modbus_cb, 5) + super().__init__(server_side, self.send_modbus_cb, mb_timeout=5) self.header_len = 11 # overwrite construcor in class Message self.control = 0 From 9e38cb93eaf2dcfba19d62fd117d8f54b92f82aa Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 21 May 2024 18:59:30 +0200 Subject: [PATCH 084/118] send StatusReq additionally every 30 minutes --- app/src/scheduler.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/app/src/scheduler.py b/app/src/scheduler.py index 610f586..a076faa 100644 --- a/app/src/scheduler.py +++ b/app/src/scheduler.py @@ -36,16 +36,12 @@ class Schedule: @classmethod async def regular_modbus_cmds(cls): - # logging.info("Regular Modbus requests") - if 0 == (cls.count % 30): - # logging.info("Regular Modbus Status request") - addr, len = 0x2007, 2 - else: - addr, len = 0x3008, 21 - cls.count += 1 - for m in Message: if m.server_side: fnc = getattr(m, "send_modbus_cmd", None) if callable(fnc): - await fnc(Modbus.READ_REGS, addr, len) + await fnc(Modbus.READ_REGS, 0x3008, 21) + if 0 == (cls.count % 30): + # logging.info("Regular Modbus Status request") + await fnc(Modbus.READ_REGS, 0x2007, 2) + cls.count += 1 From da2388941e156953d117b18f047a31c1ed825cd2 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 21 May 2024 19:37:55 +0200 Subject: [PATCH 085/118] allow only one MODBUS retry - More than one retry usually makes no sense, as random errors are usually corrected. If the first retry also fails, the chance that a second or third retry will be successful is very small --- app/src/modbus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index 9cfeacd..fc262bd 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -83,7 +83,7 @@ class Modbus(): '''Response handler to forward the response''' self.timeout = timeout '''MODBUS response timeout in seconds''' - self.max_retries = 3 + self.max_retries = 1 '''Max retransmit for MODBUS requests''' self.retry_cnt = 0 self.last_req = b'' @@ -103,7 +103,7 @@ class Modbus(): self.tim = None def __del__(self): - logging.info(f'Modbus __del__:\n {self.counter}') + logging.debug(f'Modbus __del__:\n {self.counter}') def build_msg(self, addr: int, func: int, reg: int, val: int) -> None: """Build MODBUS RTU request frame and add it to the tx queue From 55fc834a1edc9b0cd21f87bf7f11e8e4bce6f50b Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 22 May 2024 22:52:02 +0200 Subject: [PATCH 086/118] reduce default loggings --- app/src/infos.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/infos.py b/app/src/infos.py index 4eadce0..b408118 100644 --- a/app/src/infos.py +++ b/app/src/infos.py @@ -252,22 +252,22 @@ class Infos: # input measures: Register.PV1_VOLTAGE: {'name': ['input', 'pv1', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv1', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv1_', 'val_tpl': "{{ (value_json['pv1']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV1_CURRENT: {'name': ['input', 'pv1', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv1', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv1_', 'val_tpl': "{{ (value_json['pv1']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501 + Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501 Register.PV2_VOLTAGE: {'name': ['input', 'pv2', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv2', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv2_', 'val_tpl': "{{ (value_json['pv2']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV2_CURRENT: {'name': ['input', 'pv2', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv2', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv2_', 'val_tpl': "{{ (value_json['pv2']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501 + Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501 Register.PV3_VOLTAGE: {'name': ['input', 'pv3', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv3', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv3_', 'val_tpl': "{{ (value_json['pv3']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV3_CURRENT: {'name': ['input', 'pv3', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv3', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv3_', 'val_tpl': "{{ (value_json['pv3']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501 + Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501 Register.PV4_VOLTAGE: {'name': ['input', 'pv4', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv4', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv4_', 'val_tpl': "{{ (value_json['pv4']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV4_CURRENT: {'name': ['input', 'pv4', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv4', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv4_', 'val_tpl': "{{ (value_json['pv4']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501 + Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501 Register.PV5_VOLTAGE: {'name': ['input', 'pv5', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv5', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv5_', 'val_tpl': "{{ (value_json['pv5']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV5_CURRENT: {'name': ['input', 'pv5', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv5', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv5_', 'val_tpl': "{{ (value_json['pv5']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501 + Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501 Register.PV6_VOLTAGE: {'name': ['input', 'pv6', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv6', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv6_', 'val_tpl': "{{ (value_json['pv6']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV6_CURRENT: {'name': ['input', 'pv6', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv6', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv6_', 'val_tpl': "{{ (value_json['pv6']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501 - Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501 + Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501 Register.PV1_DAILY_GENERATION: {'name': ['input', 'pv1', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv1_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv1']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501 Register.PV1_TOTAL_GENERATION: {'name': ['input', 'pv1', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv1_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv1']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501 Register.PV2_DAILY_GENERATION: {'name': ['input', 'pv2', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv2_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv2']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501 From 8fc5eb3670e75ae72ee305e6fe241046f06076ee Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 22 May 2024 22:53:04 +0200 Subject: [PATCH 087/118] log MQTT to data topic --- app/src/modbus.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/modbus.py b/app/src/modbus.py index fc262bd..8f83c9c 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -21,6 +21,7 @@ if __name__ == "app.src.modbus": else: # pragma: no cover from infos import Register +logger = logging.getLogger('data') CRC_POLY = 0xA001 # (LSBF/reverse) CRC_INIT = 0xFFFF @@ -136,7 +137,7 @@ class Modbus(): # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') if not self.__check_crc(buf): self.err = 1 - logging.error('Modbus recv: CRC error') + logger.error('Modbus recv: CRC error') return False self.que.put_nowait({'req': buf, 'rsp_hdl': rsp_handler}) @@ -166,23 +167,23 @@ class Modbus(): self.err = 5 return if not self.__check_crc(buf): - logging.error('Modbus resp: CRC error') + logger.error('Modbus resp: CRC error') self.err = 1 return if buf[0] != self.last_addr: - logging.info(f'Modbus resp: Wrong addr {buf[0]}') + logger.info(f'Modbus resp: Wrong addr {buf[0]}') self.err = 2 return fcode = buf[1] if fcode != self.last_fcode: - logging.info(f'Modbus: Wrong fcode {fcode} != {self.last_fcode}') + logger.info(f'Modbus: Wrong fcode {fcode} != {self.last_fcode}') self.err = 3 return if self.last_addr == self.INV_ADDR and \ (fcode == 3 or fcode == 4): elmlen = buf[2] >> 1 if elmlen != self.last_len: - logging.info(f'Modbus: len error {elmlen} != {self.last_len}') + logger.info(f'Modbus: len error {elmlen} != {self.last_len}') self.err = 4 return first_reg = self.last_reg # save last_reg before sending next pdu @@ -241,12 +242,12 @@ class Modbus(): self.req_pend = False if self.retry_cnt < self.max_retries: - logging.debug(f'Modbus retrans {self}') + logger.debug(f'Modbus retrans {self}') self.retry_cnt += 1 self.__start_timer() self.snd_handler(self.last_req, state='Retrans') else: - logging.info(f'Modbus timeout {self}') + logger.info(f'Modbus timeout {self}') self.counter['timeouts'] += 1 self.__send_next_from_que() From 87cc3fb2050afcca3ea2e70cf2f21d57afe56534 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 22 May 2024 22:53:52 +0200 Subject: [PATCH 088/118] fix frong MQTT not found logs --- app/src/mqtt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/mqtt.py b/app/src/mqtt.py index 484d7df..2ce6333 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -129,8 +129,10 @@ class Mqtt(metaclass=Singleton): def each_inverter(self, message, func_name: str): topic = str(message.topic) node_id = topic.split('/')[1] + '/' + found = False for m in Message: if m.server_side and (m.node_id == node_id): + found = True logger_mqtt.debug(f'Found: {node_id}') fnc = getattr(m, func_name, None) if callable(fnc): @@ -138,7 +140,7 @@ class Mqtt(metaclass=Singleton): else: logger_mqtt.warning(f'Cmd not supported by: {node_id}') - else: + if not found: logger_mqtt.warning(f'Node_id: {node_id} not found') async def modbus_cmd(self, message, func, params=0, addr=0, val=0): From 0fc74b0d19b94627323d5ba7818ddcaf1c39e5fc Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 22 May 2024 22:54:23 +0200 Subject: [PATCH 089/118] improve unit test --- app/tests/test_talent.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 3387d99..31bbbef 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -794,21 +794,53 @@ def test_msg_iterator(): assert test2 == 1 def test_proxy_counter(): - m = Talent(server_side=True) + # m = MemoryStream(b'') + # m.close() + Infos.stat['proxy']['Modbus_Command'] = 1 + + m = MemoryStream(b'') + m.id_str = b"R170000000000001" + c = m.createClientStream(b'') + assert m.new_data == {} m.db.stat['proxy']['Unknown_Msg'] = 0 + c.db.stat['proxy']['Unknown_Msg'] = 0 Infos.new_stat_data['proxy'] = False m.inc_counter('Unknown_Msg') + m.close() + m = MemoryStream(b'') + assert m.new_data == {} assert Infos.new_stat_data == {'proxy': True} + assert m.db.new_stat_data == {'proxy': True} + assert c.db.new_stat_data == {'proxy': True} assert 1 == m.db.stat['proxy']['Unknown_Msg'] + assert 1 == c.db.stat['proxy']['Unknown_Msg'] + Infos.new_stat_data['proxy'] = False + + c.inc_counter('Unknown_Msg') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert m.db.new_stat_data == {'proxy': True} + assert c.db.new_stat_data == {'proxy': True} + assert 2 == m.db.stat['proxy']['Unknown_Msg'] + assert 2 == c.db.stat['proxy']['Unknown_Msg'] + Infos.new_stat_data['proxy'] = False + + c.inc_counter('Modbus_Command') + assert m.new_data == {} + assert Infos.new_stat_data == {'proxy': True} + assert m.db.new_stat_data == {'proxy': True} + assert c.db.new_stat_data == {'proxy': True} + assert 2 == m.db.stat['proxy']['Modbus_Command'] + assert 2 == c.db.stat['proxy']['Modbus_Command'] Infos.new_stat_data['proxy'] = False m.dec_counter('Unknown_Msg') assert m.new_data == {} assert Infos.new_stat_data == {'proxy': True} - assert 0 == m.db.stat['proxy']['Unknown_Msg'] + assert 1 == m.db.stat['proxy']['Unknown_Msg'] m.close() def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): From 5c6f9e74142e188dd84ef1b6fb87dd359e4a531a Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 23 May 2024 19:52:55 +0200 Subject: [PATCH 090/118] increase test coverage to 100% --- app/tests/test_solarman.py | 40 +++++++++++++++++++++ app/tests/test_talent.py | 74 +++++++++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 1092520..8d997b4 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -1451,6 +1451,46 @@ def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp): assert m.new_data['inverter'] == True m.new_data['inverter'] = False + m.mb.req_pend = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == False + + m.close() + +def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp): + '''Modbus response with a valid Modbus request must be forwarded''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusRsp) + m.append_msg(MsgModbusRsp) + + m.mb.rsp_handler = m._SolarmanV5__forward_msg + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + # assert m.db.db == {'inverter': {'Manufacturer': 'TSUN', 'Equipment_Model': 'TSOL-MSxx00'}} + m.new_data['inverter'] = False + + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusRsp + assert m._send_buffer==b'' + # assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V4.0.10' + assert m.new_data['inverter'] == True + m.new_data['inverter'] = False + m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.mb.err == 5 diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index 31bbbef..c24548c 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -874,7 +874,37 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd): assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() -def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr): +def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmd): + ConfigTsunInv1 + m = MemoryStream(b'') + m.id_str = b"R170000000000001" + + c = m.createClientStream(MsgModbusCmd) + + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.db.stat['proxy']['Invalid_Msg_Format'] = 0 + c.read() # read complete msg, and dispatch msg + assert not c.header_valid # must be invalid, since msg was handled and buffer flushed + assert c.msg_count == 1 + assert c.id_str == b"R170000000000001" + assert c.unique_id == 'R170000000000001' + assert int(c.ctrl)==112 + assert c.msg_id==119 + assert c.header_len==23 + assert c.data_len==13 + assert c._forward_buffer==b'' + assert c._send_buffer==b'' + assert m.id_str == b"R170000000000001" + assert m._forward_buffer==b'' + assert m._send_buffer==b'' + assert m.writer.sent_pdu == b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 1 + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + m.close() + +def test_msg_modbus_req3(ConfigTsunInv1, MsgModbusCmdCrcErr): ConfigTsunInv1 m = MemoryStream(b'') m.id_str = b"R170000000000001" @@ -949,8 +979,50 @@ def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusResp20): assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' assert m.new_data['inverter'] == True + + m.new_data['inverter'] = False + m.mb.req_pend = True + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 2 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == False + + m.close() + +def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20): + '''Modbus response with a valid Modbus request must be forwarded''' + ConfigTsunInv1 + m = MemoryStream(MsgModbusResp20) + m.append_msg(MsgModbusResp20) + + m.mb.rsp_handler = m.msg_forward + m.mb.last_addr = 1 + m.mb.last_fcode = 3 + m.mb.last_len = 20 + m.mb.last_reg = 0x3008 + m.mb.req_pend = True + m.mb.err = 0 + + assert m.db.db == {} m.new_data['inverter'] = False + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.mb.err == 0 + assert m.msg_count == 1 + assert m._forward_buffer==MsgModbusResp20 + assert m._send_buffer==b'' + assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}} + assert m.db.get_db_value(Register.VERSION) == 'V5.1.09' + assert m.new_data['inverter'] == True + m.new_data['inverter'] = False + assert m.mb.req_pend == False + m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed assert m.mb.err == 5 From edc2c12b5b2de11feeead036698003bcc8a6c5dd Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 27 May 2024 20:52:06 +0200 Subject: [PATCH 091/118] Send MQTT topic for responses to AT+ commands --- app/src/gen3plus/solarman_v5.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index fb5f968..423123a 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -2,6 +2,7 @@ import struct # import json import logging import time +import asyncio from datetime import datetime if __name__ == "app.src.gen3plus.solarman_v5": @@ -441,12 +442,21 @@ class SolarmanV5(Message): self.__forward_msg() + async def publish_mqtt(self, key, data): + await self.mqtt.publish(key, data) # pragma: no cover + def msg_command_rsp(self): data = self._recv_buffer[self.header_len: self.header_len+self.data_len] ftype = data[0] if ftype == self.AT_CMD: if not self.forward_at_cmd_resp: + data_json = data[14:].decode("utf-8") + node_id = self.node_id + key = 'at_resp' + logger.info(f'{key}: {data_json}') + asyncio.ensure_future( + self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json)) # noqa: E501 return elif ftype == self.MB_RTU_CMD: valid = data[1] From fdf3475909eb8431186e16b9ccb089927711e452 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 27 May 2024 20:56:03 +0200 Subject: [PATCH 092/118] fix unit test --- app/tests/test_solarman.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 8d997b4..beb83d6 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -39,6 +39,7 @@ class MemoryStream(SolarmanV5): self.db.stat['proxy']['Invalid_Msg_Format'] = 0 self.db.stat['proxy']['AT_Command'] = 0 self.test_exception_async_write = False + self.entity_prfx = '' def _timestamp(self): return timestamp From ab9e798152dbdbfdd8a28e7d7ad0b866714be63c Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 28 May 2024 19:30:58 +0200 Subject: [PATCH 093/118] add typing --- app/src/messages.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/messages.py b/app/src/messages.py index 4968609..16e6d1b 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -1,5 +1,7 @@ import logging import weakref +from typing import Callable + if __name__ == "app.src.messages": from app.src.infos import Infos @@ -56,7 +58,8 @@ class Message(metaclass=IterRegistry): STATE_UP = 2 STATE_CLOSED = 3 - def __init__(self, server_side: bool, send_modbus_cb, mb_timeout): + def __init__(self, server_side: bool, send_modbus_cb: + Callable[[bytes, int, str], None], mb_timeout): self._registry.append(weakref.ref(self)) self.server_side = server_side From 66657888ddfced280e6cdab8b822ef7d52501050 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 28 May 2024 19:32:20 +0200 Subject: [PATCH 094/118] add log_level support for modbus commands --- app/src/gen3/talent.py | 17 +++++++++-------- app/src/gen3plus/solarman_v5.py | 16 ++++++++-------- app/src/modbus.py | 18 ++++++++++++------ app/src/mqtt.py | 2 +- app/src/scheduler.py | 4 ++-- app/tests/test_modbus.py | 2 +- app/tests/test_solarman.py | 7 ++++--- app/tests/test_talent.py | 6 +++--- system_tests/test_tcp_socket_v2.py | 16 ++++++++++++---- 9 files changed, 52 insertions(+), 36 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 7e54d92..caf327d 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -122,25 +122,25 @@ class Talent(Message): f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') return - def send_modbus_cb(self, modbus_pdu: bytearray, state: str): + def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: return - self.__build_header(0x70, 0x77) + self.__build_header(0x70, 0x77, log_lvl) self._send_buffer += b'\x00\x01\xa3\x28' # fixme self._send_buffer += struct.pack('!B', len(modbus_pdu)) self._send_buffer += modbus_pdu self.__finish_send_msg() - hex_dump_memory(logging.INFO, f'Send Modbus {state}:{self.addr}:', + hex_dump_memory(log_lvl, f'Send Modbus {state}:{self.addr}:', self._send_buffer, len(self._send_buffer)) self.writer.write(self._send_buffer) self._send_buffer = bytearray(0) # self._send_buffer[sent:] - async def send_modbus_cmd(self, func, addr, val) -> None: + async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: if self.state != self.STATE_UP: return - self.mb.build_msg(Modbus.INV_ADDR, func, addr, val) + self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) def _init_new_client_conn(self) -> bool: contact_name = self.contact_name @@ -217,15 +217,16 @@ class Talent(Message): self.header_valid = True return - def __build_header(self, ctrl, msg_id=None) -> None: + def __build_header(self, ctrl, msg_id=None, + log_lvl: int = logging.INFO) -> None: if not msg_id: msg_id = self.msg_id self.send_msg_ofs = len(self._send_buffer) self._send_buffer += struct.pack(f'!l{len(self.id_str)+1}pBB', 0, self.id_str, ctrl, msg_id) fnc = self.switch.get(msg_id, self.msg_unknown) - logger.info(self.__flow_str(self.server_side, 'tx') + - f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}') + logger.log(log_lvl, self.__flow_str(self.server_side, 'tx') + + f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}') def __finish_send_msg(self) -> None: _len = len(self._send_buffer) - self.send_msg_ofs diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 423123a..1529057 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -248,15 +248,15 @@ class SolarmanV5(Message): return True - def __build_header(self, ctrl) -> None: + def __build_header(self, ctrl, log_lvl: int = logging.INFO) -> None: '''build header for new transmit message''' self.send_msg_ofs = len(self._send_buffer) self._send_buffer += struct.pack( ' None: '''finish the transmit message, set lenght and checksum''' @@ -302,23 +302,23 @@ class SolarmanV5(Message): self._heartbeat()) self.__finish_send_msg() - def send_modbus_cb(self, pdu: bytearray, state: str): + def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: return - self.__build_header(0x4510) + self.__build_header(0x4510, log_lvl) self._send_buffer += struct.pack(' None: + async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: if self.state != self.STATE_UP: return - self.mb.build_msg(Modbus.INV_ADDR, func, addr, val) + self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) async def send_at_cmd(self, AT_cmd: str) -> None: if self.state != self.STATE_UP: diff --git a/app/src/modbus.py b/app/src/modbus.py index 8f83c9c..0727650 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -74,7 +74,8 @@ class Modbus(): 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 } - def __init__(self, snd_handler: Callable[[str], None], timeout: int = 1): + def __init__(self, snd_handler: Callable[[bytes, int, str], None], + timeout: int = 1): if not len(self.__crc_tab): self.__build_crc_tab(CRC_POLY) self.que = asyncio.Queue(100) @@ -94,6 +95,7 @@ class Modbus(): self.counter['retries'] = {} for i in range(0, self.max_retries+1): self.counter['retries'][f'{i}'] = 0 + self.last_log_lvl = logging.DEBUG self.last_addr = 0 self.last_fcode = 0 self.last_len = 0 @@ -106,7 +108,8 @@ class Modbus(): def __del__(self): logging.debug(f'Modbus __del__:\n {self.counter}') - def build_msg(self, addr: int, func: int, reg: int, val: int) -> None: + def build_msg(self, addr: int, func: int, reg: int, val: int, + log_lvl=logging.DEBUG) -> None: """Build MODBUS RTU request frame and add it to the tx queue Keyword arguments: @@ -118,7 +121,8 @@ class Modbus(): msg = struct.pack('>BBHH', addr, func, reg, val) msg += struct.pack(' bytes: def get_invalid_sn(): return b'R170000000000002' +def correct_checksum(buf): + checksum = sum(buf[1:]) & 0xff + return checksum.to_bytes(length=1) @pytest.fixture def MsgContactInfo(): # Contact Info message @@ -61,10 +64,11 @@ def MsgDataInd(): msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x00\x01\x12\x02\x12\x12\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x40\x10\x08\xd8\x00\x09\x13\x84\x00\x35\x00\x00\x02\x58\x00\xd8' - msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18' + msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18' msg += b'\x00\x52\x00\x12\x00\x01\x00\x00\x00\x7c\x00\x00\x24\xed\x00\x2c' msg += b'\x00\x00\x0b\x10\x00\x26\x00\x00\x0a\x0f\x00\x30\x00\x00\x0b\x76' - msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x00\x55\xaa\x00\x01\x00\x00' + + msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x01\x55\xaa\x00\x01\x00\x00' msg += b'\x00\x00\x00\x00\xff\xff\x07\xd0\x00\x03\x04\x00\x04\x00\x04\x00' msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00' msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41' @@ -73,7 +77,9 @@ def MsgDataInd(): msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00' msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00' - msg += b'\x00\x00\x00\x00\x24\x15' + msg += b'\x00\x00\x00\x00' + msg += correct_checksum(msg) + msg += b'\x15' return msg @pytest.fixture @@ -147,4 +153,6 @@ def test_data_ind(ClientConnection,MsgDataInd, MsgDataResp): except TimeoutError: pass # time.sleep(2.5) - checkResponse(data, MsgDataResp) \ No newline at end of file + checkResponse(data, MsgDataResp) + + \ No newline at end of file From 3980ac013bf38101b2f3b5581543a69d82fcf791 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 28 May 2024 21:55:42 +0200 Subject: [PATCH 095/118] catch all OSError errors in the read loop --- CHANGELOG.md | 2 ++ app/src/async_stream.py | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8412be2..830c501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- catch all OSError errors in the read loop +- log Modbus traces with different log levels - add Modbus fifo and timeout handler - build version string in the same format as TSUN for GEN3 invterts - add graceful shutdown diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 196a01f..563b948 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -65,9 +65,7 @@ class AsyncStream(): await self.__async_forward() await self.async_publ_mqtt() - except (ConnectionResetError, - ConnectionAbortedError, - BrokenPipeError) as error: + except OSError as error: logger.error(f'{error} for l{self.l_addr} | ' f'r{self.r_addr}') await self.disc() From 063850c7fb148df981a651c36631ac0597cc983f Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 30 May 2024 18:38:05 +0200 Subject: [PATCH 096/118] add allow and block filter for AT+ commands --- app/config/default_config.toml | 5 +++++ app/src/config.py | 13 +++++++++++-- app/src/gen3plus/solarman_v5.py | 24 ++++++++++++++++++++++++ app/tests/test_config.py | 18 +++++++++++------- app/tests/test_solarman.py | 1 + 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/app/config/default_config.toml b/app/config/default_config.toml index cd95d75..fbe2651 100644 --- a/app/config/default_config.toml +++ b/app/config/default_config.toml @@ -49,3 +49,8 @@ monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker e #pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr #pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr +[gen3plus.at_acl] +tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'] +tsun.block = [] +mqtt.allow = ['AT+'] +mqtt.block = [] diff --git a/app/src/config.py b/app/src/config.py index eef0c86..e1ef749 100644 --- a/app/src/config.py +++ b/app/src/config.py @@ -3,7 +3,7 @@ import shutil import tomllib import logging -from schema import Schema, And, Use, Optional +from schema import Schema, And, Or, Use, Optional class Config(): @@ -38,6 +38,14 @@ class Config(): 'proxy_node_id': Use(str), 'proxy_unique_id': Use(str) }, + 'gen3plus': { + 'at_acl': { + Or('mqtt', 'tsun'): { + 'allow': [str], + Optional('block', default=[]): [str] + } + } + }, 'inverters': { 'allow_all': Use(bool), And(Use(str), lambda s: len(s) == 16): { Optional('monitor_sn', default=0): Use(int), @@ -125,7 +133,8 @@ class Config(): # merge the default and the user config config = def_config.copy() - for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters']: + for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters', + 'gen3plus']: if key in usr_config: config[key] |= usr_config[key] diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 1529057..e3ed438 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -91,8 +91,13 @@ class SolarmanV5(Message): # MODbus or AT cmd 0x4510: self.msg_command_req, # from server 0x1510: self.msg_command_rsp, # from inverter + # 0x0510: self.msg_command_rsp, # from inverter } self.modbus_elms = 0 # for unit tests + g3p_cnf = Config.get('gen3plus') + + if 'at_acl' in g3p_cnf: + self.at_acl = g3p_cnf['at_acl'] ''' Our puplic methods @@ -320,9 +325,24 @@ class SolarmanV5(Message): return self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) + def at_cmd_forbidden(self, cmd: str, connection: str) -> bool: + return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \ + cmd.startswith(tuple(self.at_acl[connection]['block'])) + async def send_at_cmd(self, AT_cmd: str) -> None: if self.state != self.STATE_UP: return + AT_cmd = AT_cmd.strip() + + if self.at_cmd_forbidden(cmd=AT_cmd, connection='mqtt'): + data_json = f'\'{AT_cmd}\' is forbidden' + node_id = self.node_id + key = 'at_resp' + logger.info(f'{key}: {data_json}') + asyncio.ensure_future( + self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json)) # noqa: E501 + return + self.forward_at_cmd_resp = False self.__build_header(0x4510) self._send_buffer += struct.pack(f' Date: Thu, 30 May 2024 19:32:14 +0200 Subject: [PATCH 097/118] add AT_COMMAND_BLOCKED counter --- app/src/gen3plus/solarman_v5.py | 7 ++++--- app/src/infos.py | 26 ++++++++++++++------------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index e3ed438..91ee30b 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -96,7 +96,7 @@ class SolarmanV5(Message): self.modbus_elms = 0 # for unit tests g3p_cnf = Config.get('gen3plus') - if 'at_acl' in g3p_cnf: + if 'at_acl' in g3p_cnf: # pragma: no cover self.at_acl = g3p_cnf['at_acl'] ''' @@ -450,11 +450,12 @@ class SolarmanV5(Message): result = struct.unpack_from(' Date: Thu, 30 May 2024 19:32:53 +0200 Subject: [PATCH 098/118] add missing testcases --- app/tests/test_infos.py | 4 +- app/tests/test_solarman.py | 78 +++++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/tests/test_infos.py b/app/tests/test_infos.py index c3e6ddf..4b23bfb 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, "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, "Modbus_Command": 0}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_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}}) 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, "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, "Modbus_Command": 0}}) + assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "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}}) val = i.dev_value(Register.INVERTER_CNT) assert val == 1 diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 52a66a8..06a57f0 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -39,9 +39,10 @@ class MemoryStream(SolarmanV5): self.addr = 'Test: SrvSide' self.db.stat['proxy']['Invalid_Msg_Format'] = 0 self.db.stat['proxy']['AT_Command'] = 0 + self.db.stat['proxy']['AT_Command_Blocked'] = 0 self.test_exception_async_write = False self.entity_prfx = '' - self.at_acl = {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE', 'AT+TIME'], 'block': []}} + self.at_acl = {'mqtt': {'allow': ['AT+'], 'block': ['AT+WEBU']}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE', 'AT+TIME'], 'block': ['AT+WEBU']}} def _timestamp(self): return timestamp @@ -466,6 +467,15 @@ def AtCommandIndMsg(): # 0x4510 msg += b'\x15' return msg +@pytest.fixture +def AtCommandIndMsgBlock(): # 0x4510 + msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x01\x02\x00' + msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + msg += b'AT+WEBU\r' + msg += correct_checksum(msg) + msg += b'\x15' + return msg + @pytest.fixture def AtCommandRspMsg(): # 0x1510 msg = b'\xa5\x0a\x00\x10\x15\x03\x03' +get_sn() +b'\x01\x01' @@ -1277,11 +1287,49 @@ async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIn assert m.forward_at_cmd_resp == False m.close() -def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): +@pytest.mark.asyncio +async def test_AT_cmd_blocked(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg): + ConfigTsunAllowAll + m = MemoryStream(DeviceIndMsg, (0,), True) + m.append_msg(InverterIndMsg) + m.read() + assert m.control == 0x4110 + assert str(m.seq) == '01:01' + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==DeviceRspMsg + assert m._forward_buffer==DeviceIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_at_cmd('AT+WEBU') + assert m._recv_buffer==InverterIndMsg # unhandled next message + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '01:01' + + m.read() + assert m.control == 0x4210 + assert str(m.seq) == '02:02' + assert m._recv_buffer==b'' + assert m._send_buffer==InverterRspMsg + assert m._forward_buffer==InverterIndMsg + + m._send_buffer = bytearray(0) # clear send buffer for next test + m._forward_buffer = bytearray(0) # clear send buffer for next test + await m.send_at_cmd('AT+WEBU') + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert str(m.seq) == '02:02' + assert m.forward_at_cmd_resp == False + m.close() + +def test_AT_cmd_ind(ConfigTsunInv1, AtCommandIndMsg): ConfigTsunInv1 m = MemoryStream(AtCommandIndMsg, (0,), False) m.db.stat['proxy']['Unknown_Ctrl'] = 0 m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['AT_Command_Blocked'] = 0 m.db.stat['proxy']['Modbus_Command'] = 0 m.read() # read complete msg, and dispatch msg assert not m.header_valid # must be invalid, since msg was handled and buffer flushed @@ -1297,6 +1345,32 @@ def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg): assert m._forward_buffer==AtCommandIndMsg assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 assert m.db.stat['proxy']['AT_Command'] == 1 + assert m.db.stat['proxy']['AT_Command_Blocked'] == 0 + assert m.db.stat['proxy']['Modbus_Command'] == 0 + m.close() + +def test_AT_cmd_ind_block(ConfigTsunInv1, AtCommandIndMsgBlock): + ConfigTsunInv1 + m = MemoryStream(AtCommandIndMsgBlock, (0,), False) + m.db.stat['proxy']['Unknown_Ctrl'] = 0 + m.db.stat['proxy']['AT_Command'] = 0 + m.db.stat['proxy']['AT_Command_Blocked'] = 0 + m.db.stat['proxy']['Modbus_Command'] = 0 + m.read() # read complete msg, and dispatch msg + assert not m.header_valid # must be invalid, since msg was handled and buffer flushed + assert m.msg_count == 1 + assert m.header_len==11 + assert m.snr == 2070233889 + # assert m.unique_id == '2070233889' + assert m.control == 0x4510 + assert str(m.seq) == '03:02' + assert m.data_len == 23 + assert m._recv_buffer==b'' + assert m._send_buffer==b'' + assert m._forward_buffer==b'' + assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 + assert m.db.stat['proxy']['AT_Command'] == 0 + assert m.db.stat['proxy']['AT_Command_Blocked'] == 1 assert m.db.stat['proxy']['Modbus_Command'] == 0 m.close() From 407c1ceb2b59c354a89ca86bb78c2da6eaca8c44 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 30 May 2024 19:40:25 +0200 Subject: [PATCH 099/118] control access via AT commands --- CHANGELOG.md | 1 + README.md | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 830c501..64be905 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 allow and block filter for AT+ commands - catch all OSError errors in the read loop - log Modbus traces with different log levels - add Modbus fifo and timeout handler diff --git a/README.md b/README.md index 27b84c7..89eaba3 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole. - `AT-Command` support via MQTT topics (GEN3PLUS only) - Faster DataUp interval sends measurement data to the MQTT broker every minute - Self-sufficient island operation without internet -- Runs in a non-root Docker Container +- Security-Features: + - control access via `AT commands` + - Runs in a non-root Docker Container ## Home Assistant Screenshots From 20f4fd647ca88ed5a5ec6b778069a1e51b698382 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Thu, 30 May 2024 19:44:54 +0200 Subject: [PATCH 100/118] update config example --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 89eaba3..ffe9f6d 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,12 @@ pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module de pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr +[gen3plus.at_acl] +tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'] # allow this for TSUN access +tsun.block = [] +mqtt.allow = ['AT+'] # allow all via mqtt +mqtt.block = [] + ``` ## Inverter Configuration From 33f215def2843b11170e6475c0abdaa0d1673be1 Mon Sep 17 00:00:00 2001 From: Stefan Allius <122395479+s-allius@users.noreply.github.com> Date: Thu, 30 May 2024 20:30:48 +0200 Subject: [PATCH 101/118] Update README.md fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ffe9f6d..a9c5cf4 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole. - Faster DataUp interval sends measurement data to the MQTT broker every minute - Self-sufficient island operation without internet - Security-Features: - - control access via `AT commands` + - control access via `AT-commands` - Runs in a non-root Docker Container ## Home Assistant Screenshots From e850a8c534f86b48e6d9f3cb1bd471e8e2088982 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 31 May 2024 20:02:21 +0200 Subject: [PATCH 102/118] set tracer log level by environment value --- app/src/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/server.py b/app/src/server.py index c7ee03e..18dc401 100644 --- a/app/src/server.py +++ b/app/src/server.py @@ -83,6 +83,7 @@ if __name__ == "__main__": logging.getLogger('msg').setLevel(log_level) logging.getLogger('conn').setLevel(log_level) logging.getLogger('data').setLevel(log_level) + logging.getLogger('tracer').setLevel(log_level) # logging.getLogger('mqtt').setLevel(log_level) # read config file From d27fe09006184de7fe12e5da248941c84382c347 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 31 May 2024 20:03:21 +0200 Subject: [PATCH 103/118] reduce size of trace file - trace heartbeat and regular modbus pakets only with log level DBEUG - don't forwar akc pakets from tsun to inverter since we answered in before --- app/src/gen3/talent.py | 34 ++++++++++++++++++---- app/src/gen3plus/solarman_v5.py | 51 +++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index caf327d..25d15bc 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -52,6 +52,16 @@ class Talent(Message): # 0x78: 0x04: self.msg_inverter_data, } + self.log_lvl = { + 0x00: logging.INFO, + 0x13: logging.INFO, + 0x22: logging.INFO, + 0x71: logging.INFO, + # 0x76: + 0x77: self.get_modbus_log_lvl, + # 0x78: + 0x04: logging.INFO, + } self.modbus_elms = 0 # for unit tests ''' @@ -63,6 +73,7 @@ class Talent(Message): # so we have to erase self.switch, otherwise this instance can't be # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() + self.log_lvl.clear() self.state = self.STATE_CLOSED super().close() @@ -100,7 +111,11 @@ class Talent(Message): if self.header_valid and len(self._recv_buffer) >= (self.header_len + self.data_len): - hex_dump_memory(logging.INFO, f'Received from {self.addr}:', + log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING) + if callable(log_lvl): + log_lvl = log_lvl() + + hex_dump_memory(log_lvl, f'Received from {self.addr}:', self._recv_buffer, self.header_len+self.data_len) self.__set_serial_no(self.id_str.decode("utf-8")) @@ -126,7 +141,7 @@ class Talent(Message): if self.state != self.STATE_UP: return - self.__build_header(0x70, 0x77, log_lvl) + self.__build_header(0x70, 0x77) self._send_buffer += b'\x00\x01\xa3\x28' # fixme self._send_buffer += struct.pack('!B', len(modbus_pdu)) self._send_buffer += modbus_pdu @@ -217,16 +232,15 @@ class Talent(Message): self.header_valid = True return - def __build_header(self, ctrl, msg_id=None, - log_lvl: int = logging.INFO) -> None: + def __build_header(self, ctrl, msg_id=None) -> None: if not msg_id: msg_id = self.msg_id self.send_msg_ofs = len(self._send_buffer) self._send_buffer += struct.pack(f'!l{len(self.id_str)+1}pBB', 0, self.id_str, ctrl, msg_id) fnc = self.switch.get(msg_id, self.msg_unknown) - logger.log(log_lvl, self.__flow_str(self.server_side, 'tx') + - f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}') + logger.info(self.__flow_str(self.server_side, 'tx') + + f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}') def __finish_send_msg(self) -> None: _len = len(self._send_buffer) - self.send_msg_ofs @@ -391,6 +405,14 @@ class Talent(Message): # logger.debug(f'Modbus MsgLen: {modbus_len} Func:{result[2]}') return msg_hdr_len, modbus_len + def get_modbus_log_lvl(self) -> int: + if self.ctrl.is_req(): + return logging.INFO + elif self.ctrl.is_ind(): + if self.server_side: + return self.mb.last_log_lvl + return logging.WARNING + def msg_modbus(self): hdr_len, modbus_len = self.parse_modbus_header() data = self._recv_buffer[self.header_len: diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 1529057..2db9bcf 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -92,6 +92,29 @@ class SolarmanV5(Message): 0x4510: self.msg_command_req, # from server 0x1510: self.msg_command_rsp, # from inverter } + + self.log_lvl = { + + 0x4210: logging.INFO, # real time data + 0x1210: logging.INFO, # at least every 5 minutes + + 0x4710: logging.DEBUG, # heatbeat + 0x1710: logging.DEBUG, # every 2 minutes + + 0x4110: logging.INFO, # device data, sync start + 0x1110: logging.INFO, # every 3 hours + + 0x4310: logging.INFO, # regulary after 3-6 hours + 0x1310: logging.INFO, + + 0x4810: logging.INFO, # sync end + 0x1810: logging.INFO, + + # + # MODbus or AT cmd + 0x4510: logging.INFO, # from server + 0x1510: self.get_cmd_rsp_log_lvl, + } self.modbus_elms = 0 # for unit tests ''' @@ -103,6 +126,7 @@ class SolarmanV5(Message): # so we have to erase self.switch, otherwise this instance can't be # deallocated by the garbage collector ==> we get a memory leak self.switch.clear() + self.log_lvl.clear() self.state = self.STATE_CLOSED super().close() @@ -145,7 +169,10 @@ class SolarmanV5(Message): if self.header_valid and len(self._recv_buffer) >= (self.header_len + self.data_len+2): - hex_dump_memory(logging.INFO, f'Received from {self.addr}:', + log_lvl = self.log_lvl.get(self.control, logging.WARNING) + if callable(log_lvl): + log_lvl = log_lvl() + hex_dump_memory(log_lvl, f'Received from {self.addr}:', self._recv_buffer, self.header_len+self.data_len+2) if self.__trailer_is_ok(self._recv_buffer, self.header_len + self.data_len + 2): @@ -248,15 +275,15 @@ class SolarmanV5(Message): return True - def __build_header(self, ctrl, log_lvl: int = logging.INFO) -> None: + def __build_header(self, ctrl) -> None: '''build header for new transmit message''' self.send_msg_ofs = len(self._send_buffer) self._send_buffer += struct.pack( ' None: '''finish the transmit message, set lenght and checksum''' @@ -305,7 +332,7 @@ class SolarmanV5(Message): def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: return - self.__build_header(0x4510, log_lvl) + self.__build_header(0x4510) self._send_buffer += struct.pack(' int: + ftype = self._recv_buffer[self.header_len] + if ftype == self.AT_CMD: + if self.forward_at_cmd_resp: + return logging.INFO + return logging.DEBUG + elif ftype == self.MB_RTU_CMD: + if self.server_side: + return self.mb.last_log_lvl + + return logging.WARNING + def msg_command_rsp(self): data = self._recv_buffer[self.header_len: self.header_len+self.data_len] @@ -514,4 +553,4 @@ class SolarmanV5(Message): dt = datetime.fromtimestamp(ts) logger.debug(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}') - self.__forward_msg() + # self.__forward_msg() From 685c2dc07b811eedbac4c651a5f7b1721c9b98de Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 31 May 2024 20:10:22 +0200 Subject: [PATCH 104/118] fix unit tests --- app/tests/test_solarman.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index 6734015..afa5f3c 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -938,7 +938,7 @@ def test_device_rsp(ConfigTsunInv1, DeviceRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==DeviceRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -956,7 +956,7 @@ def test_inverter_rsp(ConfigTsunInv1, InverterRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==InverterRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -992,7 +992,7 @@ def test_heartbeat_rsp(ConfigTsunInv1, HeartbeatRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==HeartbeatRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -1034,7 +1034,7 @@ def test_sync_start_rsp(ConfigTsunInv1, SyncStartRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==SyncStartRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() @@ -1070,7 +1070,7 @@ def test_sync_end_rsp(ConfigTsunInv1, SyncEndRspMsg): assert m.data_len == 0x0a assert m._recv_buffer==b'' assert m._send_buffer==b'' - assert m._forward_buffer==SyncEndRspMsg + assert m._forward_buffer==b'' assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0 m.close() From be57d11214e1f2a72bb6d9553cfa1020c039bcfc Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 31 May 2024 20:13:45 +0200 Subject: [PATCH 105/118] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 830c501..6afad84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- trace heartbeat and regular modbus pakets witl log level DEBUG +- GEN3PLUS: don't forward ack paket from tsun to the inverter - catch all OSError errors in the read loop - log Modbus traces with different log levels - add Modbus fifo and timeout handler From 5b60d5dae15ce2d61a5ae37481d1ccea920e8fda Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 31 May 2024 23:09:14 +0200 Subject: [PATCH 106/118] cleanup --- app/src/gen3plus/solarman_v5.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 978ef78..9cd424b 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -578,4 +578,3 @@ class SolarmanV5(Message): dt = datetime.fromtimestamp(ts) logger.debug(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}') - # self.__forward_msg() From 56f36e9f3ffb600d4174c2845350b8a78502fea7 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 31 May 2024 23:09:33 +0200 Subject: [PATCH 107/118] build release candidate as paket --- app/build.sh | 20 ++++++++++++++------ docker-compose.yaml | 1 + 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/build.sh b/app/build.sh index 07eefed..b87479a 100755 --- a/app/build.sh +++ b/app/build.sh @@ -18,22 +18,30 @@ arr=(${VERSION//./ }) MAJOR=${arr[0]} IMAGE=tsun-gen3-proxy -if [[ $1 == dev ]] || [[ $1 == rc ]] ;then +if [[ $1 == debug ]] || [[ $1 == dev ]] ;then IMAGE=docker.io/sallius/${IMAGE} VERSION=${VERSION}-$1 -elif [[ $1 == rel ]];then +elif [[ $1 == rc ]] || [[ $1 == rel ]];then IMAGE=ghcr.io/s-allius/${IMAGE} else echo argument missing! -echo try: $0 '[dev|rc|rel]' +echo try: $0 '[debug|dev|rc|rel]' exit 1 fi echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE -if [[ $1 == dev ]];then -docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest app +if [[ $1 == debug ]];then +docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:dev app +elif [[ $1 == dev ]];then +docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:dev app + elif [[ $1 == rc ]];then -docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest app +docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:rc -t ${IMAGE}:${VERSION} app +echo 'login to ghcr.io' +echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin +docker push ghcr.io/s-allius/tsun-gen3-proxy:rc +docker push ghcr.io/s-allius/tsun-gen3-proxy:${VERSION} + elif [[ $1 == rel ]];then docker build --no-cache --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest -t ${IMAGE}:${MAJOR} -t ${IMAGE}:${VERSION} app echo 'login to ghcr.io' diff --git a/docker-compose.yaml b/docker-compose.yaml index de3e5e5..5b74fef 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -68,6 +68,7 @@ services: tsun-proxy: container_name: tsun-proxy image: ghcr.io/s-allius/tsun-gen3-proxy:latest + # image: ghcr.io/s-allius/tsun-gen3-proxy:rc restart: unless-stopped depends_on: - mqtt From 8baa68e615994bdd2612df3daa1bd2cba8e040db Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Sun, 2 Jun 2024 14:08:06 +0200 Subject: [PATCH 108/118] fix typo (wrong bracket) --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 5b74fef..fad48fb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -78,7 +78,7 @@ services: - GID=${GID:-1000} dns: - ${DNS1:-8.8.8.8} - - $(DNS2:-4.4.4.4} + - ${DNS2:-4.4.4.4} ports: - 5005:5005 - 10000:10000 From 8204cae2b1cacff3cf1c0d7703af706cd55c4533 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 3 Jun 2024 19:52:37 +0200 Subject: [PATCH 109/118] improve logging output --- CHANGELOG.md | 3 +++ app/src/async_stream.py | 12 +++++++----- app/src/gen3/inverter_g3.py | 2 +- app/src/gen3/talent.py | 6 ++++++ app/src/gen3plus/inverter_g3p.py | 2 +- app/src/gen3plus/solarman_v5.py | 9 +++++++++ app/src/messages.py | 2 +- 7 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 820aaef..8ce0f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- improve logging: add protocol or node_id to connection logs +- improve logging: log ignored AT+ or MODBUS commands +- fix typo in docker-compose.yaml - trace heartbeat and regular modbus pakets witl log level DEBUG - GEN3PLUS: don't forward ack paket from tsun to the inverter - add allow and block filter for AT+ commands diff --git a/app/src/async_stream.py b/app/src/async_stream.py index 563b948..17f5f59 100644 --- a/app/src/async_stream.py +++ b/app/src/async_stream.py @@ -17,11 +17,12 @@ class AsyncStream(): async def server_loop(self, addr): '''Loop for receiving messages from the inverter (server-side)''' - logging.info(f'Accept connection from {addr}') + logging.info(f'[{self.node_id}] Accept connection from {addr}') self.inc_counter('Inverter_Cnt') await self.loop() self.dec_counter('Inverter_Cnt') - logging.info(f'Server loop stopped for r{self.r_addr}') + logging.info(f'[{self.node_id}] Server loop stopped for' + f' r{self.r_addr}') # if the server connection closes, we also have to disconnect # the connection to te TSUN cloud @@ -36,7 +37,8 @@ class AsyncStream(): async def client_loop(self, addr): '''Loop for receiving messages from the TSUN cloud (client-side)''' clientStream = await self.remoteStream.loop() - logging.info(f'Client loop stopped for l{clientStream.l_addr}') + logging.info(f'[{self.node_id}] Client loop stopped for' + f' l{clientStream.l_addr}') # if the client connection closes, we don't touch the server # connection. Instead we erase the client connection stream, @@ -66,14 +68,14 @@ class AsyncStream(): await self.async_publ_mqtt() except OSError as error: - logger.error(f'{error} for l{self.l_addr} | ' + logger.error(f'[{self.node_id}] {error} for l{self.l_addr} | ' f'r{self.r_addr}') await self.disc() self.close() return self except RuntimeError as error: - logger.warning(f"{error} for {self.l_addr}") + logger.info(f"[{self.node_id}] {error} for {self.l_addr}") await self.disc() self.close() return self diff --git a/app/src/gen3/inverter_g3.py b/app/src/gen3/inverter_g3.py index 5fccbc2..1930f0e 100644 --- a/app/src/gen3/inverter_g3.py +++ b/app/src/gen3/inverter_g3.py @@ -56,7 +56,7 @@ class InverterG3(Inverter, ConnectionG3): addr = (host, port) try: - logging.info(f'Connected to {addr}') + logging.info(f'[{self.node_id}] Connected to {addr}') connect = asyncio.open_connection(host, port) reader, writer = await connect self.remoteStream = ConnectionG3(reader, writer, addr, self, diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 25d15bc..be03df7 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -63,6 +63,8 @@ class Talent(Message): 0x04: logging.INFO, } self.modbus_elms = 0 # for unit tests + self.node_id = 'G3' # will be overwritten in __set_serial_no + # self.forwarding = Config.get('tsun')['enabled'] ''' Our puplic methods @@ -139,6 +141,8 @@ class Talent(Message): def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.__build_header(0x70, 0x77) @@ -154,6 +158,8 @@ class Talent(Message): async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: if self.state != self.STATE_UP: + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) diff --git a/app/src/gen3plus/inverter_g3p.py b/app/src/gen3plus/inverter_g3p.py index b7b9800..487fe1e 100644 --- a/app/src/gen3plus/inverter_g3p.py +++ b/app/src/gen3plus/inverter_g3p.py @@ -56,7 +56,7 @@ class InverterG3P(Inverter, ConnectionG3P): addr = (host, port) try: - logging.info(f'Connected to {addr}') + logging.info(f'[{self.node_id}] Connected to {addr}') connect = asyncio.open_connection(host, port) reader, writer = await connect self.remoteStream = ConnectionG3P(reader, writer, addr, self, diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 9cd424b..e823f57 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -122,6 +122,9 @@ class SolarmanV5(Message): if 'at_acl' in g3p_cnf: # pragma: no cover self.at_acl = g3p_cnf['at_acl'] + self.node_id = 'G3P' # will be overwritten in __set_serial_no + # self.forwarding = Config.get('solarman')['enabled'] + ''' Our puplic methods ''' @@ -336,6 +339,8 @@ class SolarmanV5(Message): def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.__build_header(0x4510) self._send_buffer += struct.pack(' None: if self.state != self.STATE_UP: + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) @@ -358,6 +365,8 @@ class SolarmanV5(Message): async def send_at_cmd(self, AT_cmd: str) -> None: if self.state != self.STATE_UP: + logger.warn(f'[{self.node_id}] ignore AT+ cmd,' + ' as the state is not UP') return AT_cmd = AT_cmd.strip() diff --git a/app/src/messages.py b/app/src/messages.py index 16e6d1b..6736b0b 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -72,7 +72,7 @@ class Message(metaclass=IterRegistry): self.header_len = 0 self.data_len = 0 self.unique_id = 0 - self.node_id = '' + self.node_id = '' # will be overwritten in the child class's __init__ self.sug_area = '' self._recv_buffer = bytearray(0) self._send_buffer = bytearray(0) From 8f81ceda98c171bf3fe2d524fb6fcfbf6f06f551 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 3 Jun 2024 20:28:14 +0200 Subject: [PATCH 110/118] fix warnings and remove obsolete version --- docker-compose.yaml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index fad48fb..84e2b9f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,3 @@ - -version: '3.0' - services: ####### H O M E - A S S I S T A N T ##### home-assistant: @@ -34,7 +31,7 @@ services: ports: - 8123:8123 volumes: - - ${PROJECT_DIR}./homeassistant/config:/config + - ${PROJECT_DIR:-./}homeassistant/config:/config - /etc/localtime:/etc/localtime:ro healthcheck: test: curl --fail http://0.0.0.0:8123/auth/providers || exit 1 @@ -56,8 +53,8 @@ services: expose: - 1883 volumes: - - ${PROJECT_DIR}./mosquitto/config:/mosquitto/config - - ${PROJECT_DIR}./mosquitto/data:/mosquitto/data + - ${PROJECT_DIR:-./}mosquitto/config:/mosquitto/config + - ${PROJECT_DIR:-./}mosquitto/data:/mosquitto/data networks: outside: ipv4_address: 172.28.1.5 # static IP required to receive mDNS traffic @@ -83,8 +80,8 @@ services: - 5005:5005 - 10000:10000 volumes: - - ${PROJECT_DIR}./tsun-proxy/log:/home/tsun-proxy/log - - ${PROJECT_DIR}./tsun-proxy/config:/home/tsun-proxy/config + - ${PROJECT_DIR:-./}tsun-proxy/log:/home/tsun-proxy/log + - ${PROJECT_DIR:-./}tsun-proxy/config:/home/tsun-proxy/config networks: - outside From ad885e9644c17f66807d2022048a22c4f47ad0ac Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 3 Jun 2024 20:40:35 +0200 Subject: [PATCH 111/118] add Y47 serial numbers --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a9c5cf4..de53248 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ suggested_area = 'balcony' # Optional, suggested installation area for home-a pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr -[inverters."Y17xxxxxxxxxxxx1"] +[inverters."Y17xxxxxxxxxxxx1"] # This block is also for inverters with a Y47 serial no monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter node_id = 'inv_3' # MQTT replacement for inverters serial number suggested_area = 'garage' # suggested installation place for home-assistant @@ -241,7 +241,7 @@ Legend 🚧: Proxy support in preparation ``` -❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` instead of `R17E` +❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` or `Y47E` instead of `R17E` If you have one of these combinations with a red question mark, it would be very nice if you could send me a proxy trace so that I can carry out the detailed checks and adjust the device and system tests. [Ask here how to send a trace](https://github.com/s-allius/tsun-gen3-proxy/discussions/categories/traces-for-compatibility-check) From 6e1ed5d1e76bbebd595927becefc22f877a50eab Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Mon, 3 Jun 2024 20:59:21 +0200 Subject: [PATCH 112/118] check the docker-compose.yaml file as last step --- app/build.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/build.sh b/app/build.sh index b87479a..c654c1c 100755 --- a/app/build.sh +++ b/app/build.sh @@ -49,4 +49,7 @@ echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin docker push ghcr.io/s-allius/tsun-gen3-proxy:latest docker push ghcr.io/s-allius/tsun-gen3-proxy:${MAJOR} docker push ghcr.io/s-allius/tsun-gen3-proxy:${VERSION} -fi \ No newline at end of file +fi + +echo 'check docker-compose.yaml file' +docker-compose config -q \ No newline at end of file From e6ecf5911b3cac8ec3b404662ffe3c7183640a8f Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 4 Jun 2024 20:00:39 +0200 Subject: [PATCH 113/118] remove the external network expectation --- docker-compose.yaml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 84e2b9f..4566a80 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -56,10 +56,9 @@ services: - ${PROJECT_DIR:-./}mosquitto/config:/mosquitto/config - ${PROJECT_DIR:-./}mosquitto/data:/mosquitto/data networks: - outside: - ipv4_address: 172.28.1.5 # static IP required to receive mDNS traffic - + - outside + ####### T S U N - P R O X Y ###### tsun-proxy: @@ -91,11 +90,4 @@ services: networks: outside: name: home-assistant - external: true - ipam: - driver: default - config: - - subnet: 172.28.1.0/26 - ip_range: 172.28.1.32/27 - gateway: 172.28.1.62 \ No newline at end of file From 49e2dfbd86270eced7deae1dce62e3e717cbe82a Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 4 Jun 2024 20:27:15 +0200 Subject: [PATCH 114/118] optimize docker-compose.yaml file --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce0f47..63c3f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - improve logging: add protocol or node_id to connection logs - improve logging: log ignored AT+ or MODBUS commands -- fix typo in docker-compose.yaml +- fix typo in docker-compose.yaml and remove the external network definition - trace heartbeat and regular modbus pakets witl log level DEBUG - GEN3PLUS: don't forward ack paket from tsun to the inverter - add allow and block filter for AT+ commands From 039a021cdac8560b299084e825e72c510d08bd92 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Tue, 4 Jun 2024 21:55:57 +0200 Subject: [PATCH 115/118] cleanup trace output --- app/src/gen3/infos_g3.py | 2 +- app/src/gen3plus/infos_g3p.py | 2 +- app/src/modbus.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/gen3/infos_g3.py b/app/src/gen3/infos_g3.py index 1de9bf0..0dc6a35 100644 --- a/app/src/gen3/infos_g3.py +++ b/app/src/gen3/infos_g3.py @@ -164,7 +164,7 @@ class InfosG3(Infos): name = str(f'info-id.0x{addr:x}') if update: - self.tracer.log(level, f'[\'{node_id}\']GEN3: {name} :' + self.tracer.log(level, f'[{node_id}] GEN3: {name} :' f' {result}{unit}') i += 1 diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index 213bcf2..bf0aed8 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -123,5 +123,5 @@ class InfosG3P(Infos): update = False if update: - self.tracer.log(level, f'[\'{node_id}\']GEN3PLUS: {name}' + self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}' f' : {result}{unit}') diff --git a/app/src/modbus.py b/app/src/modbus.py index 0727650..b428d05 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -216,7 +216,7 @@ class Modbus(): yield keys[0], update, result if update: info_db.tracer.log(level, - f'[\'{node_id}\']MODBUS: {name}' + f'[{node_id}] MODBUS: {name}' f' : {result}{unit}') else: self.__stop_timer() From c59bd1666403499c7f6703516b04a7ff5acc9aaa Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Wed, 5 Jun 2024 22:01:48 +0200 Subject: [PATCH 116/118] change log level for some traces --- app/src/gen3/talent.py | 8 ++++---- app/src/gen3plus/solarman_v5.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index be03df7..5c6a9be 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -141,8 +141,8 @@ class Talent(Message): def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: - logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.__build_header(0x70, 0x77) @@ -158,8 +158,8 @@ class Talent(Message): async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: if self.state != self.STATE_UP: - logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index e823f57..c8e3aaa 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -339,8 +339,8 @@ class SolarmanV5(Message): def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: - logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.__build_header(0x4510) self._send_buffer += struct.pack(' None: if self.state != self.STATE_UP: - logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) From 0b2631c16218a736be1af574358636cacda69cac Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 7 Jun 2024 19:27:36 +0200 Subject: [PATCH 117/118] beautify some traces --- app/src/gen3/talent.py | 8 ++++---- app/src/gen3plus/solarman_v5.py | 8 ++++---- app/src/modbus.py | 10 ++++++---- app/src/mqtt.py | 4 ++-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 5c6a9be..ad37337 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -141,8 +141,8 @@ class Talent(Message): def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: - logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' cause the state is not UP anymore') return self.__build_header(0x70, 0x77) @@ -158,8 +158,8 @@ class Talent(Message): async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: if self.state != self.STATE_UP: - logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index c8e3aaa..64e4536 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -339,8 +339,8 @@ class SolarmanV5(Message): def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str): if self.state != self.STATE_UP: - logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.warn(f'[{self.node_id}] ignore MODBUS cmd,' + ' cause the state is not UP anymore') return self.__build_header(0x4510) self._send_buffer += struct.pack(' None: if self.state != self.STATE_UP: - logger.debug(f'[{self.node_id}] ignore MODBUS cmd,' - ' as the state is not UP') + logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,' + ' as the state is not UP') return self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) diff --git a/app/src/modbus.py b/app/src/modbus.py index b428d05..8f3778b 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -172,23 +172,25 @@ class Modbus(): self.err = 5 return if not self.__check_crc(buf): - logger.error('Modbus resp: CRC error') + logger.error(f'[{node_id}] Modbus resp: CRC error') self.err = 1 return if buf[0] != self.last_addr: - logger.info(f'Modbus resp: Wrong addr {buf[0]}') + logger.info(f'[{node_id}] Modbus resp: Wrong addr {buf[0]}') self.err = 2 return fcode = buf[1] if fcode != self.last_fcode: - logger.info(f'Modbus: Wrong fcode {fcode} != {self.last_fcode}') + logger.info(f'[{node_id}] Modbus: Wrong fcode {fcode}' + f' != {self.last_fcode}') self.err = 3 return if self.last_addr == self.INV_ADDR and \ (fcode == 3 or fcode == 4): elmlen = buf[2] >> 1 if elmlen != self.last_len: - logger.info(f'Modbus: len error {elmlen} != {self.last_len}') + logger.info(f'[{node_id}] Modbus: len error {elmlen}' + f' != {self.last_len}') self.err = 4 return first_reg = self.last_reg # save last_reg before sending next pdu diff --git a/app/src/mqtt.py b/app/src/mqtt.py index a36943d..2f55660 100644 --- a/app/src/mqtt.py +++ b/app/src/mqtt.py @@ -148,10 +148,10 @@ class Mqtt(metaclass=Singleton): node_id = topic.split('/')[1] + '/' # refactor into a loop over a table payload = message.payload.decode("UTF-8") - logger_mqtt.info(f'InvCnf: {node_id}:{payload}') + logger_mqtt.info(f'MODBUS via MQTT: {topic} = {payload}') for m in Message: if m.server_side and (m.node_id == node_id): - logger_mqtt.info(f'Found: {node_id}') + logger_mqtt.debug(f'Found: {node_id}') fnc = getattr(m, "send_modbus_cmd", None) res = payload.split(',') if params != len(res): From a62864218d6ebb8a490850f7c6f3f53db6459691 Mon Sep 17 00:00:00 2001 From: Stefan Allius Date: Fri, 7 Jun 2024 19:48:41 +0200 Subject: [PATCH 118/118] update for version 0.8.0 --- CHANGELOG.md | 5 +- app/proxy.svg | 424 +++++++++++++++++++++++++------------------------ app/proxy.yuml | 4 +- 3 files changed, 221 insertions(+), 212 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c3f31..3d4eeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - improve logging: add protocol or node_id to connection logs - improve logging: log ignored AT+ or MODBUS commands +- improve tracelog: log level depends on message type and source - fix typo in docker-compose.yaml and remove the external network definition - trace heartbeat and regular modbus pakets witl log level DEBUG - GEN3PLUS: don't forward ack paket from tsun to the inverter -- add allow and block filter for AT+ commands +- GEN3PLUS: add allow and block filter for AT+ commands - catch all OSError errors in the read loop - log Modbus traces with different log levels - add Modbus fifo and timeout handler @@ -27,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - change logging level to DEBUG for some logs - remove experimental value Register.VALUE_1 - format Register.POWER_ON_TIME as integer -- ignore non realtime values for now +- ignore catch-up values from the inverters for now ## [0.7.0] - 2024-04-20 diff --git a/app/proxy.svg b/app/proxy.svg index 9ee1aba..b111fd4 100644 --- a/app/proxy.svg +++ b/app/proxy.svg @@ -4,246 +4,254 @@ - - + + G - + A0 - - - -You can stick notes -on diagrams too! + + + +You can stick notes +on diagrams too! A1 - -Singleton + +Singleton A2 - -Mqtt - -<static>ha_restarts -<static>__client -<static>__cb_MqttIsUp - -<async>publish() -<async>close() + +Mqtt + +<static>ha_restarts +<static>__client +<static>__cb_MqttIsUp + +<async>publish() +<async>close() A1->A2 - - + + A11 - -Inverter - -cls.db_stat -cls.entity_prfx -cls.discovery_prfx -cls.proxy_node_id -cls.proxy_unique_id -cls.mqtt:Mqtt - + +Inverter + +cls.db_stat +cls.entity_prfx +cls.discovery_prfx +cls.proxy_node_id +cls.proxy_unique_id +cls.mqtt:Mqtt + A2->A11 - + A3 - -Modbus - -err -retry_cnt - -build_msg() -recv_req() -recv_resp() + +Modbus + +que +snd_handler +rsp_handler +timeout:max_retires +last_xxx +err +retry_cnt +req_pend +tim + +build_msg() +recv_req() +recv_resp() A4 - -IterRegistry - - -__iter__ + +IterRegistry + + +__iter__ A5 - -Message - -server_side:bool -header_valid:bool -header_len:unsigned -data_len:unsigned -unique_id -node_id -sug_area -_recv_buffer:bytearray -_send_buffer:bytearray -_forward_buffer:bytearray -db:Infos -new_data:list - -_read():void<abstract> -close():void -inc_counter():void -dec_counter():void + +Message + +server_side:bool +header_valid:bool +header_len:unsigned +data_len:unsigned +unique_id +node_id +sug_area +_recv_buffer:bytearray +_send_buffer:bytearray +_forward_buffer:bytearray +db:Infos +new_data:list +state + +_read():void<abstract> +close():void +inc_counter():void +dec_counter():void A4->A5 - - + + A6 - -Talent - -await_conn_resp_cnt -id_str -contact_name -contact_mail -db:InfosG3 -mb:Modbus -switch - -msg_contact_info() -msg_ota_update() -msg_get_time() -msg_collector_data() -msg_inverter_data() -msg_unknown() -close() + +Talent + +await_conn_resp_cnt +id_str +contact_name +contact_mail +db:InfosG3 +mb:Modbus +switch + +msg_contact_info() +msg_ota_update() +msg_get_time() +msg_collector_data() +msg_inverter_data() +msg_unknown() +close() A5->A6 - - + + A7 - -SolarmanV5 - -control -serial -snr -db:InfosG3P -mb:Modbus -switch - -msg_unknown() -close() + +SolarmanV5 + +control +serial +snr +db:InfosG3P +mb:Modbus +switch + +msg_unknown() +close() A5->A7 - - + + A6->A3 - - -1 -has + + +1 +has A8 - -ConnectionG3 - -remoteStream:ConnectionG3 - -close() + +ConnectionG3 + +remoteStream:ConnectionG3 + +close() A6->A8 - - + + A7->A3 - - -1 -has + + +1 +has A9 - -ConnectionG3P - -remoteStream:ConnectionG3P - -close() + +ConnectionG3P + +remoteStream:ConnectionG3P + +close() A7->A9 - - + + A8->A8 - - -0..1 -has + + +0..1 +has A12 - -InverterG3 - -__ha_restarts - -async_create_remote() -close() + +InverterG3 + +__ha_restarts + +async_create_remote() +close() A8->A12 - - + + A9->A9 - - -0..1 -has + + +0..1 +has @@ -259,118 +267,118 @@ A9->A13 - - + + A10 - -AsyncStream - -reader -writer -addr -r_addr -l_addr - -<async>server_loop() -<async>client_loop() -<async>loop -disc() -close() -__async_read() -async_write() -__async_forward() + +AsyncStream + +reader +writer +addr +r_addr +l_addr + +<async>server_loop() +<async>client_loop() +<async>loop +disc() +close() +__async_read() +async_write() +__async_forward() A10->A8 - - + + A10->A9 - - + + A11->A12 - - + + A11->A13 - - + + A14 - -Infos - -stat -new_stat_data -info_dev - -static_init() -dev_value() -inc_counter() -dec_counter() -ha_proxy_conf -ha_conf -update_db -set_db_def_value -get_db_value -ignore_this_device + +Infos + +stat +new_stat_data +info_dev + +static_init() +dev_value() +inc_counter() +dec_counter() +ha_proxy_conf +ha_conf +update_db +set_db_def_value +get_db_value +ignore_this_device A15 - -InfosG3 - - -ha_confs() -parse() + +InfosG3 + + +ha_confs() +parse() A14->A15 - - + + A16 - -InfosG3P - - -ha_confs() -parse() + +InfosG3P + + +ha_confs() +parse() A14->A16 - - + + A15->A6 - - + + A16->A7 - - + + diff --git a/app/proxy.yuml b/app/proxy.yuml index e2ce12b..60b506e 100644 --- a/app/proxy.yuml +++ b/app/proxy.yuml @@ -4,8 +4,8 @@ [note: You can stick notes on diagrams too!{bg:cornsilk}] [Singleton]^[Mqtt|ha_restarts;__client;__cb_MqttIsUp|publish();close()] -[Modbus|err;retry_cnt|build_msg();recv_req();recv_resp()] -[IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list|_read():void;close():void;inc_counter():void;dec_counter():void] +[Modbus|que;;snd_handler;rsp_handler;timeout:max_retires;last_xxx;err;retry_cnt;req_pend;tim|build_msg();recv_req();recv_resp()] +[IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list;state|_read():void;close():void;inc_counter():void;dec_counter():void] [Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()] [Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()] [Talent]^[ConnectionG3|remoteStream:ConnectionG3|close()]
Micro Inverter ModelFw. 1.00.06Fw. 1.00.17Fw. 1.00.20Fw. 1.1.00.0B
Micro Inverter ModelFw. 1.00.06Fw. 1.00.17Fw. 1.00.20Fw. 4.0.10
GEN3 micro inverters (single MPPT):
MS300, MS350, MS400
MS400-D
GEN3 micro inverters (dual MPPT):
MS600, MS700, MS800
MS600-D, MS800-D
✔️✔️✔️
GEN3 PLUS micro inverters:
MS1600, MS1800, MS2000
MS2000-D
✔️