From e4ff17e6008211af453f1d96443988888df43737 Mon Sep 17 00:00:00 2001 From: Stefan Allius <122395479+s-allius@users.noreply.github.com> Date: Mon, 8 Jul 2024 19:08:58 +0200 Subject: [PATCH] S allius/issue117 (#118) * add shutdown flag * add more register definitions * add start commando for client side connections * add first support for port 8899 * fix shutdown * add client_mode configuration * read client_mode config to setup inverter connections * add client_mode connections over port 8899 * add preview build --- CHANGELOG.md | 2 + app/build.sh | 11 +++++- app/config/default_config.toml | 1 + app/src/config.py | 6 ++- app/src/gen3plus/solarman_v5.py | 21 +++++++++- app/src/messages.py | 1 + app/src/modbus.py | 4 +- app/src/modbus_tcp.py | 70 +++++++++++++++++++++++++++++++++ app/src/server.py | 10 +++++ 9 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 app/src/modbus_tcp.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 934a71e..aa817e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +- GEN3PLUS: add client_mode connection on port 8899 [#117](https://github.com/s-allius/tsun-gen3-proxy/issues/117) + ## [0.9.0] - 2024-07-01 - fix exception in MODBUS timeout callback diff --git a/app/build.sh b/app/build.sh index dbb1f86..31478d7 100755 --- a/app/build.sh +++ b/app/build.sh @@ -21,11 +21,11 @@ IMAGE=tsun-gen3-proxy if [[ $1 == debug ]] || [[ $1 == dev ]] ;then IMAGE=docker.io/sallius/${IMAGE} VERSION=${VERSION}-$1 -elif [[ $1 == rc ]] || [[ $1 == rel ]];then +elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then IMAGE=ghcr.io/s-allius/${IMAGE} else echo argument missing! -echo try: $0 '[debug|dev|rc|rel]' +echo try: $0 '[debug|dev|preview|rc|rel]' exit 1 fi @@ -35,6 +35,13 @@ docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --buil elif [[ $1 == dev ]];then docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:dev app +elif [[ $1 == preview ]];then +docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:preview -t ${IMAGE}:${VERSION} app +echo 'login to ghcr.io' +echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin +docker push -q ghcr.io/s-allius/tsun-gen3-proxy:preview +docker push -q ghcr.io/s-allius/tsun-gen3-proxy:${VERSION} + elif [[ $1 == rc ]];then docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:rc -t ${IMAGE}:${VERSION} app echo 'login to ghcr.io' diff --git a/app/config/default_config.toml b/app/config/default_config.toml index fbe2651..22ba82d 100644 --- a/app/config/default_config.toml +++ b/app/config/default_config.toml @@ -44,6 +44,7 @@ inverters.allow_all = true # allow inverters, even if we have no inverter mapp monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter #node_id = '' # Optional, MQTT replacement for inverters serial number #suggested_area = '' # Optional, suggested installation place for home-assistant +#client_mode = {host = '192.168.0.1', port = 8899} #pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr #pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr #pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr diff --git a/app/src/config.py b/app/src/config.py index 8121c86..b2774af 100644 --- a/app/src/config.py +++ b/app/src/config.py @@ -53,7 +53,11 @@ class Config(): Use(lambda s: s + '/' if len(s) > 0 and s[-1] != '/' else s)), - + Optional('client_mode'): { + 'host': Use(str), + Optional('port', default=8899): + And(Use(int), lambda n: 1024 <= n <= 65535) + }, Optional('suggested_area', default=""): Use(str), Optional('pv1'): { Optional('type'): Use(str), diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 432ec2e..113ea40 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -66,6 +66,8 @@ class SolarmanV5(Message): self.db = InfosG3P() self.time_ofs = 0 self.forward_at_cmd_resp = False + self.no_forwarding = False + '''not allowed to connect to TSUN cloud by connection type''' self.switch = { 0x4210: self.msg_data_ind, # real time data @@ -143,6 +145,19 @@ class SolarmanV5(Message): self.mb_timer.close() super().close() + async def send_start_cmd(self, snr: int): + self.no_forwarding = True + self.snr = snr + self.__set_serial_no(snr) + + self.__send_ack_rsp(0x1710, ftype=0) + await self.async_write('Send Start Command:') + self._send_buffer = bytearray(0) + + self.state = State.up + self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 64, logging.INFO) + self.mb_timer.start(self.MB_START_TIMEOUT) + def __set_serial_no(self, snr: int): serial_no = str(snr) if self.unique_id == serial_no: @@ -198,6 +213,8 @@ class SolarmanV5(Message): return 0 # wait 0s before sending a response def forward(self, buffer, buflen) -> None: + if self.no_forwarding: + return tsun = Config.get('solarman') if tsun['enabled']: self._forward_buffer = buffer[:buflen] @@ -380,11 +397,11 @@ class SolarmanV5(Message): def mb_timout_cb(self, exp_cnt): self.mb_timer.start(self.MB_REGULAR_TIMEOUT) - self._send_modbus_cmd(Modbus.READ_REGS, 0x3008, 21, logging.DEBUG) + self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG) if 0 == (exp_cnt % 30): # logging.info("Regular Modbus Status request") - self._send_modbus_cmd(Modbus.READ_REGS, 0x2007, 2, logging.DEBUG) + self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 64, logging.DEBUG) def at_cmd_forbidden(self, cmd: str, connection: str) -> bool: return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \ diff --git a/app/src/messages.py b/app/src/messages.py index bec2994..8944df5 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -91,6 +91,7 @@ class Message(metaclass=IterRegistry): self._forward_buffer = bytearray(0) self.new_data = {} self.state = State.init + self.shutdown_started = False ''' Empty methods, that have to be implemented in any child class which diff --git a/app/src/modbus.py b/app/src/modbus.py index 7425b56..2b3a335 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -41,7 +41,9 @@ class Modbus(): __crc_tab = [] map = { 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 - # 0x????: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 + 0x203e: {'reg': Register.NO_INPUTS, 'fmt': '!H', 'ratio': 1/256}, # noqa: E501 + + 0x3000: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 0x3009: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501 0x300a: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 diff --git a/app/src/modbus_tcp.py b/app/src/modbus_tcp.py new file mode 100644 index 0000000..5382cc6 --- /dev/null +++ b/app/src/modbus_tcp.py @@ -0,0 +1,70 @@ +import logging +import traceback +import asyncio +from config import Config + +# import gc +from gen3plus.inverter_g3p import InverterG3P + +logger = logging.getLogger('conn') + + +class ModbusConn(): + def __init__(self, host, port): + self.host = host + self.port = port + self.addr = (host, port) + self.stream = None + + async def __aenter__(self) -> 'InverterG3P': + '''Establish a client connection to the TSUN cloud''' + connection = asyncio.open_connection(self.host, self.port) + reader, writer = await connection + self.stream = InverterG3P(reader, writer, self.addr) + logging.info(f'[{self.stream.node_id}:{self.stream.conn_no}] ' + f'Connected to {self.addr}') + self.stream.inc_counter('Inverter_Cnt') + return self.stream + + async def __aexit__(self, exc_type, exc, tb): + self.stream.dec_counter('Inverter_Cnt') + + +class ModbusTcp(): + + def __init__(self, loop) -> None: + inverters = Config.get('inverters') + # logging.info(f'Inverters: {inverters}') + + for inv in inverters.values(): + if (type(inv) is dict + and 'monitor_sn' in inv + and 'client_mode' in inv): + client = inv['client_mode'] + # logging.info(f"SerialNo:{inv['monitor_sn']} host:{client['host']} port:{client['port']}") # noqa: E501 + loop.create_task(self.modbus_loop(client['host'], + client['port'], + inv['monitor_sn'])) + + async def modbus_loop(self, host, port, snr: int) -> None: + '''Loop for receiving messages from the TSUN cloud (client-side)''' + while True: + try: + async with ModbusConn(host, port) as stream: + await stream.send_start_cmd(snr) + await stream.loop() + logger.info(f'[{stream.node_id}:{stream.conn_no}] ' + f'Connection closed - Shutdown: ' + f'{stream.shutdown_started}') + if stream.shutdown_started: + return + + except (ConnectionRefusedError, TimeoutError) as error: + logging.info(f'{error}') + + except Exception: + logging.error( + f"ModbusTcpCreate: Exception for {(host,port)}:\n" + f"{traceback.format_exc()}") + + await asyncio.sleep(10) diff --git a/app/src/server.py b/app/src/server.py index d835e4b..95cc715 100644 --- a/app/src/server.py +++ b/app/src/server.py @@ -11,6 +11,7 @@ from gen3.inverter_g3 import InverterG3 from gen3plus.inverter_g3p import InverterG3P from scheduler import Schedule from config import Config +from modbus_tcp import ModbusTcp routes = web.RouteTableDef() proxy_is_up = False @@ -94,6 +95,7 @@ async def handle_shutdown(web_task): # first, disc all open TCP connections gracefully # for stream in Message: + stream.shutdown_started = True try: await asyncio.wait_for(stream.disc(), 2) except Exception: @@ -115,6 +117,13 @@ async def handle_shutdown(web_task): web_task.cancel() await web_task + # + # now cancel all remaining (pending) tasks + # + pending = asyncio.all_tasks() + for task in pending: + task.cancel() + # # at last, start a coro for stopping the loop # @@ -164,6 +173,7 @@ if __name__ == "__main__": logging.info(f'ConfigErr: {ConfigErr}') Inverter.class_init() Schedule.start() + mb_tcp = ModbusTcp(loop) # # Create tasks for our listening servers. These must be tasks! If we call