diff --git a/CHANGELOG.md b/CHANGELOG.md index 000d2aa..8f878b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.5] - 2023-12-31 + +- Fixed [#33](https://github.com/s-allius/tsun-gen3-proxy/issues/33) - Fixed detection of the connected inputs/MPPTs - Preparation for overwriting received data -- home assistant: +- home assistant improvements: - Add unit 'W' to the `Rated Power` value for home assistant - `Collect_Interval`, `Connect_Count` and `Data_Up_Interval` as diagnostic value and not as graph - Add data acquisition interval diff --git a/README.md b/README.md index 1e781dc..7e9baec 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,8 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole. ## Features -- supports TSOL MS300, MS350, MS400, MS600, MS700 and MS800 inverters from TSUN +- supports TSUN G3 inverters: TSOL MS-300, MS-350, MS-400, MS-600, MS-700 and MS-800 +- support for TSUN G3 Plus inverters is in preperation (e.g. MS-2000) - `MQTT` support - `Home-Assistant` auto-discovery support - Self-sufficient island operation without internet @@ -120,6 +121,7 @@ inverters.allow_all = false # True: allow inverters, even if we have no invert # inverter mapping, maps a `serial_no* to a `node_id` and defines an optional `suggested_area` for `home-assistant` # # for each inverter add a block starting with [inverters."<16-digit serial numbeer>"] + [inverters."R17xxxxxxxxxxxx1"] node_id = 'inv1' # Optional, MQTT replacement for inverters serial number suggested_area = 'roof' # Optional, suggested installation area for home-assistant @@ -157,12 +159,21 @@ It must be ensured that this address is not mapped to the proxy! In the following table you will find an overview of which inverter model has been tested for compatibility with which firmware version. A combination with a red question mark should work, but I have not checked it in detail. -Micro Inverter Model | Fw. 1.00.06 | Fw. 1.00.17 | Fw. 1.00.20 -:---|:---:|:---:|:---: -G3 micro inverters (single MPPT):
MS-300, MS-350, MS-400| ❓ | ❓ | ❓ -G3 micro inverters (dual MPPT):
MS-600, MS-700, MS-800| ✔️ | ✔️ | ✔️ -G3 PLUS micro inverters:
MS-1600, MS-1800, MS-2000| ❓ | ❓ | ❓ -balcony micro inverters:
MS-400-D, MS-800-D, MS-2000-D| ❓ | ❓ | ❓ +Micro Inverter Model | Fw. 1.00.06 | Fw. 1.00.17 | Fw. 1.00.20| Fw. 1.1.00.0B +:---|:---:|:---:|:---:|:---:| +G3 micro inverters (single MPPT):
MS-300, MS-350, MS-400| ❓ | ❓ | ❓ |➖ +G3 micro inverters (dual MPPT):
MS-600, MS-700, MS-800| ✔️ | ✔️ | ✔️ |➖ +G3 PLUS micro inverters:
MS-1600, MS-1800, MS-2000| ➖ |➖ | ➖ | 🚧 +balcony micro inverters:
MS-400-D, MS-800-D, MS-2000-D| ❓ | ❓ | ❓| ❓ + +``` +Legend +➖: Firmware not available for this devices +✔️: proxy support testet +❓: proxy support possible but not testet +🚧: Proxy support in preparation +``` +❗The new inverters of the G3Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. I already have such an inverter in operation and am working on the integration for the proxy version 0.6. The serial numbers of these inverters start with `Y17E` 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) diff --git a/app/src/messages.py b/app/src/messages.py index 5fa8ce4..82e702f 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -83,6 +83,7 @@ class Message(metaclass=IterRegistry): self.unique_id = 0 self.node_id = '' self.sug_area = '' + self.await_conn_resp_cnt = 0 self.id_str = id_str self.contact_name = b'' self.contact_mail = b'' @@ -182,6 +183,7 @@ class Message(metaclass=IterRegistry): def _init_new_client_conn(self, contact_name, contact_mail) -> None: logger.info(f'name: {contact_name} mail: {contact_mail}') self.msg_id = 0 + self.await_conn_resp_cnt += 1 self.__build_header(0x91) self._send_buffer += struct.pack(f'!{len(contact_name)+1}p' f'{len(contact_mail)+1}p', @@ -282,23 +284,27 @@ class Message(metaclass=IterRegistry): ''' def msg_contact_info(self): if self.ctrl.is_ind(): - self.__build_header(0x99) - self._send_buffer += b'\x01' - self.__finish_send_msg() - self.__process_contact_info() + if self.server_side and self.__process_contact_info(): + self.__build_header(0x91) + self._send_buffer += b'\x01' + self.__finish_send_msg() # don't forward this contact info here, we will build one # when the remote connection is established + elif self.await_conn_resp_cnt > 0: + self.await_conn_resp_cnt -= 1 + else: + self.forward(self._recv_buffer, self.header_len+self.data_len) return - elif self.ctrl.is_resp(): - return # ignore received response from tsun else: + logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) - def __process_contact_info(self): + def __process_contact_info(self) -> bool: result = struct.unpack_from('!B', self._recv_buffer, self.header_len) name_len = result[0] - + if self.data_len < name_len+2: + return False result = struct.unpack_from(f'!{name_len+1}pB', self._recv_buffer, self.header_len) self.contact_name = result[0] @@ -309,33 +315,34 @@ class Message(metaclass=IterRegistry): self.header_len+name_len+1) self.contact_mail = result[0] logger.info(f'mail: {self.contact_mail}') + return True def msg_get_time(self): tsun = Config.get('tsun') if tsun['enabled']: - if self.ctrl.is_resp(): - ts = self._timestamp() - result = struct.unpack_from('!q', self._recv_buffer, - self.header_len) - logger.debug(f'tsun-time: {result[0]:08x}' - f' proxy-time: {ts:08x}') - elif not self.ctrl.is_ind(): + if self.ctrl.is_ind(): + if self.data_len >= 8: + ts = self._timestamp() + result = struct.unpack_from('!q', self._recv_buffer, + self.header_len) + logger.debug(f'tsun-time: {result[0]:08x}' + f' proxy-time: {ts:08x}') + else: + logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) else: if self.ctrl.is_ind(): - ts = self._timestamp() - logger.debug(f'time: {ts:08x}') + if self.data_len == 0: + ts = self._timestamp() + logger.debug(f'time: {ts:08x}') - self.__build_header(0x99) - self._send_buffer += struct.pack('!q', ts) - self.__finish_send_msg() + self.__build_header(0x91) + self._send_buffer += struct.pack('!q', ts) + self.__finish_send_msg() - elif self.ctrl.is_resp(): - result = struct.unpack_from('!q', self._recv_buffer, - self.header_len) - logger.debug(f'tsun-time: {result[0]:08x}') else: + logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') def parse_msg_header(self): @@ -366,6 +373,7 @@ class Message(metaclass=IterRegistry): elif self.ctrl.is_resp(): return # ignore received response else: + logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) @@ -380,6 +388,7 @@ class Message(metaclass=IterRegistry): elif self.ctrl.is_resp(): return # ignore received response else: + logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) @@ -398,6 +407,7 @@ class Message(metaclass=IterRegistry): elif self.ctrl.is_ind(): pass else: + logger.warning('Unknown Ctrl') self.inc_counter('Unknown_Ctrl') self.forward(self._recv_buffer, self.header_len+self.data_len) diff --git a/app/tests/test_messages.py b/app/tests/test_messages.py index 2968a3d..582d562 100644 --- a/app/tests/test_messages.py +++ b/app/tests/test_messages.py @@ -67,11 +67,11 @@ def Msg2ContactInfo(): # two Contact Info messages @pytest.fixture def MsgContactResp(): # Contact Response message - return b'\x00\x00\x00\x14\x10R170000000000001\x99\x00\x01' + return b'\x00\x00\x00\x14\x10R170000000000001\x91\x00\x01' @pytest.fixture def MsgContactResp2(): # Contact Response message - return b'\x00\x00\x00\x14\x10R170000000000002\x99\x00\x01' + return b'\x00\x00\x00\x14\x10R170000000000002\x91\x00\x01' @pytest.fixture def MsgContactInvalid(): # Contact Response message @@ -83,7 +83,7 @@ def MsgGetTime(): # Get Time Request message @pytest.fixture def MsgTimeResp(): # Get Time Resonse message - return b'\x00\x00\x00\x1b\x10R170000000000001\x99\x22\x00\x00\x01\x89\xc6\x63\x4d\x80' + return b'\x00\x00\x00\x1b\x10R170000000000001\x91\x22\x00\x00\x01\x89\xc6\x63\x4d\x80' @pytest.fixture def MsgTimeInvalid(): # Get Time Request message @@ -316,13 +316,15 @@ def test_read_two_messages(ConfigTsunAllowAll, Msg2ContactInfo,MsgContactResp,Ms def test_msg_contact_resp(ConfigTsunInv1, MsgContactResp): ConfigTsunInv1 m = MemoryStream(MsgContactResp, (0,), False) + m.await_conn_resp_cnt = 1 m.db.stat['proxy']['Unknown_Ctrl'] = 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.await_conn_resp_cnt == 0 assert m.id_str == b"R170000000000001" assert m.unique_id == 'R170000000000001' - assert int(m.ctrl)==153 + assert int(m.ctrl)==145 assert m.msg_id==0 assert m.header_len==23 assert m.data_len==1 @@ -331,6 +333,46 @@ def test_msg_contact_resp(ConfigTsunInv1, MsgContactResp): assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 m.close() +def test_msg_contact_resp_2(ConfigTsunInv1, MsgContactResp): + ConfigTsunInv1 + m = MemoryStream(MsgContactResp, (0,), False) + m.await_conn_resp_cnt = 0 + m.db.stat['proxy']['Unknown_Ctrl'] = 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.await_conn_resp_cnt == 0 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==145 + assert m.msg_id==0 + assert m.header_len==23 + assert m.data_len==1 + assert m._forward_buffer==MsgContactResp + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + m.close() + +def test_msg_contact_resp_3(ConfigTsunInv1, MsgContactResp): + ConfigTsunInv1 + m = MemoryStream(MsgContactResp, (0,), True) + m.await_conn_resp_cnt = 0 + m.db.stat['proxy']['Unknown_Ctrl'] = 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.await_conn_resp_cnt == 0 + assert m.id_str == b"R170000000000001" + assert m.unique_id == 'R170000000000001' + assert int(m.ctrl)==145 + assert m.msg_id==0 + assert m.header_len==23 + assert m.data_len==1 + assert m._forward_buffer==MsgContactResp + assert m._send_buffer==b'' + assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 + m.close() + def test_msg_contact_invalid(ConfigTsunInv1, MsgContactInvalid): ConfigTsunInv1 m = MemoryStream(MsgContactInvalid, (0,)) @@ -381,7 +423,7 @@ def test_msg_get_time_autark(ConfigNoTsunInv1, MsgGetTime): assert m.header_len==23 assert m.data_len==0 assert m._forward_buffer==b'' - assert m._send_buffer==b'\x00\x00\x00\x1b\x10R170000000000001\x99"\x00\x00\x01\x8b\xdfs\xcc0' + assert m._send_buffer==b'\x00\x00\x00\x1b\x10R170000000000001\x91"\x00\x00\x01\x8b\xdfs\xcc0' assert m.db.stat['proxy']['Unknown_Ctrl'] == 0 m.close() @@ -394,7 +436,7 @@ def test_msg_time_resp(ConfigTsunInv1, MsgTimeResp): assert m.msg_count == 1 assert m.id_str == b"R170000000000001" assert m.unique_id == 'R170000000000001' - assert int(m.ctrl)==153 + assert int(m.ctrl)==145 assert m.msg_id==34 assert m.header_len==23 assert m.data_len==8 @@ -412,7 +454,7 @@ def test_msg_time_resp_autark(ConfigNoTsunInv1, MsgTimeResp): assert m.msg_count == 1 assert m.id_str == b"R170000000000001" assert m.unique_id == 'R170000000000001' - assert int(m.ctrl)==153 + assert int(m.ctrl)==145 assert m.msg_id==34 assert m.header_len==23 assert m.data_len==8 diff --git a/system_tests/test_tcp_socket.py b/system_tests/test_tcp_socket.py index f2d0adb..f559c00 100644 --- a/system_tests/test_tcp_socket.py +++ b/system_tests/test_tcp_socket.py @@ -20,7 +20,7 @@ def MsgContactInfo(): # Contact Info message @pytest.fixture def MsgContactResp(): # Contact Response message - return b'\x00\x00\x00\x14\x10'+get_sn()+b'\x99\x00\x01' + return b'\x00\x00\x00\x14\x10'+get_sn()+b'\x91\x00\x01' @pytest.fixture def MsgContactInfo2(): # Contact Info message @@ -28,7 +28,7 @@ def MsgContactInfo2(): # Contact Info message @pytest.fixture def MsgContactResp2(): # Contact Response message - return b'\x00\x00\x00\x14\x10'+get_invalid_sn()+b'\x99\x00\x01' + return b'\x00\x00\x00\x14\x10'+get_invalid_sn()+b'\x91\x00\x01' @pytest.fixture def MsgTimeStampReq(): # Get Time Request message @@ -199,7 +199,7 @@ def test_send_contact_resp(ClientConnection, MsgContactResp): except TimeoutError: assert True else: - assert data =='' + assert data == b'' def test_send_ctrl_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgContollerInd): s = ClientConnection