From c1442e94b751fe6cfd44b169cfda1e28e56b38c7 Mon Sep 17 00:00:00 2001 From: Andrei LAZAROV Date: Fri, 24 Oct 2025 12:46:25 +0300 Subject: [PATCH 1/4] Added Modbus TCP proxy --- docs/utilities.rst | 16 +++++ utils/solarman_tcp_proxy.py | 131 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 utils/solarman_tcp_proxy.py diff --git a/docs/utilities.rst b/docs/utilities.rst index bd84c63..61b271b 100644 --- a/docs/utilities.rst +++ b/docs/utilities.rst @@ -2,6 +2,22 @@ Utilities =================== +Solarman TCP Proxy +^^^^^^^^^^^^^^^^^^ + +.. code-block:: console + + user@host:~ $ solarman-tcp-proxy -h + usage: solarman tcp proxy [-h] [-b BIND] [-p PORT] -l LOGGER -s SERIAL + + A Modbus TCP Proxy for Solarman loggers + + options: + -h, --help show this help message and exit + -b, --bind BIND The address to listen on + -p, --port PORT The TCP port to listen on + -l, --logger LOGGER The IP address of the logger + -s, --serial SERIAL The serial number of the logger Solarman RTU Proxy ^^^^^^^^^^^^^^^^^^ diff --git a/utils/solarman_tcp_proxy.py b/utils/solarman_tcp_proxy.py new file mode 100644 index 0000000..ddfe33e --- /dev/null +++ b/utils/solarman_tcp_proxy.py @@ -0,0 +1,131 @@ +"""Modbus TCP to Solarman proxy + +Can be used with Home Assistant's native Modbus integration using config below: + +- name: "solarman-modbus-proxy" + type: tcp + host: 192.168.1.20 + port: 1502 + delay: 3 + retry_on_empty: true + sensors: + [...] + +""" + +import argparse +import asyncio +import struct +from functools import partial + +from pysolarmanv5 import PySolarmanV5Async + +def modbus_crc(data: bytes) -> bytes: + # Compute Modbus RTU CRC16. + crc = 0xFFFF + for pos in data: + crc ^= pos + for _ in range(8): + if crc & 0x0001: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + # Return in little-endian (LSB first) + return struct.pack("HHH", header) + unit_id = await reader.readexactly(1) + pdu = await reader.readexactly(length - 1) # length includes unit_id + + modbus_rtu = unit_id + pdu + modbus_crc(unit_id + pdu) + + try: + # Convert RTU back to TCP + reply_rtu = await solarmanv5.send_raw_modbus_frame(modbus_rtu) + + unit_id_reply = reply_rtu[0:1] + pdu_reply = reply_rtu[1:-2] + crc_reply = reply_rtu[-2:] + + mbap = struct.pack(">HHH", trans_id, 0, len(pdu_reply) + 1) + reply_tcp = mbap + unit_id_reply + pdu_reply + + writer.write(reply_tcp) + except: + pass + + await writer.drain() + except OSError: + # https://github.com/python/cpython/issues/83037 + pass + + print(f"{addr}: Connection closed") + await solarmanv5.disconnect() + + +async def run_proxy( + bind_address: str, port: int, logger_address: str, logger_serial: int +): + server = await asyncio.start_server( + partial( + handle_client, logger_address=logger_address, logger_serial=logger_serial + ), + bind_address, + port, + ) + async with server: + print(f"Listening on {bind_address}:{port}") + await server.serve_forever() + + +def main(): + parser = argparse.ArgumentParser( + prog="solarman rtu proxy", + description="A Modbus RTU over TCP Proxy for Solarman loggers", + ) + parser.add_argument( + "-b", "--bind", default="0.0.0.0", help="The address to listen on" + ) + parser.add_argument( + "-p", "--port", default=1502, type=int, help="The TCP port to listen on" + ) + parser.add_argument( + "-l", "--logger", required=True, help="The IP address of the logger" + ) + parser.add_argument( + "-s", + "--serial", + required=True, + type=int, + help="The serial number of the logger", + ) + args = parser.parse_args() + + asyncio.run(run_proxy(args.bind, args.port, args.logger, args.serial)) + + +if __name__ == "__main__": + main() From 899dfc42b30b227aa2ee0c5f3c6488360fb804d6 Mon Sep 17 00:00:00 2001 From: Andrei LAZAROV Date: Tue, 28 Oct 2025 13:10:45 +0200 Subject: [PATCH 2/4] Use existent crc method --- utils/solarman_tcp_proxy.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/utils/solarman_tcp_proxy.py b/utils/solarman_tcp_proxy.py index ddfe33e..771c07c 100644 --- a/utils/solarman_tcp_proxy.py +++ b/utils/solarman_tcp_proxy.py @@ -17,23 +17,9 @@ import asyncio import struct from functools import partial - +from umodbus.client.serial.redundancy_check import get_crc from pysolarmanv5 import PySolarmanV5Async -def modbus_crc(data: bytes) -> bytes: - # Compute Modbus RTU CRC16. - crc = 0xFFFF - for pos in data: - crc ^= pos - for _ in range(8): - if crc & 0x0001: - crc >>= 1 - crc ^= 0xA001 - else: - crc >>= 1 - # Return in little-endian (LSB first) - return struct.pack(" Date: Tue, 28 Oct 2025 13:29:22 +0200 Subject: [PATCH 3/4] Distinct unit and slave IDs --- utils/solarman_tcp_proxy.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/utils/solarman_tcp_proxy.py b/utils/solarman_tcp_proxy.py index 771c07c..cda9cdc 100644 --- a/utils/solarman_tcp_proxy.py +++ b/utils/solarman_tcp_proxy.py @@ -46,18 +46,19 @@ async def handle_client( unit_id = await reader.readexactly(1) pdu = await reader.readexactly(length - 1) # length includes unit_id - modbus_rtu = unit_id + pdu + get_crc(unit_id + pdu) + slave_id = b'\x01' + modbus_rtu = slave_id + pdu + get_crc(slave_id + pdu) try: # Convert RTU back to TCP reply_rtu = await solarmanv5.send_raw_modbus_frame(modbus_rtu) - unit_id_reply = reply_rtu[0:1] + slave_id_reply = reply_rtu[0:1] pdu_reply = reply_rtu[1:-2] crc_reply = reply_rtu[-2:] mbap = struct.pack(">HHH", trans_id, 0, len(pdu_reply) + 1) - reply_tcp = mbap + unit_id_reply + pdu_reply + reply_tcp = mbap + unit_id + pdu_reply writer.write(reply_tcp) except: From 87e7680428829a7d52d6211037ac80cc3aaa00e0 Mon Sep 17 00:00:00 2001 From: Andrei LAZAROV Date: Thu, 30 Oct 2025 11:32:43 +0200 Subject: [PATCH 4/4] Fix quotes --- utils/solarman_tcp_proxy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/utils/solarman_tcp_proxy.py b/utils/solarman_tcp_proxy.py index cda9cdc..84fb9d6 100644 --- a/utils/solarman_tcp_proxy.py +++ b/utils/solarman_tcp_proxy.py @@ -20,6 +20,7 @@ from umodbus.client.serial.redundancy_check import get_crc from pysolarmanv5 import PySolarmanV5Async + async def handle_client( reader: asyncio.StreamReader, writer: asyncio.StreamWriter, @@ -46,9 +47,9 @@ async def handle_client( unit_id = await reader.readexactly(1) pdu = await reader.readexactly(length - 1) # length includes unit_id - slave_id = b'\x01' + slave_id = b"\x01" modbus_rtu = slave_id + pdu + get_crc(slave_id + pdu) - + try: # Convert RTU back to TCP reply_rtu = await solarmanv5.send_raw_modbus_frame(modbus_rtu)