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..84fb9d6 --- /dev/null +++ b/utils/solarman_tcp_proxy.py @@ -0,0 +1,119 @@ +"""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 umodbus.client.serial.redundancy_check import get_crc +from pysolarmanv5 import PySolarmanV5Async + + +async def handle_client( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + logger_address: str, + logger_serial: int, +): + solarmanv5 = PySolarmanV5Async( + logger_address, logger_serial, verbose=True, auto_reconnect=True + ) + await solarmanv5.connect() + + addr = writer.get_extra_info("peername") + + print(f"{addr}: New connection") + + try: + while True: + # Convert TCP to RTU + header = await reader.readexactly(6) + if not header: + break + + trans_id, proto_id, length = struct.unpack(">HHH", header) + unit_id = await reader.readexactly(1) + pdu = await reader.readexactly(length - 1) # length includes unit_id + + 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) + + 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 + 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()