Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implemented time response for cloudless time synchronization #82

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
87abc88
feat: Implemented response to heartbeat packets
davidrapan Dec 22, 2024
22b64ca
refactor: Add _v5_heartbeat_response_frame
davidrapan Dec 22, 2024
257003c
refactor: V5_HEARTBEAT error message
davidrapan Dec 22, 2024
1afac93
refactor: Rename _v5_heartbeat_response_frame to _v5_time_response_frame
davidrapan Dec 22, 2024
8b23962
feat: Add V5_HANDSHAKE handling for server mode
davidrapan Dec 22, 2024
1424502
feat: Add V5_DATA and V5_WIFI handling for server mode
davidrapan Dec 22, 2024
f16ac48
refactor: _handle_protocol_frame through _received_frame_response
davidrapan Dec 22, 2024
9163dfd
refactor: Add variable continue to _received_frame_response
davidrapan Dec 22, 2024
ac76b6c
fix: _v5_time_response_frame argument name
davidrapan Dec 24, 2024
c43846c
refactor: _v5_time_response_frame and _received_frame_response method…
davidrapan Dec 25, 2024
e5d4d94
feat: Add control codes
davidrapan Dec 26, 2024
86398b5
refactor: Encoder & Decoder to use control codes
davidrapan Dec 26, 2024
4cceb1f
refactor: Extract _v5_header from _v5_frame_encoder and thus reuse he…
davidrapan Dec 26, 2024
4a36f3b
fix: _v5_frame_decoder control condition
davidrapan Dec 26, 2024
1889f9d
refactor: Add _get_response_code to get control code from response co…
davidrapan Dec 26, 2024
091bc96
fix: V5 frame encoder
davidrapan Dec 26, 2024
0a08116
fix: v5_magic as int
davidrapan Dec 26, 2024
cbd2e67
refactor: Extract _v5_trailer from _v5_frame_encoder to be reused
davidrapan Dec 26, 2024
4d120f7
refactor: Turn _get_response_code into staticmethod
davidrapan Dec 26, 2024
e2d1411
refactor: Generalize _received_frame_response
davidrapan Dec 26, 2024
5b19f62
refactor: Use of negative indexing
davidrapan Dec 26, 2024
5a0f55e
refactor: serial, v5_serial and v5_loggerserial were hella confusing!!!
davidrapan Dec 26, 2024
a5a0712
refactor: MockDatalogger to reuse _v5_header and _v5_trailer "constru…
davidrapan Dec 26, 2024
c4ce1f7
feat: Update protocol docs with known control codes
davidrapan Dec 26, 2024
ade1c3a
feat: Add argument & return types
davidrapan Dec 26, 2024
652fbf8
refactor: Use of PySolarmanV5._get_response_code in tests
davidrapan Dec 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions docs/solarmanv5_protocol.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^
Expand Down
158 changes: 118 additions & 40 deletions pysolarmanv5/pysolarmanv5.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"""

Expand Down Expand Up @@ -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("<H", 0x4510)
self.v5_serial = bytes.fromhex("0000") # placeholder value
self.v5_loggerserial = struct.pack("<I", self.serial)
self.v5_magic = bytes.fromhex("10")
self.v5_control_codes = CONTROL.__dict__.values()
self.v5_seq = bytes.fromhex("0000") # placeholder value
self.v5_serial = struct.pack("<I", self.serial)
self.v5_frametype = bytes.fromhex("02")
self.v5_sensortype = bytes.fromhex("0000")
self.v5_deliverytime = bytes.fromhex("00000000")
Expand All @@ -133,8 +145,17 @@ def __init__(self, address, serial, **kwargs):
self._socket_setup(kwargs.get("socket"), kwargs.get("auto_reconnect", False))

@staticmethod
def _calculate_v5_frame_checksum(frame):
"""Calculate checksum on all frame bytes except head, end and checksum
def _get_response_code(code: int) -> 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
Expand All @@ -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("<H", length)
+ self.v5_magic
+ struct.pack("<B", control)
+ seq
+ self.v5_serial
)

def _v5_trailer(self) -> 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.

Expand All @@ -162,26 +205,22 @@ 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
:return: V5 frame
:rtype: bytearray

"""
length = 15 + len(modbus_frame)

self.v5_length = struct.pack("<H", 15 + len(modbus_frame))
self.v5_serial = struct.pack("<H", self._get_next_sequence_number())
self.v5_length = struct.pack("<H", length)
self.v5_seq = struct.pack("<H", self._get_next_sequence_number())

v5_header = bytearray(
self.v5_start
+ self.v5_length
+ self.v5_controlcode
+ self.v5_serial
+ self.v5_loggerserial
)
v5_header = self._v5_header(length, CONTROL.REQUEST, self.v5_seq)

v5_payload = bytearray(
self.v5_frametype
Expand All @@ -192,15 +231,13 @@ def _v5_frame_encoder(self, modbus_frame):
+ modbus_frame
)

v5_trailer = bytearray(self.v5_checksum + self.v5_end)

v5_frame = v5_header + v5_payload + v5_trailer

v5_frame[len(v5_frame) - 2] = self._calculate_v5_frame_checksum(v5_frame)
v5_frame = v5_header + v5_payload + self._v5_trailer()
v5_frame[-2] = self._calculate_v5_frame_checksum(v5_frame)
return v5_frame

def _v5_frame_decoder(self, v5_frame):
"""Decodes a V5 data logging stick frame and returns a modbus RTU frame
def _v5_frame_decoder(self, v5_frame: bytes) -> 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``.

Expand Down Expand Up @@ -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("<H", 0x1510):
if v5_frame[4] != self._get_response_code(CONTROL.REQUEST):
raise V5FrameError("V5 frame contains incorrect control code")
if v5_frame[11] != int("02", 16):
raise V5FrameError("V5 frame contains invalid frametype")

modbus_frame = v5_frame[25 : frame_len - 2]
modbus_frame = v5_frame[25:-2]

if len(modbus_frame) < 5:
if len(modbus_frame) > 0 and (
Expand All @@ -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("<H", 0x0100) # Frame & sensor type?
+ struct.pack("<I", int(time.time()))
+ struct.pack("<I", 0) # Offset?
) + self._v5_trailer()
response_frame[5] = (response_frame[5] + 1) & 0xFF
response_frame[-2] = self._calculate_v5_frame_checksum(response_frame)
return response_frame

def _send_receive_v5_frame(self, data_logging_stick_frame: bytes) -> 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
Expand Down Expand Up @@ -298,22 +350,46 @@ 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(" "))
return False
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:
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(" "))
# Maybe do_continue = True for CONTROL.DATA|INFO|REPORT and thus process packets in the future?
return do_continue, response_frame

def _handle_protocol_frame(self, frame: bytes) -> bool:
"""
Handles frames with known control codes :func:`_received_frame_response() <pysolarmanv5.PySolarmanV5._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:
Expand Down Expand Up @@ -350,6 +426,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:
Expand Down
30 changes: 27 additions & 3 deletions pysolarmanv5/pysolarmanv5_async.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -138,15 +139,15 @@ 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
PySolarmanV5 class

"""

def _send_data(self, data: bytes):
def _send_data(self, data: bytes) -> None:
"""
Sends the data received from the socket to the receiver.

Expand All @@ -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() <pysolarmanv5.PySolarmanV5._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
Expand All @@ -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:
Expand Down
Loading