diff --git a/docs/solarmanv5_protocol.rst b/docs/solarmanv5_protocol.rst index 5acd81f..919d7d2 100644 --- a/docs/solarmanv5_protocol.rst +++ b/docs/solarmanv5_protocol.rst @@ -32,18 +32,26 @@ The Header is always 11 bytes and is composed of: * **Start** (*one byte*) – Denotes the start of the V5 frame. Always ``0xA5``. * **Length** (*two bytes*) – :ref:`Payload` length -* **Control Code** (*two bytes*) – Describes the type of V5 frame. - For Modbus RTU requests, the control code is ``0x4510``. - For Modbus RTU responses, the control code is ``0x1510``. -* **Serial** (*two bytes*) – This field acts as a two-way sequence number. On +* **Control Code** (*two bytes*) – Describes the type of V5 frame: + + * HANDSHAKE ``0x4110``, used for initial handshake in server mode + * DATA ``0x4210``, used for sending data in server mode + * INFO ``0x4310``, used for sending stick fw, ip and ssid info in server mode + * REQUEST ``0x4510``, for Modbus RTU requests in client mode + + * RESPONSE ``0x1510``, for Modbus RTU responses in client mode + * HEARTBEAT ``0x4710``, keepalive packets in both modes + * *REPORT* ``0x4810`` + *Responses are described as* ``request code - 0x3000`` *which can be seen in + Modbus RTU response - request pair:* ``0x4510 - 0x3000 = 0x1510`` +* **Sequence Number** (*two bytes*) – This field acts as a two-way sequence number. On outgoing requests, the first byte of this field is echoed back in the same position on incoming responses. pysolarmanv5 expoits this property to detect invalid responses. This is done by initialising this byte to a random value, and incrementing for each subsequent request. The second byte is incremented by the data logging stick for every response sent (either to Solarman Cloud or local requests). -* **Logger Serial** (*four bytes*) – Serial number of Solarman data logging - stick +* **Serial Number** (*four bytes*) – Serial number of data logging stick Payload ^^^^^^^ diff --git a/pysolarmanv5/pysolarmanv5.py b/pysolarmanv5/pysolarmanv5.py index 593b5a8..70c7db3 100644 --- a/pysolarmanv5/pysolarmanv5.py +++ b/pysolarmanv5/pysolarmanv5.py @@ -1,12 +1,14 @@ """pysolarmanv5.py""" +import time import errno import queue +import types import struct import socket import logging -import selectors import platform +import selectors from threading import Thread, Event from multiprocessing import Queue @@ -21,6 +23,15 @@ _WIN_PLATFORM = platform.system() == "Windows" +CONTROL = types.SimpleNamespace() +CONTROL.HANDSHAKE = 0x41 +CONTROL.DATA = 0x42 +CONTROL.INFO = 0x43 +CONTROL.REQUEST = 0x45 +CONTROL.HEARTBEAT = 0x47 +CONTROL.REPORT = 0x48 + + class V5FrameError(Exception): """V5 Frame Validation Error""" @@ -110,9 +121,10 @@ def __init__(self, address, serial, **kwargs): # Define and construct V5 request frame structure. self.v5_start = bytes.fromhex("A5") self.v5_length = bytes.fromhex("0000") # placeholder value - self.v5_controlcode = struct.pack(" int: + """ + Get response control code from request control code + + """ + return code - 0x30 + + @staticmethod + def _calculate_v5_frame_checksum(frame: bytes) -> int: + """ + Calculate checksum on all frame bytes except head, end and checksum :param frame: V5 frame :type frame: bytes @@ -147,8 +168,30 @@ def _calculate_v5_frame_checksum(frame): checksum += frame[i] & 0xFF return int(checksum & 0xFF) - def _get_next_sequence_number(self): - """Get the next sequence number for use in outgoing packets + def _v5_header(self, length: int, control: int, seq: bytes) -> bytearray: + """ + Construct V5 header + + """ + return bytearray( + self.v5_start + + struct.pack(" bytearray: + """ + Construct V5 trailer + + """ + return bytearray(self.v5_checksum + self.v5_end) + + def _get_next_sequence_number(self) -> int: + """ + Get the next sequence number for use in outgoing packets If ``sequence_number`` is None, generate a random int as initial value. @@ -162,8 +205,9 @@ def _get_next_sequence_number(self): self.sequence_number = (self.sequence_number + 1) & 0xFF return self.sequence_number - def _v5_frame_encoder(self, modbus_frame): - """Take a modbus RTU frame and encode it in a V5 data logging stick frame + def _v5_frame_encoder(self, modbus_frame: bytes) -> bytearray: + """ + Take a modbus RTU frame and encode it in a V5 data logging stick frame :param modbus_frame: Modbus RTU frame :type modbus_frame: bytes @@ -171,17 +215,12 @@ def _v5_frame_encoder(self, modbus_frame): :rtype: bytearray """ + length = 15 + len(modbus_frame) - self.v5_length = struct.pack(" bytearray: + """ + Decodes a V5 data logging stick frame and returns a modbus RTU frame Modbus RTU frame will start at position 25 through ``len(v5_frame)-2``. @@ -240,21 +277,21 @@ def _v5_frame_decoder(self, v5_frame): frame_len = frame_len_without_payload_len + payload_len if (v5_frame[0] != int.from_bytes(self.v5_start, byteorder="big")) or ( - v5_frame[frame_len - 1] != int.from_bytes(self.v5_end, byteorder="big") + v5_frame[-1] != int.from_bytes(self.v5_end, byteorder="big") ): raise V5FrameError("V5 frame contains invalid start or end values") - if v5_frame[frame_len - 2] != self._calculate_v5_frame_checksum(v5_frame): + if v5_frame[-2] != self._calculate_v5_frame_checksum(v5_frame): raise V5FrameError("V5 frame contains invalid V5 checksum") if v5_frame[5] != self.sequence_number: raise V5FrameError("V5 frame contains invalid sequence number") - if v5_frame[7:11] != self.v5_loggerserial: + if v5_frame[7:11] != self.v5_serial: raise V5FrameError("V5 frame contains incorrect data logger serial number") - if v5_frame[3:5] != struct.pack(" 0 and ( @@ -265,8 +302,23 @@ def _v5_frame_decoder(self, v5_frame): return modbus_frame - def _send_receive_v5_frame(self, data_logging_stick_frame): - """Send v5 frame to the data logger and receive response + def _v5_time_response_frame(self, frame: bytes) -> bytearray: + """ + Creates time response frame + + """ + response_frame = self._v5_header(10, self._get_response_code(frame[4]), frame[5:7]) + bytearray( + + struct.pack(" bytes: + """ + Send v5 frame to the data logger and receive response :param data_logging_stick_frame: V5 frame to transmit :type data_logging_stick_frame: bytes @@ -298,10 +350,11 @@ def _send_receive_v5_frame(self, data_logging_stick_frame): self.log.debug("[%s] RECD: %s", self.serial, v5_response.hex(" ")) return v5_response - def _received_frame_is_valid(self, frame): - """Check that the frame is valid and that the serial number of the received - frame matches with the last sent one. - Ignore also any frames with control code 0x4710 (counter frame). + def _received_frame_is_valid(self, frame: bytes) -> bool: + """ + Check that the frame is valid and that the serial number of the received + frame matches with the last sent one + """ if not frame.startswith(self.v5_start): self.log.debug("[%s] V5_MISMATCH: %s", self.serial, frame.hex(" ")) @@ -309,11 +362,35 @@ def _received_frame_is_valid(self, frame): if frame[5] != self.sequence_number: self.log.debug("[%s] V5_SEQ_NO_MISMATCH: %s", self.serial, frame.hex(" ")) return False - if frame.startswith(self.v5_start + b"\x01\x00\x10\x47"): - self.log.debug("[%s] COUNTER: %s", self.serial, frame.hex(" ")) - return False return True + def _received_frame_response(self, frame: bytes) -> tuple[bool, bytearray]: + """ + Return response to frames with control codes 0x41 (handshake), 0x42 (data), 0x43 (wifi), 0x47 (heartbeat) and 0x48 (report) + + """ + do_continue = True + response_frame = None + if frame[4] != CONTROL.REQUEST and frame[4] in self.v5_control_codes: + do_continue = False + # Maybe do_continue = True for CONTROL.DATA|INFO|REPORT and thus process packets in the future? + control_name = [i for i in CONTROL.__dict__ if CONTROL.__dict__[i]==frame[4]][0] + self.log.debug("[%s] V5_%s: %s", self.serial, control_name, frame.hex(" ")) + response_frame = self._v5_time_response_frame(frame) + self.log.debug("[%s] V5_%s RESP: %s", self.serial, control_name, response_frame.hex(" ")) + return do_continue, response_frame + + def _handle_protocol_frame(self, frame: bytes) -> bool: + """ + Handles frames with known control codes :func:`_received_frame_response() ` + + """ + do_continue, response_frame = self._received_frame_response(frame) + if response_frame is not None: + if self._reader_thr.is_alive(): + self.sock.sendall(response_frame) + return do_continue + def _data_receiver(self): self._poll.register(self.sock.fileno(), selectors.EVENT_READ) while True: @@ -350,6 +427,8 @@ def _data_receiver(self): return if not self._received_frame_is_valid(data): continue + if not self._handle_protocol_frame(data): + continue if self._data_wanted.is_set(): self._data_queue.put(data, timeout=self.socket_timeout) else: diff --git a/pysolarmanv5/pysolarmanv5_async.py b/pysolarmanv5/pysolarmanv5_async.py index 9b6c8b2..17c3761 100644 --- a/pysolarmanv5/pysolarmanv5_async.py +++ b/pysolarmanv5/pysolarmanv5_async.py @@ -1,8 +1,9 @@ """pysolarmanv5_async.py""" +import time import errno -import asyncio import struct +import asyncio from multiprocessing import Event from umodbus.client.serial import rtu @@ -138,7 +139,7 @@ async def disconnect(self) -> None: f"{e} can be during closing ignored." ) # pylint: disable=logging-fstring-interpolation - def _socket_setup(self, *args, **kwargs): + def _socket_setup(self, *args, **kwargs) -> None: """Socket setup method PySolarmanV5Async handles socket creation separately to base @@ -146,7 +147,7 @@ def _socket_setup(self, *args, **kwargs): """ - def _send_data(self, data: bytes): + def _send_data(self, data: bytes) -> None: """ Sends the data received from the socket to the receiver. @@ -160,6 +161,27 @@ def _send_data(self, data: bytes): self.data_queue.put_nowait(data) self.data_wanted_ev.clear() + async def _handle_protocol_frame(self, frame: bytes) -> bool: + """ + Handles frames with known control codes :func:`_received_frame_response() ` + """ + do_continue, response_frame = self._received_frame_response(frame) + if response_frame is not None: + try: + self.writer.write(response_frame) + await self.writer.drain() + except (AttributeError, NoSocketAvailableError, TimeoutError, OSError) as e: + if isinstance(e, AttributeError): + e = NoSocketAvailableError("Connection already closed") + if isinstance(e, OSError) and e.errno == errno.EHOSTUNREACH: + e = TimeoutError + self.log.debug( # pylint: disable=logging-fstring-interpolation + f"[{self.serial}] V5_PROTOCOL error: {type(e).__name__}{f': {e}' if f'{e}' else ''}" + ) + except Exception as e: + self.log.exception("[%s] V5_PROTOCOL error: %s", self.serial, e) + return do_continue + async def _conn_keeper(self) -> None: """ Socket reader loop with extra logic when auto-reconnect is enabled @@ -185,6 +207,8 @@ async def _conn_keeper(self) -> None: break if not self._received_frame_is_valid(data): continue + if not await self._handle_protocol_frame(data): + continue if self.data_wanted_ev.is_set(): self._send_data(data) else: diff --git a/tests/setup_test.py b/tests/setup_test.py index ce8859c..c8bc88f 100644 --- a/tests/setup_test.py +++ b/tests/setup_test.py @@ -1,7 +1,7 @@ import socket import threading -from pysolarmanv5 import PySolarmanV5 +from pysolarmanv5 import CONTROL, PySolarmanV5 import struct from umodbus.client.serial.redundancy_check import add_crc from umodbus.functions import ( @@ -75,20 +75,14 @@ def v5_frame_response_encoder(self, modbus_frame): :rtype: bytearray """ + length = 14 + len(modbus_frame) - self.v5_length = struct.pack(" None: self.sol.sequence_number = data[5] log.debug(f"[SrvHandler] RECD: {data}") data = bytearray(data) - data[3:5] = struct.pack("