Compare commits

..

5 Commits

Author SHA1 Message Date
Stefan Allius
aa009f87ca Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy into dev-0.8 2024-06-21 18:39:25 +02:00
Stefan Allius
8707357afa update changelog 2024-06-21 18:18:43 +02:00
Stefan Allius
a2ec2e59b8 update changelog 2024-06-20 00:03:27 +02:00
Stefan Allius
527ae3510f S allius/issue104 (#105)
* Update README.md

Exchange logger fw version with the real inverter fw version in the compatibility table

* Update python-app.yml

run also on pushes to issue branches
fix name for issues branches

* fix forwarding of MODBUS responses

* fix unit tests
2024-06-19 23:01:48 +02:00
Stefan Allius
6b3918797b S allius/issue102 (#103)
* hotfix: don't send two MODBUS commands together

* Update README.md

Exchange logger fw version with the real inverter fw version in the compatibility table

* Update python-app.yml

run also on pushes to issue branches
fix name for issues branches
2024-06-18 22:02:39 +02:00
34 changed files with 269 additions and 1215 deletions

2
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"configurations": [
{
"name": "Python: Aktuelle Datei",
"type": "debugpy",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",

View File

@@ -5,38 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [unreleased]
- cleanup shutdown
- add preview build
- MODBUS: the last digit of the inverter version is a hexadecimal number [#119](https://github.com/s-allius/tsun-gen3-proxy/issues/119)
- 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
## [0.9.0-RC1] - 2024-06-29
- add asyncio log and debug mode
- stop the HTTP server on shutdown gracefully
- Synchronize regular MODBUS commands with the status of the inverter to prevent the inverter from crashing due to
unexpected packets. [#111](https://github.com/s-allius/tsun-gen3-proxy/issues/111)
- GEN3: avoid sending MODBUS commands to the inverter during the inverter's reporting phase
- GEN3: determine the connection timeout based on the connection state
- GEN3: support more data encodings for DSP version V5.0.17 [#108](https://github.com/s-allius/tsun-gen3-proxy/issues/108)
- detect dead connections [#100](https://github.com/s-allius/tsun-gen3-proxy/issues/100)
- improve connection logging wirt a unique connection id
- Add healthcheck, readiness and liveness checks [#91](https://github.com/s-allius/tsun-gen3-proxy/issues/91)
- MODBUS close handler releases internal resource [#93](https://github.com/s-allius/tsun-gen3-proxy/issues/93)
- add exception handling for message forwarding [#94](https://github.com/s-allius/tsun-gen3-proxy/issues/94)
- GEN3: make timestamp handling stateless, to avoid blocking when the TSUN cloud is down [#56](https://github.com/s-allius/tsun-gen3-proxy/issues/56)
- GEN3PLUS: dump invalid packages with wrong start or stop byte
- label debug imagages als `debug`
- print imgae build time during proxy start
- add type annotations
- improve async unit test and fix pytest warnings
- run github tests even for pulls on issue branches
## [Unreleased]
## [0.8.1] - 2024-06-21
@@ -55,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- catch all OSError errors in the read loop
- log Modbus traces with different log levels
- add Modbus fifo and timeout handler
- build version string in the same format as TSUN for GEN3 inverters
- build version string in the same format as TSUN for GEN3 invterts
- add graceful shutdown
- parse Modbus values and store them in the database
- add cron task to request the output power every minute

View File

@@ -165,9 +165,6 @@ pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module de
monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter
node_id = 'inv_3' # MQTT replacement for inverters serial number
suggested_area = 'garage' # suggested installation place for home-assistant
# if your inverter supports SSL connections you must use the client_mode. Pls, uncomment
# the next line and configure the fixed IP of your inverter
#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
@@ -191,19 +188,8 @@ The standard web interface of the inverter can be accessed at `http://<ip-adress
For our purpose, the hidden URL `http://<ip-adress>/config_hide.html` should be called. There you can see and modify the parameters for accessing the cloud. Here we enter the IP address of our proxy and the IP port `10000` for the `Server A Setting` and for `Optional Server Setting`. The second entry is used as a backup in the event of connection problems.
❗If the IP port is set to 10443 in the inverter configuration, you probably have a firmware with SSL support. In this case, you must use the client-mode configuration.
If access to the web interface does not work, it can also be redirected via DNS redirection, as is necessary for the GEN3 inverters.
## Client Mode (GEN3PLUS only)
Newer GEN3PLUS inverters support SSL encrypted connections over port 10443 to the TSUN cloud. In this case you can't loop the proxy into this connection, since the certicate verification of the inverter don't allow this. You can configure the proxy in client-mode to establish an unencrypted connection to the inverter. For this porpuse the inverter listen on port `8899`.
There are some requirements to be met:
- the inverter should have a fixed IP
- the proxy must be able to reach the inverter. You must configure a corresponding route in your router if the inverter and the proxy are in different IP networks
- add a 'client_mode' line to your config.toml file, to specify the inverter's ip address
## DNS Settings
### Loop the proxy into the connection

View File

@@ -15,7 +15,7 @@ RUN apk upgrade --no-cache && \
#
# second stage for building wheels packages
FROM base AS builder
FROM base as builder
# copy the dependencies file to the root dir and install requirements
COPY ./requirements.txt /root/
@@ -26,7 +26,7 @@ RUN apk add --no-cache build-base && \
#
# third stage for our runtime image
FROM base AS runtime
FROM base as runtime
ARG SERVICE_NAME
ARG VERSION
ARG UID
@@ -63,8 +63,8 @@ RUN python -m pip install --no-cache --no-index /root/wheels/* && \
COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh
COPY config .
COPY src .
RUN date > /build-date.txt
EXPOSE 5005 8127 10000
EXPOSE 5005
# command to run on container start
ENTRYPOINT ["/root/entrypoint.sh"]
@@ -73,7 +73,7 @@ CMD [ "python3", "./server.py" ]
LABEL org.opencontainers.image.title="TSUN Gen3 Proxy"
LABEL org.opencontainers.image.authors="Stefan Allius"
LABEL org.opencontainers.image.source=https://github.com/s-allius/tsun-gen3-proxy
LABEL org.opencontainers.image.description='This proxy enables a reliable connection between TSUN third generation inverters (eg. TSOL MS600, MS800, MS2000) and an MQTT broker to integrate the inverter into typical home automations.'
LABEL org.opencontainers.image.source https://github.com/s-allius/tsun-gen3-proxy
LABEL org.opencontainers.image.description 'This proxy enables a reliable connection between TSUN third generation inverters (eg. TSOL MS600, MS800, MS2000) and an MQTT broker to integrate the inverter into typical home automations.'
LABEL org.opencontainers.image.licenses="BSD-3-Clause"
LABEL org.opencontainers.image.vendor="Stefan Allius"

View File

@@ -4,7 +4,7 @@
# rc: release candidate build
# rel: release build and push to ghcr.io
# Note: for release build, you need to set GHCR_TOKEN
# export GHCR_TOKEN=<YOUR_GITHUB_TOKEN> in your .zprofile
# export GHCR_TOKEN=<YOUR_GITHUB_TOKEN> in your .profile
# see also: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry
@@ -21,41 +21,34 @@ IMAGE=tsun-gen3-proxy
if [[ $1 == debug ]] || [[ $1 == dev ]] ;then
IMAGE=docker.io/sallius/${IMAGE}
VERSION=${VERSION}-$1
elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then
elif [[ $1 == rc ]] || [[ $1 == rel ]];then
IMAGE=ghcr.io/s-allius/${IMAGE}
else
echo argument missing!
echo try: $0 '[debug|dev|preview|rc|rel]'
echo try: $0 '[debug|dev|rc|rel]'
exit 1
fi
echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE
if [[ $1 == debug ]];then
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:debug app
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --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 == 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'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:rc
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:${VERSION}
docker push ghcr.io/s-allius/tsun-gen3-proxy:rc
docker push ghcr.io/s-allius/tsun-gen3-proxy:${VERSION}
elif [[ $1 == rel ]];then
docker build --no-cache --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}:latest -t ${IMAGE}:${MAJOR} -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:latest
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:${MAJOR}
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:${VERSION}
docker push ghcr.io/s-allius/tsun-gen3-proxy:latest
docker push ghcr.io/s-allius/tsun-gen3-proxy:${MAJOR}
docker push ghcr.io/s-allius/tsun-gen3-proxy:${VERSION}
fi
echo 'check docker-compose.yaml file'

View File

@@ -44,11 +44,6 @@ 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
# if your inverter supports SSL connections you must use the client_mode. Pls, uncomment
# the next line and configure the fixed IP of your inverter
#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

View File

@@ -5,7 +5,6 @@ user="$(id -u)"
echo "######################################################"
echo "# prepare: '$SERVICE_NAME' Version:$VERSION"
echo "# for running with UserID:$UID, GroupID:$GID"
echo "# Image built: $(cat /build-date.txt) "
echo "#"
if [ "$user" = '0' ]; then

View File

@@ -17,5 +17,6 @@ if [ "$environment" = "production" ] ; then \
-name od -o \
-name strings -o \
-name su -o \
-name wget -o \
\) -delete \
; fi

View File

@@ -1,4 +1,3 @@
aiomqtt==2.0.1
schema==0.7.5
aiocron==1.8
aiohttp==3.9.5
aiocron==1.8

View File

@@ -1,77 +1,44 @@
import asyncio
import logging
import traceback
import time
from asyncio import StreamReader, StreamWriter
from messages import hex_dump_memory, State
from typing import Self
from itertools import count
from messages import hex_dump_memory
import gc
logger = logging.getLogger('conn')
class AsyncStream():
_ids = count(0)
MAX_PROC_TIME = 2
'''maximum processing time for a received msg in sec'''
MAX_START_TIME = 400
'''maximum time without a received msg in sec'''
MAX_INV_IDLE_TIME = 90
'''maximum time without a received msg from the inverter in sec'''
MAX_CLOUD_IDLE_TIME = 360
'''maximum time without a received msg from cloud side in sec'''
def __init__(self, reader: StreamReader, writer: StreamWriter,
addr) -> None:
def __init__(self, reader, writer, addr) -> None:
logger.debug('AsyncStream.__init__')
self.reader = reader
self.writer = writer
self.addr = addr
self.r_addr = ''
self.l_addr = ''
self.conn_no = next(self._ids)
self.proc_start = None # start processing start timestamp
self.proc_max = 0
def __timeout(self) -> int:
if self.state == State.init:
to = self.MAX_START_TIME
else:
if self.server_side:
to = self.MAX_INV_IDLE_TIME
else:
to = self.MAX_CLOUD_IDLE_TIME
return to
async def server_loop(self, addr: str) -> None:
async def server_loop(self, addr):
'''Loop for receiving messages from the inverter (server-side)'''
logger.info(f'[{self.node_id}:{self.conn_no}] '
f'Accept connection from {addr}')
logging.info(f'[{self.node_id}] Accept connection from {addr}')
self.inc_counter('Inverter_Cnt')
await self.loop()
self.dec_counter('Inverter_Cnt')
logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for'
f' r{self.r_addr}')
logging.info(f'[{self.node_id}] Server loop stopped for'
f' r{self.r_addr}')
# if the server connection closes, we also have to disconnect
# the connection to te TSUN cloud
if self.remoteStream:
logger.info(f'[{self.node_id}:{self.conn_no}] disc client '
f'connection: [{self.remoteStream.node_id}:'
f'{self.remoteStream.conn_no}]')
logging.debug("disconnect client connection")
await self.remoteStream.disc()
try:
await self._async_publ_mqtt_proxy_stat('proxy')
except Exception:
pass
async def client_loop(self, addr: str) -> None:
async def client_loop(self, addr):
'''Loop for receiving messages from the TSUN cloud (client-side)'''
clientStream = await self.remoteStream.loop()
logger.info(f'[{clientStream.node_id}:{clientStream.conn_no}] '
'Client loop stopped for'
f' l{clientStream.l_addr}')
logging.info(f'[{self.node_id}] Client loop stopped for'
f' l{clientStream.l_addr}')
# if the client connection closes, we don't touch the server
# connection. Instead we erase the client connection stream,
@@ -87,45 +54,28 @@ class AsyncStream():
# than erase client connection
self.remoteStream = None
async def loop(self) -> Self:
"""Async loop handler for precessing all received messages"""
async def loop(self):
self.r_addr = self.writer.get_extra_info('peername')
self.l_addr = self.writer.get_extra_info('sockname')
self.proc_start = time.time()
while True:
try:
proc = time.time() - self.proc_start
if proc > self.proc_max:
self.proc_max = proc
self.proc_start = None
dead_conn_to = self.__timeout()
await asyncio.wait_for(self.__async_read(),
dead_conn_to)
await self.__async_read()
if self.unique_id:
await self.async_write()
await self.__async_forward()
await self.async_publ_mqtt()
except asyncio.TimeoutError:
logger.warning(f'[{self.node_id}:{self.conn_no}] Dead '
f'connection timeout ({dead_conn_to}s) '
f'for {self.l_addr}')
await self.disc()
self.close()
return self
except OSError as error:
logger.error(f'[{self.node_id}:{self.conn_no}] '
f'{error} for l{self.l_addr} | '
logger.error(f'[{self.node_id}] {error} for l{self.l_addr} | '
f'r{self.r_addr}')
await self.disc()
self.close()
return self
except RuntimeError as error:
logger.info(f'[{self.node_id}:{self.conn_no}] '
f'{error} for {self.l_addr}')
logger.info(f"[{self.node_id}] {error} for {self.l_addr}")
await self.disc()
self.close()
return self
@@ -136,8 +86,31 @@ class AsyncStream():
f"Exception for {self.addr}:\n"
f"{traceback.format_exc()}")
async def async_write(self, headline: str = 'Transmit to ') -> None:
"""Async write handler to transmit the send_buffer"""
async def disc(self) -> None:
if self.writer.is_closing():
return
logger.debug(f'AsyncStream.disc() l{self.l_addr} | r{self.r_addr}')
self.writer.close()
await self.writer.wait_closed()
def close(self):
if self.writer.is_closing():
return
logger.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}')
self.writer.close()
'''
Our private methods
'''
async def __async_read(self) -> None:
data = await self.reader.read(4096)
if data:
self._recv_buffer += data
self.read() # call read in parent class
else:
raise RuntimeError("Peer closed.")
async def async_write(self, headline='Transmit to ') -> None:
if self._send_buffer:
hex_dump_memory(logging.INFO, f'{headline}{self.addr}:',
self._send_buffer, len(self._send_buffer))
@@ -145,57 +118,8 @@ class AsyncStream():
await self.writer.drain()
self._send_buffer = bytearray(0) # self._send_buffer[sent:]
async def disc(self) -> None:
"""Async disc handler for graceful disconnect"""
if self.writer.is_closing():
return
logger.debug(f'AsyncStream.disc() l{self.l_addr} | r{self.r_addr}')
self.writer.close()
await self.writer.wait_closed()
def close(self) -> None:
"""close handler for a no waiting disconnect
hint: must be called before releasing the connection instance
"""
self.reader.feed_eof() # abort awaited read
if self.writer.is_closing():
return
logger.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}')
self.writer.close()
def healthy(self) -> bool:
elapsed = 0
if self.proc_start is not None:
elapsed = time.time() - self.proc_start
if self.state == State.closed or elapsed > self.MAX_PROC_TIME:
logging.debug(f'[{self.node_id}:{self.conn_no}:'
f'{type(self).__name__}]'
f' act:{round(1000*elapsed)}ms'
f' max:{round(1000*self.proc_max)}ms')
logging.debug(f'Healthy()) refs: {gc.get_referrers(self)}')
return elapsed < 5
'''
Our private methods
'''
async def __async_read(self) -> None:
"""Async read handler to read received data from TCP stream"""
data = await self.reader.read(4096)
if data:
self.proc_start = time.time()
self._recv_buffer += data
wait = self.read() # call read in parent class
if wait > 0:
await asyncio.sleep(wait)
else:
raise RuntimeError("Peer closed.")
async def __async_forward(self) -> None:
"""forward handler transmits data over the remote connection"""
if not self._forward_buffer:
return
try:
if self._forward_buffer:
if not self.remoteStream:
await self.async_create_remote()
if self.remoteStream:
@@ -212,30 +136,6 @@ class AsyncStream():
await self.remoteStream.writer.drain()
self._forward_buffer = bytearray(0)
except OSError as error:
if self.remoteStream:
rmt = self.remoteStream
self.remoteStream = None
logger.error(f'[{rmt.node_id}:{rmt.conn_no}] Fwd: {error} for '
f'l{rmt.l_addr} | r{rmt.r_addr}')
await rmt.disc()
rmt.close()
except RuntimeError as error:
if self.remoteStream:
rmt = self.remoteStream
self.remoteStream = None
logger.info(f'[{rmt.node_id}:{rmt.conn_no}] '
f'Fwd: {error} for {rmt.l_addr}')
await rmt.disc()
rmt.close()
except Exception:
self.inc_counter('SW_Exception')
logger.error(
f"Fwd Exception for {self.addr}:\n"
f"{traceback.format_exc()}")
def __del__(self):
logger.debug(
f"AsyncStream.__del__ l{self.l_addr} | r{self.r_addr}")

View File

@@ -53,11 +53,7 @@ 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),
@@ -88,7 +84,7 @@ class Config():
)
@classmethod
def class_init(cls) -> None | str: # pragma: no cover
def class_init(cls): # pragma: no cover
try:
# make the default config transparaent by copying it
# in the config.example file
@@ -98,9 +94,7 @@ class Config():
"config/config.example.toml")
except Exception:
pass
err_str = cls.read()
del cls.conf_schema
return err_str
cls.read()
@classmethod
def _read_config_file(cls) -> dict: # pragma: no cover

View File

@@ -1,6 +1,5 @@
import logging
# import gc
from asyncio import StreamReader, StreamWriter
from async_stream import AsyncStream
from gen3.talent import Talent
@@ -9,13 +8,12 @@ logger = logging.getLogger('conn')
class ConnectionG3(AsyncStream, Talent):
def __init__(self, reader: StreamReader, writer: StreamWriter,
addr, remote_stream: 'ConnectionG3', server_side: bool,
def __init__(self, reader, writer, addr, remote_stream, server_side: bool,
id_str=b'') -> None:
AsyncStream.__init__(self, reader, writer, addr)
Talent.__init__(self, server_side, id_str)
self.remoteStream: 'ConnectionG3' = remote_stream
self.remoteStream = remote_stream
'''
Our puplic methods
@@ -31,10 +29,6 @@ class ConnectionG3(AsyncStream, Talent):
async def async_publ_mqtt(self) -> None:
pass
def healthy(self) -> bool:
logger.debug('ConnectionG3 healthy()')
return AsyncStream.healthy(self)
'''
Our private methods
'''

View File

@@ -132,24 +132,11 @@ class InfosG3(Infos):
errors='replace')
ind += str_len+1
elif data_type == 0x00: # 'Nul' -> end
i = elms # abort the loop
elif data_type == 0x41: # 'A' -> Nop ??
# result = struct.unpack_from('!l', buf, ind)[0]
ind += 0
i += 1
continue
elif data_type == 0x42: # 'B' -> byte, int8
result = struct.unpack_from('!B', buf, ind)[0]
ind += 1
elif data_type == 0x49: # 'I' -> int32
result = struct.unpack_from('!l', buf, ind)[0]
ind += 4
elif data_type == 0x53: # 'S' -> short, int16
elif data_type == 0x53: # 'S' -> short
result = struct.unpack_from('!h', buf, ind)[0]
ind += 2
@@ -157,14 +144,13 @@ class InfosG3(Infos):
result = round(struct.unpack_from('!f', buf, ind)[0], 2)
ind += 4
elif data_type == 0x4c: # 'L' -> long, int64
elif data_type == 0x4c: # 'L' -> int64
result = struct.unpack_from('!q', buf, ind)[0]
ind += 8
else:
self.inc_counter('Invalid_Data_Type')
logging.error(f"Infos.parse: data_type: {data_type}"
f" @0x{addr:04x} No:{i}"
" not supported")
return

View File

@@ -1,8 +1,7 @@
import asyncio
import logging
import traceback
import json
import asyncio
from asyncio import StreamReader, StreamWriter
from config import Config
from inverter import Inverter
from gen3.connection_g3 import ConnectionG3
@@ -45,7 +44,7 @@ class InverterG3(Inverter, ConnectionG3):
destroyed
'''
def __init__(self, reader: StreamReader, writer: StreamWriter, addr):
def __init__(self, reader, writer, addr):
super().__init__(reader, writer, addr, None, True)
self.__ha_restarts = -1
@@ -57,14 +56,11 @@ class InverterG3(Inverter, ConnectionG3):
addr = (host, port)
try:
logging.info(f'[{self.node_id}] Connect to {addr}')
logging.info(f'[{self.node_id}] Connected to {addr}')
connect = asyncio.open_connection(host, port)
reader, writer = await connect
self.remoteStream = ConnectionG3(reader, writer, addr, self,
False, self.id_str)
logging.info(f'[{self.remoteStream.node_id}:'
f'{self.remoteStream.conn_no}] '
f'Connected to {addr}')
asyncio.create_task(self.client_loop(addr))
except (ConnectionRefusedError, TimeoutError) as error:
@@ -125,7 +121,7 @@ class InverterG3(Inverter, ConnectionG3):
def close(self) -> None:
logging.debug(f'InverterG3.close() l{self.l_addr} | r{self.r_addr}')
super().close() # call close handler in the parent class
# logging.info(f'Inverter refs: {gc.get_referrers(self)}')
# logger.debug (f'Inverter refs: {gc.get_referrers(self)}')
def __del__(self):
logging.debug("InverterG3.__del__")

View File

@@ -4,15 +4,13 @@ import time
from datetime import datetime
if __name__ == "app.src.gen3.talent":
from app.src.messages import hex_dump_memory, Message, State
from app.src.messages import hex_dump_memory, Message
from app.src.modbus import Modbus
from app.src.my_timer import Timer
from app.src.config import Config
from app.src.gen3.infos_g3 import InfosG3
else: # pragma: no cover
from messages import hex_dump_memory, Message, State
from messages import hex_dump_memory, Message
from modbus import Modbus
from my_timer import Timer
from config import Config
from gen3.infos_g3 import InfosG3
@@ -37,16 +35,12 @@ class Control:
class Talent(Message):
MB_START_TIMEOUT = 40
MB_REGULAR_TIMEOUT = 60
def __init__(self, server_side: bool, id_str=b''):
super().__init__(server_side, self.send_modbus_cb, mb_timeout=11)
self.await_conn_resp_cnt = 0
self.id_str = id_str
self.contact_name = b''
self.contact_mail = b''
self.ts_offset = 0 # time offset between tsun cloud and local
self.db = InfosG3()
self.switch = {
0x00: self.msg_contact_info,
@@ -70,20 +64,19 @@ class Talent(Message):
}
self.modbus_elms = 0 # for unit tests
self.node_id = 'G3' # will be overwritten in __set_serial_no
self.mb_timer = Timer(self.mb_timout_cb, self.node_id)
# self.forwarding = Config.get('tsun')['enabled']
'''
Our puplic methods
'''
def close(self) -> None:
logging.debug('Talent.close()')
# we have references to methods of this class in self.switch
# we have refernces to methods of this class in self.switch
# so we have to erase self.switch, otherwise this instance can't be
# deallocated by the garbage collector ==> we get a memory leak
self.switch.clear()
self.log_lvl.clear()
self.state = State.closed
self.mb_timer.close()
self.state = self.STATE_CLOSED
super().close()
def __set_serial_no(self, serial_no: str):
@@ -112,7 +105,7 @@ class Talent(Message):
self.unique_id = serial_no
def read(self) -> float:
def read(self) -> None:
self._read()
if not self.header_valid:
@@ -120,9 +113,6 @@ class Talent(Message):
if self.header_valid and len(self._recv_buffer) >= (self.header_len +
self.data_len):
if self.state == State.init:
self.state = State.received # received 1st package
log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING)
if callable(log_lvl):
log_lvl = log_lvl()
@@ -133,7 +123,7 @@ class Talent(Message):
self.__set_serial_no(self.id_str.decode("utf-8"))
self.__dispatch_msg()
self.__flush_recv_msg()
return 0.5 # wait 500ms before sending a response
return
def forward(self, buffer, buflen) -> None:
tsun = Config.get('tsun')
@@ -150,9 +140,9 @@ class Talent(Message):
return
def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str):
if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore MODBUS cmd,'
' cause the state is not UP anymore')
if self.state != self.STATE_UP:
logger.warn(f'[{self.node_id}] ignore MODBUS cmd,'
' cause the state is not UP anymore')
return
self.__build_header(0x70, 0x77)
@@ -166,25 +156,13 @@ class Talent(Message):
self.writer.write(self._send_buffer)
self._send_buffer = bytearray(0) # self._send_buffer[sent:]
def _send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
if self.state != State.up:
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
if self.state != self.STATE_UP:
logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,'
' as the state is not UP')
return
self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl)
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
self._send_modbus_cmd(func, addr, val, log_lvl)
def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.MB_REGULAR_TIMEOUT)
if 0 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.READ_REGS, 0x2007, 2, logging.DEBUG)
else:
self._send_modbus_cmd(Modbus.READ_REGS, 0x3008, 21, logging.DEBUG)
def _init_new_client_conn(self) -> bool:
contact_name = self.contact_name
contact_mail = self.contact_mail
@@ -226,24 +204,6 @@ class Talent(Message):
ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
return round(ts*1000)
def _update_header(self, _forward_buffer):
'''update header for message before forwarding,
add time offset to timestamp'''
_len = len(_forward_buffer)
result = struct.unpack_from('!lB', _forward_buffer, 0)
id_len = result[1] # len of variable id string
if _len < 2*id_len + 21:
return
result = struct.unpack_from('!B', _forward_buffer, id_len+6)
msg_code = result[0]
if msg_code == 0x71 or msg_code == 0x04:
result = struct.unpack_from('!q', _forward_buffer, 13+2*id_len)
ts = result[0] + self.ts_offset
logger.debug(f'offset: {self.ts_offset:08x}'
f' proxy-time: {ts:08x}')
struct.pack_into('!q', _forward_buffer, 13+2*id_len, ts)
# check if there is a complete header in the buffer, parse it
# and set
# self.header_len
@@ -296,8 +256,7 @@ class Talent(Message):
fnc = self.switch.get(self.msg_id, self.msg_unknown)
if self.unique_id:
logger.info(self.__flow_str(self.server_side, 'rx') +
f' Ctl: {int(self.ctrl):#02x} ({self.state}) '
f'Msg: {fnc.__name__!r}')
f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}')
fnc()
else:
logger.info(self.__flow_str(self.server_side, 'drop') +
@@ -346,37 +305,39 @@ class Talent(Message):
return True
def msg_get_time(self):
if self.ctrl.is_ind():
if self.data_len == 0:
self.state = State.pend # block MODBUS cmds
self.mb_timer.start(self.MB_START_TIMEOUT)
ts = self._timestamp()
logger.debug(f'time: {ts:08x}')
self.__build_header(0x91)
self._send_buffer += struct.pack('!q', ts)
self.__finish_send_msg()
elif self.data_len >= 8:
ts = self._timestamp()
result = struct.unpack_from('!q', self._recv_buffer,
self.header_len)
self.ts_offset = result[0]-ts
logger.debug(f'tsun-time: {int(result[0]):08x}'
f' proxy-time: {ts:08x}'
f' offset: {self.ts_offset}')
return # ignore received response
tsun = Config.get('tsun')
if tsun['enabled']:
if self.ctrl.is_ind():
if self.data_len >= 8:
ts = self._timestamp()
result = struct.unpack_from('!q', self._recv_buffer,
self.header_len)
logger.debug(f'tsun-time: {result[0]:08x}'
f' proxy-time: {ts:08x}')
else:
logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len)
else:
logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl')
if self.ctrl.is_ind():
if self.data_len == 0:
ts = self._timestamp()
logger.debug(f'time: {ts:08x}')
self.forward(self._recv_buffer, self.header_len+self.data_len)
self.__build_header(0x91)
self._send_buffer += struct.pack('!q', ts)
self.__finish_send_msg()
else:
logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl')
def parse_msg_header(self):
result = struct.unpack_from('!lB', self._recv_buffer, self.header_len)
data_id = result[0] # len of complete message
id_len = result[1] # len of variable id string
logger.debug(f'Data_ID: 0x{data_id:08x} id_len: {id_len}')
logger.debug(f'Data_ID: {data_id} id_len: {id_len}')
msg_hdr_len = 5+id_len+9
@@ -395,6 +356,7 @@ class Talent(Message):
self._send_buffer += b'\x01'
self.__finish_send_msg()
self.__process_data()
self.state = self.STATE_UP
elif self.ctrl.is_resp():
return # ignore received response
@@ -410,7 +372,7 @@ class Talent(Message):
self._send_buffer += b'\x01'
self.__finish_send_msg()
self.__process_data()
self.state = State.up # allow MODBUS cmds
self.state = self.STATE_UP
elif self.ctrl.is_resp():
return # ignore received response
@@ -470,13 +432,8 @@ class Talent(Message):
else:
self.inc_counter('Invalid_Msg_Format')
elif self.ctrl.is_ind():
self.modbus_elms = 0
# logger.debug(f'Modbus Ind MsgLen: {modbus_len}')
if not self.server_side:
logger.warning('Unknown Message')
self.inc_counter('Unknown_Msg')
return
self.modbus_elms = 0
for key, update, _ in self.mb.recv_resp(self.db, data[
hdr_len:],
self.node_id):

View File

@@ -1,6 +1,5 @@
import logging
# import gc
from asyncio import StreamReader, StreamWriter
from async_stream import AsyncStream
from gen3plus.solarman_v5 import SolarmanV5
@@ -9,13 +8,12 @@ logger = logging.getLogger('conn')
class ConnectionG3P(AsyncStream, SolarmanV5):
def __init__(self, reader: StreamReader, writer: StreamWriter,
addr, remote_stream: 'ConnectionG3P',
def __init__(self, reader, writer, addr, remote_stream,
server_side: bool) -> None:
AsyncStream.__init__(self, reader, writer, addr)
SolarmanV5.__init__(self, server_side)
self.remoteStream: 'ConnectionG3P' = remote_stream
self.remoteStream = remote_stream
'''
Our puplic methods
@@ -31,10 +29,6 @@ class ConnectionG3P(AsyncStream, SolarmanV5):
async def async_publ_mqtt(self) -> None:
pass
def healthy(self) -> bool:
logger.debug('ConnectionG3P healthy()')
return AsyncStream.healthy(self)
'''
Our private methods
'''

View File

@@ -1,8 +1,7 @@
import asyncio
import logging
import traceback
import json
import asyncio
from asyncio import StreamReader, StreamWriter
from config import Config
from inverter import Inverter
from gen3plus.connection_g3p import ConnectionG3P
@@ -45,7 +44,7 @@ class InverterG3P(Inverter, ConnectionG3P):
destroyed
'''
def __init__(self, reader: StreamReader, writer: StreamWriter, addr):
def __init__(self, reader, writer, addr):
super().__init__(reader, writer, addr, None, True)
self.__ha_restarts = -1
@@ -57,14 +56,11 @@ class InverterG3P(Inverter, ConnectionG3P):
addr = (host, port)
try:
logging.info(f'[{self.node_id}] Connect to {addr}')
logging.info(f'[{self.node_id}] Connected to {addr}')
connect = asyncio.open_connection(host, port)
reader, writer = await connect
self.remoteStream = ConnectionG3P(reader, writer, addr, self,
False)
logging.info(f'[{self.remoteStream.node_id}:'
f'{self.remoteStream.conn_no}] '
f'Connected to {addr}')
asyncio.create_task(self.client_loop(addr))
except (ConnectionRefusedError, TimeoutError) as error:

View File

@@ -6,17 +6,15 @@ import asyncio
from datetime import datetime
if __name__ == "app.src.gen3plus.solarman_v5":
from app.src.messages import hex_dump_memory, Message, State
from app.src.messages import hex_dump_memory, Message
from app.src.modbus import Modbus
from app.src.my_timer import Timer
from app.src.config import Config
from app.src.gen3plus.infos_g3p import InfosG3P
from app.src.infos import Register
else: # pragma: no cover
from messages import hex_dump_memory, Message, State
from messages import hex_dump_memory, Message
from config import Config
from modbus import Modbus
from my_timer import Timer
from gen3plus.infos_g3p import InfosG3P
from infos import Register
# import traceback
@@ -53,8 +51,6 @@ class Sequence():
class SolarmanV5(Message):
AT_CMD = 1
MB_RTU_CMD = 2
MB_START_TIMEOUT = 40
MB_REGULAR_TIMEOUT = 60
def __init__(self, server_side: bool):
super().__init__(server_side, self.send_modbus_cb, mb_timeout=5)
@@ -66,8 +62,6 @@ 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
@@ -129,35 +123,21 @@ class SolarmanV5(Message):
self.at_acl = g3p_cnf['at_acl']
self.node_id = 'G3P' # will be overwritten in __set_serial_no
self.mb_timer = Timer(self.mb_timout_cb, self.node_id)
# self.forwarding = Config.get('solarman')['enabled']
'''
Our puplic methods
'''
def close(self) -> None:
logging.debug('Solarman.close()')
# we have references to methods of this class in self.switch
# we have refernces to methods of this class in self.switch
# so we have to erase self.switch, otherwise this instance can't be
# deallocated by the garbage collector ==> we get a memory leak
self.switch.clear()
self.log_lvl.clear()
self.state = State.closed
self.mb_timer.close()
self.state = self.STATE_CLOSED
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:
@@ -189,7 +169,7 @@ class SolarmanV5(Message):
self.unique_id = serial_no
def read(self) -> float:
def read(self) -> None:
self._read()
if not self.header_valid:
@@ -204,17 +184,12 @@ class SolarmanV5(Message):
self._recv_buffer, self.header_len+self.data_len+2)
if self.__trailer_is_ok(self._recv_buffer, self.header_len
+ self.data_len + 2):
if self.state == State.init:
self.state = State.received
self.__set_serial_no(self.snr)
self.__dispatch_msg()
self.__flush_recv_msg()
return 0 # wait 0s before sending a response
return
def forward(self, buffer, buflen) -> None:
if self.no_forwarding:
return
tsun = Config.get('solarman')
if tsun['enabled']:
self._forward_buffer = buffer[:buflen]
@@ -278,10 +253,6 @@ class SolarmanV5(Message):
self.snr = result[4]
if start != 0xA5:
hex_dump_memory(logging.ERROR,
'Drop packet w invalid start byte from'
f' {self.addr}:', buf, buf_len)
self.inc_counter('Invalid_Msg_Format')
# erase broken recv buffer
self._recv_buffer = bytearray()
@@ -293,9 +264,6 @@ class SolarmanV5(Message):
crc = buf[self.data_len+11]
stop = buf[self.data_len+12]
if stop != 0x15:
hex_dump_memory(logging.ERROR,
'Drop packet w invalid stop byte from '
f'{self.addr}:', buf, buf_len)
self.inc_counter('Invalid_Msg_Format')
if len(self._recv_buffer) > (self.data_len+13):
next_start = buf[self.data_len+13]
@@ -370,9 +338,9 @@ class SolarmanV5(Message):
self.__finish_send_msg()
def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str):
if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore MODBUS cmd,'
' cause the state is not UP anymore')
if self.state != self.STATE_UP:
logger.warn(f'[{self.node_id}] ignore MODBUS cmd,'
' cause the state is not UP anymore')
return
self.__build_header(0x4510)
self._send_buffer += struct.pack('<BHLLL', self.MB_RTU_CMD,
@@ -384,33 +352,21 @@ class SolarmanV5(Message):
self.writer.write(self._send_buffer)
self._send_buffer = bytearray(0) # self._send_buffer[sent:]
def _send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
if self.state != State.up:
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
if self.state != self.STATE_UP:
logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,'
' as the state is not UP')
return
self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl)
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
self._send_modbus_cmd(func, addr, val, log_lvl)
def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.MB_REGULAR_TIMEOUT)
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, 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 \
cmd.startswith(tuple(self.at_acl[connection]['block']))
async def send_at_cmd(self, AT_cmd: str) -> None:
if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore AT+ cmd,'
' as the state is not UP')
if self.state != self.STATE_UP:
logger.warn(f'[{self.node_id}] ignore AT+ cmd,'
' as the state is not UP')
return
AT_cmd = AT_cmd.strip()
@@ -419,7 +375,8 @@ class SolarmanV5(Message):
node_id = self.node_id
key = 'at_resp'
logger.info(f'{key}: {data_json}')
await self.mqtt.publish(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501
asyncio.ensure_future(
self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json)) # noqa: E501
return
self.forward_at_cmd_resp = False
@@ -508,9 +465,7 @@ class SolarmanV5(Message):
self.__process_data(ftype)
self.__forward_msg()
self.__send_ack_rsp(0x1210, ftype)
if self.state is not State.up:
self.state = State.up
self.mb_timer.start(self.MB_START_TIMEOUT)
self.state = self.STATE_UP
def msg_sync_start(self):
data = self._recv_buffer[self.header_len:]
@@ -544,15 +499,13 @@ class SolarmanV5(Message):
__forward_msg):
self.inc_counter('Modbus_Command')
else:
logger.error('Invalid Modbus Msg')
self.inc_counter('Invalid_Msg_Format')
return
self.__forward_msg()
def publish_mqtt(self, key, data):
asyncio.ensure_future(
self.mqtt.publish(key, data))
async def publish_mqtt(self, key, data):
await self.mqtt.publish(key, data) # pragma: no cover
def get_cmd_rsp_log_lvl(self) -> int:
ftype = self._recv_buffer[self.header_len]
@@ -576,7 +529,8 @@ class SolarmanV5(Message):
node_id = self.node_id
key = 'at_resp'
logger.info(f'{key}: {data_json}')
self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501
asyncio.ensure_future(
self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json)) # noqa: E501
return
elif ftype == self.MB_RTU_CMD:
valid = data[1]
@@ -607,9 +561,7 @@ class SolarmanV5(Message):
self.__forward_msg()
self.__send_ack_rsp(0x1710, ftype)
if self.state is not State.up:
self.state = State.up
self.mb_timer.start(self.MB_START_TIMEOUT)
self.state = self.STATE_UP
def msg_sync_end(self):
data = self._recv_buffer[self.header_len:]

View File

@@ -343,7 +343,7 @@ class Infos:
dict[counter] -= 1
def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \
-> Generator[tuple[str, str, str, str], None, None]:
-> Generator[tuple[dict, str], None, None]:
'''Generator function yields json register struct for home-assistant
auto configuration and the unique entity string, for all proxy
registers

View File

@@ -1,5 +1,5 @@
[loggers]
keys=root,tracer,mesg,conn,data,mqtt,asyncio
keys=root,tracer,mesg,conn,data,mqtt
[handlers]
keys=console_handler,file_handler_name1,file_handler_name2
@@ -24,12 +24,6 @@ handlers=console_handler,file_handler_name1
propagate=0
qualname=mqtt
[logger_asyncio]
level=INFO
handlers=console_handler,file_handler_name1
propagate=0
qualname=asyncio
[logger_data]
level=DEBUG
handlers=file_handler_name1

View File

@@ -1,7 +1,6 @@
import logging
import weakref
from typing import Callable, Generator
from enum import Enum
from typing import Callable
if __name__ == "app.src.messages":
@@ -46,32 +45,21 @@ def hex_dump_memory(level, info, data, num):
class IterRegistry(type):
def __iter__(cls) -> Generator['Message', None, None]:
def __iter__(cls):
for ref in cls._registry:
obj = ref()
if obj is not None:
yield obj
class State(Enum):
'''state of the logical connection'''
init = 0
'''just created'''
received = 1
'''at least one packet received'''
up = 2
'''at least one cmd-rsp transaction'''
pend = 3
'''inverter transaction pending, don't send MODBUS cmds'''
closed = 4
'''connection closed'''
class Message(metaclass=IterRegistry):
_registry = []
STATE_INIT = 0
STATE_UP = 2
STATE_CLOSED = 3
def __init__(self, server_side: bool, send_modbus_cb:
Callable[[bytes, int, str], None], mb_timeout: int):
Callable[[bytes, int, str], None], mb_timeout):
self._registry.append(weakref.ref(self))
self.server_side = server_side
@@ -90,8 +78,7 @@ class Message(metaclass=IterRegistry):
self._send_buffer = bytearray(0)
self._forward_buffer = bytearray(0)
self.new_data = {}
self.state = State.init
self.shutdown_started = False
self.state = self.STATE_INIT
'''
Empty methods, that have to be implemented in any child class which
@@ -110,7 +97,7 @@ class Message(metaclass=IterRegistry):
'''
def close(self) -> None:
if self.mb:
self.mb.close()
del self.mb
self.mb = None
pass # pragma: no cover

View File

@@ -41,10 +41,8 @@ class Modbus():
__crc_tab = []
map = {
0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # 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:1X}'"}, # noqa: E501
# 0x????: {'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
0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
@@ -107,17 +105,7 @@ class Modbus():
self.req_pend = False
self.tim = None
def close(self):
"""free the queue and erase the callback handlers"""
logging.debug('Modbus close:')
self.__stop_timer()
self.rsp_handler = None
self.snd_handler = None
while not self.que.empty:
self.que.get_nowait()
def __del__(self):
"""log statistics on the deleting of a MODBUS instance"""
logging.debug(f'Modbus __del__:\n {self.counter}')
def build_msg(self, addr: int, func: int, reg: int, val: int,
@@ -255,7 +243,6 @@ class Modbus():
# logging.debug(f'Modbus stop timer {self}')
if self.tim:
self.tim.cancel()
self.tim = None
def __timeout_cb(self) -> None:
'''Rsponse timeout handler retransmit pdu or send next pdu'''

View File

@@ -1,73 +0,0 @@
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.debug(f'Inv-conn:{error}')
except OSError as error:
logging.info(f'os-error: {error}')
except Exception:
logging.error(
f"ModbusTcpCreate: Exception for {(host,port)}:\n"
f"{traceback.format_exc()}")
await asyncio.sleep(10)

View File

@@ -1,35 +0,0 @@
import asyncio
import logging
from itertools import count
class Timer:
def __init__(self, cb, id_str: str = ''):
self.__timeout_cb = cb
self.loop = asyncio.get_event_loop()
self.tim = None
self.id_str = id_str
self.exp_count = count(0)
def start(self, timeout: float) -> None:
'''Start timer with timeout seconds'''
if self.tim:
self.tim.cancel()
self.tim = self.loop.call_later(timeout, self.__timeout)
logging.debug(f'[{self.id_str}]Start timer')
def stop(self) -> None:
'''Stop timer'''
logging.debug(f'[{self.id_str}]Stop timer')
if self.tim:
self.tim.cancel()
self.tim = None
def __timeout(self) -> None:
'''timer expired handler'''
logging.debug(f'[{self.id_str}]Timer expired')
self.__timeout_cb(next(self.exp_count))
def close(self) -> None:
self.stop()
self.__timeout_cb = None

View File

@@ -3,6 +3,8 @@ import json
from mqtt import Mqtt
from aiocron import crontab
from infos import ClrAtMidnight
from modbus import Modbus
from messages import Message
logger_mqtt = logging.getLogger('mqtt')
@@ -19,6 +21,9 @@ class Schedule:
crontab('0 0 * * *', func=cls.atmidnight, start=True)
# every minute
crontab('* * * * *', func=cls.regular_modbus_cmds, start=True)
@classmethod
async def atmidnight(cls) -> None:
'''Clear daily counters at midnight'''
@@ -28,3 +33,15 @@ class Schedule:
logger_mqtt.debug(f'{key}: {data}')
data_json = json.dumps(data)
await cls.mqtt.publish(f"{key}", data_json)
@classmethod
async def regular_modbus_cmds(cls):
for m in Message:
if m.server_side:
fnc = getattr(m, "send_modbus_cmd", None)
if callable(fnc):
await fnc(Modbus.READ_REGS, 0x3008, 21, logging.DEBUG)
# if 0 == (cls.count % 30):
# # logging.info("Regular Modbus Status request")
# await fnc(Modbus.READ_REGS, 0x2007, 2, logging.DEBUG)
cls.count += 1

View File

@@ -2,8 +2,6 @@ import logging
import asyncio
import signal
import os
from asyncio import StreamReader, StreamWriter
from aiohttp import web
from logging import config # noqa F401
from messages import Message
from inverter import Inverter
@@ -11,96 +9,36 @@ 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
@routes.get('/')
async def hello(request):
return web.Response(text="Hello, world")
@routes.get('/-/ready')
async def ready(request):
if proxy_is_up:
status = 200
text = 'Is ready'
else:
status = 503
text = 'Not ready'
return web.Response(status=status, text=text)
@routes.get('/-/healthy')
async def healthy(request):
if proxy_is_up:
# logging.info('web reqeust healthy()')
for stream in Message:
try:
res = stream.healthy()
if not res:
return web.Response(status=503, text="I have a problem")
except Exception as err:
logging.info(f'Exception:{err}')
return web.Response(status=200, text="I'm fine")
async def webserver(addr, port):
'''coro running our webserver'''
app = web.Application()
app.add_routes(routes)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, addr, port)
await site.start()
logging.info(f'HTTP server listen on port: {port}')
try:
# Normal interaction with aiohttp
while True:
await asyncio.sleep(3600) # sleep forever
except asyncio.CancelledError:
logging.info('HTTP server cancelled')
await runner.cleanup()
logging.debug('HTTP cleanup done')
async def handle_client(reader: StreamReader, writer: StreamWriter):
async def handle_client(reader, writer):
'''Handles a new incoming connection and starts an async loop'''
addr = writer.get_extra_info('peername')
await InverterG3(reader, writer, addr).server_loop(addr)
async def handle_client_v2(reader: StreamReader, writer: StreamWriter):
async def handle_client_v2(reader, writer):
'''Handles a new incoming connection and starts an async loop'''
addr = writer.get_extra_info('peername')
await InverterG3P(reader, writer, addr).server_loop(addr)
async def handle_shutdown(web_task):
async def handle_shutdown(loop):
'''Close all TCP connections and stop the event loop'''
logging.info('Shutdown due to SIGTERM')
global proxy_is_up
proxy_is_up = False
#
# 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:
pass
logging.info('Proxy disconnecting done')
logging.info('Disconnecting done')
#
# second, close all open TCP connections
@@ -108,28 +46,13 @@ async def handle_shutdown(web_task):
for stream in Message:
stream.close()
await asyncio.sleep(0.1) # give time for closing
logging.info('Proxy closing done')
#
# third, cancel the web server
# at last, we stop the loop
#
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
#
logging.debug("Stop event loop")
loop.stop()
logging.info('Shutdown complete')
def get_log_level() -> int:
'''checks if LOG_LVL is set in the environment and returns the
@@ -161,28 +84,16 @@ if __name__ == "__main__":
logging.getLogger('conn').setLevel(log_level)
logging.getLogger('data').setLevel(log_level)
logging.getLogger('tracer').setLevel(log_level)
logging.getLogger('asyncio').setLevel(log_level)
# logging.getLogger('mqtt').setLevel(log_level)
# read config file
Config.class_init()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# read config file
ConfigErr = Config.class_init()
if ConfigErr is not None:
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
# start_server directly out of our main task, the eventloop will be blocked
# and we can't receive and handle the UNIX signals!
#
loop.create_task(asyncio.start_server(handle_client, '0.0.0.0', 5005))
loop.create_task(asyncio.start_server(handle_client_v2, '0.0.0.0', 10000))
web_task = loop.create_task(webserver('0.0.0.0', 8127))
#
# Register some UNIX Signal handler for a gracefully server shutdown
@@ -191,18 +102,22 @@ if __name__ == "__main__":
for signame in ('SIGINT', 'SIGTERM'):
loop.add_signal_handler(getattr(signal, signame),
lambda loop=loop: asyncio.create_task(
handle_shutdown(web_task)))
handle_shutdown(loop)))
#
# Create taska for our listening servera. These must be tasks! If we call
# start_server directly out of our main task, the eventloop will be blocked
# and we can't receive and handle the UNIX signals!
#
loop.create_task(asyncio.start_server(handle_client, '0.0.0.0', 5005))
loop.create_task(asyncio.start_server(handle_client_v2, '0.0.0.0', 10000))
loop.set_debug(log_level == logging.DEBUG)
try:
if ConfigErr is None:
proxy_is_up = True
loop.run_forever()
except KeyboardInterrupt:
pass
finally:
logging.info("Event loop is stopped")
Inverter.class_close(loop)
logging.debug('Close event loop')
logging.info('Close event loop')
loop.close()
logging.info(f'Finally, exit Server "{serv_name}"')

View File

@@ -140,82 +140,6 @@ def InvDataSeq2(): # Data indication from the controller
msg += b'\x53\x00\x00'
return msg
@pytest.fixture
def InvDataNew(): # Data indication from DSP V5.0.17
msg = b'\x00\x00\x00\xa3\x00\x00\x00\x00\x53\x00\x00'
msg += b'\x00\x00\x00\x80\x53\x00\x00\x00\x00\x01\x04\x53\x00\x00\x00\x00'
msg += b'\x01\x90\x41\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00'
msg += b'\x00\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00\x00\x00'
msg += b'\x00\x01\x91\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01'
msg += b'\x95\x53\x00\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\x99\x53'
msg += b'\x00\x00\x00\x00\x01\x80\x53\x00\x00\x00\x00\x01\x90\x41\x00\x00'
msg += b'\x01\x94\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x96'
msg += b'\x53\x00\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\xa0\x53\x00'
msg += b'\x00\x00\x00\x01\xf0\x41\x00\x00\x01\xf1\x53\x00\x00\x00\x00\x01'
msg += b'\xf4\x53\x00\x00\x00\x00\x01\xf5\x53\x00\x00\x00\x00\x01\xf8\x53'
msg += b'\x00\x00\x00\x00\x01\xf9\x53\x00\x00\x00\x00\x00\x00\x53\x00\x00'
msg += b'\x00\x00\x00\x01\x53\x00\x00\x00\x00\x00\x00\x53\x00\x00\x00\x00'
msg += b'\x00\x01\x53\x00\x00\x00\x00\x00\x04\x53\x00\x00\x00\x00\x00\x58'
msg += b'\x41\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00'
msg += b'\x00\x02\x02\x53\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02'
msg += b'\x04\x53\x00\x00\x00\x00\x02\x58\x41\x00\x00\x02\x59\x53\x00\x00'
msg += b'\x00\x00\x02\x40\x53\x00\x00\x00\x00\x02\x41\x53\x00\x00\x00\x00'
msg += b'\x02\x40\x53\x00\x00\x00\x00\x02\x41\x53\x00\x00\x00\x00\x02\x44'
msg += b'\x53\x00\x00\x00\x00\x02\x45\x53\x00\x00\x00\x00\x02\x60\x53\x00'
msg += b'\x00\x00\x00\x02\x61\x53\x00\x00\x00\x00\x02\x60\x53\x00\x00\x00'
msg += b'\x00\x02\x20\x41\x00\x00\x02\x24\x53\x00\x00\x00\x00\x02\x24\x53'
msg += b'\x00\x00\x00\x00\x02\x26\x53\x00\x00\x00\x00\x02\x40\x53\x00\x00'
msg += b'\x00\x00\x02\x40\x53\x00\x00\x00\x00\x02\x80\x41\x00\x00\x02\x81'
msg += b'\x53\x00\x00\x00\x00\x02\x84\x53\x00\x00\x00\x00\x02\x85\x53\x00'
msg += b'\x00\x00\x00\x02\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00'
msg += b'\x00\x02\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00\x00\x02'
msg += b'\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00\x00\x02\xc4\x53'
msg += b'\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02\x80\x53\x00\x00'
msg += b'\x00\x00\x02\xc8\x42\x00\x00\x00\x00\x48\x42\x00\x00\x00\x00\x80'
msg += b'\x42\x00\x00\x00\x00\x04\x53\x00\x00\x00\x00\x01\x20\x53\x00\x00'
msg += b'\x00\x00\x01\x84\x53\x00\x10\x00\x00\x02\x40\x46\x00\x00\x00\x00'
msg += b'\x00\x00\x04\x04\x46\x02\x00\x46\x02\x00\x00\x04\x00\x46\x00\x00'
msg += b'\x00\x00\x00\x00\x05\x04\x42\x00\x00\x00\x05\x50\x42\x00\x00\x00'
msg += b'\x00\x14\x42\x00\x00\x00\x00\x00\x46\x00\x00\x00\x00\x00\x00\x00'
msg += b'\xa4\x46\x00\x00\x00\x00\x00\x00\x01\x00\x46\x00\x00\x00\x00\x00'
msg += b'\x00\x01\x44\x46\x00\x00\x00\x00\x00\x00\x02\x00\x46\x00\x00\x00'
msg += b'\x00\x00\x00\x08\x04\x46\x00\x00\x00\x00\x00\x00\x08\x90\x46\x00'
msg += b'\x00\x00\x00\x00\x00\x08\x54\x46\x00\x00\x00\x00\x00\x00\x09\x20'
msg += b'\x46\x00\x00\x00\x00\x00\x00\x08\x04\x46\x00\x00\x00\x00\x00\x00'
msg += b'\x08\x00\x46\x00\x00\x00\x00\x00\x00\x08\x84\x46\x00\x00\x00\x00'
msg += b'\x00\x00\x08\x40\x46\x00\x00\x00\x00\x00\x00\x09\x04\x46\x00\x00'
msg += b'\x00\x00\x00\x00\x0a\x10\x46\x00\x00\x00\x00\x00\x00\x0c\x14\x46'
msg += b'\x00\x00\x00\x00\x00\x00\x0c\x80\x46\x00\x00\x00\x00\x00\x00\x0c'
msg += b'\x24\x42\x00\x00\x00\x0d\x00\x42\x00\x00\x00\x00\x04\x42\x00\x00'
msg += b'\x00\x00\x00\x42\x00\x00\x00\x00\x44\x42\x00\x00\x00\x00\x10\x42'
msg += b'\x00\x00\x00\x01\x14\x53\x00\x00\x00\x00\x01\xa0\x53\x00\x00\x00'
msg += b'\x00\x10\x04\x53\x55\xaa\x00\x00\x10\x40\x53\x00\x00\x00\x00\x10'
msg += b'\x04\x53\x00\x00\x00\x00\x11\x00\x53\x00\x00\x00\x00\x11\x84\x53'
msg += b'\x00\x00\x00\x00\x10\x50\x53\xff\xff\x00\x00\x10\x14\x53\x03\x20'
msg += b'\x00\x00\x10\x00\x53\x00\x00\x00\x00\x11\x24\x53\x00\x00\x00\x00'
msg += b'\x03\x00\x53\x00\x00\x00\x00\x03\x64\x53\x00\x00\x00\x00\x04\x50'
msg += b'\x53\x00\x00\x00\x00\x00\x34\x53\x00\x00\x00\x00\x00\x00\x42\x02'
msg += b'\x00\x00\x01\x04\x42\x00\x00\x00\x21\x00\x42\x00\x00\x00\x21\x44'
msg += b'\x42\x00\x00\x00\x22\x10\x53\x00\x00\x00\x00\x28\x14\x42\x01\x00'
msg += b'\x00\x28\xa0\x46\x42\x48\x00\x00\x00\x00\x29\x04\x42\x00\x00\x00'
msg += b'\x29\x40\x42\x00\x00\x00\x28\x04\x46\x42\x10\x00\x00\x00\x00\x28'
msg += b'\x00\x42\x00\x00\x00\x28\x84\x42\x00\x00\x00\x28\x50\x42\x00\x00'
msg += b'\x00\x29\x14\x42\x00\x00\x00\x2a\x00\x42\x00\x00\x00\x2c\x24\x46'
msg += b'\x42\x10\x00\x00\x00\x00\x2c\x80\x42\x00\x00\x00\x2c\x44\x53\x00'
msg += b'\x02\x00\x00\x2d\x00\x42\x00\x00\x00\x20\x04\x46\x42\x4d\x00\x00'
msg += b'\x00\x00\x20\x10\x42\x00\x00\x00\x20\x54\x42\x00\x00\x00\x20\x20'
msg += b'\x42\x00\x00\x00\x21\x04\x53\x00\x01\x00\x00\x22\x00\x42\x00\x00'
msg += b'\x00\x30\x04\x42\x00\x00\x00\x30\x40\x53\x00\x00\x00\x00\x30\x04'
msg += b'\x53\x00\x00\x00\x00\x31\x10\x42\x00\x00\x00\x31\x94\x53\x00\x04'
msg += b'\x00\x00\x30\x00\x53\x00\x00\x00\x00\x30\x24\x53\x00\x00\x00\x00'
msg += b'\x30\x00\x53\x00\x00\x00\x00\x31\x04\x53\x00\x00\x00\x00\x31\x80'
msg += b'\x53\x00\x00\x00\x00\x32\x44\x53\x00\x00\x00\x00\x30\x00\x53\x00'
msg += b'\x00\x00\x00\x30\x80\x53\x00\x00\x00\x00\x30\x00\x53\x00\x00\x00'
msg += b'\x00\x30\x80\x53\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03\x00'
msg += b'\x00\x00\x00\x00'
return msg
@pytest.fixture
def InvDataSeq2_Zero(): # Data indication from the controller
msg = b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00'
@@ -467,25 +391,6 @@ def test_must_incr_total2(InvDataSeq2, InvDataSeq2_Zero):
assert json.dumps(i.db['total']) == json.dumps({'Daily_Generation': 1.7, 'Total_Generation': 17.36})
assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 64.5, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 45.7, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}})
def test_new_data_types(InvDataNew):
i = InfosG3()
tests = 0
for key, update in i.parse (InvDataNew):
if key == 'events':
tests +=1
elif key == 'inverter':
assert update == True
tests +=1
elif key == 'input':
assert update == False
tests +=1
else:
assert False
assert tests==15
assert json.dumps(i.db['inverter']) == json.dumps({"Manufacturer": 0})
assert json.dumps(i.db['input']) == json.dumps({"pv1": {}})
assert json.dumps(i.db['events']) == json.dumps({"401_": 0, "404_": 0, "405_": 0, "408_": 0, "409_No_Utility": 0, "406_": 0, "416_": 0})
def test_invalid_data_type(InvalidDataSeq):
i = InfosG3()

View File

@@ -5,9 +5,9 @@ from app.src.modbus import Modbus
from app.src.infos import Infos, Register
pytest_plugins = ('pytest_asyncio',)
# pytestmark = pytest.mark.asyncio(scope="module")
pytestmark = pytest.mark.asyncio(scope="module")
class ModbusTestHelper(Modbus):
class TestHelper(Modbus):
def __init__(self):
super().__init__(self.send_cb)
self.db = Infos()
@@ -32,15 +32,10 @@ def test_modbus_crc():
assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8')
assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46')
msg = b'\x01\x03\x28\x51'
msg += b'\x0e\x08\xd3\x00\x29\x13\x87\x00\x3e\x00\x00\x01\x2c\x03\xb4\x00'
msg += b'\x08\x00\x00\x00\x00\x01\x59\x01\x21\x03\xe6\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\xe6\xef'
assert 0 == mb._Modbus__calc_crc(msg)
def test_build_modbus_pdu():
'''Check building and sending a MODBUS RTU'''
mb = ModbusTestHelper()
mb = TestHelper()
mb.build_msg(1,6,0x2000,0x12)
assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07'
assert mb._Modbus__check_crc(mb.pdu)
@@ -52,7 +47,7 @@ def test_build_modbus_pdu():
def test_recv_req():
'''Receive a valid request, which must transmitted'''
mb = ModbusTestHelper()
mb = TestHelper()
assert mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07')
assert mb.last_fcode == 6
assert mb.last_reg == 0x2000
@@ -61,7 +56,7 @@ def test_recv_req():
def test_recv_req_crc_err():
'''Receive a request with invalid CRC, which must be dropped'''
mb = ModbusTestHelper()
mb = TestHelper()
assert not mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08')
assert mb.pdu == None
assert mb.last_fcode == 0
@@ -71,7 +66,7 @@ def test_recv_req_crc_err():
def test_recv_resp_crc_err():
'''Receive a response with invalid CRC, which must be dropped'''
mb = ModbusTestHelper()
mb = TestHelper()
# simulate a transmitted request
mb.req_pend = True
mb.last_addr = 1
@@ -91,7 +86,7 @@ def test_recv_resp_crc_err():
def test_recv_resp_invalid_addr():
'''Receive a response with wrong server addr, which must be dropped'''
mb = ModbusTestHelper()
mb = TestHelper()
mb.req_pend = True
# simulate a transmitted request
mb.last_addr = 1
@@ -114,7 +109,7 @@ def test_recv_resp_invalid_addr():
def test_recv_recv_fcode():
'''Receive a response with wrong function code, which must be dropped'''
mb = ModbusTestHelper()
mb = TestHelper()
mb.build_msg(1,4,0x300e,2)
assert mb.que.qsize() == 0
assert mb.req_pend
@@ -135,7 +130,7 @@ def test_recv_recv_fcode():
def test_recv_resp_len():
'''Receive a response with wrong data length, which must be dropped'''
mb = ModbusTestHelper()
mb = TestHelper()
mb.build_msg(1,3,0x300e,3)
assert mb.que.qsize() == 0
assert mb.req_pend
@@ -157,7 +152,7 @@ def test_recv_resp_len():
def test_recv_unexpect_resp():
'''Receive a response when we havb't sent a request'''
mb = ModbusTestHelper()
mb = TestHelper()
assert not mb.req_pend
# check unexpected response, which must be dropped
@@ -172,13 +167,13 @@ def test_recv_unexpect_resp():
def test_parse_resp():
'''Receive matching response and parse the values'''
mb = ModbusTestHelper()
mb = TestHelper()
mb.build_msg(1,3,0x3007,6)
assert mb.que.qsize() == 0
assert mb.req_pend
call = 0
exp_result = ['V0.0.2C', 4.4, 0.7, 0.7, 30]
exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30]
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'):
if key == 'grid':
assert update == True
@@ -196,7 +191,7 @@ def test_parse_resp():
assert not mb.req_pend
def test_queue():
mb = ModbusTestHelper()
mb = TestHelper()
mb.build_msg(1,3,0x3022,4)
assert mb.que.qsize() == 0
assert mb.req_pend
@@ -215,7 +210,7 @@ def test_queue():
def test_queue2():
'''Check queue handling for build_msg() calls'''
mb = ModbusTestHelper()
mb = TestHelper()
mb.build_msg(1,3,0x3007,6)
mb.build_msg(1,6,0x2008,4)
assert mb.que.qsize() == 1
@@ -227,7 +222,7 @@ def test_queue2():
assert mb.send_calls == 1
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
call = 0
exp_result = ['V0.0.2C', 4.4, 0.7, 0.7, 30]
exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30]
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'):
if key == 'grid':
assert update == True
@@ -263,7 +258,7 @@ def test_queue2():
def test_queue3():
'''Check queue handling for recv_req() calls'''
mb = ModbusTestHelper()
mb = TestHelper()
assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t', mb.resp_handler)
assert mb.recv_req(b'\x01\x06\x20\x08\x00\x04\x02\x0b', mb.resp_handler)
assert mb.que.qsize() == 1
@@ -277,7 +272,7 @@ def test_queue3():
assert mb.recv_responses == 0
call = 0
exp_result = ['V0.0.2C', 4.4, 0.7, 0.7, 30]
exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30]
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'):
if key == 'grid':
assert update == True
@@ -320,7 +315,7 @@ def test_queue3():
async def test_timeout():
'''Test MODBUS response timeout and RTU retransmitting'''
assert asyncio.get_running_loop()
mb = ModbusTestHelper()
mb = TestHelper()
mb.max_retries = 2
mb.timeout = 0.1 # 100ms timeout for fast testing, expect a time resolution of at least 10ms
assert asyncio.get_running_loop() == mb.loop
@@ -368,7 +363,7 @@ async def test_timeout():
def test_recv_unknown_data():
'''Receive a response with an unknwon register'''
mb = ModbusTestHelper()
mb = TestHelper()
assert 0x9000 not in mb.map
mb.map[0x9000] = {'reg': Register.TEST_REG1, 'fmt': '!H', 'ratio': 1}

View File

@@ -7,7 +7,6 @@ from app.src.gen3plus.solarman_v5 import SolarmanV5
from app.src.config import Config
from app.src.infos import Infos, Register
from app.src.modbus import Modbus
from app.src.messages import State
pytest_plugins = ('pytest_asyncio',)
@@ -25,24 +24,12 @@ class Writer():
def write(self, pdu: bytearray):
self.sent_pdu = pdu
class Mqtt():
def __init__(self):
self.key = ''
self.data = ''
async def publish(self, key, data):
self.key = key
self.data = data
class MemoryStream(SolarmanV5):
def __init__(self, msg, chunks = (0,), server_side: bool = True):
super().__init__(server_side)
if server_side:
self.mb.timeout = 1 # overwrite for faster testing
self.writer = Writer()
self.mqtt = Mqtt()
self.__msg = msg
self.__msg_len = len(msg)
self.__chunks = chunks
@@ -56,8 +43,6 @@ class MemoryStream(SolarmanV5):
self.test_exception_async_write = False
self.entity_prfx = ''
self.at_acl = {'mqtt': {'allow': ['AT+'], 'block': ['AT+WEBU']}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE', 'AT+TIME'], 'block': ['AT+WEBU']}}
self.key = ''
self.data = ''
def _timestamp(self):
return timestamp
@@ -69,10 +54,6 @@ class MemoryStream(SolarmanV5):
self.__msg += msg
self.__msg_len += len(msg)
def publish_mqtt(self, key, data):
self.key = key
self.data = data
def _read(self) -> int:
copied_bytes = 0
try:
@@ -500,10 +481,9 @@ def AtCommandIndMsgBlock(): # 0x4510
@pytest.fixture
def AtCommandRspMsg(): # 0x1510
msg = b'\xa5\x11\x00\x10\x15\x03\x03' +get_sn() +b'\x01\x01'
msg = b'\xa5\x0a\x00\x10\x15\x03\x03' +get_sn() +b'\x01\x01'
msg += total()
msg += hb()
msg += b'\x00\x00\x00\x00+ok'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
@@ -852,7 +832,8 @@ def test_read_message_in_chunks2(ConfigTsunInv1, DeviceIndMsg):
assert m.data_len == 0xd4
assert m.msg_count == 1
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.read() # read rest of message
while m.read(): # read rest of message
pass
assert m.msg_count == 1
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
@@ -1274,64 +1255,48 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg,
m.close()
@pytest.mark.asyncio
async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg, AtCommandRspMsg):
async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg):
ConfigTsunAllowAll
m = MemoryStream(DeviceIndMsg, (0,), True)
m.append_msg(InverterIndMsg)
m.append_msg(AtCommandRspMsg)
m.read() # read device ind
m.read()
assert m.control == 0x4110
assert str(m.seq) == '01:01'
assert m._recv_buffer==InverterIndMsg + AtCommandRspMsg # unhandled next message
assert m._recv_buffer==InverterIndMsg # unhandled next message
assert m._send_buffer==DeviceRspMsg
assert m._forward_buffer==DeviceIndMsg
m._send_buffer = bytearray(0) # clear send buffer for next test
m._forward_buffer = bytearray(0) # clear send buffer for next test
await m.send_at_cmd('AT+TIME=214028,1,60,120')
assert m._recv_buffer==InverterIndMsg + AtCommandRspMsg # unhandled next message
assert m._recv_buffer==InverterIndMsg # unhandled next message
assert m._send_buffer==b''
assert m._forward_buffer==b''
assert str(m.seq) == '01:01'
assert m.mqtt.key == ''
assert m.mqtt.data == ""
m.read() # read inverter ind
m.read()
assert m.control == 0x4210
assert str(m.seq) == '02:02'
assert m._recv_buffer==AtCommandRspMsg # unhandled next message
assert m._recv_buffer==b''
assert m._send_buffer==InverterRspMsg
assert m._forward_buffer==InverterIndMsg
m._send_buffer = bytearray(0) # clear send buffer for next test
m._forward_buffer = bytearray(0) # clear send buffer for next test
await m.send_at_cmd('AT+TIME=214028,1,60,120')
assert m._recv_buffer==AtCommandRspMsg # unhandled next message
assert m._recv_buffer==b''
assert m._send_buffer==AtCommandIndMsg
assert m._forward_buffer==b''
assert str(m.seq) == '02:03'
assert m.mqtt.key == ''
assert m.mqtt.data == ""
m._send_buffer = bytearray(0) # clear send buffer for next test
m.read() # read at resp
assert m.control == 0x1510
assert str(m.seq) == '03:03'
assert m._recv_buffer==b''
assert m._send_buffer==b''
assert m._forward_buffer==b''
assert m.key == 'at_resp'
assert m.data == "+ok"
m.test_exception_async_write = True
await m.send_at_cmd('AT+TIME=214028,1,60,120')
assert m._recv_buffer==b''
assert m._send_buffer==b''
assert m._forward_buffer==b''
assert str(m.seq) == '03:04'
assert str(m.seq) == '02:04'
assert m.forward_at_cmd_resp == False
assert m.mqtt.key == ''
assert m.mqtt.data == ""
m.close()
@pytest.mark.asyncio
@@ -1353,8 +1318,6 @@ async def test_AT_cmd_blocked(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, In
assert m._send_buffer==b''
assert m._forward_buffer==b''
assert str(m.seq) == '01:01'
assert m.mqtt.key == ''
assert m.mqtt.data == ""
m.read()
assert m.control == 0x4210
@@ -1371,8 +1334,6 @@ async def test_AT_cmd_blocked(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, In
assert m._forward_buffer==b''
assert str(m.seq) == '02:02'
assert m.forward_at_cmd_resp == False
assert m.mqtt.key == 'at_resp'
assert m.mqtt.data == "'AT+WEBU' is forbidden"
m.close()
def test_AT_cmd_ind(ConfigTsunInv1, AtCommandIndMsg):
@@ -1437,7 +1398,7 @@ def test_msg_at_command_rsp1(ConfigTsunInv1, AtCommandRspMsg):
assert m.control == 0x1510
assert str(m.seq) == '03:03'
assert m.header_len==11
assert m.data_len==17
assert m.data_len==10
assert m._forward_buffer==AtCommandRspMsg
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
@@ -1456,7 +1417,7 @@ def test_msg_at_command_rsp2(ConfigTsunInv1, AtCommandRspMsg):
assert m.control == 0x1510
assert str(m.seq) == '03:03'
assert m.header_len==11
assert m.data_len==17
assert m.data_len==10
assert m._forward_buffer==b''
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
@@ -1467,7 +1428,7 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd, MsgModbusCmdFwd):
ConfigTsunInv1
m = MemoryStream(b'')
m.snr = get_sn_int()
m.state = State.up
m.state = m.STATE_UP
c = m.createClientStream(MsgModbusCmd)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1494,7 +1455,7 @@ def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr):
ConfigTsunInv1
m = MemoryStream(b'')
m.snr = get_sn_int()
m.state = State.up
m.state = m.STATE_UP
c = m.createClientStream(MsgModbusCmdCrcErr)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -1700,21 +1661,21 @@ def test_zombie_conn(ConfigTsunInv1, MsgInverterInd):
m1 = MemoryStream(MsgInverterInd, (0,))
m2 = MemoryStream(MsgInverterInd, (0,))
m3 = MemoryStream(MsgInverterInd, (0,))
assert m1.state == m1.State.init
assert m2.state == m2.State.init
assert m3.state == m3.State.init
assert m1.state == m1.STATE_INIT
assert m2.state == m2.STATE_INIT
assert m3.state == m3.STATE_INIT
m1.read() # read complete msg, and set unique_id
assert m1.state == m1.State.init
assert m2.state == m2.State.init
assert m3.state == m3.State.init
assert m1.state == m1.STATE_INIT
assert m2.state == m2.STATE_INIT
assert m3.state == m3.STATE_INIT
m2.read() # read complete msg, and set unique_id
assert m1.state == m1.State.closed
assert m2.state == m2.State.init
assert m3.state == m3.State.init
assert m1.state == m1.STATE_CLOSED
assert m2.state == m2.STATE_INIT
assert m3.state == m3.STATE_INIT
m3.read() # read complete msg, and set unique_id
assert m1.state == m1.State.closed
assert m2.state == m2.State.closed
assert m3.state == m3.State.init
assert m1.state == m1.STATE_CLOSED
assert m2.state == m2.STATE_CLOSED
assert m3.state == m3.STATE_INIT
m1.close()
m2.close()
m3.close()

View File

@@ -4,7 +4,6 @@ from app.src.gen3.talent import Talent, Control
from app.src.config import Config
from app.src.infos import Infos, Register
from app.src.modbus import Modbus
from app.src.messages import State
pytest_plugins = ('pytest_asyncio',)
@@ -61,8 +60,7 @@ class MemoryStream(Talent):
return copied_bytes
def _timestamp(self):
# return 1700260990000
return 1691246944000
return 1700260990000
def createClientStream(self, msg, chunks = (0,)):
c = MemoryStream(msg, chunks, False)
@@ -115,10 +113,6 @@ def MsgGetTime(): # Get Time Request message
def MsgTimeResp(): # Get Time Resonse message
return b'\x00\x00\x00\x1b\x10R170000000000001\x91\x22\x00\x00\x01\x89\xc6\x63\x4d\x80'
@pytest.fixture
def MsgTimeRespInv(): # Get Time Resonse message
return b'\x00\x00\x00\x17\x10R170000000000001\x91\x22\x00\x00\x01\x89'
@pytest.fixture
def MsgTimeInvalid(): # Get Time Request message
return b'\x00\x00\x00\x13\x10R170000000000001\x94\x22'
@@ -135,18 +129,6 @@ def MsgControllerInd(): # Data indication from the controller
msg += b'\x49\x00\x00\x00\x02\x00\x0d\x04\x08\x49\x00\x00\x00\x00\x00\x07\xa1\x84\x49\x00\x00\x00\x01\x00\x0c\x50\x59\x49\x00\x00\x00\x4c\x00\x0d\x1f\x60\x49\x00\x00\x00\x00'
return msg
@pytest.fixture
def MsgControllerIndTsOffs(): # Data indication from the controller - offset 0x1000
msg = b'\x00\x00\x01\x2f\x10R170000000000001\x91\x71\x0e\x10\x00\x00\x10R170000000000001'
msg += b'\x01\x00\x00\x01\x89\xc6\x63\x45\x50'
msg += b'\x00\x00\x00\x15\x00\x09\x2b\xa8\x54\x10\x52\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x30\x36\x00\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f'
msg += b'\x6e\x00\x09\x2f\x90\x54\x0b\x52\x53\x57\x2d\x31\x2d\x31\x30\x30\x30\x31\x00\x09\x5a\x88\x54\x0f\x74\x2e\x72\x61\x79\x6d\x6f\x6e\x69\x6f\x74\x2e\x63\x6f\x6d\x00\x09\x5a\xec\x54'
msg += b'\x1c\x6c\x6f\x67\x67\x65\x72\x2e\x74\x61\x6c\x65\x6e\x74\x2d\x6d\x6f\x6e\x69\x74\x6f\x72\x69\x6e\x67\x2e\x63\x6f\x6d\x00\x0d\x00\x20\x49\x00\x00\x00\x01\x00\x0c\x35\x00\x49\x00'
msg += b'\x00\x00\x64\x00\x0c\x96\xa8\x49\x00\x00\x00\x1d\x00\x0c\x7f\x38\x49\x00\x00\x00\x01\x00\x0c\xfc\x38\x49\x00\x00\x00\x01\x00\x0c\xf8\x50\x49\x00\x00\x01\x2c\x00\x0c\x63\xe0\x49'
msg += b'\x00\x00\x00\x00\x00\x0c\x67\xc8\x49\x00\x00\x00\x00\x00\x0c\x50\x58\x49\x00\x00\x00\x01\x00\x09\x5e\x70\x49\x00\x00\x13\x8d\x00\x09\x5e\xd4\x49\x00\x00\x13\x8d\x00\x09\x5b\x50'
msg += b'\x49\x00\x00\x00\x02\x00\x0d\x04\x08\x49\x00\x00\x00\x00\x00\x07\xa1\x84\x49\x00\x00\x00\x01\x00\x0c\x50\x59\x49\x00\x00\x00\x4c\x00\x0d\x1f\x60\x49\x00\x00\x00\x00'
return msg
@pytest.fixture
def MsgControllerAck(): # Get Time Request message
return b'\x00\x00\x00\x14\x10R170000000000001\x99\x71\x01'
@@ -163,92 +145,6 @@ def MsgInverterInd(): # Data indication from the controller
msg += b'\x54\x10T170000000000001\x00\x00\x00\x32\x54\x0a\x54\x53\x4f\x4c\x2d\x4d\x53\x36\x30\x30\x00\x00\x00\x3c\x54\x05\x41\x2c\x42\x2c\x43'
return msg
@pytest.fixture
def MsgInverterIndTsOffs(): # Data indication from the controller + offset 256
msg = b'\x00\x00\x00\x8b\x10R170000000000001\x91\x04\x01\x90\x00\x01\x10R170000000000001'
msg += b'\x01\x00\x00\x01\x89\xc6\x63\x62\x08'
msg += b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x54\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28'
msg += b'\x54\x10T170000000000001\x00\x00\x00\x32\x54\x0a\x54\x53\x4f\x4c\x2d\x4d\x53\x36\x30\x30\x00\x00\x00\x3c\x54\x05\x41\x2c\x42\x2c\x43'
return msg
@pytest.fixture
def MsgInverterIndNew(): # Data indication from DSP V5.0.17
msg = b'\x00\x00\x04\xa0\x10R170000000000001\x91\x04\x01\x90\x00\x01\x10R170000000000001'
msg += b'\x01\x00\x00\x01'
msg += b'\x90\x31\x4d\x68\x78\x00\x00\x00\xa3\x00\x00\x00\x00\x53\x00\x00'
msg += b'\x00\x00\x00\x80\x53\x00\x00\x00\x00\x01\x04\x53\x00\x00\x00\x00'
msg += b'\x01\x90\x41\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00'
msg += b'\x00\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00\x00\x00'
msg += b'\x00\x01\x91\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01'
msg += b'\x95\x53\x00\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\x99\x53'
msg += b'\x00\x00\x00\x00\x01\x80\x53\x00\x00\x00\x00\x01\x90\x41\x00\x00'
msg += b'\x01\x94\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x96'
msg += b'\x53\x00\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\xa0\x53\x00'
msg += b'\x00\x00\x00\x01\xf0\x41\x00\x00\x01\xf1\x53\x00\x00\x00\x00\x01'
msg += b'\xf4\x53\x00\x00\x00\x00\x01\xf5\x53\x00\x00\x00\x00\x01\xf8\x53'
msg += b'\x00\x00\x00\x00\x01\xf9\x53\x00\x00\x00\x00\x00\x00\x53\x00\x00'
msg += b'\x00\x00\x00\x01\x53\x00\x00\x00\x00\x00\x00\x53\x00\x00\x00\x00'
msg += b'\x00\x01\x53\x00\x00\x00\x00\x00\x04\x53\x00\x00\x00\x00\x00\x58'
msg += b'\x41\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00'
msg += b'\x00\x02\x02\x53\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02'
msg += b'\x04\x53\x00\x00\x00\x00\x02\x58\x41\x00\x00\x02\x59\x53\x00\x00'
msg += b'\x00\x00\x02\x40\x53\x00\x00\x00\x00\x02\x41\x53\x00\x00\x00\x00'
msg += b'\x02\x40\x53\x00\x00\x00\x00\x02\x41\x53\x00\x00\x00\x00\x02\x44'
msg += b'\x53\x00\x00\x00\x00\x02\x45\x53\x00\x00\x00\x00\x02\x60\x53\x00'
msg += b'\x00\x00\x00\x02\x61\x53\x00\x00\x00\x00\x02\x60\x53\x00\x00\x00'
msg += b'\x00\x02\x20\x41\x00\x00\x02\x24\x53\x00\x00\x00\x00\x02\x24\x53'
msg += b'\x00\x00\x00\x00\x02\x26\x53\x00\x00\x00\x00\x02\x40\x53\x00\x00'
msg += b'\x00\x00\x02\x40\x53\x00\x00\x00\x00\x02\x80\x41\x00\x00\x02\x81'
msg += b'\x53\x00\x00\x00\x00\x02\x84\x53\x00\x00\x00\x00\x02\x85\x53\x00'
msg += b'\x00\x00\x00\x02\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00'
msg += b'\x00\x02\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00\x00\x02'
msg += b'\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00\x00\x02\xc4\x53'
msg += b'\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02\x80\x53\x00\x00'
msg += b'\x00\x00\x02\xc8\x42\x00\x00\x00\x00\x48\x42\x00\x00\x00\x00\x80'
msg += b'\x42\x00\x00\x00\x00\x04\x53\x00\x00\x00\x00\x01\x20\x53\x00\x00'
msg += b'\x00\x00\x01\x84\x53\x00\x10\x00\x00\x02\x40\x46\x00\x00\x00\x00'
msg += b'\x00\x00\x04\x04\x46\x02\x00\x46\x02\x00\x00\x04\x00\x46\x00\x00'
msg += b'\x00\x00\x00\x00\x05\x04\x42\x00\x00\x00\x05\x50\x42\x00\x00\x00'
msg += b'\x00\x14\x42\x00\x00\x00\x00\x00\x46\x00\x00\x00\x00\x00\x00\x00'
msg += b'\xa4\x46\x00\x00\x00\x00\x00\x00\x01\x00\x46\x00\x00\x00\x00\x00'
msg += b'\x00\x01\x44\x46\x00\x00\x00\x00\x00\x00\x02\x00\x46\x00\x00\x00'
msg += b'\x00\x00\x00\x08\x04\x46\x00\x00\x00\x00\x00\x00\x08\x90\x46\x00'
msg += b'\x00\x00\x00\x00\x00\x08\x54\x46\x00\x00\x00\x00\x00\x00\x09\x20'
msg += b'\x46\x00\x00\x00\x00\x00\x00\x08\x04\x46\x00\x00\x00\x00\x00\x00'
msg += b'\x08\x00\x46\x00\x00\x00\x00\x00\x00\x08\x84\x46\x00\x00\x00\x00'
msg += b'\x00\x00\x08\x40\x46\x00\x00\x00\x00\x00\x00\x09\x04\x46\x00\x00'
msg += b'\x00\x00\x00\x00\x0a\x10\x46\x00\x00\x00\x00\x00\x00\x0c\x14\x46'
msg += b'\x00\x00\x00\x00\x00\x00\x0c\x80\x46\x00\x00\x00\x00\x00\x00\x0c'
msg += b'\x24\x42\x00\x00\x00\x0d\x00\x42\x00\x00\x00\x00\x04\x42\x00\x00'
msg += b'\x00\x00\x00\x42\x00\x00\x00\x00\x44\x42\x00\x00\x00\x00\x10\x42'
msg += b'\x00\x00\x00\x01\x14\x53\x00\x00\x00\x00\x01\xa0\x53\x00\x00\x00'
msg += b'\x00\x10\x04\x53\x55\xaa\x00\x00\x10\x40\x53\x00\x00\x00\x00\x10'
msg += b'\x04\x53\x00\x00\x00\x00\x11\x00\x53\x00\x00\x00\x00\x11\x84\x53'
msg += b'\x00\x00\x00\x00\x10\x50\x53\xff\xff\x00\x00\x10\x14\x53\x03\x20'
msg += b'\x00\x00\x10\x00\x53\x00\x00\x00\x00\x11\x24\x53\x00\x00\x00\x00'
msg += b'\x03\x00\x53\x00\x00\x00\x00\x03\x64\x53\x00\x00\x00\x00\x04\x50'
msg += b'\x53\x00\x00\x00\x00\x00\x34\x53\x00\x00\x00\x00\x00\x00\x42\x02'
msg += b'\x00\x00\x01\x04\x42\x00\x00\x00\x21\x00\x42\x00\x00\x00\x21\x44'
msg += b'\x42\x00\x00\x00\x22\x10\x53\x00\x00\x00\x00\x28\x14\x42\x01\x00'
msg += b'\x00\x28\xa0\x46\x42\x48\x00\x00\x00\x00\x29\x04\x42\x00\x00\x00'
msg += b'\x29\x40\x42\x00\x00\x00\x28\x04\x46\x42\x10\x00\x00\x00\x00\x28'
msg += b'\x00\x42\x00\x00\x00\x28\x84\x42\x00\x00\x00\x28\x50\x42\x00\x00'
msg += b'\x00\x29\x14\x42\x00\x00\x00\x2a\x00\x42\x00\x00\x00\x2c\x24\x46'
msg += b'\x42\x10\x00\x00\x00\x00\x2c\x80\x42\x00\x00\x00\x2c\x44\x53\x00'
msg += b'\x02\x00\x00\x2d\x00\x42\x00\x00\x00\x20\x04\x46\x42\x4d\x00\x00'
msg += b'\x00\x00\x20\x10\x42\x00\x00\x00\x20\x54\x42\x00\x00\x00\x20\x20'
msg += b'\x42\x00\x00\x00\x21\x04\x53\x00\x01\x00\x00\x22\x00\x42\x00\x00'
msg += b'\x00\x30\x04\x42\x00\x00\x00\x30\x40\x53\x00\x00\x00\x00\x30\x04'
msg += b'\x53\x00\x00\x00\x00\x31\x10\x42\x00\x00\x00\x31\x94\x53\x00\x04'
msg += b'\x00\x00\x30\x00\x53\x00\x00\x00\x00\x30\x24\x53\x00\x00\x00\x00'
msg += b'\x30\x00\x53\x00\x00\x00\x00\x31\x04\x53\x00\x00\x00\x00\x31\x80'
msg += b'\x53\x00\x00\x00\x00\x32\x44\x53\x00\x00\x00\x00\x30\x00\x53\x00'
msg += b'\x00\x00\x00\x30\x80\x53\x00\x00\x00\x00\x30\x00\x53\x00\x00\x00'
msg += b'\x00\x30\x80\x53\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03\x00'
msg += b'\x00\x00\x00\x00'
return msg
@pytest.fixture
def MsgInverterAck(): # Get Time Request message
return b'\x00\x00\x00\x14\x10R170000000000001\x99\x04\x01'
@@ -339,15 +235,6 @@ def MsgModbusResp20():
msg += b'\x00\x00\x00\x00\x00\x00\x00\xdb\x6b'
return msg
@pytest.fixture
def MsgModbusResp21():
msg = b'\x00\x00\x00\x45\x10R170000000000001'
msg += b'\x91\x77\x17\x18\x19\x1a\x2d\x01\x03\x28\x51'
msg += b'\x0e\x08\xd3\x00\x29\x13\x87\x00\x3e\x00\x00\x01\x2c\x03\xb4\x00'
msg += b'\x08\x00\x00\x00\x00\x01\x59\x01\x21\x03\xe6\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\xe6\xef'
return msg
def test_read_message(MsgContactInfo):
m = MemoryStream(MsgContactInfo, (0,))
m.read() # read complete msg, and dispatch msg
@@ -444,7 +331,8 @@ def test_read_message_in_chunks2(MsgContactInfo):
assert int(m.ctrl)==145
assert m.msg_id==0
assert m.msg_count == 1
m.read() # read rest of message
while m.read(): # read rest of message
pass
assert m.msg_count == 1
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
m.close()
@@ -583,10 +471,9 @@ def test_msg_get_time(ConfigTsunInv1, MsgGetTime):
assert int(m.ctrl)==145
assert m.msg_id==34
assert m.header_len==23
assert m.ts_offset==0
assert m.data_len==0
assert m._forward_buffer==MsgGetTime
assert m._send_buffer==b'\x00\x00\x00\x1b\x10R170000000000001\x91"\x00\x00\x01\x89\xc6,_\x00'
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.close()
@@ -602,10 +489,9 @@ def test_msg_get_time_autark(ConfigNoTsunInv1, MsgGetTime):
assert int(m.ctrl)==145
assert m.msg_id==34
assert m.header_len==23
assert m.ts_offset==0
assert m.data_len==0
assert m._forward_buffer==b''
assert m._send_buffer==bytearray(b'\x00\x00\x00\x1b\x10R170000000000001\x91"\x00\x00\x01\x89\xc6,_\x00')
assert m._send_buffer==b'\x00\x00\x00\x1b\x10R170000000000001\x91"\x00\x00\x01\x8b\xdfs\xcc0'
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.close()
@@ -621,9 +507,8 @@ def test_msg_time_resp(ConfigTsunInv1, MsgTimeResp):
assert int(m.ctrl)==145
assert m.msg_id==34
assert m.header_len==23
assert m.ts_offset==3600000
assert m.data_len==8
assert m._forward_buffer==b''
assert m._forward_buffer==MsgTimeResp
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.close()
@@ -640,32 +525,12 @@ def test_msg_time_resp_autark(ConfigNoTsunInv1, MsgTimeResp):
assert int(m.ctrl)==145
assert m.msg_id==34
assert m.header_len==23
assert m.ts_offset==3600000
assert m.data_len==8
assert m._forward_buffer==b''
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.close()
def test_msg_time_inv_resp(ConfigTsunInv1, MsgTimeRespInv):
ConfigTsunInv1
m = MemoryStream(MsgTimeRespInv, (0,), False)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
m.read() # read complete msg, and dispatch msg
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 1
assert m.id_str == b"R170000000000001"
assert m.unique_id == 'R170000000000001'
assert int(m.ctrl)==145
assert m.msg_id==34
assert m.header_len==23
assert m.ts_offset==0
assert m.data_len==4
assert m._forward_buffer==MsgTimeRespInv
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.close()
def test_msg_time_invalid(ConfigTsunInv1, MsgTimeInvalid):
ConfigTsunInv1
m = MemoryStream(MsgTimeInvalid, (0,), False)
@@ -678,7 +543,6 @@ def test_msg_time_invalid(ConfigTsunInv1, MsgTimeInvalid):
assert int(m.ctrl)==148
assert m.msg_id==34
assert m.header_len==23
assert m.ts_offset==0
assert m.data_len==0
assert m._forward_buffer==MsgTimeInvalid
assert m._send_buffer==b''
@@ -696,7 +560,6 @@ def test_msg_time_invalid_autark(ConfigNoTsunInv1, MsgTimeInvalid):
assert m.unique_id == 'R170000000000001'
assert int(m.ctrl)==148
assert m.msg_id==34
assert m.ts_offset==0
assert m.header_len==23
assert m.data_len==0
assert m._forward_buffer==b''
@@ -704,7 +567,7 @@ def test_msg_time_invalid_autark(ConfigNoTsunInv1, MsgTimeInvalid):
assert m.db.stat['proxy']['Unknown_Ctrl'] == 1
m.close()
def test_msg_cntrl_ind(ConfigTsunInv1, MsgControllerInd, MsgControllerIndTsOffs, MsgControllerAck):
def test_msg_cntrl_ind(ConfigTsunInv1, MsgControllerInd, MsgControllerAck):
ConfigTsunInv1
m = MemoryStream(MsgControllerInd, (0,))
m.db.stat['proxy']['Unknown_Ctrl'] = 0
@@ -717,12 +580,7 @@ def test_msg_cntrl_ind(ConfigTsunInv1, MsgControllerInd, MsgControllerIndTsOffs,
assert m.msg_id==113
assert m.header_len==23
assert m.data_len==284
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgControllerInd
m.ts_offset = -4096
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgControllerIndTsOffs
assert m._send_buffer==MsgControllerAck
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.close()
@@ -758,17 +616,12 @@ def test_msg_cntrl_invalid(ConfigTsunInv1, MsgControllerInvalid):
assert m.msg_id==113
assert m.header_len==23
assert m.data_len==1
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgControllerInvalid
m.ts_offset = -4096
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgControllerInvalid
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 1
m.close()
def test_msg_inv_ind(ConfigTsunInv1, MsgInverterInd, MsgInverterIndTsOffs, MsgInverterAck):
def test_msg_inv_ind(ConfigTsunInv1, MsgInverterInd, MsgInverterAck):
ConfigTsunInv1
tracer.setLevel(logging.DEBUG)
m = MemoryStream(MsgInverterInd, (0,))
@@ -782,62 +635,11 @@ def test_msg_inv_ind(ConfigTsunInv1, MsgInverterInd, MsgInverterIndTsOffs, MsgIn
assert m.msg_id==4
assert m.header_len==23
assert m.data_len==120
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgInverterInd
m.ts_offset = +256
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgInverterIndTsOffs
assert m._send_buffer==MsgInverterAck
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.close()
def test_msg_inv_ind2(ConfigTsunInv1, MsgInverterIndNew, MsgInverterIndTsOffs, MsgInverterAck):
ConfigTsunInv1
tracer.setLevel(logging.DEBUG)
m = MemoryStream(MsgInverterIndNew, (0,))
m.db.stat['proxy']['Unknown_Ctrl'] = 0
m.db.stat['proxy']['Invalid_Data_Type'] = 0
m.read() # read complete msg, and dispatch msg
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
assert m.db.stat['proxy']['Invalid_Data_Type'] == 0
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 1
assert m.id_str == b"R170000000000001"
assert m.unique_id == 'R170000000000001'
assert int(m.ctrl)==145
assert m.msg_id==4
assert m.header_len==23
assert m.data_len==1165
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgInverterIndNew
assert m._send_buffer==MsgInverterAck
m.close()
def test_msg_inv_ind2(ConfigTsunInv1, MsgInverterIndNew, MsgInverterIndTsOffs, MsgInverterAck):
ConfigTsunInv1
tracer.setLevel(logging.DEBUG)
m = MemoryStream(MsgInverterIndNew, (0,))
m.db.stat['proxy']['Unknown_Ctrl'] = 0
m.db.stat['proxy']['Invalid_Data_Type'] = 0
m.read() # read complete msg, and dispatch msg
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
assert m.db.stat['proxy']['Invalid_Data_Type'] == 0
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 1
assert m.id_str == b"R170000000000001"
assert m.unique_id == 'R170000000000001'
assert int(m.ctrl)==145
assert m.msg_id==4
assert m.header_len==23
assert m.data_len==1165
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgInverterIndNew
assert m._send_buffer==MsgInverterAck
m.close()
def test_msg_inv_ack(ConfigTsunInv1, MsgInverterAck):
ConfigTsunInv1
tracer.setLevel(logging.ERROR)
@@ -871,11 +673,6 @@ def test_msg_inv_invalid(ConfigTsunInv1, MsgInverterInvalid):
assert m.msg_id==4
assert m.header_len==23
assert m.data_len==1
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgInverterInvalid
m.ts_offset = 256
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgInverterInvalid
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 1
@@ -895,11 +692,6 @@ def test_msg_ota_req(ConfigTsunInv1, MsgOtaReq):
assert m.msg_id==19
assert m.header_len==23
assert m.data_len==259
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgOtaReq
m.ts_offset = 4096
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgOtaReq
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
@@ -922,11 +714,6 @@ def test_msg_ota_ack(ConfigTsunInv1, MsgOtaAck):
assert m.msg_id==19
assert m.header_len==23
assert m.data_len==1
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgOtaAck
m.ts_offset = 256
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgOtaAck
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
@@ -947,12 +734,7 @@ def test_msg_ota_invalid(ConfigTsunInv1, MsgOtaInvalid):
assert m.msg_id==19
assert m.header_len==23
assert m.data_len==1
m.ts_offset = 0
m._update_header(m._forward_buffer)
assert m._forward_buffer==MsgOtaInvalid
m.ts_offset = 4096
assert m._forward_buffer==MsgOtaInvalid
m._update_header(m._forward_buffer)
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Ctrl'] == 1
assert m.db.stat['proxy']['OTA_Start_Msg'] == 0
@@ -1065,7 +847,7 @@ def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd):
ConfigTsunInv1
m = MemoryStream(b'')
m.id_str = b"R170000000000001"
m.state = State.up
m.state = m.STATE_UP
c = m.createClientStream(MsgModbusCmd)
@@ -1171,29 +953,6 @@ def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp):
assert m.db.stat['proxy']['Modbus_Command'] == 0
m.close()
def test_msg_modbus_cloud_rsp(ConfigTsunInv1, MsgModbusRsp):
'''Modbus response from TSUN without a valid Modbus request must be dropped'''
ConfigTsunInv1
m = MemoryStream(MsgModbusRsp, (0,), False)
m.db.stat['proxy']['Unknown_Ctrl'] = 0
m.db.stat['proxy']['Unknown_Msg'] = 0
m.db.stat['proxy']['Modbus_Command'] = 0
m.read() # read complete msg, and dispatch msg
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 1
assert m.id_str == b"R170000000000001"
assert m.unique_id == 'R170000000000001'
assert int(m.ctrl)==145
assert m.msg_id==119
assert m.header_len==23
assert m.data_len==13
assert m._forward_buffer==b''
assert m._send_buffer==b''
assert m.db.stat['proxy']['Unknown_Msg'] == 1
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
assert m.db.stat['proxy']['Modbus_Command'] == 0
m.close()
def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusResp20):
'''Modbus response with a valid Modbus request must be forwarded'''
ConfigTsunInv1
@@ -1235,11 +994,11 @@ def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusResp20):
m.close()
def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp21):
def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20):
'''Modbus response with a valid Modbus request must be forwarded'''
ConfigTsunInv1
m = MemoryStream(MsgModbusResp21)
m.append_msg(MsgModbusResp21)
m = MemoryStream(MsgModbusResp20)
m.append_msg(MsgModbusResp20)
m.mb.rsp_handler = m.msg_forward
m.mb.last_addr = 1
@@ -1256,10 +1015,10 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp21):
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.mb.err == 0
assert m.msg_count == 1
assert m._forward_buffer==MsgModbusResp21
assert m._forward_buffer==MsgModbusResp20
assert m._send_buffer==b''
assert m.db.db == {'inverter': {'Version': 'V5.1.0E', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}}
assert m.db.get_db_value(Register.VERSION) == 'V5.1.0E'
assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}}
assert m.db.get_db_value(Register.VERSION) == 'V5.1.09'
assert m.new_data['inverter'] == True
m.new_data['inverter'] = False
assert m.mb.req_pend == False
@@ -1268,10 +1027,10 @@ def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp21):
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.mb.err == 5
assert m.msg_count == 2
assert m._forward_buffer==MsgModbusResp21
assert m._forward_buffer==MsgModbusResp20
assert m._send_buffer==b''
assert m.db.db == {'inverter': {'Version': 'V5.1.0E', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}}
assert m.db.get_db_value(Register.VERSION) == 'V5.1.0E'
assert m.db.db == {'inverter': {'Version': 'V5.1.09', 'Rated_Power': 300}, 'grid': {'Voltage': 225.9, 'Current': 0.41, 'Frequency': 49.99, 'Output_Power': 94.8}, 'env': {'Inverter_Temp': 22}, 'input': {'pv1': {'Voltage': 0.8, 'Current': 0.0, 'Power': 0.0}, 'pv2': {'Voltage': 34.5, 'Current': 2.89, 'Power': 99.8}, 'pv3': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}, 'pv4': {'Voltage': 0.0, 'Current': 0.0, 'Power': 0.0}}}
assert m.db.get_db_value(Register.VERSION) == 'V5.1.09'
assert m.new_data['inverter'] == False
m.close()
@@ -1338,7 +1097,7 @@ async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd):
assert m._send_buffer == b''
assert m.writer.sent_pdu == b''
m.state = State.up
m.state = m.STATE_UP
await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG)
assert 0 == m.send_msg_ofs
assert m._forward_buffer == b''
@@ -1368,21 +1127,21 @@ def test_zombie_conn(ConfigTsunInv1, MsgInverterInd):
m3 = MemoryStream(MsgInverterInd, (0,))
assert MemoryStream._RefNo == 3 + start_val
assert m3.RefNo == 3 + start_val
assert m1.state == m1.State.init
assert m2.state == m2.State.init
assert m3.state == m3.State.init
assert m1.state == m1.STATE_INIT
assert m2.state == m2.STATE_INIT
assert m3.state == m3.STATE_INIT
m1.read() # read complete msg, and set unique_id
assert m1.state == m1.State.up
assert m2.state == m2.State.init
assert m3.state == m3.State.init
assert m1.state == m1.STATE_UP
assert m2.state == m2.STATE_INIT
assert m3.state == m3.STATE_INIT
m2.read() # read complete msg, and set unique_id
assert m1.state == m1.State.closed
assert m2.state == m2.State.up
assert m3.state == m3.State.init
assert m1.state == m1.STATE_CLOSED
assert m2.state == m2.STATE_UP
assert m3.state == m3.STATE_INIT
m3.read() # read complete msg, and set unique_id
assert m1.state == m1.State.closed
assert m2.state == m2.State.closed
assert m3.state == m3.State.up
assert m1.state == m1.STATE_CLOSED
assert m2.state == m2.STATE_CLOSED
assert m3.state == m3.STATE_UP
m1.close()
m2.close()
m3.close()

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -1,26 +0,0 @@
// {type:sequence}
// {generate:true}
[Inverter]ContactInd>[Proxy]
[Proxy]-[note: store Contact Info in proxy{bg:cornsilk}]
[Proxy]ContactRsp (Ok).>[Inverter]
[Inverter]getTimeReq>[Proxy]
[Proxy]ContactInd>[Cloud]
[Cloud]ContactRsp (Ok).>[Proxy]
[Proxy]getTimeReq>[Cloud]
[Cloud]TimeRsp (time).>[Proxy]
[Proxy]TimeRsp (time).>[Inverter]
[Inverter]-[note: set clock in inverter{bg:cornsilk}]
[Inverter]DataInd (ts:=time)>[Proxy]
[Proxy]DataRsp>[Inverter]
[Proxy]DataInd (ts)>>[Cloud]
[Proxy]DataInd>>[MQTT-Broker]
[Cloud]DataRsp>>[Proxy]
[Inverter]DataInd (ts:=time)>[Proxy]
[Proxy]DataRsp>[Inverter]
[Proxy]DataInd (ts)>>[Cloud]
[Proxy]DataInd>>[MQTT-Broker]
[Cloud]DataRsp>>[Proxy]

View File

@@ -77,15 +77,10 @@ services:
- ${DNS2:-4.4.4.4}
ports:
- 5005:5005
- 8127:8127
- 10000:10000
volumes:
- ${PROJECT_DIR:-./}tsun-proxy/log:/home/tsun-proxy/log
- ${PROJECT_DIR:-./}tsun-proxy/config:/home/tsun-proxy/config
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8127/-/healthy || exit 1
interval: 10s
timeout: 3s
networks:
- outside

View File

@@ -89,24 +89,6 @@ def MsgDataResp(): # Contact Response message
return msg
@pytest.fixture
def MsgInvalidInfo(): # Contact Info message wrong start byte
msg = b'\x47\xd4\x00\x10\x41\x00\x01' +get_sn() +b'\x02\xba\xd2\x00\x00'
msg += b'\x19\x00\x00\x00\x00\x00\x00\x00\x05\x3c\x78\x01\x64\x01\x4c\x53'
msg += b'\x57\x35\x42\x4c\x45\x5f\x31\x37\x5f\x30\x32\x42\x30\x5f\x31\x2e'
msg += b'\x30\x35\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x40\x2a\x8f\x4f\x51\x54\x31\x39\x32\x2e'
msg += b'\x31\x36\x38\x2e\x38\x30\x2e\x34\x39\x00\x00\x00\x0f\x00\x01\xb0'
msg += b'\x02\x0f\x00\xff\x56\x31\x2e\x31\x2e\x30\x30\x2e\x30\x42\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xfe\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x41\x6c\x6c\x69\x75\x73\x2d\x48\x6f'
msg += b'\x6d\x65\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x3c'
msg += b'\x15'
return msg
@pytest.fixture(scope="session")
@@ -173,22 +155,4 @@ def test_data_ind(ClientConnection,MsgDataInd, MsgDataResp):
# time.sleep(2.5)
checkResponse(data, MsgDataResp)
def test_inavlid_msg(ClientConnection,MsgInvalidInfo,MsgContactInfo, MsgContactResp):
s = ClientConnection
try:
s.sendall(MsgInvalidInfo)
# time.sleep(2.5)
data = s.recv(1024)
except TimeoutError:
pass
# time.sleep(2.5)
try:
s.sendall(MsgContactInfo)
# time.sleep(2.5)
data = s.recv(1024)
except TimeoutError:
pass
# time.sleep(2.5)
checkResponse(data, MsgContactResp)