diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 8f4f09a..2c7031b 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -29,15 +29,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python 3.12
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install flake8 pytest
+ pip install flake8 pytest pytest-asyncio
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ddf9b0..3d4eeed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,12 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- improve logging: add protocol or node_id to connection logs
+- improve logging: log ignored AT+ or MODBUS commands
+- improve tracelog: log level depends on message type and source
+- fix typo in docker-compose.yaml and remove the external network definition
+- trace heartbeat and regular modbus pakets witl log level DEBUG
+- GEN3PLUS: don't forward ack paket from tsun to the inverter
+- GEN3PLUS: add allow and block filter for AT+ commands
+- 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 invterts
+- add graceful shutdown
+- parse Modbus values and store them in the database
+- add cron task to request the output power every minute
+- GEN3PLUS: add MQTT topics to send AT commands to the inverter
+- add MQTT topics to send Modbus commands to the inverter
- convert data collect interval to minutes
- add postfix for rc and dev versions to the version number
- change logging level to DEBUG for some logs
- remove experimental value Register.VALUE_1
- format Register.POWER_ON_TIME as integer
-- ignore non realtime values for now
+- ignore catch-up values from the inverters for now
## [0.7.0] - 2024-04-20
diff --git a/README.md b/README.md
index 1a26179..67a6ead 100644
--- a/README.md
+++ b/README.md
@@ -39,12 +39,17 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole.
## Features
-- supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600
-- supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300
+- Supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600
+- Supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300
- `MQTT` support
- `Home-Assistant` auto-discovery support
+- `MODBUS` support via MQTT topics
+- `AT-Command` support via MQTT topics (GEN3PLUS only)
+- Faster DataUp interval sends measurement data to the MQTT broker every minute
- Self-sufficient island operation without internet
-- runs in a non-root Docker Container
+- Security-Features:
+ - control access via `AT-commands`
+ - Runs in a non-root Docker Container
## Home Assistant Screenshots
@@ -156,7 +161,7 @@ suggested_area = 'balcony' # Optional, suggested installation area for home-a
pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
-[inverters."Y17xxxxxxxxxxxx1"]
+[inverters."Y17xxxxxxxxxxxx1"] # This block is also for inverters with a Y47 serial no
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
@@ -165,6 +170,12 @@ pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module de
pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
+[gen3plus.at_acl]
+tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'] # allow this for TSUN access
+tsun.block = []
+mqtt.allow = ['AT+'] # allow all via mqtt
+mqtt.block = []
+
```
## Inverter Configuration
@@ -215,7 +226,7 @@ In the following table you will find an overview of which inverter model has bee
A combination with a red question mark should work, but I have not checked it in detail.
- Micro Inverter Model Fw. 1.00.06 Fw. 1.00.17 Fw. 1.00.20 Fw. 1.1.00.0B
+ Micro Inverter Model Fw. 1.00.06 Fw. 1.00.17 Fw. 1.00.20 Fw. 4.0.10
GEN3 micro inverters (single MPPT): MS300, MS350, MS400 MS400-D ❓ ❓ ❓ ➖
GEN3 micro inverters (dual MPPT): MS600, MS700, MS800 MS600-D, MS800-D ✔️ ✔️ ✔️ ➖
GEN3 PLUS micro inverters: MS1600, MS1800, MS2000 MS2000-D ➖ ➖ ➖ ✔️
@@ -230,7 +241,7 @@ Legend
🚧: Proxy support in preparation
```
-❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` instead of `R17E`
+❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` or `Y47E` instead of `R17E`
If you have one of these combinations with a red question mark, it would be very nice if you could send me a proxy trace so that I can carry out the detailed checks and adjust the device and system tests. [Ask here how to send a trace](https://github.com/s-allius/tsun-gen3-proxy/discussions/categories/traces-for-compatibility-check)
diff --git a/app/build.sh b/app/build.sh
index 07eefed..c654c1c 100755
--- a/app/build.sh
+++ b/app/build.sh
@@ -18,22 +18,30 @@ arr=(${VERSION//./ })
MAJOR=${arr[0]}
IMAGE=tsun-gen3-proxy
-if [[ $1 == dev ]] || [[ $1 == rc ]] ;then
+if [[ $1 == debug ]] || [[ $1 == dev ]] ;then
IMAGE=docker.io/sallius/${IMAGE}
VERSION=${VERSION}-$1
-elif [[ $1 == rel ]];then
+elif [[ $1 == rc ]] || [[ $1 == rel ]];then
IMAGE=ghcr.io/s-allius/${IMAGE}
else
echo argument missing!
-echo try: $0 '[dev|rc|rel]'
+echo try: $0 '[debug|dev|rc|rel]'
exit 1
fi
echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE
-if [[ $1 == dev ]];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}:latest app
+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}: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 == 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}:latest app
+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 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'
@@ -41,4 +49,7 @@ echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
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
\ No newline at end of file
+fi
+
+echo 'check docker-compose.yaml file'
+docker-compose config -q
\ No newline at end of file
diff --git a/app/config/default_config.toml b/app/config/default_config.toml
index cd95d75..fbe2651 100644
--- a/app/config/default_config.toml
+++ b/app/config/default_config.toml
@@ -49,3 +49,8 @@ monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker e
#pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
#pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
+[gen3plus.at_acl]
+tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE']
+tsun.block = []
+mqtt.allow = ['AT+']
+mqtt.block = []
diff --git a/app/proxy.svg b/app/proxy.svg
index 588835e..b111fd4 100644
--- a/app/proxy.svg
+++ b/app/proxy.svg
@@ -4,340 +4,381 @@
-
-
+
+
G
-
+
A0
-
-
-
-You can stick notes
-on diagrams too!
+
+
+
+You can stick notes
+on diagrams too!
A1
-
-Singleton
+
+Singleton
A2
-
-Mqtt
-
-<static>ha_restarts
-<static>__client
-<static>__cb_MqttIsUp
-
-<async>publish()
-<async>close()
+
+Mqtt
+
+<static>ha_restarts
+<static>__client
+<static>__cb_MqttIsUp
+
+<async>publish()
+<async>close()
A1->A2
-
-
-
-
-
-A10
-
-Inverter
-
-cls.db_stat
-cls.entity_prfx
-cls.discovery_prfx
-cls.proxy_node_id
-cls.proxy_unique_id
-cls.mqtt:Mqtt
-
-
-
-
-A2->A10
-
-
-
-
-A3
-
-IterRegistry
-
-
-__iter__
-
-
-
-A4
-
-Message
-
-server_side:bool
-header_valid:bool
-header_len:unsigned
-data_len:unsigned
-unique_id
-node_id
-sug_area
-_recv_buffer:bytearray
-_send_buffer:bytearray
-_forward_buffer:bytearray
-db:Infos
-new_data:list
-
-_read():void<abstract>
-close():void
-inc_counter():void
-dec_counter():void
-
-
-
-A3->A4
-
-
-
-
-
-A5
-
-Talent
-
-await_conn_resp_cnt
-id_str
-contact_name
-contact_mail
-switch
-
-msg_contact_info()
-msg_ota_update()
-msg_get_time()
-msg_collector_data()
-msg_inverter_data()
-msg_unknown()
-close()
-
-
-
-A4->A5
-
-
-
-
-
-A6
-
-SolarmanV5
-
-control
-serial
-snr
-switch
-
-msg_unknown()
-close()
-
-
-
-A4->A6
-
-
-
-
-
-A7
-
-ConnectionG3
-
-remoteStream:ConnectionG3
-
-close()
-
-
-
-A5->A7
-
-
-
-
-
-A8
-
-ConnectionG3P
-
-remoteStream:ConnectionG3P
-
-close()
-
-
-
-A6->A8
-
-
-
-
-
-A7->A7
-
-
-0..1
-has
+
+
A11
-
-InverterG3
-
-__ha_restarts
-
-async_create_remote()
-close()
+
+Inverter
+
+cls.db_stat
+cls.entity_prfx
+cls.discovery_prfx
+cls.proxy_node_id
+cls.proxy_unique_id
+cls.mqtt:Mqtt
+
-
-
-A7->A11
-
-
+
+
+A2->A11
+
-
-
-A8->A8
-
-
-0..1
-has
+
+
+A3
+
+Modbus
+
+que
+snd_handler
+rsp_handler
+timeout:max_retires
+last_xxx
+err
+retry_cnt
+req_pend
+tim
+
+build_msg()
+recv_req()
+recv_resp()
-
-
-A12
-
-InverterG3P
-
-__ha_restarts
-
-async_create_remote()
-close()
+
+
+A4
+
+IterRegistry
+
+
+__iter__
-
-
-A8->A12
-
-
+
+
+A5
+
+Message
+
+server_side:bool
+header_valid:bool
+header_len:unsigned
+data_len:unsigned
+unique_id
+node_id
+sug_area
+_recv_buffer:bytearray
+_send_buffer:bytearray
+_forward_buffer:bytearray
+db:Infos
+new_data:list
+state
+
+_read():void<abstract>
+close():void
+inc_counter():void
+dec_counter():void
+
+
+
+A4->A5
+
+
+
+
+
+A6
+
+Talent
+
+await_conn_resp_cnt
+id_str
+contact_name
+contact_mail
+db:InfosG3
+mb:Modbus
+switch
+
+msg_contact_info()
+msg_ota_update()
+msg_get_time()
+msg_collector_data()
+msg_inverter_data()
+msg_unknown()
+close()
+
+
+
+A5->A6
+
+
+
+
+
+A7
+
+SolarmanV5
+
+control
+serial
+snr
+db:InfosG3P
+mb:Modbus
+switch
+
+msg_unknown()
+close()
+
+
+
+A5->A7
+
+
+
+
+
+A6->A3
+
+
+1
+has
+
+
+
+A8
+
+ConnectionG3
+
+remoteStream:ConnectionG3
+
+close()
+
+
+
+A6->A8
+
+
+
+
+
+A7->A3
+
+
+1
+has
A9
-
-AsyncStream
-
-reader
-writer
-addr
-r_addr
-l_addr
-
-<async>server_loop()
-<async>client_loop()
-<async>loop
-disc()
-close()
-__async_read()
-__async_write()
-__async_forward()
+
+ConnectionG3P
+
+remoteStream:ConnectionG3P
+
+close()
-
+
-A9->A7
-
-
+A7->A9
+
+
-
-
-A9->A8
-
-
+
+
+A8->A8
+
+
+0..1
+has
-
-
-A10->A11
-
-
+
+
+A12
+
+InverterG3
+
+__ha_restarts
+
+async_create_remote()
+close()
-
-
-A10->A12
-
-
+
+
+A8->A12
+
+
+
+
+
+A9->A9
+
+
+0..1
+has
A13
-
-Infos
-
-stat
-new_stat_data
-info_dev
-
-static_init()
-dev_value()
-inc_counter()
-dec_counter()
-ha_proxy_conf
-ha_conf
-update_db
-set_db_def_value
-get_db_value
-ignore_this_device
+
+InverterG3P
+
+__ha_restarts
+
+async_create_remote()
+close()
+
+
+
+A9->A13
+
+
+
+
+
+A10
+
+AsyncStream
+
+reader
+writer
+addr
+r_addr
+l_addr
+
+<async>server_loop()
+<async>client_loop()
+<async>loop
+disc()
+close()
+__async_read()
+async_write()
+__async_forward()
+
+
+
+A10->A8
+
+
+
+
+
+A10->A9
+
+
+
+
+
+A11->A12
+
+
+
+
+
+A11->A13
+
+
A14
-
-InfosG3
-
-
-ha_confs()
-parse()
-
-
-
-A13->A14
-
-
+
+Infos
+
+stat
+new_stat_data
+info_dev
+
+static_init()
+dev_value()
+inc_counter()
+dec_counter()
+ha_proxy_conf
+ha_conf
+update_db
+set_db_def_value
+get_db_value
+ignore_this_device
A15
-
-InfosG3P
-
-
-ha_confs()
-parse()
+
+InfosG3
+
+
+ha_confs()
+parse()
-
-
-A13->A15
-
-
+
+
+A14->A15
+
+
-
+
+
+A16
+
+InfosG3P
+
+
+ha_confs()
+parse()
+
+
-A14->A5
-
-
+A14->A16
+
+
-
+
A15->A6
-
-
+
+
+
+
+
+A16->A7
+
+
diff --git a/app/proxy.yuml b/app/proxy.yuml
index 7f5be21..60b506e 100644
--- a/app/proxy.yuml
+++ b/app/proxy.yuml
@@ -4,13 +4,15 @@
[note: You can stick notes on diagrams too!{bg:cornsilk}]
[Singleton]^[Mqtt|ha_restarts;__client;__cb_MqttIsUp|publish();close()]
-
-[IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list|_read():void;close():void;inc_counter():void;dec_counter():void]
-[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()]
-[Message]^[SolarmanV5|control;serial;snr;switch|msg_unknown();;close()]
+[Modbus|que;;snd_handler;rsp_handler;timeout:max_retires;last_xxx;err;retry_cnt;req_pend;tim|build_msg();recv_req();recv_resp()]
+[IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list;state|_read():void;close():void;inc_counter():void;dec_counter():void]
+[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()]
+[Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()]
[Talent]^[ConnectionG3|remoteStream:ConnectionG3|close()]
+[Talent]has-1>[Modbus]
[SolarmanV5]^[ConnectionG3P|remoteStream:ConnectionG3P|close()]
-[AsyncStream|reader;writer;addr;r_addr;l_addr|server_loop();client_loop();loop;disc();close();;__async_read();__async_write();__async_forward()]^[ConnectionG3]
+[SolarmanV5]has-1>[Modbus]
+[AsyncStream|reader;writer;addr;r_addr;l_addr|server_loop();client_loop();loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3]
[AsyncStream]^[ConnectionG3P]
[Inverter|cls.db_stat;cls.entity_prfx;cls.discovery_prfx;cls.proxy_node_id;cls.proxy_unique_id;cls.mqtt:Mqtt|]^[InverterG3|__ha_restarts|async_create_remote();;close()]
[Inverter]^[InverterG3P|__ha_restarts|async_create_remote();;close()]
diff --git a/app/src/async_stream.py b/app/src/async_stream.py
index 6c1136c..17f5f59 100644
--- a/app/src/async_stream.py
+++ b/app/src/async_stream.py
@@ -17,17 +17,18 @@ class AsyncStream():
async def server_loop(self, addr):
'''Loop for receiving messages from the inverter (server-side)'''
- logging.info(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')
- logging.info(f'Server loop stopped for 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:
logging.debug("disconnect client connection")
- self.remoteStream.disc()
+ await self.remoteStream.disc()
try:
await self._async_publ_mqtt_proxy_stat('proxy')
except Exception:
@@ -36,7 +37,8 @@ class AsyncStream():
async def client_loop(self, addr):
'''Loop for receiving messages from the TSUN cloud (client-side)'''
clientStream = await self.remoteStream.loop()
- logging.info(f'Client loop stopped for 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,
@@ -61,31 +63,39 @@ class AsyncStream():
await self.__async_read()
if self.unique_id:
- await self.__async_write()
+ await self.async_write()
await self.__async_forward()
await self.async_publ_mqtt()
- except (ConnectionResetError,
- ConnectionAbortedError,
- BrokenPipeError,
- RuntimeError) as error:
- logger.warning(f'In loop for l{self.l_addr} | '
- f'r{self.r_addr}: {error}')
+ except OSError as error:
+ 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}] {error} for {self.l_addr}")
+ await self.disc()
+ self.close()
+ return self
+
except Exception:
self.inc_counter('SW_Exception')
logger.error(
f"Exception for {self.addr}:\n"
f"{traceback.format_exc()}")
- self.close()
- return self
- def disc(self) -> None:
+ 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()
@@ -100,9 +110,9 @@ class AsyncStream():
else:
raise RuntimeError("Peer closed.")
- async def __async_write(self) -> None:
+ async def async_write(self, headline='Transmit to ') -> None:
if self._send_buffer:
- hex_dump_memory(logging.INFO, f'Transmit to {self.addr}:',
+ hex_dump_memory(logging.INFO, f'{headline}{self.addr}:',
self._send_buffer, len(self._send_buffer))
self.writer.write(self._send_buffer)
await self.writer.drain()
@@ -114,7 +124,7 @@ class AsyncStream():
await self.async_create_remote()
if self.remoteStream:
if self.remoteStream._init_new_client_conn():
- await self.remoteStream.__async_write()
+ await self.remoteStream.async_write()
if self.remoteStream:
self.remoteStream._update_header(self._forward_buffer)
diff --git a/app/src/config.py b/app/src/config.py
index 73a633c..e1ef749 100644
--- a/app/src/config.py
+++ b/app/src/config.py
@@ -3,7 +3,7 @@
import shutil
import tomllib
import logging
-from schema import Schema, And, Use, Optional
+from schema import Schema, And, Or, Use, Optional
class Config():
@@ -38,6 +38,14 @@ class Config():
'proxy_node_id': Use(str),
'proxy_unique_id': Use(str)
},
+ 'gen3plus': {
+ 'at_acl': {
+ Or('mqtt', 'tsun'): {
+ 'allow': [str],
+ Optional('block', default=[]): [str]
+ }
+ }
+ },
'inverters': {
'allow_all': Use(bool), And(Use(str), lambda s: len(s) == 16): {
Optional('monitor_sn', default=0): Use(int),
@@ -80,7 +88,7 @@ class Config():
try:
# make the default config transparaent by copying it
# in the config.example file
- logging.debug('Copy Defaul Config to config.example.toml')
+ logging.debug('Copy Default Config to config.example.toml')
shutil.copy2("default_config.toml",
"config/config.example.toml")
@@ -125,7 +133,8 @@ class Config():
# merge the default and the user config
config = def_config.copy()
- for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters']:
+ for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters',
+ 'gen3plus']:
if key in usr_config:
config[key] |= usr_config[key]
diff --git a/app/src/gen3/infos_g3.py b/app/src/gen3/infos_g3.py
index 7e45634..0dc6a35 100644
--- a/app/src/gen3/infos_g3.py
+++ b/app/src/gen3/infos_g3.py
@@ -30,6 +30,7 @@ class RegisterMap:
0xffffff05: Register.UNKNOWN_CTRL,
0xffffff06: Register.OTA_START_MSG,
0xffffff07: Register.SW_EXCEPTION,
+ 0xffffff08: Register.MAX_DESIGNED_POWER,
0xfffffffe: Register.TEST_REG1,
0xffffffff: Register.TEST_REG2,
0x00000640: Register.OUTPUT_POWER,
@@ -104,7 +105,8 @@ class InfosG3(Infos):
if res:
yield res
- def parse(self, buf, ind=0) -> Generator[tuple[str, bool], None, None]:
+ def parse(self, buf, ind=0, node_id: str = '') -> \
+ Generator[tuple[str, bool], None, None]:
'''parse a data sequence received from the inverter and
stores the values in Infos.db
@@ -161,7 +163,8 @@ class InfosG3(Infos):
update = False
name = str(f'info-id.0x{addr:x}')
- self.tracer.log(level, f'GEN3: {name} : {result}{unit}'
- f' update: {update}')
+ if update:
+ self.tracer.log(level, f'[{node_id}] GEN3: {name} :'
+ f' {result}{unit}')
i += 1
diff --git a/app/src/gen3/inverter_g3.py b/app/src/gen3/inverter_g3.py
index 5fccbc2..1930f0e 100644
--- a/app/src/gen3/inverter_g3.py
+++ b/app/src/gen3/inverter_g3.py
@@ -56,7 +56,7 @@ class InverterG3(Inverter, ConnectionG3):
addr = (host, port)
try:
- logging.info(f'Connected 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,
diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py
index b94c331..b4c7aa6 100644
--- a/app/src/gen3/talent.py
+++ b/app/src/gen3/talent.py
@@ -5,10 +5,12 @@ from datetime import datetime
if __name__ == "app.src.gen3.talent":
from app.src.messages import hex_dump_memory, Message
+ from app.src.modbus import Modbus
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
+ from modbus import Modbus
from config import Config
from gen3.infos_g3 import InfosG3
@@ -33,9 +35,8 @@ class Control:
class Talent(Message):
-
def __init__(self, server_side: bool, id_str=b''):
- super().__init__(server_side)
+ 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''
@@ -47,8 +48,24 @@ class Talent(Message):
0x13: self.msg_ota_update,
0x22: self.msg_get_time,
0x71: self.msg_collector_data,
+ # 0x76:
+ 0x77: self.msg_modbus,
+ # 0x78:
0x04: self.msg_inverter_data,
}
+ self.log_lvl = {
+ 0x00: logging.INFO,
+ 0x13: logging.INFO,
+ 0x22: logging.INFO,
+ 0x71: logging.INFO,
+ # 0x76:
+ 0x77: self.get_modbus_log_lvl,
+ # 0x78:
+ 0x04: logging.INFO,
+ }
+ self.modbus_elms = 0 # for unit tests
+ self.node_id = 'G3' # will be overwritten in __set_serial_no
+ # self.forwarding = Config.get('tsun')['enabled']
'''
Our puplic methods
@@ -59,6 +76,9 @@ class Talent(Message):
# 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 = self.STATE_CLOSED
+ super().close()
def __set_serial_no(self, serial_no: str):
@@ -94,7 +114,11 @@ class Talent(Message):
if self.header_valid and len(self._recv_buffer) >= (self.header_len +
self.data_len):
- hex_dump_memory(logging.INFO, f'Received from {self.addr}:',
+ log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING)
+ if callable(log_lvl):
+ log_lvl = log_lvl()
+
+ hex_dump_memory(log_lvl, f'Received from {self.addr}:',
self._recv_buffer, self.header_len+self.data_len)
self.__set_serial_no(self.id_str.decode("utf-8"))
@@ -116,6 +140,30 @@ class Talent(Message):
f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}')
return
+ def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str):
+ 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)
+ self._send_buffer += b'\x00\x01\xa3\x28' # fixme
+ self._send_buffer += struct.pack('!B', len(modbus_pdu))
+ self._send_buffer += modbus_pdu
+ self.__finish_send_msg()
+
+ hex_dump_memory(log_lvl, f'Send Modbus {state}:{self.addr}:',
+ self._send_buffer, len(self._send_buffer))
+ self.writer.write(self._send_buffer)
+ self._send_buffer = bytearray(0) # self._send_buffer[sent:]
+
+ 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)
+
def _init_new_client_conn(self) -> bool:
contact_name = self.contact_name
contact_mail = self.contact_mail
@@ -209,11 +257,13 @@ class Talent(Message):
self.header_valid = True
return
- def __build_header(self, ctrl) -> None:
+ def __build_header(self, ctrl, msg_id=None) -> None:
+ if not msg_id:
+ msg_id = self.msg_id
self.send_msg_ofs = len(self._send_buffer)
self._send_buffer += struct.pack(f'!l{len(self.id_str)+1}pBB',
- 0, self.id_str, ctrl, self.msg_id)
- fnc = self.switch.get(self.msg_id, self.msg_unknown)
+ 0, self.id_str, ctrl, msg_id)
+ fnc = self.switch.get(msg_id, self.msg_unknown)
logger.info(self.__flow_str(self.server_side, 'tx') +
f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}')
@@ -322,6 +372,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
@@ -337,6 +388,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
@@ -350,7 +402,7 @@ class Talent(Message):
msg_hdr_len = self.parse_msg_header()
for key, update in self.db.parse(self._recv_buffer, self.header_len
- + msg_hdr_len):
+ + msg_hdr_len, self.node_id):
if update:
self.new_data[key] = True
@@ -364,6 +416,53 @@ class Talent(Message):
self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len)
+ def parse_modbus_header(self):
+
+ msg_hdr_len = 5
+
+ result = struct.unpack_from('!lBB', self._recv_buffer,
+ self.header_len)
+ modbus_len = result[1]
+ # logger.debug(f'Ref: {result[0]}')
+ # logger.debug(f'Modbus MsgLen: {modbus_len} Func:{result[2]}')
+ return msg_hdr_len, modbus_len
+
+ def get_modbus_log_lvl(self) -> int:
+ if self.ctrl.is_req():
+ return logging.INFO
+ elif self.ctrl.is_ind():
+ if self.server_side:
+ return self.mb.last_log_lvl
+ return logging.WARNING
+
+ def msg_modbus(self):
+ hdr_len, modbus_len = self.parse_modbus_header()
+ data = self._recv_buffer[self.header_len:
+ self.header_len+self.data_len]
+
+ if self.ctrl.is_req():
+ if self.remoteStream.mb.recv_req(data[hdr_len:],
+ self.msg_forward):
+ self.inc_counter('Modbus_Command')
+ else:
+ self.inc_counter('Invalid_Msg_Format')
+ elif self.ctrl.is_ind():
+ # logger.debug(f'Modbus Ind MsgLen: {modbus_len}')
+ self.modbus_elms = 0
+ for key, update, _ in self.mb.recv_resp(self.db, data[
+ hdr_len:],
+ self.node_id):
+ if update:
+ self.new_data[key] = True
+ self.modbus_elms += 1 # count for unit tests
+ else:
+ logger.warning('Unknown Ctrl')
+ self.inc_counter('Unknown_Ctrl')
+ self.forward(self._recv_buffer, self.header_len+self.data_len)
+
+ def msg_forward(self):
+ self.forward(self._recv_buffer, self.header_len+self.data_len)
+
def msg_unknown(self):
logger.warning(f"Unknow Msg: ID:{self.msg_id}")
self.inc_counter('Unknown_Msg')
diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py
index 0191d04..bf0aed8 100644
--- a/app/src/gen3plus/infos_g3p.py
+++ b/app/src/gen3plus/infos_g3p.py
@@ -19,13 +19,13 @@ class RegisterMap:
0x4102001a: {'reg': Register.HEARTBEAT_INTERVAL, 'fmt': '>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501
+ 0x420100d0: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501
0x420100d2: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
0x420100d4: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x420100d6: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
@@ -88,7 +88,7 @@ class InfosG3P(Infos):
if res:
yield res
- def parse(self, buf, msg_type: int, rcv_ftype: int) \
+ def parse(self, buf, msg_type: int, rcv_ftype: int, node_id: str = '') \
-> Generator[tuple[str, bool], None, None]:
'''parse a data sequence received from the inverter and
stores the values in Infos.db
@@ -122,5 +122,6 @@ class InfosG3P(Infos):
name = str(f'info-id.0x{addr:x}')
update = False
- self.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}'
- f' update: {update}')
+ if update:
+ self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}'
+ f' : {result}{unit}')
diff --git a/app/src/gen3plus/inverter_g3p.py b/app/src/gen3plus/inverter_g3p.py
index b7b9800..487fe1e 100644
--- a/app/src/gen3plus/inverter_g3p.py
+++ b/app/src/gen3plus/inverter_g3p.py
@@ -56,7 +56,7 @@ class InverterG3P(Inverter, ConnectionG3P):
addr = (host, port)
try:
- logging.info(f'Connected 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,
diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py
index d23efa3..64e4536 100644
--- a/app/src/gen3plus/solarman_v5.py
+++ b/app/src/gen3plus/solarman_v5.py
@@ -2,16 +2,19 @@ import struct
# import json
import logging
import time
+import asyncio
from datetime import datetime
if __name__ == "app.src.gen3plus.solarman_v5":
from app.src.messages import hex_dump_memory, Message
+ from app.src.modbus import Modbus
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
from config import Config
+ from modbus import Modbus
from gen3plus.infos_g3p import InfosG3P
from infos import Register
# import traceback
@@ -46,9 +49,11 @@ class Sequence():
class SolarmanV5(Message):
+ AT_CMD = 1
+ MB_RTU_CMD = 2
def __init__(self, server_side: bool):
- super().__init__(server_side)
+ super().__init__(server_side, self.send_modbus_cb, mb_timeout=5)
self.header_len = 11 # overwrite construcor in class Message
self.control = 0
@@ -56,6 +61,7 @@ class SolarmanV5(Message):
self.snr = 0
self.db = InfosG3P()
self.time_ofs = 0
+ self.forward_at_cmd_resp = False
self.switch = {
0x4210: self.msg_data_ind, # real time data
@@ -84,9 +90,41 @@ class SolarmanV5(Message):
#
# MODbus or AT cmd
0x4510: self.msg_command_req, # from server
- 0x1510: self.msg_response, # from inverter
+ 0x1510: self.msg_command_rsp, # from inverter
+ # 0x0510: self.msg_command_rsp, # from inverter
}
+ self.log_lvl = {
+
+ 0x4210: logging.INFO, # real time data
+ 0x1210: logging.INFO, # at least every 5 minutes
+
+ 0x4710: logging.DEBUG, # heatbeat
+ 0x1710: logging.DEBUG, # every 2 minutes
+
+ 0x4110: logging.INFO, # device data, sync start
+ 0x1110: logging.INFO, # every 3 hours
+
+ 0x4310: logging.INFO, # regulary after 3-6 hours
+ 0x1310: logging.INFO,
+
+ 0x4810: logging.INFO, # sync end
+ 0x1810: logging.INFO,
+
+ #
+ # MODbus or AT cmd
+ 0x4510: logging.INFO, # from server
+ 0x1510: self.get_cmd_rsp_log_lvl,
+ }
+ self.modbus_elms = 0 # for unit tests
+ g3p_cnf = Config.get('gen3plus')
+
+ if 'at_acl' in g3p_cnf: # pragma: no cover
+ self.at_acl = g3p_cnf['at_acl']
+
+ self.node_id = 'G3P' # will be overwritten in __set_serial_no
+ # self.forwarding = Config.get('solarman')['enabled']
+
'''
Our puplic methods
'''
@@ -96,6 +134,9 @@ class SolarmanV5(Message):
# 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 = self.STATE_CLOSED
+ super().close()
def __set_serial_no(self, snr: int):
serial_no = str(snr)
@@ -136,7 +177,10 @@ class SolarmanV5(Message):
if self.header_valid and len(self._recv_buffer) >= (self.header_len +
self.data_len+2):
- hex_dump_memory(logging.INFO, f'Received from {self.addr}:',
+ log_lvl = self.log_lvl.get(self.control, logging.WARNING)
+ if callable(log_lvl):
+ log_lvl = log_lvl()
+ hex_dump_memory(log_lvl, f'Received from {self.addr}:',
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):
@@ -293,41 +337,90 @@ class SolarmanV5(Message):
self._heartbeat())
self.__finish_send_msg()
- def send_at_cmd(self, AT_cmd: str) -> None:
+ def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str):
+ 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(f' 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)
+
+ 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 != self.STATE_UP:
+ logger.warn(f'[{self.node_id}] ignore AT+ cmd,'
+ ' as the state is not UP')
+ return
+ AT_cmd = AT_cmd.strip()
+
+ if self.at_cmd_forbidden(cmd=AT_cmd, connection='mqtt'):
+ data_json = f'\'{AT_cmd}\' is forbidden'
+ node_id = self.node_id
+ key = 'at_resp'
+ logger.info(f'{key}: {data_json}')
+ asyncio.ensure_future(
+ self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json)) # noqa: E501
+ return
+
+ self.forward_at_cmd_resp = False
+ self.__build_header(0x4510)
+ self._send_buffer += struct.pack(f'> 8
- for key, update in self.db.parse(self._recv_buffer, msg_type, ftype):
+ for key, update in self.db.parse(self._recv_buffer, msg_type, ftype,
+ self.node_id):
if update:
if key == 'inverter':
inv_update = True
self.new_data[key] = True
if inv_update:
- db = self.db
- MaxPow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
- Rated = db.get_db_value(Register.RATED_POWER, 0)
- Model = None
- if MaxPow == 2000:
- if Rated == 800 or Rated == 600:
- Model = f'TSOL-MS{MaxPow}({Rated})'
- else:
- Model = f'TSOL-MS{MaxPow}'
- elif MaxPow == 1800 or MaxPow == 1600:
- Model = f'TSOL-MS{MaxPow}'
- if Model:
- logger.info(f'Model: {Model}')
- self.db.set_db_def_value(Register.EQUIPMENT_MODEL, Model)
-
+ self.__build_model_name()
'''
Message handler methods
'''
@@ -340,14 +433,14 @@ class SolarmanV5(Message):
data = self._recv_buffer[self.header_len:]
result = struct.unpack_from(' int:
+ ftype = self._recv_buffer[self.header_len]
+ if ftype == self.AT_CMD:
+ if self.forward_at_cmd_resp:
+ return logging.INFO
+ return logging.DEBUG
+ elif ftype == self.MB_RTU_CMD:
+ if self.server_side:
+ return self.mb.last_log_lvl
+
+ return logging.WARNING
+
+ def msg_command_rsp(self):
+ data = self._recv_buffer[self.header_len:
+ self.header_len+self.data_len]
+ ftype = data[0]
+ if ftype == self.AT_CMD:
+ if not self.forward_at_cmd_resp:
+ data_json = data[14:].decode("utf-8")
+ node_id = self.node_id
+ key = 'at_resp'
+ logger.info(f'{key}: {data_json}')
+ 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]
+ modbus_msg_len = self.data_len - 14
+ # logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}')
+ if valid == 1 and modbus_msg_len > 4:
+ # logger.info(f'first byte modbus:{data[14]}')
+ inv_update = False
+ self.modbus_elms = 0
+
+ for key, update, _ in self.mb.recv_resp(self.db, data[14:],
+ self.node_id):
+ self.modbus_elms += 1
+ if update:
+ if key == 'inverter':
+ inv_update = True
+ self.new_data[key] = True
+
+ if inv_update:
+ self.__build_model_name()
+ return
+ self.__forward_msg()
def msg_hbeat_ind(self):
data = self._recv_buffer[self.header_len:]
@@ -402,6 +560,7 @@ class SolarmanV5(Message):
self.__forward_msg()
self.__send_ack_rsp(0x1710, ftype)
+ self.state = self.STATE_UP
def msg_sync_end(self):
data = self._recv_buffer[self.header_len:]
@@ -423,8 +582,8 @@ class SolarmanV5(Message):
valid = result[1] == 1 # status
ts = result[2]
set_hb = result[3] # always 60 or 120
- logger.info(f'ftype:{ftype} accepted:{valid}'
- f' ts:{ts:08x} nextHeartbeat: {set_hb}s')
+ logger.debug(f'ftype:{ftype} accepted:{valid}'
+ f' ts:{ts:08x} nextHeartbeat: {set_hb}s')
dt = datetime.fromtimestamp(ts)
- logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
+ logger.debug(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
diff --git a/app/src/infos.py b/app/src/infos.py
index ba0655e..ee5a38a 100644
--- a/app/src/infos.py
+++ b/app/src/infos.py
@@ -28,6 +28,8 @@ class Register(Enum):
SW_EXCEPTION = 57
INVALID_MSG_FMT = 58
AT_COMMAND = 59
+ MODBUS_COMMAND = 60
+ AT_COMMAND_BLOCKED = 61
OUTPUT_POWER = 83
RATED_POWER = 84
INVERTER_TEMP = 85
@@ -86,7 +88,7 @@ class Register(Enum):
DATA_UP_INTERVAL = 404
CONNECT_COUNT = 405
HEARTBEAT_INTERVAL = 406
- IP_ADRESS = 407
+ IP_ADDRESS = 407
EVENT_401 = 500
EVENT_402 = 501
EVENT_403 = 502
@@ -192,7 +194,7 @@ class Infos:
Register.SERIAL_NUMBER: {'name': ['inverter', 'Serial_Number'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.EQUIPMENT_MODEL: {'name': ['inverter', 'Equipment_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.NO_INPUTS: {'name': ['inverter', 'No_Inputs'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
- Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'fmt': '| string + " W"', 'name': 'Max Designed Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'fmt': '| string + " W"', 'name': 'Max Designed Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.RATED_POWER: {'name': ['inverter', 'Rated_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'rated_power_', 'fmt': '| string + " W"', 'name': 'Rated Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV1_MANUFACTURER: {'name': ['inverter', 'PV1_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
@@ -209,16 +211,18 @@ class Infos:
Register.PV6_MODEL: {'name': ['inverter', 'PV6_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
# proxy:
- Register.INVERTER_CNT: {'name': ['proxy', 'Inverter_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_count_', 'fmt': '| int', 'name': 'Active Inverter Connections', 'icon': 'mdi:counter'}}, # noqa: E501
- Register.UNKNOWN_SNR: {'name': ['proxy', 'Unknown_SNR'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_snr_', 'fmt': '| int', 'name': 'Unknown Serial No', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': '| int', 'name': 'Unknown Msg Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.INVALID_DATA_TYPE: {'name': ['proxy', 'Invalid_Data_Type'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_data_type_', 'fmt': '| int', 'name': 'Invalid Data Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.INTERNAL_ERROR: {'name': ['proxy', 'Internal_Error'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'intern_err_', 'fmt': '| int', 'name': 'Internal Error', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic', 'en': False}}, # noqa: E501
- Register.UNKNOWN_CTRL: {'name': ['proxy', 'Unknown_Ctrl'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_ctrl_', 'fmt': '| int', 'name': 'Unknown Control Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.OTA_START_MSG: {'name': ['proxy', 'OTA_Start_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'ota_start_cmd_', 'fmt': '| int', 'name': 'OTA Start Cmd', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.SW_EXCEPTION: {'name': ['proxy', 'SW_Exception'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'sw_exception_', 'fmt': '| int', 'name': 'Internal SW Exception', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.INVALID_MSG_FMT: {'name': ['proxy', 'Invalid_Msg_Format'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_msg_fmt_', 'fmt': '| int', 'name': 'Invalid Message Format', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.AT_COMMAND: {'name': ['proxy', 'AT_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_', 'fmt': '| int', 'name': 'AT Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.INVERTER_CNT: {'name': ['proxy', 'Inverter_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_count_', 'fmt': '| int', 'name': 'Active Inverter Connections', 'icon': 'mdi:counter'}}, # noqa: E501
+ Register.UNKNOWN_SNR: {'name': ['proxy', 'Unknown_SNR'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_snr_', 'fmt': '| int', 'name': 'Unknown Serial No', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': '| int', 'name': 'Unknown Msg Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.INVALID_DATA_TYPE: {'name': ['proxy', 'Invalid_Data_Type'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_data_type_', 'fmt': '| int', 'name': 'Invalid Data Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.INTERNAL_ERROR: {'name': ['proxy', 'Internal_Error'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'intern_err_', 'fmt': '| int', 'name': 'Internal Error', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic', 'en': False}}, # noqa: E501
+ Register.UNKNOWN_CTRL: {'name': ['proxy', 'Unknown_Ctrl'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_ctrl_', 'fmt': '| int', 'name': 'Unknown Control Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.OTA_START_MSG: {'name': ['proxy', 'OTA_Start_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'ota_start_cmd_', 'fmt': '| int', 'name': 'OTA Start Cmd', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.SW_EXCEPTION: {'name': ['proxy', 'SW_Exception'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'sw_exception_', 'fmt': '| int', 'name': 'Internal SW Exception', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.INVALID_MSG_FMT: {'name': ['proxy', 'Invalid_Msg_Format'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_msg_fmt_', 'fmt': '| int', 'name': 'Invalid Message Format', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.AT_COMMAND: {'name': ['proxy', 'AT_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_', 'fmt': '| int', 'name': 'AT Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.AT_COMMAND_BLOCKED: {'name': ['proxy', 'AT_Command_Blocked'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_blocked_', 'fmt': '| int', 'name': 'AT Command Blocked', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.MODBUS_COMMAND: {'name': ['proxy', 'Modbus_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'modbus_cmd_', 'fmt': '| int', 'name': 'Modbus Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
# 0xffffff03: {'name':['proxy', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha':{'dev':'proxy', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id':'proxy_volt_', 'fmt':'| float','name': 'Grid Voltage'}}, # noqa: E501
# events
@@ -230,7 +234,7 @@ class Infos:
Register.EVENT_406: {'name': ['events', '406_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.EVENT_407: {'name': ['events', '407_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.EVENT_408: {'name': ['events', '408_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
- Register.EVENT_409: {'name': ['events', '409_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
+ Register.EVENT_409: {'name': ['events', '409_No_Utility'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.EVENT_410: {'name': ['events', '410_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.EVENT_411: {'name': ['events', '411_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.EVENT_412: {'name': ['events', '412_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
@@ -250,22 +254,22 @@ class Infos:
# input measures:
Register.PV1_VOLTAGE: {'name': ['input', 'pv1', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv1', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv1_', 'val_tpl': "{{ (value_json['pv1']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV1_CURRENT: {'name': ['input', 'pv1', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv1', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv1_', 'val_tpl': "{{ (value_json['pv1']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501
+ Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501
Register.PV2_VOLTAGE: {'name': ['input', 'pv2', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv2', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv2_', 'val_tpl': "{{ (value_json['pv2']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV2_CURRENT: {'name': ['input', 'pv2', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv2', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv2_', 'val_tpl': "{{ (value_json['pv2']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501
+ Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501
Register.PV3_VOLTAGE: {'name': ['input', 'pv3', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv3', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv3_', 'val_tpl': "{{ (value_json['pv3']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV3_CURRENT: {'name': ['input', 'pv3', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv3', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv3_', 'val_tpl': "{{ (value_json['pv3']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501
+ Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501
Register.PV4_VOLTAGE: {'name': ['input', 'pv4', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv4', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv4_', 'val_tpl': "{{ (value_json['pv4']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV4_CURRENT: {'name': ['input', 'pv4', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv4', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv4_', 'val_tpl': "{{ (value_json['pv4']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501
+ Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501
Register.PV5_VOLTAGE: {'name': ['input', 'pv5', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv5', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv5_', 'val_tpl': "{{ (value_json['pv5']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV5_CURRENT: {'name': ['input', 'pv5', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv5', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv5_', 'val_tpl': "{{ (value_json['pv5']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501
+ Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501
Register.PV6_VOLTAGE: {'name': ['input', 'pv6', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv6', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv6_', 'val_tpl': "{{ (value_json['pv6']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV6_CURRENT: {'name': ['input', 'pv6', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv6', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv6_', 'val_tpl': "{{ (value_json['pv6']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501
+ Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501
Register.PV1_DAILY_GENERATION: {'name': ['input', 'pv1', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv1_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv1']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV1_TOTAL_GENERATION: {'name': ['input', 'pv1', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv1_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv1']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
Register.PV2_DAILY_GENERATION: {'name': ['input', 'pv2', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv2_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv2']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
@@ -290,7 +294,7 @@ class Infos:
Register.COMMUNICATION_TYPE: {'name': ['controller', 'Communication_Type'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'comm_type_', 'name': 'Communication Type', 'val_tpl': __comm_type_val_tpl, 'comp': 'sensor', 'icon': 'mdi:wifi'}}, # noqa: E501
Register.DATA_UP_INTERVAL: {'name': ['controller', 'Data_Up_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_up_intval_', 'fmt': '| string + " s"', 'name': 'Data Up Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.HEARTBEAT_INTERVAL: {'name': ['controller', 'Heartbeat_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'heartbeat_intval_', 'fmt': '| string + " s"', 'name': 'Heartbeat Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
- Register.IP_ADRESS: {'name': ['controller', 'IP_Adress'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_adress_', 'fmt': '| string', 'name': 'IP Adress', 'icon': 'mdi:wifi', 'ent_cat': 'diagnostic'}}, # noqa: E501
+ Register.IP_ADDRESS: {'name': ['controller', 'IP_Address'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_address_', 'fmt': '| string', 'name': 'IP Address', 'icon': 'mdi:wifi', 'ent_cat': 'diagnostic'}}, # noqa: E501
}
@property
@@ -402,7 +406,7 @@ class Infos:
attr['unit_of_meas'] = row['unit'] # 'unit_of_meas'
if 'icon' in ha:
attr['ic'] = ha['icon'] # icon for the entity
- if 'nat_prc' in ha:
+ if 'nat_prc' in ha: # pragma: no cover
attr['sug_dsp_prc'] = ha['nat_prc'] # precison of floats
if 'ent_cat' in ha:
attr['ent_cat'] = ha['ent_cat'] # diagnostic, config
diff --git a/app/src/messages.py b/app/src/messages.py
index 5bcf711..6736b0b 100644
--- a/app/src/messages.py
+++ b/app/src/messages.py
@@ -1,10 +1,14 @@
import logging
import weakref
+from typing import Callable
+
if __name__ == "app.src.messages":
from app.src.infos import Infos
+ from app.src.modbus import Modbus
else: # pragma: no cover
from infos import Infos
+ from modbus import Modbus
logger = logging.getLogger('msg')
@@ -50,21 +54,31 @@ class IterRegistry(type):
class Message(metaclass=IterRegistry):
_registry = []
+ STATE_INIT = 0
+ STATE_UP = 2
+ STATE_CLOSED = 3
- def __init__(self, server_side: bool):
+ def __init__(self, server_side: bool, send_modbus_cb:
+ Callable[[bytes, int, str], None], mb_timeout):
self._registry.append(weakref.ref(self))
self.server_side = server_side
+ if server_side:
+ self.mb = Modbus(send_modbus_cb, mb_timeout)
+ else:
+ self.mb = None
+
self.header_valid = False
self.header_len = 0
self.data_len = 0
self.unique_id = 0
- self.node_id = ''
+ self.node_id = '' # will be overwritten in the child class's __init__
self.sug_area = ''
self._recv_buffer = bytearray(0)
self._send_buffer = bytearray(0)
self._forward_buffer = bytearray(0)
self.new_data = {}
+ self.state = self.STATE_INIT
'''
Empty methods, that have to be implemented in any child class which
@@ -82,6 +96,9 @@ class Message(metaclass=IterRegistry):
Our puplic methods
'''
def close(self) -> None:
+ if self.mb:
+ del self.mb
+ self.mb = None
pass # pragma: no cover
def inc_counter(self, counter: str) -> None:
diff --git a/app/src/modbus.py b/app/src/modbus.py
new file mode 100644
index 0000000..8f3778b
--- /dev/null
+++ b/app/src/modbus.py
@@ -0,0 +1,309 @@
+'''MODBUS module for TSUN inverter support
+
+TSUN uses the MODBUS in the RTU transmission mode over serial line.
+see: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
+see: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf
+
+A Modbus PDU consists of: 'Function-Code' + 'Data'
+A Modbus RTU message consists of: 'Addr' + 'Modbus-PDU' + 'CRC-16'
+The inverter is a MODBUS server and the proxy the MODBUS client.
+
+The 16-bit CRC is known as CRC-16-ANSI(reverse)
+see: https://en.wikipedia.org/wiki/Computation_of_cyclic_redundancy_checks
+'''
+import struct
+import logging
+import asyncio
+from typing import Generator, Callable
+
+if __name__ == "app.src.modbus":
+ from app.src.infos import Register
+else: # pragma: no cover
+ from infos import Register
+
+logger = logging.getLogger('data')
+
+CRC_POLY = 0xA001 # (LSBF/reverse)
+CRC_INIT = 0xFFFF
+
+
+class Modbus():
+ '''Simple MODBUS implementation with TX queue and retransmit timer'''
+ INV_ADDR = 1
+ '''MODBUS server address of the TSUN inverter'''
+ READ_REGS = 3
+ '''MODBUS function code: Read Holding Register'''
+ READ_INPUTS = 4
+ '''MODBUS function code: Read Input Register'''
+ WRITE_SINGLE_REG = 6
+ '''Modbus function code: Write Single Register'''
+
+ __crc_tab = []
+ map = {
+ 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # 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
+ 0x300c: {'reg': Register.INVERTER_TEMP, 'fmt': '!H', 'eval': 'result-40'}, # noqa: E501
+ # 0x300d
+ 0x300e: {'reg': Register.RATED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
+ 0x300f: {'reg': Register.OUTPUT_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x3010: {'reg': Register.PV1_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x3011: {'reg': Register.PV1_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x3012: {'reg': Register.PV1_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x3013: {'reg': Register.PV2_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x3014: {'reg': Register.PV2_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x3015: {'reg': Register.PV2_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x3016: {'reg': Register.PV3_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x3017: {'reg': Register.PV3_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x3018: {'reg': Register.PV3_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x3019: {'reg': Register.PV4_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x301a: {'reg': Register.PV4_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x301b: {'reg': Register.PV4_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
+ 0x301c: {'reg': Register.DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x301d: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
+ 0x301f: {'reg': Register.PV1_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x3020: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
+ 0x3022: {'reg': Register.PV2_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x3023: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
+ 0x3025: {'reg': Register.PV3_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x3026: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
+ 0x3028: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
+ 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
+ }
+
+ def __init__(self, snd_handler: Callable[[bytes, int, str], None],
+ timeout: int = 1):
+ if not len(self.__crc_tab):
+ self.__build_crc_tab(CRC_POLY)
+ self.que = asyncio.Queue(100)
+ self.snd_handler = snd_handler
+ '''Send handler to transmit a MODBUS RTU request'''
+ self.rsp_handler = None
+ '''Response handler to forward the response'''
+ self.timeout = timeout
+ '''MODBUS response timeout in seconds'''
+ self.max_retries = 1
+ '''Max retransmit for MODBUS requests'''
+ self.retry_cnt = 0
+ self.last_req = b''
+ self.counter = {}
+ '''Dictenary with statistic counter'''
+ self.counter['timeouts'] = 0
+ self.counter['retries'] = {}
+ for i in range(0, self.max_retries+1):
+ self.counter['retries'][f'{i}'] = 0
+ self.last_log_lvl = logging.DEBUG
+ self.last_addr = 0
+ self.last_fcode = 0
+ self.last_len = 0
+ self.last_reg = 0
+ self.err = 0
+ self.loop = asyncio.get_event_loop()
+ self.req_pend = False
+ self.tim = None
+
+ def __del__(self):
+ logging.debug(f'Modbus __del__:\n {self.counter}')
+
+ def build_msg(self, addr: int, func: int, reg: int, val: int,
+ log_lvl=logging.DEBUG) -> None:
+ """Build MODBUS RTU request frame and add it to the tx queue
+
+ Keyword arguments:
+ addr: RTU server address (inverter)
+ func: MODBUS function code
+ reg: 16-bit register number
+ val: 16 bit value
+ """
+ msg = struct.pack('>BBHH', addr, func, reg, val)
+ msg += struct.pack(' bool:
+ """Add the received Modbus RTU request to the tx queue
+
+ Keyword arguments:
+ buf: Modbus RTU pdu incl ADDR byte and trailing CRC
+ rsp_handler: Callback, if the received pdu is valid
+
+ Returns:
+ True: PDU was added to the queue
+ False: PDU was ignored, due to an error
+ """
+ # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}')
+ if not self.__check_crc(buf):
+ self.err = 1
+ logger.error('Modbus recv: CRC error')
+ return False
+ self.que.put_nowait({'req': buf,
+ 'rsp_hdl': rsp_handler,
+ 'log_lvl': logging.INFO})
+ if self.que.qsize() == 1:
+ self.__send_next_from_que()
+
+ return True
+
+ def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \
+ Generator[tuple[str, bool, int | float | str], None, None]:
+ """Generator which check and parse a received MODBUS response.
+
+ Keyword arguments:
+ info_db: database for info lockups
+ buf: received Modbus RTU response frame
+ node_id: string for logging which identifies the slave
+
+ Returns on error and set Self.err to:
+ 1: CRC error
+ 2: Wrong server address
+ 3: Unexpected function code
+ 4: Unexpected data length
+ 5: No MODBUS request pending
+ """
+ # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}')
+ if not self.req_pend:
+ self.err = 5
+ return
+ if not self.__check_crc(buf):
+ logger.error(f'[{node_id}] Modbus resp: CRC error')
+ self.err = 1
+ return
+ if buf[0] != self.last_addr:
+ logger.info(f'[{node_id}] Modbus resp: Wrong addr {buf[0]}')
+ self.err = 2
+ return
+ fcode = buf[1]
+ if fcode != self.last_fcode:
+ logger.info(f'[{node_id}] Modbus: Wrong fcode {fcode}'
+ f' != {self.last_fcode}')
+ self.err = 3
+ return
+ if self.last_addr == self.INV_ADDR and \
+ (fcode == 3 or fcode == 4):
+ elmlen = buf[2] >> 1
+ if elmlen != self.last_len:
+ logger.info(f'[{node_id}] Modbus: len error {elmlen}'
+ f' != {self.last_len}')
+ self.err = 4
+ return
+ first_reg = self.last_reg # save last_reg before sending next pdu
+ self.__stop_timer() # stop timer and send next pdu
+
+ for i in range(0, elmlen):
+ addr = first_reg+i
+ if addr in self.map:
+ row = self.map[addr]
+ info_id = row['reg']
+ fmt = row['fmt']
+ val = struct.unpack_from(fmt, buf, 3+2*i)
+ result = val[0]
+
+ if 'eval' in row:
+ result = eval(row['eval'])
+ if 'ratio' in row:
+ result = round(result * row['ratio'], 2)
+
+ keys, level, unit, must_incr = info_db._key_obj(info_id)
+
+ if keys:
+ name, update = info_db.update_db(keys, must_incr,
+ result)
+ yield keys[0], update, result
+ if update:
+ info_db.tracer.log(level,
+ f'[{node_id}] MODBUS: {name}'
+ f' : {result}{unit}')
+ else:
+ self.__stop_timer()
+
+ self.counter['retries'][f'{self.retry_cnt}'] += 1
+ if self.rsp_handler:
+ self.rsp_handler()
+ self.__send_next_from_que()
+
+ '''
+ MODBUS response timer
+ '''
+ def __start_timer(self) -> None:
+ '''Start response timer and set `req_pend` to True'''
+ self.req_pend = True
+ self.tim = self.loop.call_later(self.timeout, self.__timeout_cb)
+ # logging.debug(f'Modbus start timer {self}')
+
+ def __stop_timer(self) -> None:
+ '''Stop response timer and set `req_pend` to False'''
+ self.req_pend = False
+ # logging.debug(f'Modbus stop timer {self}')
+ if self.tim:
+ self.tim.cancel()
+
+ def __timeout_cb(self) -> None:
+ '''Rsponse timeout handler retransmit pdu or send next pdu'''
+ self.req_pend = False
+
+ if self.retry_cnt < self.max_retries:
+ logger.debug(f'Modbus retrans {self}')
+ self.retry_cnt += 1
+ self.__start_timer()
+ self.snd_handler(self.last_req, self.last_log_lvl, state='Retrans')
+ else:
+ logger.info(f'Modbus timeout {self}')
+ self.counter['timeouts'] += 1
+ self.__send_next_from_que()
+
+ def __send_next_from_que(self) -> None:
+ '''Get next MODBUS pdu from queue and transmit it'''
+ if self.req_pend:
+ return
+ try:
+ item = self.que.get_nowait()
+ req = item['req']
+ self.last_req = req
+ self.rsp_handler = item['rsp_hdl']
+ self.last_log_lvl = item['log_lvl']
+ self.last_addr = req[0]
+ self.last_fcode = req[1]
+
+ res = struct.unpack_from('>HH', req, 2)
+ self.last_reg = res[0]
+ self.last_len = res[1]
+ self.retry_cnt = 0
+ self.__start_timer()
+ self.snd_handler(self.last_req, self.last_log_lvl, state='Command')
+ except asyncio.QueueEmpty:
+ pass
+
+ '''
+ Helper function for CRC-16 handling
+ '''
+ def __check_crc(self, msg: bytearray) -> bool:
+ '''Check CRC-16 and returns True if valid'''
+ return 0 == self.__calc_crc(msg)
+
+ def __calc_crc(self, buffer: bytearray) -> int:
+ '''Build CRC-16 for buffer and returns it'''
+ crc = CRC_INIT
+
+ for cur in buffer:
+ crc = (crc >> 8) ^ self.__crc_tab[(crc ^ cur) & 0xFF]
+ return crc
+
+ def __build_crc_tab(self, poly: int) -> None:
+ '''Build CRC-16 helper table, must be called exactly one time'''
+ for index in range(256):
+ data = index << 1
+ crc = 0
+ for _ in range(8, 0, -1):
+ data >>= 1
+ if (data ^ crc) & 1:
+ crc = (crc >> 1) ^ poly
+ else:
+ crc >>= 1
+ self.__crc_tab.append(crc)
diff --git a/app/src/mqtt.py b/app/src/mqtt.py
index 5b2de02..2f55660 100644
--- a/app/src/mqtt.py
+++ b/app/src/mqtt.py
@@ -1,22 +1,15 @@
import asyncio
import logging
import aiomqtt
+import traceback
+from modbus import Modbus
+from messages import Message
from config import Config
+from singleton import Singleton
logger_mqtt = logging.getLogger('mqtt')
-class Singleton(type):
- _instances = {}
-
- def __call__(cls, *args, **kwargs):
- logger_mqtt.debug('singleton: __call__')
- if cls not in cls._instances:
- cls._instances[cls] = super(Singleton,
- cls).__call__(*args, **kwargs)
- return cls._instances[cls]
-
-
class Mqtt(metaclass=Singleton):
__client = None
__cb_MqttIsUp = None
@@ -65,6 +58,12 @@ class Mqtt(metaclass=Singleton):
password=mqtt['passwd'])
interval = 5 # Seconds
+ ha_status_topic = f"{ha['auto_conf_prefix']}/status"
+ mb_rated_topic = "tsun/+/rated_load" # fixme
+ mb_reads_topic = "tsun/+/modbus_read_regs" # fixme
+ mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme
+ mb_at_cmd_topic = "tsun/+/at_cmd" # fixme
+
while True:
try:
async with self.__client:
@@ -74,16 +73,36 @@ class Mqtt(metaclass=Singleton):
await self.__cb_MqttIsUp()
# async with self.__client.messages() as messages:
- await self.__client.subscribe(
- f"{ha['auto_conf_prefix']}"
- "/status")
+ await self.__client.subscribe(ha_status_topic)
+ await self.__client.subscribe(mb_rated_topic)
+ await self.__client.subscribe(mb_reads_topic)
+ await self.__client.subscribe(mb_inputs_topic)
+ await self.__client.subscribe(mb_at_cmd_topic)
+
async for message in self.__client.messages:
- status = message.payload.decode("UTF-8")
- logger_mqtt.info('Home-Assistant Status:'
- f' {status}')
- if status == 'online':
- self.ha_restarts += 1
- await self.__cb_MqttIsUp()
+ if message.topic.matches(ha_status_topic):
+ status = message.payload.decode("UTF-8")
+ logger_mqtt.info('Home-Assistant Status:'
+ f' {status}')
+ if status == 'online':
+ self.ha_restarts += 1
+ await self.__cb_MqttIsUp()
+
+ if message.topic.matches(mb_rated_topic):
+ await self.modbus_cmd(message,
+ Modbus.WRITE_SINGLE_REG,
+ 1, 0x2008)
+
+ if message.topic.matches(mb_reads_topic):
+ await self.modbus_cmd(message,
+ Modbus.READ_REGS, 2)
+
+ if message.topic.matches(mb_inputs_topic):
+ await self.modbus_cmd(message,
+ Modbus.READ_INPUTS, 2)
+
+ if message.topic.matches(mb_at_cmd_topic):
+ await self.at_cmd(message)
except aiomqtt.MqttError:
if Config.is_default('mqtt'):
@@ -101,3 +120,54 @@ class Mqtt(metaclass=Singleton):
logger_mqtt.debug("MQTT task cancelled")
self.__client = None
return
+ except Exception:
+ # self.inc_counter('SW_Exception') # fixme
+ logger_mqtt.error(
+ f"Exception:\n"
+ f"{traceback.format_exc()}")
+
+ def each_inverter(self, message, func_name: str):
+ topic = str(message.topic)
+ node_id = topic.split('/')[1] + '/'
+ found = False
+ for m in Message:
+ if m.server_side and (m.node_id == node_id):
+ found = True
+ logger_mqtt.debug(f'Found: {node_id}')
+ fnc = getattr(m, func_name, None)
+ if callable(fnc):
+ yield fnc
+ else:
+ logger_mqtt.warning(f'Cmd not supported by: {node_id}')
+
+ if not found:
+ logger_mqtt.warning(f'Node_id: {node_id} not found')
+
+ async def modbus_cmd(self, message, func, params=0, addr=0, val=0):
+ topic = str(message.topic)
+ node_id = topic.split('/')[1] + '/'
+ # refactor into a loop over a table
+ payload = message.payload.decode("UTF-8")
+ logger_mqtt.info(f'MODBUS via MQTT: {topic} = {payload}')
+ for m in Message:
+ if m.server_side and (m.node_id == node_id):
+ logger_mqtt.debug(f'Found: {node_id}')
+ fnc = getattr(m, "send_modbus_cmd", None)
+ res = payload.split(',')
+ if params != len(res):
+ logger_mqtt.error(f'Parameter expected: {params}, '
+ f'got: {len(res)}')
+ return
+
+ if callable(fnc):
+ if params == 1:
+ val = int(payload)
+ elif params == 2:
+ addr = int(res[0], base=16)
+ val = int(res[1]) # lenght
+ await fnc(func, addr, val, logging.INFO)
+
+ async def at_cmd(self, message):
+ payload = message.payload.decode("UTF-8")
+ for fnc in self.each_inverter(message, "send_at_cmd"):
+ await fnc(payload)
diff --git a/app/src/scheduler.py b/app/src/scheduler.py
index b5d238d..785bbcd 100644
--- a/app/src/scheduler.py
+++ b/app/src/scheduler.py
@@ -3,12 +3,15 @@ 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')
class Schedule:
mqtt = None
+ count = 0
@classmethod
def start(cls) -> None:
@@ -17,7 +20,9 @@ class Schedule:
cls.mqtt = Mqtt(None)
crontab('0 0 * * *', func=cls.atmidnight, start=True)
- # crontab('*/5 * * * *', func=cls.atmidnight, start=True)
+
+ # every minute
+ crontab('* * * * *', func=cls.regular_modbus_cmds, start=True)
@classmethod
async def atmidnight(cls) -> None:
@@ -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
diff --git a/app/src/server.py b/app/src/server.py
index 7151cf2..18dc401 100644
--- a/app/src/server.py
+++ b/app/src/server.py
@@ -1,7 +1,6 @@
import logging
import asyncio
import signal
-import functools
import os
from logging import config # noqa F401
from messages import Message
@@ -26,13 +25,23 @@ async def handle_client_v2(reader, writer):
await InverterG3P(reader, writer, addr).server_loop(addr)
-def handle_SIGTERM(loop):
+async def handle_shutdown(loop):
'''Close all TCP connections and stop the event loop'''
logging.info('Shutdown due to SIGTERM')
#
- # first, close all open TCP connections
+ # first, disc all open TCP connections gracefully
+ #
+ for stream in Message:
+ try:
+ await asyncio.wait_for(stream.disc(), 2)
+ except Exception:
+ pass
+ logging.info('Disconnecting done')
+
+ #
+ # second, close all open TCP connections
#
for stream in Message:
stream.close()
@@ -74,6 +83,7 @@ if __name__ == "__main__":
logging.getLogger('msg').setLevel(log_level)
logging.getLogger('conn').setLevel(log_level)
logging.getLogger('data').setLevel(log_level)
+ logging.getLogger('tracer').setLevel(log_level)
# logging.getLogger('mqtt').setLevel(log_level)
# read config file
@@ -91,7 +101,8 @@ if __name__ == "__main__":
#
for signame in ('SIGINT', 'SIGTERM'):
loop.add_signal_handler(getattr(signal, signame),
- functools.partial(handle_SIGTERM, loop))
+ lambda loop=loop: asyncio.create_task(
+ handle_shutdown(loop)))
#
# Create taska for our listening servera. These must be tasks! If we call
diff --git a/app/src/singleton.py b/app/src/singleton.py
new file mode 100644
index 0000000..48778b9
--- /dev/null
+++ b/app/src/singleton.py
@@ -0,0 +1,9 @@
+class Singleton(type):
+ _instances = {}
+
+ def __call__(cls, *args, **kwargs):
+ # logger_mqtt.debug('singleton: __call__')
+ if cls not in cls._instances:
+ cls._instances[cls] = super(Singleton,
+ cls).__call__(*args, **kwargs)
+ return cls._instances[cls]
diff --git a/app/tests/test_config.py b/app/tests/test_config.py
index ef602bc..746d1d8 100644
--- a/app/tests/test_config.py
+++ b/app/tests/test_config.py
@@ -31,10 +31,12 @@ def test_default_config():
assert True
except:
assert False
- assert validated == {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'suggested_area': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
+ assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'suggested_area': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
def test_full_config():
cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005},
+ 'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []},
+ 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}},
'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000},
'mqtt': {'host': 'mqtt', 'port': 1883, 'user': '', 'passwd': ''},
'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
@@ -46,10 +48,12 @@ def test_full_config():
assert True
except:
assert False
- assert validated == {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'pv1': {'manufacturer': 'man1','type': 'type1'},'pv2': {'manufacturer': 'man2','type': 'type2'},'pv3': {'manufacturer': 'man3','type': 'type3'}, 'suggested_area': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
+ assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'pv1': {'manufacturer': 'man1','type': 'type1'},'pv2': {'manufacturer': 'man2','type': 'type2'},'pv3': {'manufacturer': 'man3','type': 'type3'}, 'suggested_area': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
def test_mininum_config():
cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005},
+ 'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+']},
+ 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE']}}},
'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000},
'mqtt': {'host': 'mqtt', 'port': 1883, 'user': '', 'passwd': ''},
'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
@@ -62,7 +66,7 @@ def test_mininum_config():
assert True
except:
assert False
- assert validated == {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'suggested_area': ''}}}
+ assert validated == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'node_id': '', 'monitor_sn': 0, 'suggested_area': ''}}}
def test_read_empty():
cnf = {}
@@ -70,7 +74,7 @@ def test_read_empty():
err = TstConfig.read('app/config/')
assert err == None
cnf = TstConfig.get()
- assert cnf == {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
+ assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
defcnf = TstConfig.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
@@ -92,7 +96,7 @@ def test_read_cnf1():
err = TstConfig.read('app/config/')
assert err == None
cnf = TstConfig.get()
- assert cnf == {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
+ assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
cnf = TstConfig.get('solarman')
assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}
defcnf = TstConfig.def_config.get('solarman')
@@ -105,7 +109,7 @@ def test_read_cnf2():
err = TstConfig.read('app/config/')
assert err == None
cnf = TstConfig.get()
- assert cnf == {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
+ assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
assert True == TstConfig.is_default('solarman')
def test_read_cnf3():
@@ -122,7 +126,7 @@ def test_read_cnf4():
err = TstConfig.read('app/config/')
assert err == None
cnf = TstConfig.get()
- assert cnf == {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 5000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
+ assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 5000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'}, 'inverters': {'allow_all': True, 'R170000000000001': {'suggested_area': '', 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
assert False == TstConfig.is_default('solarman')
def test_read_cnf5():
diff --git a/app/tests/test_infos.py b/app/tests/test_infos.py
index d3b542e..4b23bfb 100644
--- a/app/tests/test_infos.py
+++ b/app/tests/test_infos.py
@@ -17,13 +17,13 @@ def test_statistic_counter():
assert val == None or val == 0
i.static_init() # initialize counter
- assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0}})
+ assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}})
val = i.dev_value(Register.INVERTER_CNT) # valid and initiliazed addr
assert val == 0
i.inc_counter('Inverter_Cnt')
- assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0}})
+ assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}})
val = i.dev_value(Register.INVERTER_CNT)
assert val == 1
diff --git a/app/tests/test_infos_g3p.py b/app/tests/test_infos_g3p.py
index 3878dff..a127d13 100644
--- a/app/tests/test_infos_g3p.py
+++ b/app/tests/test_infos_g3p.py
@@ -70,7 +70,7 @@ def test_parse_4110(DeviceData: bytes):
pass
assert json.dumps(i.db) == json.dumps({
- 'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Adress": "192.168.80.49"},
+ 'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": "192.168.80.49"},
'collector': {"Chip_Model": "LSW5BLE_17_02B0_1.05", "Collector_Fw_Version": "V1.1.00.0B"},
})
@@ -83,7 +83,7 @@ def test_parse_4210(InverterData: bytes):
assert json.dumps(i.db) == json.dumps({
"controller": {"Power_On_Time": 2051},
- "inverter": {"Serial_Number": "Y17E00000000000E", "Version": "v4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000, "No_Inputs": 4},
+ "inverter": {"Serial_Number": "Y17E00000000000E", "Version": "V4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000, "No_Inputs": 4},
"env": {"Inverter_Status": 1, "Inverter_Temp": 14},
"grid": {"Voltage": 224.8, "Current": 0.73, "Frequency": 50.05, "Output_Power": 165.8},
"input": {"pv1": {"Voltage": 35.3, "Current": 1.68, "Power": 59.6, "Daily_Generation": 0.04, "Total_Generation": 30.76},
diff --git a/app/tests/test_modbus.py b/app/tests/test_modbus.py
new file mode 100644
index 0000000..b44cb12
--- /dev/null
+++ b/app/tests/test_modbus.py
@@ -0,0 +1,380 @@
+# test_with_pytest.py
+import pytest
+import asyncio
+from app.src.modbus import Modbus
+from app.src.infos import Infos, Register
+
+pytest_plugins = ('pytest_asyncio',)
+pytestmark = pytest.mark.asyncio(scope="module")
+
+class TestHelper(Modbus):
+ def __init__(self):
+ super().__init__(self.send_cb)
+ self.db = Infos()
+ self.pdu = None
+ self.send_calls = 0
+ self.recv_responses = 0
+ def send_cb(self, pdu: bytearray, log_lvl: int, state: str):
+ self.pdu = pdu
+ self.send_calls += 1
+ def resp_handler(self):
+ self.recv_responses += 1
+
+def test_modbus_crc():
+ '''Check CRC-16 calculation'''
+ mb = Modbus(None)
+ assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04')
+ assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b')
+ assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b')
+
+ assert 0xc803 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00')
+ assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8')
+ 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')
+
+def test_build_modbus_pdu():
+ '''Check building and sending a MODBUS RTU'''
+ 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)
+ assert mb.last_addr == 1
+ assert mb.last_fcode == 6
+ assert mb.last_reg == 0x2000
+ assert mb.last_len == 18
+ assert mb.err == 0
+
+def test_recv_req():
+ '''Receive a valid request, which must transmitted'''
+ 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
+ assert mb.last_len == 0x12
+ assert mb.err == 0
+
+def test_recv_req_crc_err():
+ '''Receive a request with invalid CRC, which must be dropped'''
+ 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
+ assert mb.last_reg == 0
+ assert mb.last_len == 0
+ assert mb.err == 1
+
+def test_recv_resp_crc_err():
+ '''Receive a response with invalid CRC, which must be dropped'''
+ mb = TestHelper()
+ # simulate a transmitted request
+ mb.req_pend = True
+ mb.last_addr = 1
+ mb.last_fcode = 3
+ mb.last_reg == 0x300e
+ mb.last_len == 2
+ # check matching response, but with CRC error
+ call = 0
+ for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'):
+ call += 1
+ assert mb.err == 1
+ assert 0 == call
+ assert mb.req_pend == True
+ # cleanup queue
+ mb._Modbus__stop_timer()
+ assert not mb.req_pend
+
+def test_recv_resp_invalid_addr():
+ '''Receive a response with wrong server addr, which must be dropped'''
+ mb = TestHelper()
+ mb.req_pend = True
+ # simulate a transmitted request
+ mb.last_addr = 1
+ mb.last_fcode = 3
+ mb.last_reg == 0x300e
+ mb.last_len == 2
+
+ # check not matching response, with wrong server addr
+ call = 0
+ for key, update in mb.recv_resp(mb.db, b'\x02\x03\x04\x01\x2c\x00\x46\x88\xf4', 'test'):
+ call += 1
+ assert mb.err == 2
+ assert 0 == call
+ assert mb.req_pend == True
+ assert mb.que.qsize() == 0
+
+ # cleanup queue
+ mb._Modbus__stop_timer()
+ assert not mb.req_pend
+
+def test_recv_recv_fcode():
+ '''Receive a response with wrong function code, which must be dropped'''
+ mb = TestHelper()
+ mb.build_msg(1,4,0x300e,2)
+ assert mb.que.qsize() == 0
+ assert mb.req_pend
+
+ # check not matching response, with wrong function code
+ call = 0
+ for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
+ call += 1
+
+ assert mb.err == 3
+ assert 0 == call
+ assert mb.req_pend == True
+ assert mb.que.qsize() == 0
+
+ # cleanup queue
+ mb._Modbus__stop_timer()
+ assert not mb.req_pend
+
+def test_recv_resp_len():
+ '''Receive a response with wrong data length, which must be dropped'''
+ mb = TestHelper()
+ mb.build_msg(1,3,0x300e,3)
+ assert mb.que.qsize() == 0
+ assert mb.req_pend
+ assert mb.last_len == 3
+
+ # check not matching response, with wrong data length
+ call = 0
+ for key, update, _ in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
+ call += 1
+
+ assert mb.err == 4
+ assert 0 == call
+ assert mb.req_pend == True
+ assert mb.que.qsize() == 0
+
+ # cleanup queue
+ mb._Modbus__stop_timer()
+ assert not mb.req_pend
+
+def test_recv_unexpect_resp():
+ '''Receive a response when we havb't sent a request'''
+ mb = TestHelper()
+ assert not mb.req_pend
+
+ # check unexpected response, which must be dropped
+ call = 0
+ for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
+ call += 1
+
+ assert mb.err == 5
+ assert 0 == call
+ assert mb.req_pend == False
+ assert mb.que.qsize() == 0
+
+def test_parse_resp():
+ '''Receive matching response and parse the values'''
+ mb = TestHelper()
+ mb.build_msg(1,3,0x3007,6)
+ assert mb.que.qsize() == 0
+ assert mb.req_pend
+
+ call = 0
+ 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
+ elif key == 'inverter':
+ assert update == True
+ elif key == 'env':
+ assert update == True
+ else:
+ assert False
+ assert exp_result[call] == val
+ call += 1
+ assert 0 == mb.err
+ assert 5 == call
+ assert mb.que.qsize() == 0
+ assert not mb.req_pend
+
+def test_queue():
+ mb = TestHelper()
+ mb.build_msg(1,3,0x3022,4)
+ assert mb.que.qsize() == 0
+ assert mb.req_pend
+
+ assert mb.send_calls == 1
+ assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03'
+ mb.pdu = None
+ assert mb.send_calls == 1
+ assert mb.pdu == None
+
+ assert mb.que.qsize() == 0
+
+ # cleanup queue
+ mb._Modbus__stop_timer()
+ assert not mb.req_pend
+
+def test_queue2():
+ '''Check queue handling for build_msg() calls'''
+ mb = TestHelper()
+ mb.build_msg(1,3,0x3007,6)
+ mb.build_msg(1,6,0x2008,4)
+ assert mb.que.qsize() == 1
+ assert mb.req_pend
+ mb.build_msg(1,3,0x3007,6)
+ assert mb.que.qsize() == 2
+ assert mb.req_pend
+
+ assert mb.send_calls == 1
+ assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
+ call = 0
+ 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
+ elif key == 'inverter':
+ assert update == True
+ elif key == 'env':
+ assert update == True
+ else:
+ assert False
+ assert exp_result[call] == val
+ call += 1
+ assert 0 == mb.err
+ assert 5 == call
+
+ assert mb.que.qsize() == 1
+ assert mb.send_calls == 2
+ assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
+
+ for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
+ pass
+
+ assert mb.que.qsize() == 0
+ assert mb.send_calls == 3
+ assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
+ call = 0
+ 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'):
+ call += 1
+ assert 0 == mb.err
+ assert 5 == call
+
+ assert mb.que.qsize() == 0
+ assert not mb.req_pend
+
+def test_queue3():
+ '''Check queue handling for recv_req() calls'''
+ 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
+ assert mb.req_pend
+ assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t')
+ assert mb.que.qsize() == 2
+ assert mb.req_pend
+
+ assert mb.send_calls == 1
+ assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
+ assert mb.recv_responses == 0
+
+ call = 0
+ 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
+ elif key == 'inverter':
+ assert update == True
+ elif key == 'env':
+ assert update == True
+ else:
+ assert False
+ assert exp_result[call] == val
+ call += 1
+ assert 0 == mb.err
+ assert 5 == call
+ assert mb.recv_responses == 1
+
+ assert mb.que.qsize() == 1
+ assert mb.send_calls == 2
+ assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
+
+ for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
+ pass
+ assert 0 == mb.err
+ assert mb.recv_responses == 2
+
+ assert mb.que.qsize() == 0
+ assert mb.send_calls == 3
+ assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
+ call = 0
+ 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'):
+ call += 1
+ assert 0 == mb.err
+ assert mb.recv_responses == 2
+ assert 5 == call
+
+
+ assert mb.que.qsize() == 0
+ assert not mb.req_pend
+
+@pytest.mark.asyncio
+async def test_timeout():
+ '''Test MODBUS response timeout and RTU retransmitting'''
+ assert asyncio.get_running_loop()
+ 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
+ mb.build_msg(1,3,0x3007,6)
+ mb.build_msg(1,6,0x2008,4)
+
+ assert mb.que.qsize() == 1
+ assert mb.req_pend
+ assert mb.retry_cnt == 0
+ assert mb.send_calls == 1
+ assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
+
+ mb.pdu = None
+ await asyncio.sleep(0.11) # wait for first timeout and retransmittion
+ assert mb.que.qsize() == 1
+ assert mb.req_pend
+ assert mb.retry_cnt == 1
+ assert mb.send_calls == 2
+ assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
+
+ mb.pdu = None
+ await asyncio.sleep(0.11) # wait for second timeout and retransmittion
+ assert mb.que.qsize() == 1
+ assert mb.req_pend
+ assert mb.retry_cnt == 2
+ assert mb.send_calls == 3
+ assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
+
+ mb.pdu = None
+ await asyncio.sleep(0.11) # wait for third timeout and next pdu
+ assert mb.que.qsize() == 0
+ assert mb.req_pend
+ assert mb.retry_cnt == 0
+ assert mb.send_calls == 4
+ assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
+
+ mb.max_retries = 0 # next pdu without retranmsission
+ await asyncio.sleep(0.11) # wait for fourth timout
+ assert mb.que.qsize() == 0
+ assert not mb.req_pend
+ assert mb.retry_cnt == 0
+ assert mb.send_calls == 4
+
+ # assert mb.counter == {}
+
+def test_recv_unknown_data():
+ '''Receive a response with an unknwon register'''
+ mb = TestHelper()
+ assert 0x9000 not in mb.map
+ mb.map[0x9000] = {'reg': Register.TEST_REG1, 'fmt': '!H', 'ratio': 1}
+
+ mb.build_msg(1,3,0x9000,2)
+
+ # check matching response, but with CRC error
+ call = 0
+ for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
+ call += 1
+ assert mb.err == 0
+ assert 0 == call
+ assert not mb.req_pend
+
+ del mb.map[0x9000]
diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py
index 48f5509..9deae56 100644
--- a/app/tests/test_solarman.py
+++ b/app/tests/test_solarman.py
@@ -1,10 +1,15 @@
import pytest
import struct
import time
+import logging
from datetime import datetime
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
+
+
+pytest_plugins = ('pytest_asyncio',)
# initialize the proxy statistics
Infos.static_init()
@@ -12,9 +17,19 @@ Infos.static_init()
timestamp = int(time.time()) # 1712861197
heartbeat = 60
+class Writer():
+ def __init__(self):
+ self.sent_pdu = b''
+
+ def write(self, pdu: bytearray):
+ self.sent_pdu = pdu
+
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.__msg = msg
self.__msg_len = len(msg)
self.__chunks = chunks
@@ -24,13 +39,16 @@ class MemoryStream(SolarmanV5):
self.addr = 'Test: SrvSide'
self.db.stat['proxy']['Invalid_Msg_Format'] = 0
self.db.stat['proxy']['AT_Command'] = 0
+ self.db.stat['proxy']['AT_Command_Blocked'] = 0
+ 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']}}
def _timestamp(self):
return timestamp
def _heartbeat(self) -> int:
return heartbeat
-
def append_msg(self, msg):
self.__msg += msg
@@ -54,6 +72,16 @@ class MemoryStream(SolarmanV5):
pass
return copied_bytes
+ async def async_write(self, headline=''):
+ if self.test_exception_async_write:
+ raise RuntimeError("Peer closed.")
+
+ def createClientStream(self, msg, chunks = (0,)):
+ c = MemoryStream(msg, chunks, False)
+ self.remoteStream = c
+ c. remoteStream = self
+ return c
+
def _SolarmanV5__flush_recv_msg(self) -> None:
super()._SolarmanV5__flush_recv_msg()
self.msg_count += 1
@@ -308,6 +336,72 @@ def InverterIndMsg2000(): # 0x4210 rated Power 2000W
msg += b'\x15'
return msg
+@pytest.fixture
+def InverterIndMsg800(): # 0x4210 rated Power 800W
+ msg = b'\xa5\x99\x01\x10\x42\xe6\x9e' +get_sn() +b'\x01\xb0\x02\xbc\xc8'
+ msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x47\xe4\x33\x01\x00\x03\x08\x00\x00'
+ msg += b'\x59\x31\x37\x45\x37\x41\x30\x46\x30\x31\x30\x42\x30\x31\x33\x45'
+ 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\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\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\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\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+ msg += b'\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+ msg += b'\x40\x10\x08\xc8\x00\x49\x13\x8d\x00\x36\x00\x00\x03\x20\x06\x7a'
+ msg += b'\x01\x61\x00\xa8\x02\x54\x01\x5a\x00\x8a\x01\xe4\x01\x5a\x00\xbd'
+ msg += b'\x02\x8f\x00\x11\x00\x01\x00\x00\x00\x0b\x00\x00\x27\x98\x00\x04'
+ msg += b'\x00\x00\x0c\x04\x00\x03\x00\x00\x0a\xe7\x00\x05\x00\x00\x0c\x75'
+ msg += b'\x00\x00\x00\x00\x06\x16\x02\x00\x00\x00\x55\xaa\x00\x01\x00\x00'
+ msg += b'\x00\x00\x00\x00\xff\xff\x03\x20\x00\x03\x04\x00\x04\x00\x04\x00'
+ msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00'
+ msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41'
+ msg += b'\x00\x0f\x0a\x64\x0a\x64\x00\x06\x00\x06\x09\xf6\x12\x8c\x12\x8c'
+ msg += b'\x00\x10\x00\x10\x14\x52\x14\x52\x00\x10\x00\x10\x01\x51\x00\x05'
+ msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00'
+ msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00'
+ msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00'
+ msg += b'\x00\x00\x00\x00'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
+@pytest.fixture
+def InverterIndMsg_81(): # 0x4210 fcode 0x81
+ msg = b'\xa5\x99\x01\x10\x42\x02\x03' +get_sn() +b'\x81\xb0\x02\xbc\xc8'
+ msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x07\x04\x03\x01\x00\x03\x08\x00\x00'
+ msg += b'\x59\x31\x37\x45\x37\x41\x30\x46\x30\x31\x30\x42\x30\x31\x33\x45'
+ 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\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\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\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\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+ msg += b'\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+ msg += b'\x40\x10\x08\xc8\x00\x49\x13\x8d\x00\x36\x00\x00\x02\x58\x06\x7a'
+ msg += b'\x01\x61\x00\xa8\x02\x54\x01\x5a\x00\x8a\x01\xe4\x01\x5a\x00\xbd'
+ msg += b'\x02\x8f\x00\x11\x00\x01\x00\x00\x00\x0b\x00\x00\x27\x98\x00\x04'
+ msg += b'\x00\x00\x0c\x04\x00\x03\x00\x00\x0a\xe7\x00\x05\x00\x00\x0c\x75'
+ msg += b'\x00\x00\x00\x00\x06\x16\x02\x00\x00\x00\x55\xaa\x00\x01\x00\x00'
+ msg += b'\x00\x00\x00\x00\xff\xff\x07\xd0\x00\x03\x04\x00\x04\x00\x04\x00'
+ msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00'
+ msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41'
+ msg += b'\x00\x0f\x0a\x64\x0a\x64\x00\x06\x00\x06\x09\xf6\x12\x8c\x12\x8c'
+ msg += b'\x00\x10\x00\x10\x14\x52\x14\x52\x00\x10\x00\x10\x01\x51\x00\x05'
+ msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00'
+ msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00'
+ msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00'
+ msg += b'\x00\x00\x00\x00'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
@pytest.fixture
def InverterRspMsg(): # 0x1210
msg = b'\xa5\x0a\x00\x10\x12\x02\02' +get_sn() +b'\x01\x01'
@@ -317,6 +411,15 @@ def InverterRspMsg(): # 0x1210
msg += b'\x15'
return msg
+@pytest.fixture
+def InverterRspMsg_81(): # 0x1210 fcode 0x81
+ msg = b'\xa5\x0a\x00\x10\x12\x03\03' +get_sn() +b'\x81\x01'
+ msg += total()
+ msg += hb()
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
@pytest.fixture
def UnknownMsg(): # 0x5110
msg = b'\xa5\x0a\x00\x10\x51\x10\x84' +get_sn() +b'\x01\x01\x69\x6f\x09'
@@ -346,7 +449,7 @@ def SyncStartRspMsg(): # 0x1310
@pytest.fixture
def SyncStartFwdMsg(): # 0x4310
- msg = b'\xa5\x2f\x00\x10\x43\x0e\x0d' +get_sn() +b'\x81\x7a\x0b\x2e\x32'
+ msg = b'\xa5\x2f\x00\x10\x43\x0d\x0e' +get_sn() +b'\x81\x7a\x0b\x2e\x32'
msg += b'\x39\x00\x00\x00\x00\x00\x00\x00\x0c\x00\x41\x6c\x6c\x69\x75\x73'
msg += b'\x2d\x48\x6f\x6d\x65\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x61\x01'
@@ -357,16 +460,25 @@ def SyncStartFwdMsg(): # 0x4310
@pytest.fixture
def AtCommandIndMsg(): # 0x4510
- msg = b'\xa5\x27\x00\x10\x45\x02\x01' +get_sn() +b'\x01\x02\x00'
+ msg = b'\xa5\x27\x00\x10\x45\x03\x02' +get_sn() +b'\x01\x02\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'AT+TIME=214028,1,60,120\r'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
+@pytest.fixture
+def AtCommandIndMsgBlock(): # 0x4510
+ msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x01\x02\x00'
+ msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+ msg += b'AT+WEBU\r'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
@pytest.fixture
def AtCommandRspMsg(): # 0x1510
- msg = b'\xa5\x0a\x00\x10\x15\x02\x02' +get_sn() +b'\x01\x01'
+ msg = b'\xa5\x0a\x00\x10\x15\x03\x03' +get_sn() +b'\x01\x01'
msg += total()
msg += hb()
msg += correct_checksum(msg)
@@ -410,6 +522,72 @@ def SyncEndRspMsg(): # 0x1810
msg += b'\x15'
return msg
+@pytest.fixture
+def MsgModbusCmd():
+ msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x02\xb0\x02'
+ msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08'
+ msg += b'\x00\x00\x03\xc8'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
+@pytest.fixture
+def MsgModbusCmdCrcErr():
+ msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x02\xb0\x02'
+ msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08'
+ msg += b'\x00\x00\x04\xc8'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
+@pytest.fixture
+def MsgModbusRsp(): # 0x1510
+ msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01'
+ msg += total()
+ msg += hb()
+ msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8'
+ msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02'
+ msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04'
+ msg += b'\x00\x01\x00\x00\x6c\x68'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
+@pytest.fixture
+def MsgModbusInvalid(): # 0x1510
+ msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x00'
+ msg += total()
+ msg += hb()
+ msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8'
+ msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02'
+ msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04'
+ msg += b'\x00\x01\x00\x00\x6c\x68'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
+@pytest.fixture
+def MsgUnknownCmd():
+ msg = b'\xa5\x17\x00\x10\x45\x03\x02' +get_sn() +b'\x03\xb0\x02'
+ msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x06\x20\x08'
+ msg += b'\x00\x00\x03\xc8'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
+@pytest.fixture
+def MsgUnknownCmdRsp(): # 0x1510
+ msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x03\x01'
+ msg += total()
+ msg += hb()
+ msg += b'\x0a\xe2\xfa\x33\x01\x03\x28\x40\x10\x08\xd8'
+ msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02'
+ msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04'
+ msg += b'\x00\x01\x00\x00\x6c\x68'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
+ return msg
+
@pytest.fixture
def ConfigTsunAllowAll():
Config.config = {'solarman':{'enabled': True}, 'inverters':{'allow_all':True}}
@@ -693,6 +871,52 @@ def test_read_two_messages(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, Inver
assert m._send_buffer==b''
m.close()
+def test_read_two_messages2(ConfigTsunAllowAll, InverterIndMsg, InverterIndMsg_81, InverterRspMsg, InverterRspMsg_81):
+ ConfigTsunAllowAll
+ m = MemoryStream(InverterIndMsg, (0,))
+ m.append_msg(InverterIndMsg_81)
+ 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.header_len==11
+ assert m.snr == 2070233889
+ assert m.unique_id == '2070233889'
+ assert m.control == 0x4210
+ assert m.time_ofs == 0x33e447a0
+ assert str(m.seq) == '02:02'
+ assert m.data_len == 0x199
+ assert m.msg_count == 1
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ assert m._forward_buffer==InverterIndMsg
+ assert m._send_buffer==InverterRspMsg
+
+ m._send_buffer = bytearray(0) # clear send buffer for next test
+ m._init_new_client_conn()
+ assert m._send_buffer==b''
+ assert m._recv_buffer==InverterIndMsg_81
+
+ m._send_buffer = bytearray(0) # clear send buffer for next test
+ m._forward_buffer = bytearray(0) # clear forward buffer for next test
+ m.read() # read complete msg, and dispatch msg
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
+ assert m.msg_count == 2
+ assert m.header_len==11
+ assert m.snr == 2070233889
+ assert m.unique_id == '2070233889'
+ assert m.control == 0x4210
+ assert m.time_ofs == 0x33e447a0
+ assert str(m.seq) == '03:03'
+ assert m.data_len == 0x199
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ assert m._forward_buffer==InverterIndMsg_81
+ assert m._send_buffer==InverterRspMsg_81
+
+ m._send_buffer = bytearray(0) # clear send buffer for next test
+ m._init_new_client_conn()
+ assert m._send_buffer==b''
+ m.close()
+
def test_unkown_message(ConfigTsunInv1, UnknownMsg):
ConfigTsunInv1
m = MemoryStream(UnknownMsg, (0,))
@@ -725,7 +949,7 @@ def test_device_rsp(ConfigTsunInv1, DeviceRspMsg):
assert m.data_len == 0x0a
assert m._recv_buffer==b''
assert m._send_buffer==b''
- assert m._forward_buffer==b'' # DeviceRspMsg
+ assert m._forward_buffer==b''
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
@@ -743,7 +967,7 @@ def test_inverter_rsp(ConfigTsunInv1, InverterRspMsg):
assert m.data_len == 0x0a
assert m._recv_buffer==b''
assert m._send_buffer==b''
- assert m._forward_buffer==b'' # InverterRspMsg
+ assert m._forward_buffer==b''
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
@@ -779,7 +1003,7 @@ def test_heartbeat_rsp(ConfigTsunInv1, HeartbeatRspMsg):
assert m.data_len == 0x0a
assert m._recv_buffer==b''
assert m._send_buffer==b''
- assert m._forward_buffer==b'' # HeartbeatRspMsg
+ assert m._forward_buffer==b''
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
@@ -800,6 +1024,7 @@ def test_sync_start_ind(ConfigTsunInv1, SyncStartIndMsg, SyncStartRspMsg, SyncSt
assert m._forward_buffer==SyncStartIndMsg
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ m.seq.server_side = False # simulate forawding to TSUN cloud
m._update_header(m._forward_buffer)
assert str(m.seq) == '0d:0e' # value after forwarding indication
assert m._forward_buffer==SyncStartFwdMsg
@@ -820,7 +1045,7 @@ def test_sync_start_rsp(ConfigTsunInv1, SyncStartRspMsg):
assert m.data_len == 0x0a
assert m._recv_buffer==b''
assert m._send_buffer==b''
- assert m._forward_buffer==b'' # HeartbeatRspMsg
+ assert m._forward_buffer==b''
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
@@ -856,29 +1081,10 @@ def test_sync_end_rsp(ConfigTsunInv1, SyncEndRspMsg):
assert m.data_len == 0x0a
assert m._recv_buffer==b''
assert m._send_buffer==b''
- assert m._forward_buffer==b'' # HeartbeatRspMsg
+ assert m._forward_buffer==b''
assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
m.close()
-def test_at_command_ind(ConfigTsunInv1, AtCommandIndMsg, AtCommandRspMsg):
- ConfigTsunInv1
- m = MemoryStream(AtCommandIndMsg, (0,), False)
- 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.header_len==11
- assert m.snr == 2070233889
- # assert m.unique_id == '2070233889'
- assert m.control == 0x4510
- assert str(m.seq) == '02:02'
- assert m.data_len == 39
- assert m._recv_buffer==b''
- assert m._send_buffer==AtCommandRspMsg
- assert m._forward_buffer==AtCommandIndMsg
- assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
- assert m.db.stat['proxy']['AT_Command'] == 1
- m.close()
-
def test_build_modell_600(ConfigTsunAllowAll, InverterIndMsg):
ConfigTsunAllowAll
m = MemoryStream(InverterIndMsg, (0,))
@@ -931,6 +1137,18 @@ def test_build_modell_2000(ConfigTsunAllowAll, InverterIndMsg2000):
assert 'TSOL-MS2000' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close()
+def test_build_modell_800(ConfigTsunAllowAll, InverterIndMsg800):
+ ConfigTsunAllowAll
+ m = MemoryStream(InverterIndMsg800, (0,))
+ assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
+ assert None == m.db.get_db_value(Register.RATED_POWER, None)
+ assert None == m.db.get_db_value(Register.INVERTER_TEMP, None)
+ m.read() # read complete msg, and dispatch msg
+ assert 800 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
+ assert 800 == m.db.get_db_value(Register.RATED_POWER, 0)
+ assert 'TSOL-MSxx00' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
+ m.close()
+
def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg):
ConfigTsunAllowAll
m = MemoryStream(DeviceIndMsg, (0,))
@@ -942,21 +1160,505 @@ def test_build_logger_modell(ConfigTsunAllowAll, DeviceIndMsg):
assert 'V1.1.00.0B' == m.db.get_db_value(Register.COLLECTOR_FW_VERSION, 0).rstrip('\00')
m.close()
-def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, AtCommandIndMsg):
- ConfigTsunAllowAll
+def test_msg_iterator():
+ m1 = SolarmanV5(server_side=True)
+ m2 = SolarmanV5(server_side=True)
+ m3 = SolarmanV5(server_side=True)
+ m3.close()
+ del m3
+ test1 = 0
+ test2 = 0
+ for key in SolarmanV5:
+ if key == m1:
+ test1+=1
+ elif key == m2:
+ test2+=1
+ elif type(key) != SolarmanV5:
+ continue
+ else:
+ assert False
+ assert test1 == 1
+ assert test2 == 1
+
+def test_proxy_counter():
+ m = SolarmanV5(server_side=True)
+ assert m.new_data == {}
+ m.db.stat['proxy']['Unknown_Msg'] = 0
+ Infos.new_stat_data['proxy'] = False
+
+ m.inc_counter('Unknown_Msg')
+ assert m.new_data == {}
+ assert Infos.new_stat_data == {'proxy': True}
+ assert 1 == m.db.stat['proxy']['Unknown_Msg']
+
+ Infos.new_stat_data['proxy'] = False
+ m.dec_counter('Unknown_Msg')
+ assert m.new_data == {}
+ assert Infos.new_stat_data == {'proxy': True}
+ assert 0 == m.db.stat['proxy']['Unknown_Msg']
+ m.close()
+
+@pytest.mark.asyncio
+async def test_msg_build_modbus_req(ConfigTsunInv1, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, MsgModbusCmd):
+ ConfigTsunInv1
m = MemoryStream(DeviceIndMsg, (0,), True)
+ m.append_msg(InverterIndMsg)
m.read()
assert m.control == 0x4110
assert str(m.seq) == '01:01'
- assert m._recv_buffer==b''
+ 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_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG)
+ assert m._recv_buffer==InverterIndMsg # unhandled next message
+ assert 0 == m.send_msg_ofs
+ assert m._forward_buffer == b''
+ assert m.writer.sent_pdu == b'' # modbus command must be ignore, cause connection is still not up
+ assert m._send_buffer == b'' # modbus command must be ignore, cause connection is still not up
+
+ m.read()
+ assert m.control == 0x4210
+ assert str(m.seq) == '02:02'
+ 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_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG)
+ assert 0 == m.send_msg_ofs
+ assert m._forward_buffer == b''
+ assert m.writer.sent_pdu == MsgModbusCmd
+ assert m._send_buffer == b''
+
+ m._send_buffer = bytearray(0) # clear send buffer for next test
+ m.test_exception_async_write = True
+ await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG)
+ assert 0 == m.send_msg_ofs
+ assert m._forward_buffer == b''
+ assert m._send_buffer == b''
+ m.close()
+
+@pytest.mark.asyncio
+async def test_AT_cmd(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg):
+ ConfigTsunAllowAll
+ m = MemoryStream(DeviceIndMsg, (0,), True)
+ m.append_msg(InverterIndMsg)
+ m.read()
+ assert m.control == 0x4110
+ assert str(m.seq) == '01:01'
+ 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 # unhandled next message
+ assert m._send_buffer==b''
+ assert m._forward_buffer==b''
+ assert str(m.seq) == '01:01'
+
+ m.read()
+ assert m.control == 0x4210
+ assert str(m.seq) == '02:02'
+ 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
- m.send_at_cmd('AT+TIME=214028,1,60,120')
+ await m.send_at_cmd('AT+TIME=214028,1,60,120')
assert m._recv_buffer==b''
assert m._send_buffer==AtCommandIndMsg
assert m._forward_buffer==b''
- assert str(m.seq) == '01:02'
+ assert str(m.seq) == '02:03'
+
+ m._send_buffer = bytearray(0) # clear send buffer for next test
+ 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) == '02:04'
+ assert m.forward_at_cmd_resp == False
m.close()
+
+@pytest.mark.asyncio
+async def test_AT_cmd_blocked(ConfigTsunAllowAll, DeviceIndMsg, DeviceRspMsg, InverterIndMsg, InverterRspMsg, AtCommandIndMsg):
+ ConfigTsunAllowAll
+ m = MemoryStream(DeviceIndMsg, (0,), True)
+ m.append_msg(InverterIndMsg)
+ m.read()
+ assert m.control == 0x4110
+ assert str(m.seq) == '01:01'
+ 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+WEBU')
+ assert m._recv_buffer==InverterIndMsg # unhandled next message
+ assert m._send_buffer==b''
+ assert m._forward_buffer==b''
+ assert str(m.seq) == '01:01'
+
+ m.read()
+ assert m.control == 0x4210
+ assert str(m.seq) == '02:02'
+ 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+WEBU')
+ assert m._recv_buffer==b''
+ assert m._send_buffer==b''
+ assert m._forward_buffer==b''
+ assert str(m.seq) == '02:02'
+ assert m.forward_at_cmd_resp == False
+ m.close()
+
+def test_AT_cmd_ind(ConfigTsunInv1, AtCommandIndMsg):
+ ConfigTsunInv1
+ m = MemoryStream(AtCommandIndMsg, (0,), False)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['AT_Command'] = 0
+ m.db.stat['proxy']['AT_Command_Blocked'] = 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.header_len==11
+ assert m.snr == 2070233889
+ # assert m.unique_id == '2070233889'
+ assert m.control == 0x4510
+ assert str(m.seq) == '03:02'
+ assert m.data_len == 39
+ assert m._recv_buffer==b''
+ assert m._send_buffer==b''
+ assert m._forward_buffer==AtCommandIndMsg
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ assert m.db.stat['proxy']['AT_Command'] == 1
+ assert m.db.stat['proxy']['AT_Command_Blocked'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_AT_cmd_ind_block(ConfigTsunInv1, AtCommandIndMsgBlock):
+ ConfigTsunInv1
+ m = MemoryStream(AtCommandIndMsgBlock, (0,), False)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['AT_Command'] = 0
+ m.db.stat['proxy']['AT_Command_Blocked'] = 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.header_len==11
+ assert m.snr == 2070233889
+ # assert m.unique_id == '2070233889'
+ assert m.control == 0x4510
+ assert str(m.seq) == '03:02'
+ assert m.data_len == 23
+ assert m._recv_buffer==b''
+ assert m._send_buffer==b''
+ assert m._forward_buffer==b''
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ assert m.db.stat['proxy']['AT_Command'] == 0
+ assert m.db.stat['proxy']['AT_Command_Blocked'] == 1
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_msg_at_command_rsp1(ConfigTsunInv1, AtCommandRspMsg):
+ ConfigTsunInv1
+ m = MemoryStream(AtCommandRspMsg)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.forward_at_cmd_resp = True
+ 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.control == 0x1510
+ assert str(m.seq) == '03:03'
+ assert m.header_len==11
+ assert m.data_len==10
+ assert m._forward_buffer==AtCommandRspMsg
+ assert m._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_msg_at_command_rsp2(ConfigTsunInv1, AtCommandRspMsg):
+ ConfigTsunInv1
+ m = MemoryStream(AtCommandRspMsg)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.forward_at_cmd_resp = False
+ 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.control == 0x1510
+ assert str(m.seq) == '03:03'
+ assert m.header_len==11
+ assert m.data_len==10
+ assert m._forward_buffer==b''
+ assert m._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd):
+ ConfigTsunInv1
+ m = MemoryStream(b'')
+ c = m.createClientStream(MsgModbusCmd)
+
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['AT_Command'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.db.stat['proxy']['Invalid_Msg_Format'] = 0
+ c.read() # read complete msg, and dispatch msg
+ assert not c.header_valid # must be invalid, since msg was handled and buffer flushed
+ assert c.msg_count == 1
+ assert c.control == 0x4510
+ assert str(c.seq) == '03:02'
+ assert c.header_len==11
+ assert c.data_len==23
+ assert c._forward_buffer==MsgModbusCmd
+ assert c._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['AT_Command'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 1
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ m.close()
+
+def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmdCrcErr):
+ ConfigTsunInv1
+ m = MemoryStream(b'')
+ c = m.createClientStream(MsgModbusCmdCrcErr)
+
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['AT_Command'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.db.stat['proxy']['Invalid_Msg_Format'] = 0
+ c.read() # read complete msg, and dispatch msg
+ assert not c.header_valid # must be invalid, since msg was handled and buffer flushed
+ assert c.msg_count == 1
+ assert c.control == 0x4510
+ assert str(c.seq) == '03:02'
+ assert c.header_len==11
+ assert c.data_len==23
+ assert c._forward_buffer==MsgModbusCmdCrcErr
+ assert c._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['AT_Command'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
+ m.close()
+
+def test_msg_unknown_cmd_req(ConfigTsunInv1, MsgUnknownCmd):
+ ConfigTsunInv1
+ m = MemoryStream(MsgUnknownCmd, (0,), False)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['AT_Command'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.db.stat['proxy']['Invalid_Msg_Format'] = 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.control == 0x4510
+ assert str(m.seq) == '03:02'
+ assert m.header_len==11
+ assert m.data_len==23
+ assert m._forward_buffer==MsgUnknownCmd
+ assert m._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['AT_Command'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ m.close()
+
+def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp):
+ '''Modbus response without a valid Modbus request must be dropped'''
+ ConfigTsunInv1
+ m = MemoryStream(MsgModbusRsp)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 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.control == 0x1510
+ assert str(m.seq) == '03:03'
+ assert m.header_len==11
+ assert m.data_len==59
+ assert m._forward_buffer==b''
+ assert m._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_msg_modbus_rsp2(ConfigTsunInv1, MsgModbusRsp):
+ '''Modbus response with a valid Modbus request must be forwarded'''
+ ConfigTsunInv1
+ m = MemoryStream(MsgModbusRsp)
+ m.append_msg(MsgModbusRsp)
+
+ m.mb.rsp_handler = m._SolarmanV5__forward_msg
+ m.mb.last_addr = 1
+ m.mb.last_fcode = 3
+ m.mb.last_len = 20
+ m.mb.last_reg = 0x3008
+ m.mb.req_pend = True
+ m.mb.err = 0
+ # assert m.db.db == {'inverter': {'Manufacturer': 'TSUN', 'Equipment_Model': 'TSOL-MSxx00'}}
+ m.new_data['inverter'] = False
+
+ 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.mb.err == 0
+ assert m.msg_count == 1
+ assert m._forward_buffer==MsgModbusRsp
+ assert m._send_buffer==b''
+ # 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) == 'V4.0.10'
+ assert m.new_data['inverter'] == True
+ m.new_data['inverter'] = False
+
+ m.mb.req_pend = True
+ 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.mb.err == 0
+ assert m.msg_count == 2
+ assert m._forward_buffer==MsgModbusRsp
+ assert m._send_buffer==b''
+ # 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) == 'V4.0.10'
+ assert m.new_data['inverter'] == False
+
+ m.close()
+
+def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusRsp):
+ '''Modbus response with a valid Modbus request must be forwarded'''
+ ConfigTsunInv1
+ m = MemoryStream(MsgModbusRsp)
+ m.append_msg(MsgModbusRsp)
+
+ m.mb.rsp_handler = m._SolarmanV5__forward_msg
+ m.mb.last_addr = 1
+ m.mb.last_fcode = 3
+ m.mb.last_len = 20
+ m.mb.last_reg = 0x3008
+ m.mb.req_pend = True
+ m.mb.err = 0
+ # assert m.db.db == {'inverter': {'Manufacturer': 'TSUN', 'Equipment_Model': 'TSOL-MSxx00'}}
+ m.new_data['inverter'] = False
+
+ 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.mb.err == 0
+ assert m.msg_count == 1
+ assert m._forward_buffer==MsgModbusRsp
+ assert m._send_buffer==b''
+ # 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) == 'V4.0.10'
+ assert m.new_data['inverter'] == True
+ m.new_data['inverter'] = False
+
+ 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.mb.err == 5
+ assert m.msg_count == 2
+ assert m._forward_buffer==MsgModbusRsp
+ assert m._send_buffer==b''
+ # 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) == 'V4.0.10'
+ assert m.new_data['inverter'] == False
+
+ m.close()
+
+def test_msg_unknown_rsp(ConfigTsunInv1, MsgUnknownCmdRsp):
+ ConfigTsunInv1
+ m = MemoryStream(MsgUnknownCmdRsp)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 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.control == 0x1510
+ assert str(m.seq) == '03:03'
+ assert m.header_len==11
+ assert m.data_len==59
+ assert m._forward_buffer==MsgUnknownCmdRsp
+ assert m._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInvalid):
+ ConfigTsunInv1
+ m = MemoryStream(MsgModbusInvalid, (0,), False)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 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._forward_buffer==b''
+ assert m._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusRsp):
+ ConfigTsunInv1
+ # receive more bytes than expected (7 bytes from the next msg)
+ m = MemoryStream(MsgModbusRsp+b'\x00\x00\x00\x45\x10\x52\x31', (0,))
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.mb.rsp_handler = m._SolarmanV5__forward_msg
+ m.mb.last_addr = 1
+ m.mb.last_fcode = 3
+ m.mb.last_len = 20
+ m.mb.last_reg = 0x3008
+ m.mb.req_pend = True
+ m.mb.err = 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._forward_buffer==MsgModbusRsp
+ assert m._send_buffer == b''
+ assert m.mb.err == 0
+ assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+'''
+def test_zombie_conn(ConfigTsunInv1, MsgInverterInd):
+ ConfigTsunInv1
+ tracer.setLevel(logging.DEBUG)
+ 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
+ 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
+ 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
+ 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
+ m1.close()
+ m2.close()
+ m3.close()
+'''
\ No newline at end of file
diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py
index 0b806c7..126dab9 100644
--- a/app/tests/test_talent.py
+++ b/app/tests/test_talent.py
@@ -2,16 +2,31 @@
import pytest, logging
from app.src.gen3.talent import Talent, Control
from app.src.config import Config
-from app.src.infos import Infos
+from app.src.infos import Infos, Register
+from app.src.modbus import Modbus
+
+
+pytest_plugins = ('pytest_asyncio',)
# initialize the proxy statistics
Infos.static_init()
tracer = logging.getLogger('tracer')
-
+
+
+class Writer():
+ def __init__(self):
+ self.sent_pdu = b''
+
+ def write(self, pdu: bytearray):
+ self.sent_pdu = pdu
+
class MemoryStream(Talent):
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.__msg = msg
self.__msg_len = len(msg)
self.__chunks = chunks
@@ -19,6 +34,8 @@ class MemoryStream(Talent):
self.__chunk_idx = 0
self.msg_count = 0
self.addr = 'Test: SrvSide'
+ self.send_msg_ofs = 0
+ self.test_exception_async_write = False
def append_msg(self, msg):
self.__msg += msg
@@ -46,11 +63,22 @@ class MemoryStream(Talent):
# return 1700260990000
return 1691246944000
+ def createClientStream(self, msg, chunks = (0,)):
+ c = MemoryStream(msg, chunks, False)
+ self.remoteStream = c
+ c. remoteStream = self
+ return c
+
def _Talent__flush_recv_msg(self) -> None:
super()._Talent__flush_recv_msg()
self.msg_count += 1
return
+ async def async_write(self, headline=''):
+ if self.test_exception_async_write:
+ raise RuntimeError("Peer closed.")
+
+
@pytest.fixture
def MsgContactInfo(): # Contact Info message
@@ -191,6 +219,42 @@ def MsgOtaAck(): # Over the air update rewuest from tsun cloud
def MsgOtaInvalid(): # Get Time Request message
return b'\x00\x00\x00\x14\x10R170000000000001\x99\x13\x01'
+@pytest.fixture
+def MsgModbusCmd():
+ msg = b'\x00\x00\x00\x20\x10R170000000000001'
+ msg += b'\x70\x77\x00\x01\xa3\x28\x08\x01\x06\x20\x08'
+ msg += b'\x00\x00\x03\xc8'
+ return msg
+
+@pytest.fixture
+def MsgModbusCmdCrcErr():
+ msg = b'\x00\x00\x00\x20\x10R170000000000001'
+ msg += b'\x70\x77\x00\x01\xa3\x28\x08\x01\x06\x20\x08'
+ msg += b'\x00\x00\x04\xc8'
+ return msg
+
+@pytest.fixture
+def MsgModbusRsp():
+ msg = b'\x00\x00\x00\x20\x10R170000000000001'
+ msg += b'\x91\x77\x17\x18\x19\x1a\x08\x01\x06\x20\x08'
+ msg += b'\x00\x00\x03\xc8'
+ return msg
+
+@pytest.fixture
+def MsgModbusInv():
+ msg = b'\x00\x00\x00\x20\x10R170000000000001'
+ msg += b'\x99\x77\x17\x18\x19\x1a\x08\x01\x06\x20\x08'
+ msg += b'\x00\x00\x03\xc8'
+ return msg
+
+@pytest.fixture
+def MsgModbusResp20():
+ msg = b'\x00\x00\x00\x45\x10R170000000000001'
+ msg += b'\x91\x77\x17\x18\x19\x1a\x2d\x01\x03\x28\x51'
+ msg += b'\x09\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\xdb\x6b'
+ return msg
def test_read_message(MsgContactInfo):
m = MemoryStream(MsgContactInfo, (0,))
@@ -757,10 +821,16 @@ def test_msg_unknown(ConfigTsunInv1, MsgUnknown):
m.close()
def test_ctrl_byte():
+ c = Control(0x70)
+ assert not c.is_ind()
+ assert not c.is_resp()
+ assert c.is_req()
c = Control(0x91)
+ assert not c.is_req()
assert c.is_ind()
assert not c.is_resp()
c = Control(0x99)
+ assert not c.is_req()
assert not c.is_ind()
assert c.is_resp()
@@ -786,19 +856,355 @@ def test_msg_iterator():
assert test2 == 1
def test_proxy_counter():
- m = Talent(server_side=True)
+ # m = MemoryStream(b'')
+ # m.close()
+ Infos.stat['proxy']['Modbus_Command'] = 1
+
+ m = MemoryStream(b'')
+ m.id_str = b"R170000000000001"
+ c = m.createClientStream(b'')
+
assert m.new_data == {}
m.db.stat['proxy']['Unknown_Msg'] = 0
+ c.db.stat['proxy']['Unknown_Msg'] = 0
Infos.new_stat_data['proxy'] = False
m.inc_counter('Unknown_Msg')
+ m.close()
+ m = MemoryStream(b'')
+
assert m.new_data == {}
assert Infos.new_stat_data == {'proxy': True}
+ assert m.db.new_stat_data == {'proxy': True}
+ assert c.db.new_stat_data == {'proxy': True}
assert 1 == m.db.stat['proxy']['Unknown_Msg']
+ assert 1 == c.db.stat['proxy']['Unknown_Msg']
+ Infos.new_stat_data['proxy'] = False
+
+ c.inc_counter('Unknown_Msg')
+ assert m.new_data == {}
+ assert Infos.new_stat_data == {'proxy': True}
+ assert m.db.new_stat_data == {'proxy': True}
+ assert c.db.new_stat_data == {'proxy': True}
+ assert 2 == m.db.stat['proxy']['Unknown_Msg']
+ assert 2 == c.db.stat['proxy']['Unknown_Msg']
+ Infos.new_stat_data['proxy'] = False
+
+ c.inc_counter('Modbus_Command')
+ assert m.new_data == {}
+ assert Infos.new_stat_data == {'proxy': True}
+ assert m.db.new_stat_data == {'proxy': True}
+ assert c.db.new_stat_data == {'proxy': True}
+ assert 2 == m.db.stat['proxy']['Modbus_Command']
+ assert 2 == c.db.stat['proxy']['Modbus_Command']
Infos.new_stat_data['proxy'] = False
m.dec_counter('Unknown_Msg')
assert m.new_data == {}
assert Infos.new_stat_data == {'proxy': True}
- assert 0 == m.db.stat['proxy']['Unknown_Msg']
+ assert 1 == m.db.stat['proxy']['Unknown_Msg']
m.close()
+
+def test_msg_modbus_req(ConfigTsunInv1, MsgModbusCmd):
+ ConfigTsunInv1
+ m = MemoryStream(b'')
+ m.id_str = b"R170000000000001"
+ m.state = m.STATE_UP
+
+ c = m.createClientStream(MsgModbusCmd)
+
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.db.stat['proxy']['Invalid_Msg_Format'] = 0
+ c.read() # read complete msg, and dispatch msg
+ assert not c.header_valid # must be invalid, since msg was handled and buffer flushed
+ assert c.msg_count == 1
+ assert c.id_str == b"R170000000000001"
+ assert c.unique_id == 'R170000000000001'
+ assert int(c.ctrl)==112
+ assert c.msg_id==119
+ assert c.header_len==23
+ assert c.data_len==13
+ assert c._forward_buffer==b''
+ assert c._send_buffer==b''
+ assert m.id_str == b"R170000000000001"
+ assert m._forward_buffer==b''
+ assert m._send_buffer==b''
+ assert m.writer.sent_pdu == MsgModbusCmd
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 1
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ m.close()
+
+def test_msg_modbus_req2(ConfigTsunInv1, MsgModbusCmd):
+ ConfigTsunInv1
+ m = MemoryStream(b'')
+ m.id_str = b"R170000000000001"
+
+ c = m.createClientStream(MsgModbusCmd)
+
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.db.stat['proxy']['Invalid_Msg_Format'] = 0
+ c.read() # read complete msg, and dispatch msg
+ assert not c.header_valid # must be invalid, since msg was handled and buffer flushed
+ assert c.msg_count == 1
+ assert c.id_str == b"R170000000000001"
+ assert c.unique_id == 'R170000000000001'
+ assert int(c.ctrl)==112
+ assert c.msg_id==119
+ assert c.header_len==23
+ assert c.data_len==13
+ assert c._forward_buffer==b''
+ assert c._send_buffer==b''
+ assert m.id_str == b"R170000000000001"
+ assert m._forward_buffer==b''
+ assert m._send_buffer==b''
+ assert m.writer.sent_pdu == b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 1
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 0
+ m.close()
+
+def test_msg_modbus_req3(ConfigTsunInv1, MsgModbusCmdCrcErr):
+ ConfigTsunInv1
+ m = MemoryStream(b'')
+ m.id_str = b"R170000000000001"
+ c = m.createClientStream(MsgModbusCmdCrcErr)
+
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.db.stat['proxy']['Invalid_Msg_Format'] = 0
+ c.read() # read complete msg, and dispatch msg
+ assert not c.header_valid # must be invalid, since msg was handled and buffer flushed
+ assert c.msg_count == 1
+ assert c.id_str == b"R170000000000001"
+ assert c.unique_id == 'R170000000000001'
+ assert int(c.ctrl)==112
+ assert c.msg_id==119
+ assert c.header_len==23
+ assert c.data_len==13
+ assert c._forward_buffer==b''
+ assert c._send_buffer==b''
+ assert m._forward_buffer==b''
+ assert m._send_buffer==b''
+ assert m.writer.sent_pdu ==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ assert m.db.stat['proxy']['Invalid_Msg_Format'] == 1
+ m.close()
+
+def test_msg_modbus_rsp1(ConfigTsunInv1, MsgModbusRsp):
+ '''Modbus response without a valid Modbus request must be dropped'''
+ ConfigTsunInv1
+ m = MemoryStream(MsgModbusRsp)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 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_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
+ m = MemoryStream(MsgModbusResp20)
+ m.append_msg(MsgModbusResp20)
+
+ m.mb.rsp_handler = m.msg_forward
+ m.mb.last_addr = 1
+ m.mb.last_fcode = 3
+ m.mb.last_len = 20
+ m.mb.last_reg = 0x3008
+ m.mb.req_pend = True
+ m.mb.err = 0
+
+ assert m.db.db == {}
+ m.new_data['inverter'] = False
+
+ 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.mb.err == 0
+ assert m.msg_count == 1
+ assert m._forward_buffer==MsgModbusResp20
+ assert m._send_buffer==b''
+ 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
+ m.mb.req_pend = True
+ 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.mb.err == 0
+ assert m.msg_count == 2
+ assert m._forward_buffer==MsgModbusResp20
+ assert m._send_buffer==b''
+ 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()
+
+def test_msg_modbus_rsp3(ConfigTsunInv1, MsgModbusResp20):
+ '''Modbus response with a valid Modbus request must be forwarded'''
+ ConfigTsunInv1
+ m = MemoryStream(MsgModbusResp20)
+ m.append_msg(MsgModbusResp20)
+
+ m.mb.rsp_handler = m.msg_forward
+ m.mb.last_addr = 1
+ m.mb.last_fcode = 3
+ m.mb.last_len = 20
+ m.mb.last_reg = 0x3008
+ m.mb.req_pend = True
+ m.mb.err = 0
+
+ assert m.db.db == {}
+ m.new_data['inverter'] = False
+
+ 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.mb.err == 0
+ assert m.msg_count == 1
+ assert m._forward_buffer==MsgModbusResp20
+ assert m._send_buffer==b''
+ 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
+
+ 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.mb.err == 5
+ assert m.msg_count == 2
+ assert m._forward_buffer==MsgModbusResp20
+ assert m._send_buffer==b''
+ 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()
+
+def test_msg_modbus_invalid(ConfigTsunInv1, MsgModbusInv):
+ ConfigTsunInv1
+ m = MemoryStream(MsgModbusInv, (0,), False)
+ m.db.stat['proxy']['Unknown_Ctrl'] = 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)==153
+ assert m.msg_id==119
+ assert m.header_len==23
+ assert m.data_len==13
+ assert m._forward_buffer==MsgModbusInv
+ assert m._send_buffer==b''
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 1
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+def test_msg_modbus_fragment(ConfigTsunInv1, MsgModbusResp20):
+ ConfigTsunInv1
+ # receive more bytes than expected (7 bytes from the next msg)
+ m = MemoryStream(MsgModbusResp20+b'\x00\x00\x00\x45\x10\x52\x31', (0,))
+ m.db.stat['proxy']['Unknown_Ctrl'] = 0
+ m.db.stat['proxy']['Modbus_Command'] = 0
+ m.mb.rsp_handler = m.msg_forward
+ m.mb.last_addr = 1
+ m.mb.last_fcode = 3
+ m.mb.last_len = 20
+ m.mb.last_reg = 0x3008
+ m.mb.req_pend = True
+ m.mb.err = 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) == 0x91
+ assert m.msg_id == 119
+ assert m.header_len == 23
+ assert m.data_len == 50
+ assert m._forward_buffer==MsgModbusResp20
+ assert m._send_buffer == b''
+ assert m.mb.err == 0
+ assert m.modbus_elms == 20-1 # register 0x300d is unknown, so one value can't be mapped
+ assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
+ assert m.db.stat['proxy']['Modbus_Command'] == 0
+ m.close()
+
+@pytest.mark.asyncio
+async def test_msg_build_modbus_req(ConfigTsunInv1, MsgModbusCmd):
+ ConfigTsunInv1
+ m = MemoryStream(b'', (0,), True)
+ m.id_str = b"R170000000000001"
+ await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG)
+ assert 0 == m.send_msg_ofs
+ assert m._forward_buffer == b''
+ assert m._send_buffer == b''
+ assert m.writer.sent_pdu == b''
+
+ 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''
+ assert m._send_buffer == b''
+ assert m.writer.sent_pdu == MsgModbusCmd
+
+ m.writer.sent_pdu = bytearray(0) # clear send buffer for next test
+ m.test_exception_async_write = True
+ await m.send_modbus_cmd(Modbus.WRITE_SINGLE_REG, 0x2008, 0, logging.DEBUG)
+ assert 0 == m.send_msg_ofs
+ assert m._forward_buffer == b''
+ assert m._send_buffer == b''
+ assert m.writer.sent_pdu == b''
+ m.close()
+'''
+def test_zombie_conn(ConfigTsunInv1, MsgInverterInd):
+ ConfigTsunInv1
+ tracer.setLevel(logging.DEBUG)
+ start_val = MemoryStream._RefNo
+
+ m1 = MemoryStream(MsgInverterInd, (0,))
+ assert MemoryStream._RefNo == 1 + start_val
+ assert m1.RefNo == 1 + start_val
+ m2 = MemoryStream(MsgInverterInd, (0,))
+ assert MemoryStream._RefNo == 2 + start_val
+ assert m2.RefNo == 2 + start_val
+ 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
+ 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
+ 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
+ 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
+ m1.close()
+ m2.close()
+ m3.close()
+'''
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
index de3e5e5..4566a80 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,6 +1,3 @@
-
-version: '3.0'
-
services:
####### H O M E - A S S I S T A N T #####
home-assistant:
@@ -34,7 +31,7 @@ services:
ports:
- 8123:8123
volumes:
- - ${PROJECT_DIR}./homeassistant/config:/config
+ - ${PROJECT_DIR:-./}homeassistant/config:/config
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: curl --fail http://0.0.0.0:8123/auth/providers || exit 1
@@ -56,18 +53,18 @@ services:
expose:
- 1883
volumes:
- - ${PROJECT_DIR}./mosquitto/config:/mosquitto/config
- - ${PROJECT_DIR}./mosquitto/data:/mosquitto/data
+ - ${PROJECT_DIR:-./}mosquitto/config:/mosquitto/config
+ - ${PROJECT_DIR:-./}mosquitto/data:/mosquitto/data
networks:
- outside:
- ipv4_address: 172.28.1.5 # static IP required to receive mDNS traffic
-
+ - outside
+
####### T S U N - P R O X Y ######
tsun-proxy:
container_name: tsun-proxy
image: ghcr.io/s-allius/tsun-gen3-proxy:latest
+ # image: ghcr.io/s-allius/tsun-gen3-proxy:rc
restart: unless-stopped
depends_on:
- mqtt
@@ -77,13 +74,13 @@ services:
- GID=${GID:-1000}
dns:
- ${DNS1:-8.8.8.8}
- - $(DNS2:-4.4.4.4}
+ - ${DNS2:-4.4.4.4}
ports:
- 5005:5005
- 10000:10000
volumes:
- - ${PROJECT_DIR}./tsun-proxy/log:/home/tsun-proxy/log
- - ${PROJECT_DIR}./tsun-proxy/config:/home/tsun-proxy/config
+ - ${PROJECT_DIR:-./}tsun-proxy/log:/home/tsun-proxy/log
+ - ${PROJECT_DIR:-./}tsun-proxy/config:/home/tsun-proxy/config
networks:
- outside
@@ -93,11 +90,4 @@ services:
networks:
outside:
name: home-assistant
- external: true
- ipam:
- driver: default
- config:
- - subnet: 172.28.1.0/26
- ip_range: 172.28.1.32/27
- gateway: 172.28.1.62
\ No newline at end of file
diff --git a/system_tests/test_tcp_socket.py b/system_tests/test_tcp_socket.py
index 606ea68..e2b64f8 100644
--- a/system_tests/test_tcp_socket.py
+++ b/system_tests/test_tcp_socket.py
@@ -224,7 +224,7 @@ def test_send_inv_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgI
data = s.recv(1024)
except TimeoutError:
pass
- # time.sleep(32.5)
+ # time.sleep(32.5)
# assert data == MsgTimeStampResp
try:
s.sendall(MsgInvData)
diff --git a/system_tests/test_tcp_socket_v2.py b/system_tests/test_tcp_socket_v2.py
index a4326db..c41b41c 100644
--- a/system_tests/test_tcp_socket_v2.py
+++ b/system_tests/test_tcp_socket_v2.py
@@ -19,6 +19,9 @@ def get_inv_no() -> bytes:
def get_invalid_sn():
return b'R170000000000002'
+def correct_checksum(buf):
+ checksum = sum(buf[1:]) & 0xff
+ return checksum.to_bytes(length=1)
@pytest.fixture
def MsgContactInfo(): # Contact Info message
@@ -61,10 +64,11 @@ def MsgDataInd():
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x01\x12\x02\x12\x12\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x40\x10\x08\xd8\x00\x09\x13\x84\x00\x35\x00\x00\x02\x58\x00\xd8'
- msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18'
+ msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18'
msg += b'\x00\x52\x00\x12\x00\x01\x00\x00\x00\x7c\x00\x00\x24\xed\x00\x2c'
msg += b'\x00\x00\x0b\x10\x00\x26\x00\x00\x0a\x0f\x00\x30\x00\x00\x0b\x76'
- msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x00\x55\xaa\x00\x01\x00\x00'
+
+ msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x01\x55\xaa\x00\x01\x00\x00'
msg += b'\x00\x00\x00\x00\xff\xff\x07\xd0\x00\x03\x04\x00\x04\x00\x04\x00'
msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00'
msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41'
@@ -73,7 +77,9 @@ def MsgDataInd():
msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00'
msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00'
- msg += b'\x00\x00\x00\x00\x24\x15'
+ msg += b'\x00\x00\x00\x00'
+ msg += correct_checksum(msg)
+ msg += b'\x15'
return msg
@pytest.fixture
@@ -147,4 +153,6 @@ def test_data_ind(ClientConnection,MsgDataInd, MsgDataResp):
except TimeoutError:
pass
# time.sleep(2.5)
- checkResponse(data, MsgDataResp)
\ No newline at end of file
+ checkResponse(data, MsgDataResp)
+
+
\ No newline at end of file