Compare commits

..

37 Commits

Author SHA1 Message Date
Stefan Allius
b752144e94 Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy into dev-0.10 2024-08-10 14:15:19 +02:00
Stefan Allius
e56aa5d709 Version 0.10.0 no longer displays the version string (#154)
Fixes #153
2024-08-10 14:01:09 +02:00
Stefan Allius
b364fb3f8e Dev 0.10 (#151)
* S allius/issue117 (#118)

* add shutdown flag

* add more register definitions

* add start commando for client side connections

* add first support for port 8899

* fix shutdown

* add client_mode configuration

* read client_mode config to setup inverter connections

* add client_mode connections over port 8899

* add preview build

* Update README.md

describe the new client-mode over port 8899 for GEN3PLUS

* MODBUS: the last digit of the inverter version is a hexadecimal number (#121)

* S allius/issue117 (#122)

* add shutdown flag

* add more register definitions

* add start commando for client side connections

* add first support for port 8899

* fix shutdown

* add client_mode configuration

* read client_mode config to setup inverter connections

* add client_mode connections over port 8899

* add preview build

* add documentation for client_mode

* catch os error and log thme with DEBUG level

* update changelog

* make the maximum output coefficient configurable (#124)

* S allius/issue120 (#126)

* add config option to disable the modbus polling

* read more modbus regs in polling mode

* extend connection timeouts if polling mode is disabled

* update changelog

* S allius/issue125 (#127)

* fix linter warning

* move sequence diagramm to wiki

* catch asyncio.CancelledError

* S allius/issue128 (#130)

* set Register.NO_INPUTS fix to 4 for GEN3PLUS

* don't set Register.NO_INPUTS per MODBUS

* fix unit tests

* register OUTPUT_COEFFICIENT at HA

* update changelog

* - Home Assistant: improve inverter status value texts

* - GEN3: add inverter status

* on closing send outstanding MQTT data to the broker

* force MQTT publish on every conn open and close

* reset inverter state on close

- workaround which reset the inverter status to
  offline when the inverter has a very low
  output power on connection close

* improve client modified
- reduce the polling cadence to 30s
- set controller statistics for HA

* client mode set controller IP for HA

* S allius/issue131 (#132)

* Make __publish_outstanding_mqtt public

* update proxy counter

- on client mode connection establishment or
  disconnecting update tje counection counter

* Update README.md (#133)

* reset inverter state on close

- workaround which reset the inverter status to
  offline when the inverter has a very low
  output power on connection close

* S allius/issue134 (#135)

* add polling invertval and method ha_remove()

* add client_mode arg to constructors

- add PollingInvervall

* hide some topics in client mode

- we hide topics in HA by sending an empty register
  MQTT topic during HA auto configuration

* add client_mode value

* update class diagram

* fix modbus close handler

- fix empty call and cleanup que
- add unit test

* don't sent an initial 1710 msg in client mode

* change HA icon for inverter status

* increase test coverage

* accelerate timer tests

* bump aiomqtt and schema to latest release (#137)

* MQTT timestamps and protocol improvements (#140)

* add TS_INPUT, TS_GRID and TS_TOTAL

* prepare MQTT timestamps

- add _set_mqtt_timestamp method
- fix hexdump printing

* push dev and debug images to docker.io

* add unix epoche timestamp for MQTT pakets

* set timezone for unit tests

* set name für setting timezone step

* trigger new action

* GEN3 and GEN3PLUS: handle multiple message

- read: iterate over the receive buffer
- forward: append messages to the forward buffer
- _update_header: iterate over the forward buffer

* GEN3: optimize timeout handling

- longer timeout in state init and reveived
- got to state pending only from state up

* update changelog

* cleanup

* print coloured logs

* Create sonarcloud.yml (#143)

* Update sonarcloud.yml

* Update sonarcloud.yml

* Update sonarcloud.yml

* Update sonarcloud.yml

* Update sonarcloud.yml

* build multi arch images with sboms (#146)

* don't send MODBUS request when state is not up (#147)

* adapt timings

* don't send MODBUS request when state is note up

* adapt unit test

* make test code more clean (#148)

* Make test code more clean (#149)

* cleanup

* Code coverage for SonarCloud (#150)


* cleanup code and unit tests

* add test coverage for SonarCloud

* configure SonarCloud

* update changelog

* Do no build on *.yml changes

* prepare release 0.10.0

* disable MODBUS_POLLING for GEN§PLUS in example config

* bump aiohttp to version 3.10.2

* code cleanup

* Fetch all history for all tags and branches
2024-08-09 23:16:47 +02:00
Stefan Allius
416fe16609 Fetch all history for all tags and branches 2024-08-09 23:08:12 +02:00
Stefan Allius
d8aee5f789 code cleanup 2024-08-09 23:01:16 +02:00
Stefan Allius
04ec8ac8a7 bump aiohttp to version 3.10.2 2024-08-09 23:01:02 +02:00
Stefan Allius
39577b2415 disable MODBUS_POLLING for GEN§PLUS in example config 2024-08-09 22:59:47 +02:00
Stefan Allius
8fc1e83b42 prepare release 0.10.0 2024-08-09 22:14:20 +02:00
Stefan Allius
13f9febb47 Do no build on *.yml changes 2024-08-09 22:12:17 +02:00
Stefan Allius
d891e486e7 Code coverage for SonarCloud (#150)
* cleanup code and unit tests

* add test coverage for SonarCloud

* configure SonarCloud

* update changelog
2024-08-09 17:45:53 +02:00
Stefan Allius
155b6d4e67 Make test code more clean (#149)
* cleanup
2024-08-09 00:02:01 +02:00
Stefan Allius
24ece4fece make test code more clean (#148) 2024-08-08 20:27:39 +02:00
Stefan Allius
a779c90965 don't send MODBUS request when state is not up (#147)
* adapt timings

* don't send MODBUS request when state is note up

* adapt unit test
2024-08-08 19:46:27 +02:00
Stefan Allius
e33153fc1f build multi arch images with sboms (#146) 2024-08-08 18:55:17 +02:00
Stefan Allius
2a5e3b6507 Update sonarcloud.yml 2024-08-08 00:57:52 +02:00
Stefan Allius
6c6ce3e0a8 Update sonarcloud.yml 2024-08-08 00:46:31 +02:00
Stefan Allius
83a33efa18 Update sonarcloud.yml 2024-08-08 00:42:36 +02:00
Stefan Allius
25e55e4286 Update sonarcloud.yml 2024-08-08 00:40:14 +02:00
Stefan Allius
b3f1d97c3d Update sonarcloud.yml 2024-08-08 00:27:35 +02:00
Stefan Allius
4d1a5fbd21 Create sonarcloud.yml (#143) 2024-08-08 00:16:39 +02:00
Stefan Allius
6dbd9bc85f print coloured logs 2024-08-06 20:08:22 +02:00
Stefan Allius
47f7580184 MQTT timestamps and protocol improvements (#140)
* add TS_INPUT, TS_GRID and TS_TOTAL

* prepare MQTT timestamps

- add _set_mqtt_timestamp method
- fix hexdump printing

* push dev and debug images to docker.io

* add unix epoche timestamp for MQTT pakets

* set timezone for unit tests

* set name für setting timezone step

* trigger new action

* GEN3 and GEN3PLUS: handle multiple message

- read: iterate over the receive buffer
- forward: append messages to the forward buffer
- _update_header: iterate over the forward buffer

* GEN3: optimize timeout handling

- longer timeout in state init and reveived
- got to state pending only from state up

* update changelog

* cleanup
2024-08-04 19:43:09 +02:00
Stefan Allius
b8e44b7379 bump aiomqtt and schema to latest release (#137) 2024-07-27 20:36:17 +02:00
Stefan Allius
95954fa84e S allius/issue134 (#135)
* add polling invertval and method ha_remove()

* add client_mode arg to constructors

- add PollingInvervall

* hide some topics in client mode

- we hide topics in HA by sending an empty register
  MQTT topic during HA auto configuration

* add client_mode value

* update class diagram

* fix modbus close handler

- fix empty call and cleanup que
- add unit test

* don't sent an initial 1710 msg in client mode

* change HA icon for inverter status

* increase test coverage

* accelerate timer tests
2024-07-27 19:37:40 +02:00
Stefan Allius
3c656e8c63 Merge branch 'dev-0.10' of https://github.com/s-allius/tsun-gen3-proxy into dev-0.10 2024-07-24 22:37:09 +02:00
Stefan Allius
387c014763 reset inverter state on close
- workaround which reset the inverter status to
  offline when the inverter has a very low
  output power on connection close
2024-07-24 22:36:51 +02:00
Stefan Allius
19916453f2 Update README.md (#133) 2024-07-23 23:38:48 +02:00
Stefan Allius
7f81799dd9 S allius/issue131 (#132)
* Make __publish_outstanding_mqtt public

* update proxy counter

- on client mode connection establishment or
  disconnecting update tje counection counter
2024-07-23 21:54:01 +02:00
Stefan Allius
dc4728122e S allius/issue128 (#130)
* set Register.NO_INPUTS fix to 4 for GEN3PLUS

* don't set Register.NO_INPUTS per MODBUS

* fix unit tests

* register OUTPUT_COEFFICIENT at HA

* update changelog

* - Home Assistant: improve inverter status value texts

* - GEN3: add inverter status

* on closing send outstanding MQTT data to the broker

* force MQTT publish on every conn open and close

* reset inverter state on close

- workaround which reset the inverter status to
  offline when the inverter has a very low
  output power on connection close

* improve client modified
- reduce the polling cadence to 30s
- set controller statistics for HA

* client mode set controller IP for HA
2024-07-22 23:27:17 +02:00
Stefan Allius
6f35c47254 S allius/issue125 (#127)
* fix linter warning

* move sequence diagramm to wiki

* catch asyncio.CancelledError
2024-07-15 21:45:45 +02:00
Stefan Allius
92a5fd22b8 S allius/issue120 (#126)
* add config option to disable the modbus polling

* read more modbus regs in polling mode

* extend connection timeouts if polling mode is disabled

* update changelog
2024-07-14 17:04:05 +02:00
Stefan Allius
f3dd87e03c make the maximum output coefficient configurable (#124) 2024-07-11 19:31:30 +02:00
Stefan Allius
112c7e66f2 S allius/issue117 (#122)
* add shutdown flag

* add more register definitions

* add start commando for client side connections

* add first support for port 8899

* fix shutdown

* add client_mode configuration

* read client_mode config to setup inverter connections

* add client_mode connections over port 8899

* add preview build

* add documentation for client_mode

* catch os error and log thme with DEBUG level

* update changelog
2024-07-10 20:43:03 +02:00
Stefan Allius
c7a33b4a35 MODBUS: the last digit of the inverter version is a hexadecimal number (#121) 2024-07-10 20:28:12 +02:00
Stefan Allius
da8f39c401 Update README.md
describe the new client-mode over port 8899 for GEN3PLUS
2024-07-08 20:31:36 +02:00
Stefan Allius
e4ff17e600 S allius/issue117 (#118)
* add shutdown flag

* add more register definitions

* add start commando for client side connections

* add first support for port 8899

* fix shutdown

* add client_mode configuration

* read client_mode config to setup inverter connections

* add client_mode connections over port 8899

* add preview build
2024-07-08 19:08:58 +02:00
Stefan Allius
a42ba8a8c6 Dev 0.9 (#115)
* make timestamp handling stateless

* adapt tests for stateless timestamp handling

* initial version

* add more type annotations

* add more type annotations

* fix Generator annotation for ha_proxy_confs

* fix names of issue branches

* add more type annotations

* don't use depricated varn anymore

* don't mark all test as async

* fix imports

* fix solarman unit tests

- fake Mqtt class

* print image build time during proxy start

* update changelog

* fix pytest collect warning

* cleanup msg_get_time handler

* addapt unit test

* label debug images with debug

* dump droped packages

* fix warnings

* add systemtest with invalid start byte

* update changelog

* update changelog

* add exposed ports and healthcheck

* add wget for healthcheck

* add aiohttp

* use config validation for healthcheck

* add http server for healthcheck

* calculate msg prossesing time

* add healthy check methods

* fix typo

* log ConfigErr with DEBUG level

* Update async_stream.py

- check if processing time is < 5 sec

* add a close handler to release internal resources

* call modbus close hanlder on a close call

* add exception handling for forward handler

* update changelog

* isolate Modbus fix

* cleanup

* update changelog

* add heaithy handler

* log unrelease references

* add healtcheck

* complete exposed port list

* add wget for healtcheck

* add aiohttp

* use Enum class for State

* calc processing time for healthcheck

* add HTTP server for healthcheck

* cleanup

* Update CHANGELOG.md

* updat changelog

* add docstrings to state enum

* set new state State.received

* add healthy method

* log healthcheck infos with DEBUG level

* update changelog

* S allius/issue100 (#101)

* detect dead connections

- disconnect connection on Msg receive timeout
- improve connection trace (add connection id)

* update changelog

* fix merge conflict

* fix unittests

* S allius/issue108 (#109)

* add more data types

* adapt unittests

* improve test coverage

* fix linter warning

* update changelog

* S allius/issue102 (#110)

* hotfix: don't send two MODBUS commands together

* fix unit tests

* remove read loop

* optional sleep between msg read and sending rsp

* wait after read 0.5s before sending a response

* add pending state

* fix state definitions

* determine the connection timeout by the conn state

* avoid sending MODBUS cmds in the inverter's reporting phase

* update changelog

* S allius/issue111 (#112)

Synchronize regular MODBUS commands with the status of the inverter to prevent the inverter from crashing due to unexpected packets.

* inital checkin

* remove crontab entry for regular MODBUS cmds

* add timer for regular MODBUS polling

* fix Stop method call for already stopped timer

* optimize MB_START_TIMEOUT value

* cleanup

* update changelog

* fix buildx warnings

* fix timer cleanup

* fix Config.class_init()

- return error string or None
- release Schema structure after building thr config

* add quit flag to docker push

* fix timout calculation

* rename python to debugpy

* add asyncio log

* cleanup shutdown
- stop webserver on shutdown
- enable asyncio debug mode for debug versions

* update changelog

* update changelog

* fix exception in MODBUS timeout callback

* update changelog
2024-07-01 23:41:56 +02:00
37 changed files with 2100 additions and 1355 deletions

View File

@@ -1,2 +1,3 @@
[run] [run]
branch = True branch = True
relative_files = True

View File

@@ -18,10 +18,11 @@ on:
- '**.dockerfile' # Do no build on *.dockerfile changes - '**.dockerfile' # Do no build on *.dockerfile changes
- '**.sh' # Do no build on *.sh changes - '**.sh' # Do no build on *.sh changes
pull_request: pull_request:
branches: [ "main" ] branches: [ "main", "dev-*" ]
permissions: permissions:
contents: read contents: read
pull-requests: read # allows SonarCloud to decorate PRs with analysis results
jobs: jobs:
build: build:
@@ -29,7 +30,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: "Europe/Berlin"
timezoneMacos: "Europe/Berlin"
timezoneWindows: "Europe/Berlin"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for all tags and branches
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -47,4 +56,19 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest - name: Test with pytest
run: | run: |
python -m pytest app pip install pytest pytest-cov
#pytest app --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html
python -m pytest app --cov=app/src --cov-report=xml
- name: Analyze with SonarCloud
uses: SonarSource/sonarcloud-github-action@v2.2.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
projectBaseDir: .
args:
-Dsonar.projectKey=s-allius_tsun-gen3-proxy
-Dsonar.organization=s-allius
-Dsonar.python.version=3.12
-Dsonar.python.coverage.reportPaths=coverage.xml
-Dsonar.tests=system_tests,app/tests
-Dsonar.source=app/src

View File

@@ -0,0 +1,4 @@
{
"sonarCloudOrganization": "s-allius",
"projectKey": "s-allius_tsun-gen3-proxy"
}

View File

@@ -11,5 +11,9 @@
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"flake8.args": [ "flake8.args": [
"--extend-exclude=app/tests/*.py system_tests/*.py" "--extend-exclude=app/tests/*.py system_tests/*.py"
] ],
"sonarlint.connectedMode.project": {
"connectionId": "s-allius",
"projectKey": "s-allius_tsun-gen3-proxy"
}
} }

View File

@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
## [0.10.1] - 2024-08-10
- fix displaying the version string at startup and in HA [#153](https://github.com/s-allius/tsun-gen3-proxy/issues/153)
## [0.10.0] - 2024-08-09
- bump aiohttp to version 3.10.2
- add SonarQube and code coverage support
- don't send MODBUS request when state is note up; adapt timeouts [#141](https://github.com/s-allius/tsun-gen3-proxy/issues/141)
- build multi arch images with sboms [#144](https://github.com/s-allius/tsun-gen3-proxy/issues/144)
- add timestamp to MQTT topics [#138](https://github.com/s-allius/tsun-gen3-proxy/issues/138)
- improve the message handling, to avoid hangs
- GEN3: allow long timeouts until we received first inverter data (not only device data)
- bump aiomqtt to version 2.2.0
- bump schema to version 0.7.7
- Home Assistant: improve inverter status value texts
- GEN3: add inverter status
- fix flapping registers [#128](https://github.com/s-allius/tsun-gen3-proxy/issues/128)
- register OUTPUT_COEFFICIENT at HA
- GEN3: INVERTER_STATUS,
- add config option to disable the MODBUS polling [#120](https://github.com/s-allius/tsun-gen3-proxy/issues/120)
- make the maximum output coefficient configurable [#123](https://github.com/s-allius/tsun-gen3-proxy/issues/123)
- cleanup shutdown
- add preview build
- MODBUS: the last digit of the inverter version is a hexadecimal number [#119](https://github.com/s-allius/tsun-gen3-proxy/issues/119)
- GEN3PLUS: add client_mode connection on port 8899 [#117](https://github.com/s-allius/tsun-gen3-proxy/issues/117)
## [0.9.0] - 2024-07-01 ## [0.9.0] - 2024-07-01
- fix exception in MODBUS timeout callback - fix exception in MODBUS timeout callback

View File

@@ -7,7 +7,7 @@
<p align="center"> <p align="center">
<a href="https://opensource.org/licenses/BSD-3-Clause"><img alt="License: BSD-3-Clause" src="https://img.shields.io/badge/License-BSD_3--Clause-green.svg"></a> <a href="https://opensource.org/licenses/BSD-3-Clause"><img alt="License: BSD-3-Clause" src="https://img.shields.io/badge/License-BSD_3--Clause-green.svg"></a>
<a href="https://www.python.org/downloads/release/python-3120/"><img alt="Supported Python versions" src="https://img.shields.io/badge/python-3.12-blue.svg"></a> <a href="https://www.python.org/downloads/release/python-3120/"><img alt="Supported Python versions" src="https://img.shields.io/badge/python-3.12-blue.svg"></a>
<a href="https://sbtinstruments.github.io/aiomqtt/introduction.html"><img alt="Supported aiomqtt versions" src="https://img.shields.io/badge/aiomqtt-2.0.1-lightblue.svg"></a> <a href="https://sbtinstruments.github.io/aiomqtt/introduction.html"><img alt="Supported aiomqtt versions" src="https://img.shields.io/badge/aiomqtt-2.2.0-lightblue.svg"></a>
<a href="https://libraries.io/pypi/aiocron"><img alt="Supported aiocron versions" src="https://img.shields.io/badge/aiocron-1.8-lightblue.svg"></a> <a href="https://libraries.io/pypi/aiocron"><img alt="Supported aiocron versions" src="https://img.shields.io/badge/aiocron-1.8-lightblue.svg"></a>
<a href="https://toml.io/en/v1.0.0"><img alt="Supported toml versions" src="https://img.shields.io/badge/toml-1.0.0-lightblue.svg"></a> <a href="https://toml.io/en/v1.0.0"><img alt="Supported toml versions" src="https://img.shields.io/badge/toml-1.0.0-lightblue.svg"></a>
</p> </p>
@@ -165,6 +165,9 @@ pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module de
monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter 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 node_id = 'inv_3' # MQTT replacement for inverters serial number
suggested_area = 'garage' # suggested installation place for home-assistant suggested_area = 'garage' # suggested installation place for home-assistant
# if your inverter supports SSL connections you must use the client_mode. Pls, uncomment
# the next line and configure the fixed IP of your inverter
#client_mode = {host = '192.168.0.1', port = 8899}
pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
@@ -188,8 +191,20 @@ The standard web interface of the inverter can be accessed at `http://<ip-adress
For our purpose, the hidden URL `http://<ip-adress>/config_hide.html` should be called. There you can see and modify the parameters for accessing the cloud. Here we enter the IP address of our proxy and the IP port `10000` for the `Server A Setting` and for `Optional Server Setting`. The second entry is used as a backup in the event of connection problems. For our purpose, the hidden URL `http://<ip-adress>/config_hide.html` should be called. There you can see and modify the parameters for accessing the cloud. Here we enter the IP address of our proxy and the IP port `10000` for the `Server A Setting` and for `Optional Server Setting`. The second entry is used as a backup in the event of connection problems.
❗If the IP port is set to 10443 in the inverter configuration, you probably have a firmware with SSL support. In this case, you must use the client-mode configuration.
If access to the web interface does not work, it can also be redirected via DNS redirection, as is necessary for the GEN3 inverters. If access to the web interface does not work, it can also be redirected via DNS redirection, as is necessary for the GEN3 inverters.
## Client Mode (GEN3PLUS only)
Newer GEN3PLUS inverters support SSL encrypted connections over port 10443 to the TSUN cloud. In this case you can't loop the proxy into this connection, since the certicate verification of the inverter don't allow this. You can configure the proxy in client-mode to establish an unencrypted connection to the inverter. For this porpuse the inverter listen on port `8899`.
There are some requirements to be met:
- the inverter should have a fixed IP
- the proxy must be able to reach the inverter. You must configure a corresponding route in your router if the inverter and the proxy are in different IP networks
- add a 'client_mode' line to your config.toml file, to specify the inverter's ip address
## DNS Settings ## DNS Settings
### Loop the proxy into the connection ### Loop the proxy into the connection
@@ -226,11 +241,11 @@ 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. A combination with a red question mark should work, but I have not checked it in detail.
<table align="center"> <table align="center">
<tr><th align="center">Micro Inverter Model</th><th align="center">Fw. 1.00.06</th><th align="center">Fw. 1.00.17</th><th align="center">Fw. 1.00.20</th><th align="center">Fw. 4.0.10</th></tr> <tr><th align="center">Micro Inverter Model</th><th align="center">Fw. 1.00.06</th><th align="center">Fw. 1.00.17</th><th align="center">Fw. 1.00.20</th><th align="center">Fw. 4.0.10</th><th align="center">Fw. 4.0.20</th></tr>
<tr><td>GEN3 micro inverters (single MPPT):<br>MS300, MS350, MS400<br>MS400-D</td><td align="center">❓</td><td align="center">❓</td><td align="center">❓</td><td align="center"></td></tr> <tr><td>GEN3 micro inverters (single MPPT):<br>MS300, MS350, MS400<br>MS400-D</td><td align="center">❓</td><td align="center">❓</td><td align="center">❓</td><td align="center"></td><td align="center"></td></tr>
<tr><td>GEN3 micro inverters (dual MPPT):<br>MS600, MS700, MS800<br>MS600-D, MS800-D</td><td align="center">✔️</td><td align="center">✔️</td><td align="center">✔️</td><td align="center"></td></tr> <tr><td>GEN3 micro inverters (dual MPPT):<br>MS600, MS700, MS800<br>MS600-D, MS800-D</td><td align="center">✔️</td><td align="center">✔️</td><td align="center">✔️</td><td align="center"></td><td align="center"></td></tr>
<tr><td>GEN3 PLUS micro inverters:<br>MS1600, MS1800, MS2000<br>MS2000-D</td><td align="center"></td><td align="center"></td><td align="center"></td><td align="center">✔️</td></tr> <tr><td>GEN3 PLUS micro inverters:<br>MS1600, MS1800, MS2000<br>MS2000-D</td><td align="center"></td><td align="center"></td><td align="center"></td><td align="center">✔️</td><td align="center">✔️</td></tr>
<tr><td>TITAN micro inverters:<br>TSOL-MP3000, MP2250, MS3000</td><td align="center">❓</td><td align="center">❓</td><td align="center">❓</td><td align="center">❓</td></tr> <tr><td>TITAN micro inverters:<br>TSOL-MP3000, MP2250, MS3000</td><td align="center">❓</td><td align="center">❓</td><td align="center">❓</td><td align="center">❓</td><td align="center">❓</td></tr>
</table> </table>
```txt ```txt

View File

@@ -17,39 +17,56 @@ VERSION="${VERSION:1}"
arr=(${VERSION//./ }) arr=(${VERSION//./ })
MAJOR=${arr[0]} MAJOR=${arr[0]}
IMAGE=tsun-gen3-proxy IMAGE=tsun-gen3-proxy
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
if [[ $1 == debug ]] || [[ $1 == dev ]] ;then if [[ $1 == debug ]] || [[ $1 == dev ]] ;then
IMAGE=docker.io/sallius/${IMAGE} IMAGE=docker.io/sallius/${IMAGE}
VERSION=${VERSION}-$1 VERSION=${VERSION}+$1
elif [[ $1 == rc ]] || [[ $1 == rel ]];then elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then
IMAGE=ghcr.io/s-allius/${IMAGE} IMAGE=ghcr.io/s-allius/${IMAGE}
else else
echo argument missing! echo argument missing!
echo try: $0 '[debug|dev|rc|rel]' echo try: $0 '[debug|dev|preview|rc|rel]'
exit 1 exit 1
fi fi
echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE if [[ $1 == debug ]] ;then
if [[ $1 == debug ]];then BUILD_ENV="dev"
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:debug app else
elif [[ $1 == dev ]];then BUILD_ENV="production"
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}:rc -t ${IMAGE}:${VERSION} app
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:rc
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:${VERSION}
elif [[ $1 == rel ]];then
docker build --no-cache --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest -t ${IMAGE}:${MAJOR} -t ${IMAGE}:${VERSION} app
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:latest
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:${MAJOR}
docker push -q ghcr.io/s-allius/tsun-gen3-proxy:${VERSION}
fi fi
echo 'check docker-compose.yaml file' BUILD_CMD="buildx build --push --build-arg VERSION=${VERSION} --build-arg environment=${BUILD_ENV} --attest type=provenance,mode=max --attest type=sbom,generator=docker/scout-sbom-indexer:latest"
docker-compose config -q ARCH="--platform linux/amd64,linux/arm64,linux/arm/v7"
LABELS="--label org.opencontainers.image.created=${BUILD_DATE} --label org.opencontainers.image.version=${VERSION} --label org.opencontainers.image.revision=${BRANCH}"
echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE
if [[ $1 == debug ]];then
docker ${BUILD_CMD} ${ARCH} ${LABELS} --build-arg "LOG_LVL=DEBUG" -t ${IMAGE}:debug app
elif [[ $1 == dev ]];then
docker ${BUILD_CMD} ${ARCH} ${LABELS} -t ${IMAGE}:dev app
elif [[ $1 == preview ]];then
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
docker ${BUILD_CMD} ${ARCH} ${LABELS} -t ${IMAGE}:preview -t ${IMAGE}:${VERSION} app
elif [[ $1 == rc ]];then
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
docker ${BUILD_CMD} ${ARCH} ${LABELS} -t ${IMAGE}:rc -t ${IMAGE}:${VERSION} app
elif [[ $1 == rel ]];then
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
docker ${BUILD_CMD} ${ARCH} ${LABELS} --no-cache -t ${IMAGE}:latest -t ${IMAGE}:${MAJOR} -t ${IMAGE}:${VERSION} app
fi
echo -e "${BLUE} => checking docker-compose.yaml file${NC}"
docker-compose config -q
echo
echo -e "${GREEN}${BUILD_DATE} => Version: ${VERSION}${NC} finished"
echo

View File

@@ -31,12 +31,14 @@ inverters.allow_all = true # allow inverters, even if we have no inverter mapp
[inverters."R170000000000001"] [inverters."R170000000000001"]
#node_id = '' # Optional, MQTT replacement for inverters serial number #node_id = '' # Optional, MQTT replacement for inverters serial number
#suggested_area = '' # Optional, suggested installation area for home-assistant #suggested_area = '' # Optional, suggested installation area for home-assistant
modbus_polling = false # Disable optional MODBUS polling
#pv1 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr #pv1 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr
#pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr #pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr
#[inverters."R17xxxxxxxxxxxx2"] #[inverters."R17xxxxxxxxxxxx2"]
#node_id = '' # Optional, MQTT replacement for inverters serial number #node_id = '' # Optional, MQTT replacement for inverters serial number
#suggested_area = '' # Optional, suggested installation area for home-assistant #suggested_area = '' # Optional, suggested installation area for home-assistant
#modbus_polling = false # Disable optional MODBUS polling
#pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr #pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
#pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr #pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
@@ -44,6 +46,12 @@ inverters.allow_all = true # allow inverters, even if we have no inverter mapp
monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter
#node_id = '' # Optional, MQTT replacement for inverters serial number #node_id = '' # Optional, MQTT replacement for inverters serial number
#suggested_area = '' # Optional, suggested installation place for home-assistant #suggested_area = '' # Optional, suggested installation place for home-assistant
modbus_polling = true # Enable optional MODBUS polling
# if your inverter supports SSL connections you must use the client_mode. Pls, uncomment
# the next line and configure the fixed IP of your inverter
#client_mode = {host = '192.168.0.1', port = 8899}
#pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr #pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
#pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr #pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
#pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr #pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr

View File

@@ -4,381 +4,408 @@
<!-- Generated by graphviz version 2.40.1 (20161225.0304) <!-- Generated by graphviz version 2.40.1 (20161225.0304)
--> -->
<!-- Title: G Pages: 1 --> <!-- Title: G Pages: 1 -->
<svg width="691pt" height="1312pt" <svg width="720pt" height="1360pt"
viewBox="0.00 0.00 691.35 1312.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> viewBox="0.00 0.00 719.50 1360.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1308)"> <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1356)">
<title>G</title> <title>G</title>
<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-1308 687.348,-1308 687.348,4 -4,4"/> <polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-1356 715.5,-1356 715.5,4 -4,4"/>
<!-- A0 --> <!-- A0 -->
<g id="node1" class="node"> <g id="node1" class="node">
<title>A0</title> <title>A0</title>
<polygon fill="#fff8dc" stroke="#000000" points="108.5444,-1208 .1516,-1208 .1516,-1172 114.5444,-1172 114.5444,-1202 108.5444,-1208"/> <polygon fill="#fff8dc" stroke="#000000" points="153.6964,-1250 45.3036,-1250 45.3036,-1214 159.6964,-1214 159.6964,-1244 153.6964,-1250"/>
<polyline fill="none" stroke="#000000" points="108.5444,-1208 108.5444,-1202 "/> <polyline fill="none" stroke="#000000" points="153.6964,-1250 153.6964,-1244 "/>
<polyline fill="none" stroke="#000000" points="114.5444,-1202 108.5444,-1202 "/> <polyline fill="none" stroke="#000000" points="159.6964,-1244 153.6964,-1244 "/>
<text text-anchor="middle" x="57.348" y="-1193" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">You can stick notes</text> <text text-anchor="middle" x="102.5" y="-1235" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">You can stick notes</text>
<text text-anchor="middle" x="57.348" y="-1181" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">on diagrams too!</text> <text text-anchor="middle" x="102.5" y="-1223" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">on diagrams too!</text>
</g> </g>
<!-- A1 --> <!-- A1 -->
<g id="node2" class="node"> <g id="node2" class="node">
<title>A1</title> <title>A1</title>
<polygon fill="none" stroke="#000000" points="657.0297,-906 587.6663,-906 587.6663,-870 657.0297,-870 657.0297,-906"/> <polygon fill="none" stroke="#000000" points="685.1817,-942 615.8183,-942 615.8183,-906 685.1817,-906 685.1817,-942"/>
<text text-anchor="middle" x="622.348" y="-885" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Singleton</text> <text text-anchor="middle" x="650.5" y="-921" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Singleton</text>
</g> </g>
<!-- A2 --> <!-- A2 -->
<g id="node3" class="node"> <g id="node3" class="node">
<title>A2</title> <title>A2</title>
<polygon fill="none" stroke="#000000" points="561.348,-608 561.348,-640 683.348,-640 683.348,-608 561.348,-608"/> <polygon fill="none" stroke="#000000" points="589.5,-644 589.5,-676 711.5,-676 711.5,-644 589.5,-644"/>
<text text-anchor="start" x="612.625" y="-621" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Mqtt</text> <text text-anchor="start" x="640.777" y="-657" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Mqtt</text>
<polygon fill="none" stroke="#000000" points="561.348,-552 561.348,-608 683.348,-608 683.348,-552 561.348,-552"/> <polygon fill="none" stroke="#000000" points="589.5,-588 589.5,-644 711.5,-644 711.5,-588 589.5,-588"/>
<text text-anchor="start" x="579.8355" y="-589" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;static&gt;ha_restarts</text> <text text-anchor="start" x="607.9875" y="-625" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;static&gt;ha_restarts</text>
<text text-anchor="start" x="587.6145" y="-577" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;static&gt;__client</text> <text text-anchor="start" x="615.7665" y="-613" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;static&gt;__client</text>
<text text-anchor="start" x="571.2215" y="-565" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;static&gt;__cb_MqttIsUp</text> <text text-anchor="start" x="599.3735" y="-601" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;static&gt;__cb_MqttIsUp</text>
<polygon fill="none" stroke="#000000" points="561.348,-508 561.348,-552 683.348,-552 683.348,-508 561.348,-508"/> <polygon fill="none" stroke="#000000" points="589.5,-544 589.5,-588 711.5,-588 711.5,-544 589.5,-544"/>
<text text-anchor="start" x="584.284" y="-533" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;publish()</text> <text text-anchor="start" x="612.436" y="-569" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;publish()</text>
<text text-anchor="start" x="588.4525" y="-521" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;close()</text> <text text-anchor="start" x="616.6045" y="-557" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;close()</text>
</g> </g>
<!-- A1&#45;&gt;A2 --> <!-- A1&#45;&gt;A2 -->
<g id="edge1" class="edge"> <g id="edge1" class="edge">
<title>A1&#45;&gt;A2</title> <title>A1&#45;&gt;A2</title>
<path fill="none" stroke="#000000" d="M622.348,-859.5395C622.348,-810.311 622.348,-708.0351 622.348,-640.2069"/> <path fill="none" stroke="#000000" d="M650.5,-895.5395C650.5,-846.311 650.5,-744.0351 650.5,-676.2069"/>
<polygon fill="none" stroke="#000000" points="618.8481,-859.7608 622.348,-869.7608 625.8481,-859.7608 618.8481,-859.7608"/> <polygon fill="none" stroke="#000000" points="647.0001,-895.7608 650.5,-905.7608 654.0001,-895.7608 647.0001,-895.7608"/>
</g> </g>
<!-- A11 --> <!-- A11 -->
<g id="node12" class="node"> <g id="node12" class="node">
<title>A11</title> <title>A11</title>
<polygon fill="none" stroke="#000000" points="568.348,-324 568.348,-356 676.348,-356 676.348,-324 568.348,-324"/> <polygon fill="none" stroke="#000000" points="596.5,-348 596.5,-380 704.5,-380 704.5,-348 596.5,-348"/>
<text text-anchor="start" x="605.4015" y="-337" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Inverter</text> <text text-anchor="start" x="633.5535" y="-361" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Inverter</text>
<polygon fill="none" stroke="#000000" points="568.348,-232 568.348,-324 676.348,-324 676.348,-232 568.348,-232"/> <polygon fill="none" stroke="#000000" points="596.5,-256 596.5,-348 704.5,-348 704.5,-256 596.5,-256"/>
<text text-anchor="start" x="598.452" y="-305" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.db_stat</text> <text text-anchor="start" x="626.604" y="-329" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.db_stat</text>
<text text-anchor="start" x="591.7885" y="-293" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.entity_prfx</text> <text text-anchor="start" x="619.9405" y="-317" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.entity_prfx</text>
<text text-anchor="start" x="582.6235" y="-281" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.discovery_prfx</text> <text text-anchor="start" x="610.7755" y="-305" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.discovery_prfx</text>
<text text-anchor="start" x="582.0595" y="-269" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.proxy_node_id</text> <text text-anchor="start" x="610.2115" y="-293" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.proxy_node_id</text>
<text text-anchor="start" x="578.1705" y="-257" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.proxy_unique_id</text> <text text-anchor="start" x="606.3225" y="-281" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.proxy_unique_id</text>
<text text-anchor="start" x="594.0135" y="-245" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.mqtt:Mqtt</text> <text text-anchor="start" x="622.1655" y="-269" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.mqtt:Mqtt</text>
<polygon fill="none" stroke="#000000" points="568.348,-212 568.348,-232 676.348,-232 676.348,-212 568.348,-212"/> <polygon fill="none" stroke="#000000" points="596.5,-236 596.5,-256 704.5,-256 704.5,-236 596.5,-236"/>
</g> </g>
<!-- A2&#45;&gt;A11 --> <!-- A2&#45;&gt;A11 -->
<g id="edge13" class="edge"> <g id="edge13" class="edge">
<title>A2&#45;&gt;A11</title> <title>A2&#45;&gt;A11</title>
<path fill="none" stroke="#000000" d="M622.348,-507.8316C622.348,-462.6124 622.348,-402.6972 622.348,-356.2361"/> <path fill="none" stroke="#000000" d="M650.5,-543.7248C650.5,-495.3688 650.5,-429.8734 650.5,-380.1918"/>
</g> </g>
<!-- A3 --> <!-- A3 -->
<g id="node4" class="node"> <g id="node4" class="node">
<title>A3</title> <title>A3</title>
<polygon fill="none" stroke="#000000" points="257.348,-366 257.348,-398 364.348,-398 364.348,-366 257.348,-366"/> <polygon fill="none" stroke="#000000" points="318.5,-402 318.5,-434 393.5,-434 393.5,-402 318.5,-402"/>
<text text-anchor="start" x="293.0655" y="-379" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Modbus</text> <text text-anchor="start" x="338.2175" y="-415" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Modbus</text>
<polygon fill="none" stroke="#000000" points="257.348,-226 257.348,-366 364.348,-366 364.348,-226 257.348,-226"/> <polygon fill="none" stroke="#000000" points="318.5,-250 318.5,-402 393.5,-402 393.5,-250 318.5,-250"/>
<text text-anchor="start" x="302.5095" y="-347" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">que</text> <text text-anchor="start" x="347.6615" y="-383" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">que</text>
<text text-anchor="start" x="283.338" y="-323" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">snd_handler</text> <text text-anchor="start" x="328.49" y="-359" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">snd_handler</text>
<text text-anchor="start" x="284.453" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">rsp_handler</text> <text text-anchor="start" x="329.605" y="-347" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">rsp_handler</text>
<text text-anchor="start" x="266.9565" y="-299" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">timeout:max_retires</text> <text text-anchor="start" x="339.6085" y="-335" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">timeout</text>
<text text-anchor="start" x="292.79" y="-287" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">last_xxx</text> <text text-anchor="start" x="329.8895" y="-323" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">max_retires</text>
<text text-anchor="start" x="304.7395" y="-275" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">err</text> <text text-anchor="start" x="337.942" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">last_xxx</text>
<text text-anchor="start" x="291.4015" y="-263" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">retry_cnt</text> <text text-anchor="start" x="349.8915" y="-299" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">err</text>
<text text-anchor="start" x="289.727" y="-251" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">req_pend</text> <text text-anchor="start" x="336.5535" y="-287" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">retry_cnt</text>
<text text-anchor="start" x="304.1845" y="-239" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">tim</text> <text text-anchor="start" x="334.879" y="-275" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">req_pend</text>
<polygon fill="none" stroke="#000000" points="257.348,-170 257.348,-226 364.348,-226 364.348,-170 257.348,-170"/> <text text-anchor="start" x="349.3365" y="-263" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">tim</text>
<text text-anchor="start" x="284.738" y="-207" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">build_msg()</text> <polygon fill="none" stroke="#000000" points="318.5,-182 318.5,-250 393.5,-250 393.5,-182 318.5,-182"/>
<text text-anchor="start" x="288.072" y="-195" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">recv_req()</text> <text text-anchor="start" x="329.89" y="-231" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">build_msg()</text>
<text text-anchor="start" x="285.572" y="-183" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">recv_resp()</text> <text text-anchor="start" x="333.224" y="-219" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">recv_req()</text>
<text text-anchor="start" x="330.724" y="-207" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">recv_resp()</text>
<text text-anchor="start" x="341.0025" y="-195" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
</g> </g>
<!-- A4 --> <!-- A4 -->
<g id="node5" class="node"> <g id="node5" class="node">
<title>A4</title> <title>A4</title>
<polygon fill="none" stroke="#000000" points="263.348,-1200 263.348,-1232 334.348,-1232 334.348,-1200 263.348,-1200"/> <polygon fill="none" stroke="#000000" points="308.5,-1242 308.5,-1274 379.5,-1274 379.5,-1242 308.5,-1242"/>
<text text-anchor="start" x="273.293" y="-1213" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">IterRegistry</text> <text text-anchor="start" x="318.445" y="-1255" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">IterRegistry</text>
<polygon fill="none" stroke="#000000" points="263.348,-1180 263.348,-1200 334.348,-1200 334.348,-1180 263.348,-1180"/> <polygon fill="none" stroke="#000000" points="308.5,-1222 308.5,-1242 379.5,-1242 379.5,-1222 308.5,-1222"/>
<polygon fill="none" stroke="#000000" points="263.348,-1148 263.348,-1180 334.348,-1180 334.348,-1148 263.348,-1148"/> <polygon fill="none" stroke="#000000" points="308.5,-1190 308.5,-1222 379.5,-1222 379.5,-1190 308.5,-1190"/>
<text text-anchor="start" x="280.787" y="-1161" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__iter__</text> <text text-anchor="start" x="325.939" y="-1203" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__iter__</text>
</g> </g>
<!-- A5 --> <!-- A5 -->
<g id="node6" class="node"> <g id="node6" class="node">
<title>A5</title> <title>A5</title>
<polygon fill="none" stroke="#000000" points="231.348,-994 231.348,-1026 365.348,-1026 365.348,-994 231.348,-994"/> <polygon fill="none" stroke="#000000" points="276.5,-1030 276.5,-1062 410.5,-1062 410.5,-1030 276.5,-1030"/>
<text text-anchor="start" x="278.0655" y="-1007" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Message</text> <text text-anchor="start" x="323.2175" y="-1043" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Message</text>
<polygon fill="none" stroke="#000000" points="231.348,-818 231.348,-994 365.348,-994 365.348,-818 231.348,-818"/> <polygon fill="none" stroke="#000000" points="276.5,-854 276.5,-1030 410.5,-1030 410.5,-854 276.5,-854"/>
<text text-anchor="start" x="261.6745" y="-975" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">server_side:bool</text> <text text-anchor="start" x="306.8265" y="-1011" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">server_side:bool</text>
<text text-anchor="start" x="258.891" y="-963" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">header_valid:bool</text> <text text-anchor="start" x="304.043" y="-999" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">header_valid:bool</text>
<text text-anchor="start" x="251.662" y="-951" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">header_len:unsigned</text> <text text-anchor="start" x="296.814" y="-987" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">header_len:unsigned</text>
<text text-anchor="start" x="257.496" y="-939" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">data_len:unsigned</text> <text text-anchor="start" x="302.648" y="-975" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">data_len:unsigned</text>
<text text-anchor="start" x="276.6725" y="-927" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">unique_id</text> <text text-anchor="start" x="321.8245" y="-963" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">unique_id</text>
<text text-anchor="start" x="280.5615" y="-915" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">node_id</text> <text text-anchor="start" x="325.7135" y="-951" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">node_id</text>
<text text-anchor="start" x="277.5065" y="-903" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">sug_area</text> <text text-anchor="start" x="322.6585" y="-939" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">sug_area</text>
<text text-anchor="start" x="248.337" y="-891" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_recv_buffer:bytearray</text> <text text-anchor="start" x="293.489" y="-927" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_recv_buffer:bytearray</text>
<text text-anchor="start" x="246.9425" y="-879" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_send_buffer:bytearray</text> <text text-anchor="start" x="292.0945" y="-915" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_send_buffer:bytearray</text>
<text text-anchor="start" x="241.1145" y="-867" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_forward_buffer:bytearray</text> <text text-anchor="start" x="286.2665" y="-903" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_forward_buffer:bytearray</text>
<text text-anchor="start" x="280.5615" y="-855" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:Infos</text> <text text-anchor="start" x="325.7135" y="-891" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:Infos</text>
<text text-anchor="start" x="269.174" y="-843" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">new_data:list</text> <text text-anchor="start" x="314.326" y="-879" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">new_data:list</text>
<text text-anchor="start" x="287.51" y="-831" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">state</text> <text text-anchor="start" x="332.662" y="-867" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">state</text>
<polygon fill="none" stroke="#000000" points="231.348,-750 231.348,-818 365.348,-818 365.348,-750 231.348,-750"/> <polygon fill="none" stroke="#000000" points="276.5,-786 276.5,-854 410.5,-854 410.5,-786 276.5,-786"/>
<text text-anchor="start" x="248.0575" y="-799" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_read():void&lt;abstract&gt;</text> <text text-anchor="start" x="293.2095" y="-835" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_read():void&lt;abstract&gt;</text>
<text text-anchor="start" x="272.7925" y="-787" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close():void</text> <text text-anchor="start" x="317.9445" y="-823" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close():void</text>
<text text-anchor="start" x="258.6205" y="-775" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_counter():void</text> <text text-anchor="start" x="303.7725" y="-811" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_counter():void</text>
<text text-anchor="start" x="256.9505" y="-763" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dec_counter():void</text> <text text-anchor="start" x="302.1025" y="-799" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dec_counter():void</text>
</g> </g>
<!-- A4&#45;&gt;A5 --> <!-- A4&#45;&gt;A5 -->
<g id="edge2" class="edge"> <g id="edge2" class="edge">
<title>A4&#45;&gt;A5</title> <title>A4&#45;&gt;A5</title>
<path fill="none" stroke="#000000" d="M298.348,-1137.5879C298.348,-1106.6429 298.348,-1065.8843 298.348,-1026.2983"/> <path fill="none" stroke="#000000" d="M343.5,-1179.6793C343.5,-1147.2188 343.5,-1103.8616 343.5,-1062.0836"/>
<polygon fill="none" stroke="#000000" points="294.8481,-1137.6902 298.348,-1147.6902 301.8481,-1137.6902 294.8481,-1137.6902"/> <polygon fill="none" stroke="#000000" points="340.0001,-1179.8197 343.5,-1189.8197 347.0001,-1179.8198 340.0001,-1179.8197"/>
</g> </g>
<!-- A6 --> <!-- A6 -->
<g id="node7" class="node"> <g id="node7" class="node">
<title>A6</title> <title>A6</title>
<polygon fill="none" stroke="#000000" points="370.348,-668 370.348,-700 484.348,-700 484.348,-668 370.348,-668"/> <polygon fill="none" stroke="#000000" points="415.5,-704 415.5,-736 529.5,-736 529.5,-704 415.5,-704"/>
<text text-anchor="start" x="413.456" y="-681" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Talent</text> <text text-anchor="start" x="458.608" y="-717" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Talent</text>
<polygon fill="none" stroke="#000000" points="370.348,-564 370.348,-668 484.348,-668 484.348,-564 370.348,-564"/> <polygon fill="none" stroke="#000000" points="415.5,-600 415.5,-704 529.5,-704 529.5,-600 415.5,-600"/>
<text text-anchor="start" x="380.111" y="-649" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">await_conn_resp_cnt</text> <text text-anchor="start" x="425.263" y="-685" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">await_conn_resp_cnt</text>
<text text-anchor="start" x="415.1255" y="-637" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">id_str</text> <text text-anchor="start" x="460.2775" y="-673" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">id_str</text>
<text text-anchor="start" x="395.948" y="-625" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">contact_name</text> <text text-anchor="start" x="441.1" y="-661" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">contact_name</text>
<text text-anchor="start" x="399.288" y="-613" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">contact_mail</text> <text text-anchor="start" x="444.44" y="-649" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">contact_mail</text>
<text text-anchor="start" x="402.8925" y="-601" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:InfosG3</text> <text text-anchor="start" x="448.0445" y="-637" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:InfosG3</text>
<text text-anchor="start" x="401.232" y="-589" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">mb:Modbus</text> <text text-anchor="start" x="446.384" y="-625" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">mb:Modbus</text>
<text text-anchor="start" x="413.46" y="-577" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">switch</text> <text text-anchor="start" x="458.612" y="-613" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">switch</text>
<polygon fill="none" stroke="#000000" points="370.348,-448 370.348,-564 484.348,-564 484.348,-448 370.348,-448"/> <polygon fill="none" stroke="#000000" points="415.5,-484 415.5,-600 529.5,-600 529.5,-484 415.5,-484"/>
<text text-anchor="start" x="384.8405" y="-545" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_contact_info()</text> <text text-anchor="start" x="429.9925" y="-581" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_contact_info()</text>
<text text-anchor="start" x="386.7805" y="-533" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_ota_update()</text> <text text-anchor="start" x="431.9325" y="-569" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_ota_update()</text>
<text text-anchor="start" x="392.6245" y="-521" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_get_time()</text> <text text-anchor="start" x="437.7765" y="-557" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_get_time()</text>
<text text-anchor="start" x="380.6765" y="-509" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_collector_data()</text> <text text-anchor="start" x="425.8285" y="-545" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_collector_data()</text>
<text text-anchor="start" x="382.6215" y="-497" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_inverter_data()</text> <text text-anchor="start" x="427.7735" y="-533" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_inverter_data()</text>
<text text-anchor="start" x="391.7885" y="-485" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_unknown()</text> <text text-anchor="start" x="436.9405" y="-521" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_unknown()</text>
<text text-anchor="start" x="412.3505" y="-461" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="457.5025" y="-497" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
</g> </g>
<!-- A5&#45;&gt;A6 --> <!-- A5&#45;&gt;A6 -->
<g id="edge3" class="edge"> <g id="edge3" class="edge">
<title>A5&#45;&gt;A6</title> <title>A5&#45;&gt;A6</title>
<path fill="none" stroke="#000000" d="M358.9294,-740.5383C364.4479,-727.1056 370.0049,-713.5794 375.4378,-700.355"/> <path fill="none" stroke="#000000" d="M404.0814,-776.5383C409.5999,-763.1056 415.1569,-749.5794 420.5898,-736.355"/>
<polygon fill="none" stroke="#000000" points="355.6797,-739.2382 355.117,-749.8181 362.1546,-741.8983 355.6797,-739.2382"/> <polygon fill="none" stroke="#000000" points="400.8317,-775.2382 400.269,-785.8181 407.3066,-777.8983 400.8317,-775.2382"/>
</g> </g>
<!-- A7 --> <!-- A7 -->
<g id="node8" class="node"> <g id="node8" class="node">
<title>A7</title> <title>A7</title>
<polygon fill="none" stroke="#000000" points="127.348,-632 127.348,-664 218.348,-664 218.348,-632 127.348,-632"/> <polygon fill="none" stroke="#000000" points="172.5,-668 172.5,-700 263.5,-700 263.5,-668 172.5,-668"/>
<text text-anchor="start" x="145.343" y="-645" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">SolarmanV5</text> <text text-anchor="start" x="190.495" y="-681" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">SolarmanV5</text>
<polygon fill="none" stroke="#000000" points="127.348,-540 127.348,-632 218.348,-632 218.348,-540 127.348,-540"/> <polygon fill="none" stroke="#000000" points="172.5,-576 172.5,-668 263.5,-668 263.5,-576 172.5,-576"/>
<text text-anchor="start" x="157.846" y="-613" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">control</text> <text text-anchor="start" x="202.998" y="-649" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">control</text>
<text text-anchor="start" x="160.9055" y="-601" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">serial</text> <text text-anchor="start" x="206.0575" y="-637" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">serial</text>
<text text-anchor="start" x="165.904" y="-589" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">snr</text> <text text-anchor="start" x="211.056" y="-625" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">snr</text>
<text text-anchor="start" x="145.058" y="-577" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:InfosG3P</text> <text text-anchor="start" x="190.21" y="-613" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:InfosG3P</text>
<text text-anchor="start" x="146.732" y="-565" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">mb:Modbus</text> <text text-anchor="start" x="191.884" y="-601" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">mb:Modbus</text>
<text text-anchor="start" x="158.96" y="-553" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">switch</text> <text text-anchor="start" x="204.112" y="-589" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">switch</text>
<polygon fill="none" stroke="#000000" points="127.348,-484 127.348,-540 218.348,-540 218.348,-484 127.348,-484"/> <polygon fill="none" stroke="#000000" points="172.5,-520 172.5,-576 263.5,-576 263.5,-520 172.5,-520"/>
<text text-anchor="start" x="137.2885" y="-521" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_unknown()</text> <text text-anchor="start" x="182.4405" y="-557" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_unknown()</text>
<text text-anchor="start" x="157.8505" y="-497" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="203.0025" y="-533" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
</g> </g>
<!-- A5&#45;&gt;A7 --> <!-- A5&#45;&gt;A7 -->
<g id="edge4" class="edge"> <g id="edge4" class="edge">
<title>A5&#45;&gt;A7</title> <title>A5&#45;&gt;A7</title>
<path fill="none" stroke="#000000" d="M239.076,-740.2903C228.6761,-714.3733 218.1403,-688.1174 208.6075,-664.3611"/> <path fill="none" stroke="#000000" d="M284.228,-776.2903C273.8281,-750.3733 263.2923,-724.1174 253.7595,-700.3611"/>
<polygon fill="none" stroke="#000000" points="235.9268,-741.8409 242.8992,-749.8181 242.4233,-739.2339 235.9268,-741.8409"/> <polygon fill="none" stroke="#000000" points="281.0788,-777.8409 288.0512,-785.8181 287.5753,-775.2339 281.0788,-777.8409"/>
</g> </g>
<!-- A6&#45;&gt;A3 --> <!-- A6&#45;&gt;A3 -->
<g id="edge6" class="edge"> <g id="edge6" class="edge">
<title>A6&#45;&gt;A3</title> <title>A6&#45;&gt;A3</title>
<path fill="none" stroke="#000000" d="M376.3705,-447.6454C371.0187,-434.3805 365.5816,-420.9039 360.2423,-407.6696"/> <path fill="none" stroke="#000000" d="M420.9917,-483.781C414.3472,-467.074 407.7026,-450.1475 401.5,-434 399.8828,-429.7898 398.247,-425.4956 396.6047,-421.154"/>
<polygon fill="#000000" stroke="#000000" points="356.3743,-398.0824 364.289,-405.6724 358.2451,-402.7192 360.1158,-407.3561 360.1158,-407.3561 360.1158,-407.3561 358.2451,-402.7192 355.9427,-409.0397 356.3743,-398.0824 356.3743,-398.0824"/> <polygon fill="#000000" stroke="#000000" points="393.0037,-411.5882 400.7383,-419.3616 394.7652,-416.2676 396.5268,-420.947 396.5268,-420.947 396.5268,-420.947 394.7652,-416.2676 392.3154,-422.5325 393.0037,-411.5882 393.0037,-411.5882"/>
<text text-anchor="middle" x="370.9946" y="-408.7296" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">1</text> <text text-anchor="middle" x="407.3001" y="-422.5743" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">1</text>
<text text-anchor="middle" x="361.7502" y="-430.9982" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text> <text text-anchor="middle" x="406.4454" y="-467.0549" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text>
</g> </g>
<!-- A8 --> <!-- A8 -->
<g id="node9" class="node"> <g id="node9" class="node">
<title>A8</title> <title>A8</title>
<polygon fill="none" stroke="#000000" points="382.348,-300 382.348,-332 532.348,-332 532.348,-300 382.348,-300"/> <polygon fill="none" stroke="#000000" points="410.5,-330 410.5,-362 560.5,-362 560.5,-330 410.5,-330"/>
<text text-anchor="start" x="425.3935" y="-313" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3</text> <text text-anchor="start" x="453.5455" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3</text>
<polygon fill="none" stroke="#000000" points="382.348,-268 382.348,-300 532.348,-300 532.348,-268 382.348,-268"/> <polygon fill="none" stroke="#000000" points="410.5,-298 410.5,-330 560.5,-330 560.5,-298 410.5,-298"/>
<text text-anchor="start" x="392.335" y="-281" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remoteStream:ConnectionG3</text> <text text-anchor="start" x="420.487" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remote_stream:ConnectionG3</text>
<polygon fill="none" stroke="#000000" points="382.348,-236 382.348,-268 532.348,-268 532.348,-236 382.348,-236"/> <polygon fill="none" stroke="#000000" points="410.5,-254 410.5,-298 560.5,-298 560.5,-254 410.5,-254"/>
<text text-anchor="start" x="442.3505" y="-249" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="466.054" y="-279" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">healthy()</text>
<text text-anchor="start" x="470.5025" y="-267" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
</g> </g>
<!-- A6&#45;&gt;A8 --> <!-- A6&#45;&gt;A8 -->
<g id="edge5" class="edge"> <g id="edge5" class="edge">
<title>A6&#45;&gt;A8</title> <title>A6&#45;&gt;A8</title>
<path fill="none" stroke="#000000" d="M441.464,-437.5454C445.3714,-399.7739 449.3591,-361.2265 452.3615,-332.203"/> <path fill="none" stroke="#000000" d="M478.3685,-473.6691C480.0687,-434.1731 481.827,-393.3258 483.1723,-362.0732"/>
<polygon fill="none" stroke="#000000" points="437.9668,-437.3383 440.4192,-447.6454 444.9297,-438.0587 437.9668,-437.3383"/> <polygon fill="none" stroke="#000000" points="474.8712,-473.5333 477.9378,-483.6747 481.8648,-473.8345 474.8712,-473.5333"/>
</g> </g>
<!-- A7&#45;&gt;A3 --> <!-- A7&#45;&gt;A3 -->
<g id="edge8" class="edge"> <g id="edge8" class="edge">
<title>A7&#45;&gt;A3</title> <title>A7&#45;&gt;A3</title>
<path fill="none" stroke="#000000" d="M210.935,-483.8952C216.3404,-471.7801 221.9084,-459.553 227.348,-448 235.1472,-431.4354 243.6196,-414.0579 252.0717,-397.0641"/> <path fill="none" stroke="#000000" d="M254.1161,-519.7083C259.8714,-507.5039 266.0613,-495.3029 272.5,-484 286.0537,-460.2067 295.6001,-458.1541 308.5,-434 310.2529,-430.7178 311.9697,-427.3559 313.6482,-423.937"/>
<polygon fill="#000000" stroke="#000000" points="256.7608,-387.6701 256.3209,-398.6272 254.5277,-392.1437 252.2946,-396.6174 252.2946,-396.6174 252.2946,-396.6174 254.5277,-392.1437 248.2683,-394.6076 256.7608,-387.6701 256.7608,-387.6701"/> <polygon fill="#000000" stroke="#000000" points="317.9998,-414.7692 317.7769,-425.7328 315.8557,-419.2862 313.7116,-423.8032 313.7116,-423.8032 313.7116,-423.8032 315.8557,-419.2862 309.6463,-421.8735 317.9998,-414.7692 317.9998,-414.7692"/>
<text text-anchor="middle" x="256.228" y="-404.663" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">1</text> <text text-anchor="middle" x="317.8629" y="-431.7687" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">1</text>
<text text-anchor="middle" x="210.6174" y="-460.8977" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text> <text text-anchor="middle" x="254.262" y="-496.7088" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text>
</g> </g>
<!-- A9 --> <!-- A9 -->
<g id="node10" class="node"> <g id="node10" class="node">
<title>A9</title> <title>A9</title>
<polygon fill="none" stroke="#000000" points="64.348,-300 64.348,-332 220.348,-332 220.348,-300 64.348,-300"/> <polygon fill="none" stroke="#000000" points="125.5,-330 125.5,-362 281.5,-362 281.5,-330 125.5,-330"/>
<text text-anchor="start" x="107.059" y="-313" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3P</text> <text text-anchor="start" x="168.211" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3P</text>
<polygon fill="none" stroke="#000000" points="64.348,-268 64.348,-300 220.348,-300 220.348,-268 64.348,-268"/> <polygon fill="none" stroke="#000000" points="125.5,-298 125.5,-330 281.5,-330 281.5,-298 125.5,-298"/>
<text text-anchor="start" x="74.0005" y="-281" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remoteStream:ConnectionG3P</text> <text text-anchor="start" x="135.1525" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remote_stream:ConnectionG3P</text>
<polygon fill="none" stroke="#000000" points="64.348,-236 64.348,-268 220.348,-268 220.348,-236 64.348,-236"/> <polygon fill="none" stroke="#000000" points="125.5,-254 125.5,-298 281.5,-298 281.5,-254 125.5,-254"/>
<text text-anchor="start" x="127.3505" y="-249" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="184.054" y="-279" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">healthy()</text>
<text text-anchor="start" x="188.5025" y="-267" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
</g> </g>
<!-- A7&#45;&gt;A9 --> <!-- A7&#45;&gt;A9 -->
<g id="edge7" class="edge"> <g id="edge7" class="edge">
<title>A7&#45;&gt;A9</title> <title>A7&#45;&gt;A9</title>
<path fill="none" stroke="#000000" d="M161.9757,-473.7349C157.0236,-425.8645 151.3255,-370.7828 147.349,-332.3431"/> <path fill="none" stroke="#000000" d="M212.8528,-509.7531C210.5717,-460.5471 207.9134,-403.2043 206.0152,-362.2565"/>
<polygon fill="none" stroke="#000000" points="158.5098,-474.2451 163.0203,-483.8319 165.4726,-473.5248 158.5098,-474.2451"/> <polygon fill="none" stroke="#000000" points="209.3591,-509.972 213.3185,-519.7991 216.3516,-509.6477 209.3591,-509.972"/>
</g> </g>
<!-- A8&#45;&gt;A8 --> <!-- A8&#45;&gt;A8 -->
<g id="edge15" class="edge"> <g id="edge15" class="edge">
<title>A8&#45;&gt;A8</title> <title>A8&#45;&gt;A8</title>
<path fill="none" stroke="#000000" d="M532.5164,-321.6908C543.1874,-315.5948 550.348,-303.0313 550.348,-284 550.348,-270.3213 546.6488,-259.9838 540.6058,-252.9875"/> <path fill="none" stroke="#000000" d="M560.6684,-348.7195C571.3394,-342.1337 578.5,-328.5605 578.5,-308 578.5,-292.9008 574.6382,-281.57 568.3604,-274.0076"/>
<polygon fill="#000000" stroke="#000000" points="532.5164,-246.3092 543.0929,-249.2054 536.3722,-249.4924 540.228,-252.6756 540.228,-252.6756 540.228,-252.6756 536.3722,-249.4924 537.3632,-256.1459 532.5164,-246.3092 532.5164,-246.3092"/> <polygon fill="#000000" stroke="#000000" points="560.6684,-267.2805 571.1583,-270.4763 564.4321,-270.5721 568.1958,-273.8637 568.1958,-273.8637 568.1958,-273.8637 564.4321,-270.5721 565.2334,-277.251 560.6684,-267.2805 560.6684,-267.2805"/>
<text text-anchor="middle" x="551.8757" y="-248.3308" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</text> <text text-anchor="middle" x="579.877" y="-269.8507" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</text>
<text text-anchor="middle" x="543.0584" y="-301.6947" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text> <text text-anchor="middle" x="570.593" y="-328.3557" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text>
</g> </g>
<!-- A12 --> <!-- A12 -->
<g id="node13" class="node"> <g id="node13" class="node">
<title>A12</title> <title>A12</title>
<polygon fill="none" stroke="#000000" points="478.348,-88 478.348,-120 600.348,-120 600.348,-88 478.348,-88"/> <polygon fill="none" stroke="#000000" points="506.5,-100 506.5,-132 628.5,-132 628.5,-100 506.5,-100"/>
<text text-anchor="start" x="515.7325" y="-101" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3</text> <text text-anchor="start" x="543.8845" y="-113" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3</text>
<polygon fill="none" stroke="#000000" points="478.348,-56 478.348,-88 600.348,-88 600.348,-56 478.348,-56"/> <polygon fill="none" stroke="#000000" points="506.5,-68 506.5,-100 628.5,-100 628.5,-68 506.5,-68"/>
<text text-anchor="start" x="508.7835" y="-69" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text> <text text-anchor="start" x="536.9355" y="-81" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text>
<polygon fill="none" stroke="#000000" points="478.348,0 478.348,-56 600.348,-56 600.348,0 478.348,0"/> <polygon fill="none" stroke="#000000" points="506.5,0 506.5,-68 628.5,-68 628.5,0 506.5,0"/>
<text text-anchor="start" x="487.9515" y="-37" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote()</text> <text text-anchor="start" x="516.1035" y="-49" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote()</text>
<text text-anchor="start" x="524.3505" y="-13" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="526.382" y="-37" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_publ_mqtt()</text>
<text text-anchor="start" x="552.5025" y="-13" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
</g> </g>
<!-- A8&#45;&gt;A12 --> <!-- A8&#45;&gt;A12 -->
<g id="edge14" class="edge"> <g id="edge14" class="edge">
<title>A8&#45;&gt;A12</title> <title>A8&#45;&gt;A12</title>
<path fill="none" stroke="#000000" d="M478.5265,-226.1465C490.409,-193.6871 505.2165,-153.2373 517.2458,-120.3767"/> <path fill="none" stroke="#000000" d="M507.022,-244.4839C518.719,-209.9635 533.1714,-167.3112 545.0148,-132.3588"/>
<polygon fill="none" stroke="#000000" points="475.09,-225.3526 474.9391,-235.9464 481.6634,-227.759 475.09,-225.3526"/> <polygon fill="none" stroke="#000000" points="503.6947,-243.3974 503.8003,-253.9917 510.3245,-245.6439 503.6947,-243.3974"/>
</g> </g>
<!-- A9&#45;&gt;A9 --> <!-- A9&#45;&gt;A9 -->
<g id="edge17" class="edge"> <g id="edge17" class="edge">
<title>A9&#45;&gt;A9</title> <title>A9&#45;&gt;A9</title>
<path fill="none" stroke="#000000" d="M220.6951,-321.2601C231.2923,-315.0138 238.348,-302.5938 238.348,-284 238.348,-270.6357 234.703,-260.4608 228.7179,-253.4753"/> <path fill="none" stroke="#000000" d="M281.8471,-348.2542C292.4443,-341.506 299.5,-328.0879 299.5,-308 299.5,-293.248 295.6948,-282.093 289.4763,-274.5351"/>
<polygon fill="#000000" stroke="#000000" points="220.6951,-246.7399 231.2473,-249.7232 224.5245,-249.9548 228.3539,-253.1697 228.3539,-253.1697 228.3539,-253.1697 224.5245,-249.9548 225.4605,-256.6162 220.6951,-246.7399 220.6951,-246.7399"/> <polygon fill="#000000" stroke="#000000" points="281.8471,-267.7458 292.3089,-271.0321 285.5822,-271.0697 289.3174,-274.3937 289.3174,-274.3937 289.3174,-274.3937 285.5822,-271.0697 286.3258,-277.7553 281.8471,-267.7458 281.8471,-267.7458"/>
<text text-anchor="middle" x="240.0123" y="-248.9211" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</text> <text text-anchor="middle" x="301.0069" y="-270.4817" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</text>
<text text-anchor="middle" x="231.039" y="-301.1428" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text> <text text-anchor="middle" x="291.5637" y="-327.7732" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text>
</g> </g>
<!-- A13 --> <!-- A13 -->
<g id="node14" class="node"> <g id="node14" class="node">
<title>A13</title> <title>A13</title>
<polygon fill="none" stroke="#000000" points="251.348,-88 251.348,-120 373.348,-120 373.348,-88 251.348,-88"/> <polygon fill="none" stroke="#000000" points="144.5,-94 144.5,-126 263.5,-126 263.5,-94 144.5,-94"/>
<text text-anchor="start" x="285.398" y="-101" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3P</text> <text text-anchor="start" x="177.05" y="-107" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3P</text>
<polygon fill="none" stroke="#000000" points="251.348,-56 251.348,-88 373.348,-88 373.348,-56 251.348,-56"/> <polygon fill="none" stroke="#000000" points="144.5,-62 144.5,-94 263.5,-94 263.5,-62 144.5,-62"/>
<text text-anchor="start" x="281.7835" y="-69" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text> <text text-anchor="start" x="173.4355" y="-75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text>
<polygon fill="none" stroke="#000000" points="251.348,0 251.348,-56 373.348,-56 373.348,0 251.348,0"/> <polygon fill="none" stroke="#000000" points="144.5,-6 144.5,-62 263.5,-62 263.5,-6 144.5,-6"/>
<text text-anchor="start" x="260.9515" y="-37" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote()</text> <text text-anchor="start" x="154.268" y="-43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote(</text>
<text text-anchor="start" x="297.3505" y="-13" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="161.2175" y="-31" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">)async_publ_mqtt()</text>
<text text-anchor="start" x="189.0025" y="-19" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
</g> </g>
<!-- A9&#45;&gt;A13 --> <!-- A9&#45;&gt;A13 -->
<g id="edge16" class="edge"> <g id="edge16" class="edge">
<title>A9&#45;&gt;A13</title> <title>A9&#45;&gt;A13</title>
<path fill="none" stroke="#000000" d="M184.9859,-227.8183C209.8288,-195.0842 241.1576,-153.8039 266.5264,-120.3767"/> <path fill="none" stroke="#000000" d="M203.5,-243.955C203.5,-207.4743 203.5,-162.045 203.5,-126.2187"/>
<polygon fill="none" stroke="#000000" points="182.0747,-225.8647 178.8173,-235.9464 187.6507,-230.0965 182.0747,-225.8647"/> <polygon fill="none" stroke="#000000" points="200.0001,-243.9917 203.5,-253.9917 207.0001,-243.9917 200.0001,-243.9917"/>
</g> </g>
<!-- A10 --> <!-- A10 -->
<g id="node11" class="node"> <g id="node11" class="node">
<title>A10</title> <title>A10</title>
<polygon fill="none" stroke="#000000" points="236.348,-662 236.348,-694 352.348,-694 352.348,-662 236.348,-662"/> <polygon fill="none" stroke="#000000" points="281.5,-698 281.5,-730 397.5,-730 397.5,-698 281.5,-698"/>
<text text-anchor="start" x="264.622" y="-675" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">AsyncStream</text> <text text-anchor="start" x="309.774" y="-711" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">AsyncStream</text>
<polygon fill="none" stroke="#000000" points="236.348,-582 236.348,-662 352.348,-662 352.348,-582 236.348,-582"/> <polygon fill="none" stroke="#000000" points="281.5,-618 281.5,-698 397.5,-698 397.5,-618 281.5,-618"/>
<text text-anchor="start" x="279.901" y="-643" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">reader</text> <text text-anchor="start" x="325.053" y="-679" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">reader</text>
<text text-anchor="start" x="282.131" y="-631" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">writer</text> <text text-anchor="start" x="327.283" y="-667" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">writer</text>
<text text-anchor="start" x="284.345" y="-619" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">addr</text> <text text-anchor="start" x="329.497" y="-655" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">addr</text>
<text text-anchor="start" x="279.901" y="-607" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">r_addr</text> <text text-anchor="start" x="325.053" y="-643" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">r_addr</text>
<text text-anchor="start" x="280.456" y="-595" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">l_addr</text> <text text-anchor="start" x="325.608" y="-631" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">l_addr</text>
<polygon fill="none" stroke="#000000" points="236.348,-454 236.348,-582 352.348,-582 352.348,-454 236.348,-454"/> <polygon fill="none" stroke="#000000" points="281.5,-490 281.5,-618 397.5,-618 397.5,-490 281.5,-490"/>
<text text-anchor="start" x="246.0055" y="-563" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;server_loop()</text> <text text-anchor="start" x="291.1575" y="-599" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;server_loop()</text>
<text text-anchor="start" x="248.226" y="-551" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;client_loop()</text> <text text-anchor="start" x="293.378" y="-587" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;client_loop()</text>
<text text-anchor="start" x="266.002" y="-539" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;loop</text> <text text-anchor="start" x="311.154" y="-575" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">&lt;async&gt;loop</text>
<text text-anchor="start" x="282.13" y="-527" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">disc()</text> <text text-anchor="start" x="327.282" y="-563" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">disc()</text>
<text text-anchor="start" x="279.3505" y="-515" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="324.5025" y="-551" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
<text text-anchor="start" x="259.6185" y="-491" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__async_read()</text> <text text-anchor="start" x="304.7705" y="-527" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__async_read()</text>
<text text-anchor="start" x="264.628" y="-479" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_write()</text> <text text-anchor="start" x="309.78" y="-515" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_write()</text>
<text text-anchor="start" x="252.955" y="-467" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__async_forward()</text> <text text-anchor="start" x="298.107" y="-503" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__async_forward()</text>
</g> </g>
<!-- A10&#45;&gt;A8 --> <!-- A10&#45;&gt;A8 -->
<g id="edge9" class="edge"> <g id="edge9" class="edge">
<title>A10&#45;&gt;A8</title> <title>A10&#45;&gt;A8</title>
<path fill="none" stroke="#000000" d="M357.3002,-455.2837C358.6569,-452.8318 360.0073,-450.4016 361.348,-448 383.2991,-408.6787 409.1348,-364.6637 428.4716,-332.1398"/> <path fill="none" stroke="#000000" d="M402.0319,-480.6532C422.1536,-439.0316 443.3588,-395.1687 459.3606,-362.0691"/>
<polygon fill="none" stroke="#000000" points="354.1241,-453.7956 352.3659,-464.2436 360.2557,-457.1724 354.1241,-453.7956"/> <polygon fill="none" stroke="#000000" points="398.824,-479.2474 397.6226,-489.7739 405.1262,-482.2941 398.824,-479.2474"/>
</g> </g>
<!-- A10&#45;&gt;A9 --> <!-- A10&#45;&gt;A9 -->
<g id="edge10" class="edge"> <g id="edge10" class="edge">
<title>A10&#45;&gt;A9</title> <title>A10&#45;&gt;A9</title>
<path fill="none" stroke="#000000" d="M231.478,-454.0506C209.0706,-411.2997 185.0929,-365.5527 167.6574,-332.2876"/> <path fill="none" stroke="#000000" d="M281.2511,-480.6532C262.5076,-439.0316 242.7548,-395.1687 227.849,-362.0691"/>
<polygon fill="none" stroke="#000000" points="228.4903,-455.8898 236.2327,-463.1221 234.6903,-452.6401 228.4903,-455.8898"/> <polygon fill="none" stroke="#000000" points="278.0609,-482.0929 285.3584,-489.7739 284.4435,-479.2186 278.0609,-482.0929"/>
</g> </g>
<!-- A11&#45;&gt;A12 --> <!-- A11&#45;&gt;A12 -->
<g id="edge11" class="edge"> <g id="edge11" class="edge">
<title>A11&#45;&gt;A12</title> <title>A11&#45;&gt;A12</title>
<path fill="none" stroke="#000000" d="M592.1173,-202.4136C582.0634,-175.28 571.0546,-145.5697 561.7056,-120.3387"/> <path fill="none" stroke="#000000" d="M622.3544,-225.9369C611.8702,-195.3687 600.1133,-161.0894 590.181,-132.1301"/>
<polygon fill="none" stroke="#000000" points="588.8729,-203.7311 595.6294,-211.892 595.4368,-201.2989 588.8729,-203.7311"/> <polygon fill="none" stroke="#000000" points="619.1547,-227.3962 625.7097,-235.7198 625.7761,-225.1252 619.1547,-227.3962"/>
</g> </g>
<!-- A11&#45;&gt;A13 --> <!-- A11&#45;&gt;A13 -->
<g id="edge12" class="edge"> <g id="edge12" class="edge">
<title>A11&#45;&gt;A13</title> <title>A11&#45;&gt;A13</title>
<path fill="none" stroke="#000000" d="M586.2753,-202.8933C578.5712,-190.8884 569.6045,-179.4114 559.348,-170 530.8998,-143.8959 437.024,-105.9199 373.5518,-82.1078"/> <path fill="none" stroke="#000000" d="M622.0673,-226.7211C613.147,-210.1001 601.7805,-194.0346 587.5,-182 538.0407,-140.3192 358.189,-98.0533 263.2057,-77.9953"/>
<polygon fill="none" stroke="#000000" points="583.4606,-204.9994 591.6624,-211.7061 589.4331,-201.3485 583.4606,-204.9994"/> <polygon fill="none" stroke="#000000" points="619.1257,-228.6587 626.7743,-235.9901 625.367,-225.4892 619.1257,-228.6587"/>
</g> </g>
<!-- A14 --> <!-- A14 -->
<g id="node15" class="node"> <g id="node15" class="node">
<title>A14</title> <title>A14</title>
<polygon fill="none" stroke="#000000" points="133.348,-1272 133.348,-1304 236.348,-1304 236.348,-1272 133.348,-1272"/> <polygon fill="none" stroke="#000000" points="178.5,-1320 178.5,-1352 281.5,-1352 281.5,-1320 178.5,-1320"/>
<text text-anchor="start" x="174.01" y="-1285" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Infos</text> <text text-anchor="start" x="219.162" y="-1333" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Infos</text>
<polygon fill="none" stroke="#000000" points="133.348,-1216 133.348,-1272 236.348,-1272 236.348,-1216 133.348,-1216"/> <polygon fill="none" stroke="#000000" points="178.5,-1264 178.5,-1320 281.5,-1320 281.5,-1264 178.5,-1264"/>
<text text-anchor="start" x="176.7895" y="-1253" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">stat</text> <text text-anchor="start" x="221.9415" y="-1301" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">stat</text>
<text text-anchor="start" x="152.334" y="-1241" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">new_stat_data</text> <text text-anchor="start" x="197.486" y="-1289" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">new_stat_data</text>
<text text-anchor="start" x="165.9515" y="-1229" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">info_dev</text> <text text-anchor="start" x="211.1035" y="-1277" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">info_dev</text>
<polygon fill="none" stroke="#000000" points="133.348,-1076 133.348,-1216 236.348,-1216 236.348,-1076 133.348,-1076"/> <polygon fill="none" stroke="#000000" points="178.5,-1112 178.5,-1264 281.5,-1264 281.5,-1112 178.5,-1112"/>
<text text-anchor="start" x="160.6835" y="-1197" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">static_init()</text> <text text-anchor="start" x="205.8355" y="-1245" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">static_init()</text>
<text text-anchor="start" x="158.7325" y="-1185" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dev_value()</text> <text text-anchor="start" x="203.8845" y="-1233" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dev_value()</text>
<text text-anchor="start" x="155.6785" y="-1173" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_counter()</text> <text text-anchor="start" x="200.8305" y="-1221" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_counter()</text>
<text text-anchor="start" x="154.0085" y="-1161" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dec_counter()</text> <text text-anchor="start" x="199.1605" y="-1209" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dec_counter()</text>
<text text-anchor="start" x="152.058" y="-1149" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_proxy_conf</text> <text text-anchor="start" x="197.21" y="-1197" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_proxy_conf</text>
<text text-anchor="start" x="167.061" y="-1137" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_conf</text> <text text-anchor="start" x="212.213" y="-1185" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_conf</text>
<text text-anchor="start" x="161.2225" y="-1125" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">update_db</text> <text text-anchor="start" x="204.994" y="-1173" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_remove</text>
<text text-anchor="start" x="145.385" y="-1113" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">set_db_def_value</text> <text text-anchor="start" x="206.3745" y="-1161" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">update_db</text>
<text text-anchor="start" x="154.8335" y="-1101" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">get_db_value</text> <text text-anchor="start" x="190.537" y="-1149" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">set_db_def_value</text>
<text text-anchor="start" x="143.1705" y="-1089" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ignore_this_device</text> <text text-anchor="start" x="199.9855" y="-1137" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">get_db_value</text>
<text text-anchor="start" x="188.3225" y="-1125" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ignore_this_device</text>
</g> </g>
<!-- A15 --> <!-- A15 -->
<g id="node16" class="node"> <g id="node16" class="node">
<title>A15</title> <title>A15</title>
<polygon fill="none" stroke="#000000" points="386.348,-904 386.348,-936 453.348,-936 453.348,-904 386.348,-904"/> <polygon fill="none" stroke="#000000" points="431.5,-940 431.5,-972 498.5,-972 498.5,-940 431.5,-940"/>
<text text-anchor="start" x="402.341" y="-917" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3</text> <text text-anchor="start" x="447.493" y="-953" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3</text>
<polygon fill="none" stroke="#000000" points="386.348,-884 386.348,-904 453.348,-904 453.348,-884 386.348,-884"/> <polygon fill="none" stroke="#000000" points="431.5,-920 431.5,-940 498.5,-940 498.5,-920 431.5,-920"/>
<polygon fill="none" stroke="#000000" points="386.348,-840 386.348,-884 453.348,-884 453.348,-840 386.348,-840"/> <polygon fill="none" stroke="#000000" points="431.5,-876 431.5,-920 498.5,-920 498.5,-876 431.5,-876"/>
<text text-anchor="start" x="396.232" y="-865" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_confs()</text> <text text-anchor="start" x="441.384" y="-901" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_confs()</text>
<text text-anchor="start" x="404.016" y="-853" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text> <text text-anchor="start" x="449.168" y="-889" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text>
</g> </g>
<!-- A14&#45;&gt;A15 --> <!-- A14&#45;&gt;A15 -->
<g id="edge18" class="edge"> <g id="edge18" class="edge">
<title>A14&#45;&gt;A15</title> <title>A14&#45;&gt;A15</title>
<path fill="none" stroke="#000000" d="M242.8857,-1086.9876C246.5464,-1083.0913 250.3682,-1079.4032 254.348,-1076 298.2601,-1038.4501 335.1504,-1068.4478 374.348,-1026 397.0004,-1001.4693 408.2589,-965.3633 413.8498,-936.2357"/> <path fill="none" stroke="#000000" d="M287.6238,-1123.7067C291.4001,-1119.5529 295.359,-1115.6229 299.5,-1112 342.985,-1073.9563 380.3024,-1104.4478 419.5,-1062 442.1524,-1037.4693 453.4109,-1001.3633 459.0018,-972.2357"/>
<polygon fill="none" stroke="#000000" points="240.0515,-1084.9088 236.0452,-1094.717 245.2936,-1089.548 240.0515,-1084.9088"/> <polygon fill="none" stroke="#000000" points="284.8741,-1121.5366 281.0238,-1131.4071 290.1891,-1126.0921 284.8741,-1121.5366"/>
</g> </g>
<!-- A16 --> <!-- A16 -->
<g id="node17" class="node"> <g id="node17" class="node">
<title>A16</title> <title>A16</title>
<polygon fill="none" stroke="#000000" points="142.348,-904 142.348,-936 209.348,-936 209.348,-904 142.348,-904"/> <polygon fill="none" stroke="#000000" points="187.5,-940 187.5,-972 254.5,-972 254.5,-940 187.5,-940"/>
<text text-anchor="start" x="155.0065" y="-917" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3P</text> <text text-anchor="start" x="200.1585" y="-953" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3P</text>
<polygon fill="none" stroke="#000000" points="142.348,-884 142.348,-904 209.348,-904 209.348,-884 142.348,-884"/> <polygon fill="none" stroke="#000000" points="187.5,-920 187.5,-940 254.5,-940 254.5,-920 187.5,-920"/>
<polygon fill="none" stroke="#000000" points="142.348,-840 142.348,-884 209.348,-884 209.348,-840 142.348,-840"/> <polygon fill="none" stroke="#000000" points="187.5,-876 187.5,-920 254.5,-920 254.5,-876 187.5,-876"/>
<text text-anchor="start" x="152.232" y="-865" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_confs()</text> <text text-anchor="start" x="197.384" y="-901" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_confs()</text>
<text text-anchor="start" x="160.016" y="-853" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text> <text text-anchor="start" x="205.168" y="-889" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text>
</g> </g>
<!-- A14&#45;&gt;A16 --> <!-- A14&#45;&gt;A16 -->
<g id="edge19" class="edge"> <g id="edge19" class="edge">
<title>A14&#45;&gt;A16</title> <title>A14&#45;&gt;A16</title>
<path fill="none" stroke="#000000" d="M180.6399,-1065.5724C179.2846,-1020.0932 177.8303,-971.2935 176.7899,-936.3828"/> <path fill="none" stroke="#000000" d="M225.6878,-1101.5366C224.3454,-1055.5988 222.9195,-1006.7991 221.9029,-972.012"/>
<polygon fill="none" stroke="#000000" points="177.1491,-1065.9355 180.9455,-1075.8267 184.146,-1065.7269 177.1491,-1065.9355"/> <polygon fill="none" stroke="#000000" points="222.191,-1101.7024 225.9817,-1111.5959 229.188,-1101.4979 222.191,-1101.7024"/>
</g> </g>
<!-- A15&#45;&gt;A6 --> <!-- A15&#45;&gt;A6 -->
<g id="edge21" class="edge"> <g id="edge21" class="edge">
<title>A15&#45;&gt;A6</title> <title>A15&#45;&gt;A6</title>
<path fill="none" stroke="#000000" d="M420.5717,-839.9684C421.4566,-805.2366 422.6992,-756.4655 423.879,-710.1572"/> <path fill="none" stroke="#000000" d="M465.7237,-875.9684C466.6086,-841.2366 467.8512,-792.4655 469.031,-746.1572"/>
<polygon fill="#000000" stroke="#000000" points="424.1376,-700.0098 428.3813,-710.1212 424.0102,-705.0082 423.8828,-710.0066 423.8828,-710.0066 423.8828,-710.0066 424.0102,-705.0082 419.3842,-709.8919 424.1376,-700.0098 424.1376,-700.0098"/> <polygon fill="#000000" stroke="#000000" points="469.2896,-736.0098 473.5333,-746.1212 469.1622,-741.0082 469.0348,-746.0066 469.0348,-746.0066 469.0348,-746.0066 469.1622,-741.0082 464.5362,-745.8919 469.2896,-736.0098 469.2896,-736.0098"/>
</g> </g>
<!-- A16&#45;&gt;A7 --> <!-- A16&#45;&gt;A7 -->
<g id="edge20" class="edge"> <g id="edge20" class="edge">
<title>A16&#45;&gt;A7</title> <title>A16&#45;&gt;A7</title>
<path fill="none" stroke="#000000" d="M174.8891,-839.9684C174.4696,-796.0581 173.8357,-729.7079 173.3059,-674.2644"/> <path fill="none" stroke="#000000" d="M220.0411,-875.9684C219.6216,-832.0581 218.9877,-765.7079 218.4579,-710.2644"/>
<polygon fill="#000000" stroke="#000000" points="173.2083,-664.0467 177.8037,-674.0032 173.2561,-669.0465 173.304,-674.0463 173.304,-674.0463 173.304,-674.0463 173.2561,-669.0465 168.8042,-674.0893 173.2083,-664.0467 173.2083,-664.0467"/> <polygon fill="#000000" stroke="#000000" points="218.3603,-700.0467 222.9557,-710.0032 218.4081,-705.0465 218.456,-710.0463 218.456,-710.0463 218.456,-710.0463 218.4081,-705.0465 213.9562,-710.0893 218.3603,-700.0467 218.3603,-700.0467"/>
</g>
<!-- A17 -->
<g id="node18" class="node">
<title>A17</title>
<polygon fill="none" stroke="#000000" points=".5,-336 .5,-368 107.5,-368 107.5,-336 .5,-336"/>
<text text-anchor="start" x="24.2695" y="-349" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ModbusConn</text>
<polygon fill="none" stroke="#000000" points=".5,-268 .5,-336 107.5,-336 107.5,-268 .5,-268"/>
<text text-anchor="start" x="44.5515" y="-317" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">host</text>
<text text-anchor="start" x="45.387" y="-305" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">port</text>
<text text-anchor="start" x="43.997" y="-293" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">addr</text>
<text text-anchor="start" x="10.383" y="-281" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">stream:InverterG3P</text>
<polygon fill="none" stroke="#000000" points=".5,-248 .5,-268 107.5,-268 107.5,-248 .5,-248"/>
</g>
<!-- A17&#45;&gt;A13 -->
<g id="edge22" class="edge">
<title>A17&#45;&gt;A13</title>
<path fill="none" stroke="#000000" d="M80.8473,-247.8342C91.2165,-226.5814 103.6422,-202.8044 116.5,-182 126.2708,-166.1905 137.6417,-149.852 148.8772,-134.6044"/>
<polygon fill="#000000" stroke="#000000" points="155.0942,-126.2561 152.7306,-136.9642 152.1078,-130.2663 149.1214,-134.2765 149.1214,-134.2765 149.1214,-134.2765 152.1078,-130.2663 145.5123,-131.5887 155.0942,-126.2561 155.0942,-126.2561"/>
<text text-anchor="middle" x="151.047" y="-142.8423" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">1</text>
<text text-anchor="middle" x="81.2636" y="-224.8385" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -4,25 +4,27 @@
[note: You can stick notes on diagrams too!{bg:cornsilk}] [note: You can stick notes on diagrams too!{bg:cornsilk}]
[Singleton]^[Mqtt|<static>ha_restarts;<static>__client;<static>__cb_MqttIsUp|<async>publish();<async>close()] [Singleton]^[Mqtt|<static>ha_restarts;<static>__client;<static>__cb_MqttIsUp|<async>publish();<async>close()]
[Modbus|que;;snd_handler;rsp_handler;timeout:max_retires;last_xxx;err;retry_cnt;req_pend;tim|build_msg();recv_req();recv_resp()] [Modbus|que;;snd_handler;rsp_handler;timeout;max_retires;last_xxx;err;retry_cnt;req_pend;tim|build_msg();recv_req();recv_resp();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;state|_read():void<abstract>;close():void;inc_counter():void;dec_counter():void] [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<abstract>;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]^[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()] [Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()]
[Talent]^[ConnectionG3|remoteStream:ConnectionG3|close()] [Talent]^[ConnectionG3|remote_stream:ConnectionG3|healthy();close()]
[Talent]has-1>[Modbus] [Talent]has-1>[Modbus]
[SolarmanV5]^[ConnectionG3P|remoteStream:ConnectionG3P|close()] [SolarmanV5]^[ConnectionG3P|remote_stream:ConnectionG3P|healthy();close()]
[SolarmanV5]has-1>[Modbus] [SolarmanV5]has-1>[Modbus]
[AsyncStream|reader;writer;addr;r_addr;l_addr|<async>server_loop();<async>client_loop();<async>loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3] [AsyncStream|reader;writer;addr;r_addr;l_addr|<async>server_loop();<async>client_loop();<async>loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3]
[AsyncStream]^[ConnectionG3P] [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|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();async_publ_mqtt();;close()]
[Inverter]^[InverterG3P|__ha_restarts|async_create_remote();;close()] [Inverter]^[InverterG3P|__ha_restarts|async_create_remote(;)async_publ_mqtt();close()]
[Mqtt]-[Inverter] [Mqtt]-[Inverter]
[ConnectionG3]^[InverterG3] [ConnectionG3]^[InverterG3]
[ConnectionG3]has-0..1>[ConnectionG3] [ConnectionG3]has-0..1>[ConnectionG3]
[ConnectionG3P]^[InverterG3P] [ConnectionG3P]^[InverterG3P]
[ConnectionG3P]has-0..1>[ConnectionG3P] [ConnectionG3P]has-0..1>[ConnectionG3P]
[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]^[InfosG3||ha_confs();parse()]
[Infos|stat;new_stat_data;info_dev|static_init();dev_value();inc_counter();dec_counter();ha_proxy_conf;ha_conf;ha_remove;update_db;set_db_def_value;get_db_value;ignore_this_device]^[InfosG3||ha_confs();parse()]
[Infos]^[InfosG3P||ha_confs();parse()] [Infos]^[InfosG3P||ha_confs();parse()]
[InfosG3P]->[SolarmanV5] [InfosG3P]->[SolarmanV5]
[InfosG3]->[Talent] [InfosG3]->[Talent]
[ModbusConn|host;port;addr;stream:InverterG3P;|]has-1>[InverterG3P]

View File

@@ -1,4 +1,4 @@
aiomqtt==2.0.1 aiomqtt==2.2.0
schema==0.7.5 schema==0.7.7
aiocron==1.8 aiocron==1.8
aiohttp==3.9.5 aiohttp==3.10.2

View File

@@ -17,10 +17,10 @@ class AsyncStream():
'''maximum processing time for a received msg in sec''' '''maximum processing time for a received msg in sec'''
MAX_START_TIME = 400 MAX_START_TIME = 400
'''maximum time without a received msg in sec''' '''maximum time without a received msg in sec'''
MAX_INV_IDLE_TIME = 90 MAX_INV_IDLE_TIME = 120
'''maximum time without a received msg from the inverter in sec''' '''maximum time without a received msg from the inverter in sec'''
MAX_CLOUD_IDLE_TIME = 360 MAX_DEF_IDLE_TIME = 360
'''maximum time without a received msg from cloud side in sec''' '''maximum default time without a received msg in sec'''
def __init__(self, reader: StreamReader, writer: StreamWriter, def __init__(self, reader: StreamReader, writer: StreamWriter,
addr) -> None: addr) -> None:
@@ -35,43 +35,50 @@ class AsyncStream():
self.proc_max = 0 self.proc_max = 0
def __timeout(self) -> int: def __timeout(self) -> int:
if self.state == State.init: if self.state == State.init or self.state == State.received:
to = self.MAX_START_TIME to = self.MAX_START_TIME
elif self.state == State.up and \
self.server_side and self.modbus_polling:
to = self.MAX_INV_IDLE_TIME
else: else:
if self.server_side: to = self.MAX_DEF_IDLE_TIME
to = self.MAX_INV_IDLE_TIME
else:
to = self.MAX_CLOUD_IDLE_TIME
return to return to
async def publish_outstanding_mqtt(self):
'''Publish all outstanding MQTT topics'''
try:
if self.unique_id:
await self.async_publ_mqtt()
await self._async_publ_mqtt_proxy_stat('proxy')
except Exception:
pass
async def server_loop(self, addr: str) -> None: async def server_loop(self, addr: str) -> None:
'''Loop for receiving messages from the inverter (server-side)''' '''Loop for receiving messages from the inverter (server-side)'''
logger.info(f'[{self.node_id}:{self.conn_no}] ' logger.info(f'[{self.node_id}:{self.conn_no}] '
f'Accept connection from {addr}') f'Accept connection from {addr}')
self.inc_counter('Inverter_Cnt') self.inc_counter('Inverter_Cnt')
await self.publish_outstanding_mqtt()
await self.loop() await self.loop()
self.dec_counter('Inverter_Cnt') self.dec_counter('Inverter_Cnt')
await self.publish_outstanding_mqtt()
logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for' logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for'
f' r{self.r_addr}') f' r{self.r_addr}')
# if the server connection closes, we also have to disconnect # if the server connection closes, we also have to disconnect
# the connection to te TSUN cloud # the connection to te TSUN cloud
if self.remoteStream: if self.remote_stream:
logger.info(f'[{self.node_id}:{self.conn_no}] disc client ' logger.info(f'[{self.node_id}:{self.conn_no}] disc client '
f'connection: [{self.remoteStream.node_id}:' f'connection: [{self.remote_stream.node_id}:'
f'{self.remoteStream.conn_no}]') f'{self.remote_stream.conn_no}]')
await self.remoteStream.disc() await self.remote_stream.disc()
try:
await self._async_publ_mqtt_proxy_stat('proxy')
except Exception:
pass
async def client_loop(self, addr: str) -> None: async def client_loop(self, _: str) -> None:
'''Loop for receiving messages from the TSUN cloud (client-side)''' '''Loop for receiving messages from the TSUN cloud (client-side)'''
clientStream = await self.remoteStream.loop() client_stream = await self.remote_stream.loop()
logger.info(f'[{clientStream.node_id}:{clientStream.conn_no}] ' logger.info(f'[{client_stream.node_id}:{client_stream.conn_no}] '
'Client loop stopped for' 'Client loop stopped for'
f' l{clientStream.l_addr}') f' l{client_stream.l_addr}')
# if the client connection closes, we don't touch the server # if the client connection closes, we don't touch the server
# connection. Instead we erase the client connection stream, # connection. Instead we erase the client connection stream,
@@ -79,13 +86,13 @@ class AsyncStream():
# establish a new connection to the TSUN cloud # establish a new connection to the TSUN cloud
# erase backlink to inverter # erase backlink to inverter
clientStream.remoteStream = None client_stream.remote_stream = None
if self.remoteStream == clientStream: if self.remote_stream == client_stream:
# logging.debug(f'Client l{clientStream.l_addr} refs:' # logging.debug(f'Client l{client_stream.l_addr} refs:'
# f' {gc.get_referrers(clientStream)}') # f' {gc.get_referrers(client_stream)}')
# than erase client connection # than erase client connection
self.remoteStream = None self.remote_stream = None
async def loop(self) -> Self: async def loop(self) -> Self:
"""Async loop handler for precessing all received messages""" """Async loop handler for precessing all received messages"""
@@ -196,35 +203,35 @@ class AsyncStream():
if not self._forward_buffer: if not self._forward_buffer:
return return
try: try:
if not self.remoteStream: if not self.remote_stream:
await self.async_create_remote() await self.async_create_remote()
if self.remoteStream: if self.remote_stream:
if self.remoteStream._init_new_client_conn(): if self.remote_stream._init_new_client_conn():
await self.remoteStream.async_write() await self.remote_stream.async_write()
if self.remoteStream: if self.remote_stream:
self.remoteStream._update_header(self._forward_buffer) self.remote_stream._update_header(self._forward_buffer)
hex_dump_memory(logging.INFO, hex_dump_memory(logging.INFO,
f'Forward to {self.remoteStream.addr}:', f'Forward to {self.remote_stream.addr}:',
self._forward_buffer, self._forward_buffer,
len(self._forward_buffer)) len(self._forward_buffer))
self.remoteStream.writer.write(self._forward_buffer) self.remote_stream.writer.write(self._forward_buffer)
await self.remoteStream.writer.drain() await self.remote_stream.writer.drain()
self._forward_buffer = bytearray(0) self._forward_buffer = bytearray(0)
except OSError as error: except OSError as error:
if self.remoteStream: if self.remote_stream:
rmt = self.remoteStream rmt = self.remote_stream
self.remoteStream = None self.remote_stream = None
logger.error(f'[{rmt.node_id}:{rmt.conn_no}] Fwd: {error} for ' logger.error(f'[{rmt.node_id}:{rmt.conn_no}] Fwd: {error} for '
f'l{rmt.l_addr} | r{rmt.r_addr}') f'l{rmt.l_addr} | r{rmt.r_addr}')
await rmt.disc() await rmt.disc()
rmt.close() rmt.close()
except RuntimeError as error: except RuntimeError as error:
if self.remoteStream: if self.remote_stream:
rmt = self.remoteStream rmt = self.remote_stream
self.remoteStream = None self.remote_stream = None
logger.info(f'[{rmt.node_id}:{rmt.conn_no}] ' logger.info(f'[{rmt.node_id}:{rmt.conn_no}] '
f'Fwd: {error} for {rmt.l_addr}') f'Fwd: {error} for {rmt.l_addr}')
await rmt.disc() await rmt.disc()

View File

@@ -53,7 +53,12 @@ class Config():
Use(lambda s: s + '/' Use(lambda s: s + '/'
if len(s) > 0 and if len(s) > 0 and
s[-1] != '/' else s)), s[-1] != '/' else s)),
Optional('client_mode'): {
'host': Use(str),
Optional('port', default=8899):
And(Use(int), lambda n: 1024 <= n <= 65535)
},
Optional('modbus_polling', default=True): Use(bool),
Optional('suggested_area', default=""): Use(str), Optional('suggested_area', default=""): Use(str),
Optional('pv1'): { Optional('pv1'): {
Optional('type'): Use(str), Optional('type'): Use(str),

View File

@@ -1,5 +1,4 @@
import logging import logging
# import gc
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
from async_stream import AsyncStream from async_stream import AsyncStream
from gen3.talent import Talent from gen3.talent import Talent
@@ -15,7 +14,7 @@ class ConnectionG3(AsyncStream, Talent):
AsyncStream.__init__(self, reader, writer, addr) AsyncStream.__init__(self, reader, writer, addr)
Talent.__init__(self, server_side, id_str) Talent.__init__(self, server_side, id_str)
self.remoteStream: 'ConnectionG3' = remote_stream self.remote_stream: 'ConnectionG3' = remote_stream
''' '''
Our puplic methods Our puplic methods
@@ -26,10 +25,10 @@ class ConnectionG3(AsyncStream, Talent):
# logger.info(f'AsyncStream refs: {gc.get_referrers(self)}') # logger.info(f'AsyncStream refs: {gc.get_referrers(self)}')
async def async_create_remote(self) -> None: async def async_create_remote(self) -> None:
pass pass # virtual interface
async def async_publ_mqtt(self) -> None: async def async_publ_mqtt(self) -> None:
pass pass # virtual interface
def healthy(self) -> bool: def healthy(self) -> bool:
logger.debug('ConnectionG3 healthy()') logger.debug('ConnectionG3 healthy()')

View File

@@ -31,6 +31,9 @@ class RegisterMap:
0xffffff06: Register.OTA_START_MSG, 0xffffff06: Register.OTA_START_MSG,
0xffffff07: Register.SW_EXCEPTION, 0xffffff07: Register.SW_EXCEPTION,
0xffffff08: Register.MAX_DESIGNED_POWER, 0xffffff08: Register.MAX_DESIGNED_POWER,
0xffffff09: Register.OUTPUT_COEFFICIENT,
0xffffff0a: Register.INVERTER_STATUS,
0xffffff0b: Register.POLLING_INTERVAL,
0xfffffffe: Register.TEST_REG1, 0xfffffffe: Register.TEST_REG1,
0xffffffff: Register.TEST_REG2, 0xffffffff: Register.TEST_REG2,
0x00000640: Register.OUTPUT_POWER, 0x00000640: Register.OUTPUT_POWER,

View File

@@ -9,9 +9,7 @@ from gen3.connection_g3 import ConnectionG3
from aiomqtt import MqttCodeError from aiomqtt import MqttCodeError
from infos import Infos from infos import Infos
# import gc
# logger = logging.getLogger('conn')
logger_mqtt = logging.getLogger('mqtt') logger_mqtt = logging.getLogger('mqtt')
@@ -60,10 +58,10 @@ class InverterG3(Inverter, ConnectionG3):
logging.info(f'[{self.node_id}] Connect to {addr}') logging.info(f'[{self.node_id}] Connect to {addr}')
connect = asyncio.open_connection(host, port) connect = asyncio.open_connection(host, port)
reader, writer = await connect reader, writer = await connect
self.remoteStream = ConnectionG3(reader, writer, addr, self, self.remote_stream = ConnectionG3(reader, writer, addr, self,
False, self.id_str) False, self.id_str)
logging.info(f'[{self.remoteStream.node_id}:' logging.info(f'[{self.remote_stream.node_id}:'
f'{self.remoteStream.conn_no}] ' f'{self.remote_stream.conn_no}] '
f'Connected to {addr}') f'Connected to {addr}')
asyncio.create_task(self.client_loop(addr)) asyncio.create_task(self.client_loop(addr))

View File

@@ -1,7 +1,8 @@
import struct import struct
import logging import logging
import time import pytz
from datetime import datetime from datetime import datetime
from tzlocal import get_localzone
if __name__ == "app.src.gen3.talent": if __name__ == "app.src.gen3.talent":
from app.src.messages import hex_dump_memory, Message, State from app.src.messages import hex_dump_memory, Message, State
@@ -9,12 +10,14 @@ if __name__ == "app.src.gen3.talent":
from app.src.my_timer import Timer from app.src.my_timer import Timer
from app.src.config import Config from app.src.config import Config
from app.src.gen3.infos_g3 import InfosG3 from app.src.gen3.infos_g3 import InfosG3
from app.src.infos import Register
else: # pragma: no cover else: # pragma: no cover
from messages import hex_dump_memory, Message, State from messages import hex_dump_memory, Message, State
from modbus import Modbus from modbus import Modbus
from my_timer import Timer from my_timer import Timer
from config import Config from config import Config
from gen3.infos_g3 import InfosG3 from gen3.infos_g3 import InfosG3
from infos import Register
logger = logging.getLogger('msg') logger = logging.getLogger('msg')
@@ -41,7 +44,7 @@ class Talent(Message):
MB_REGULAR_TIMEOUT = 60 MB_REGULAR_TIMEOUT = 60
def __init__(self, server_side: bool, id_str=b''): def __init__(self, server_side: bool, id_str=b''):
super().__init__(server_side, self.send_modbus_cb, mb_timeout=11) super().__init__(server_side, self.send_modbus_cb, mb_timeout=15)
self.await_conn_resp_cnt = 0 self.await_conn_resp_cnt = 0
self.id_str = id_str self.id_str = id_str
self.contact_name = b'' self.contact_name = b''
@@ -71,12 +74,23 @@ class Talent(Message):
self.modbus_elms = 0 # for unit tests self.modbus_elms = 0 # for unit tests
self.node_id = 'G3' # will be overwritten in __set_serial_no self.node_id = 'G3' # will be overwritten in __set_serial_no
self.mb_timer = Timer(self.mb_timout_cb, self.node_id) self.mb_timer = Timer(self.mb_timout_cb, self.node_id)
self.mb_timeout = self.MB_REGULAR_TIMEOUT
self.mb_start_timeout = self.MB_START_TIMEOUT
self.modbus_polling = False
''' '''
Our puplic methods Our puplic methods
''' '''
def close(self) -> None: def close(self) -> None:
logging.debug('Talent.close()') logging.debug('Talent.close()')
if self.server_side:
# set inverter state to offline, if output power is very low
logging.debug('close power: '
f'{self.db.get_db_value(Register.OUTPUT_POWER, -1)}')
if self.db.get_db_value(Register.OUTPUT_POWER, 999) < 2:
self.db.set_db_def_value(Register.INVERTER_STATUS, 0)
self.new_data['env'] = True
# we have references to methods of this class in self.switch # we have references to methods of this class in self.switch
# so we have to erase self.switch, otherwise this instance can't be # so we have to erase self.switch, otherwise this instance can't be
# deallocated by the garbage collector ==> we get a memory leak # deallocated by the garbage collector ==> we get a memory leak
@@ -98,6 +112,7 @@ class Talent(Message):
inv = inverters[serial_no] inv = inverters[serial_no]
self.node_id = inv['node_id'] self.node_id = inv['node_id']
self.sug_area = inv['suggested_area'] self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
self.db.set_pv_module_details(inv) self.db.set_pv_module_details(inv)
else: else:
@@ -113,41 +128,46 @@ class Talent(Message):
self.unique_id = serial_no self.unique_id = serial_no
def read(self) -> float: def read(self) -> float:
'''process all received messages in the _recv_buffer'''
self._read() self._read()
while True:
if not self.header_valid:
self.__parse_header(self._recv_buffer, len(self._recv_buffer))
if not self.header_valid: if self.header_valid and \
self.__parse_header(self._recv_buffer, len(self._recv_buffer)) len(self._recv_buffer) >= (self.header_len + self.data_len):
if self.state == State.init:
self.state = State.received # received 1st package
if self.header_valid and len(self._recv_buffer) >= (self.header_len + log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING)
self.data_len): if callable(log_lvl):
if self.state == State.init: log_lvl = log_lvl()
self.state = State.received # received 1st package
log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING) hex_dump_memory(log_lvl, f'Received from {self.addr}:'
if callable(log_lvl): f' BufLen: {len(self._recv_buffer)}'
log_lvl = log_lvl() f' HdrLen: {self.header_len}'
f' DtaLen: {self.data_len}',
self._recv_buffer, len(self._recv_buffer))
hex_dump_memory(log_lvl, f'Received from {self.addr}:', self.__set_serial_no(self.id_str.decode("utf-8"))
self._recv_buffer, self.header_len+self.data_len) self.__dispatch_msg()
self.__flush_recv_msg()
else:
return 0 # don not wait before sending a response
self.__set_serial_no(self.id_str.decode("utf-8")) def forward(self) -> None:
self.__dispatch_msg() '''add the actual receive msg to the forwarding queue'''
self.__flush_recv_msg()
return 0.5 # wait 500ms before sending a response
def forward(self, buffer, buflen) -> None:
tsun = Config.get('tsun') tsun = Config.get('tsun')
if tsun['enabled']: if tsun['enabled']:
self._forward_buffer = buffer[:buflen] buffer = self._recv_buffer
buflen = self.header_len+self.data_len
self._forward_buffer += buffer[:buflen]
hex_dump_memory(logging.DEBUG, 'Store for forwarding:', hex_dump_memory(logging.DEBUG, 'Store for forwarding:',
buffer, buflen) buffer, buflen)
self.__parse_header(self._forward_buffer,
len(self._forward_buffer))
fnc = self.switch.get(self.msg_id, self.msg_unknown) fnc = self.switch.get(self.msg_id, self.msg_unknown)
logger.info(self.__flow_str(self.server_side, 'forwrd') + logger.info(self.__flow_str(self.server_side, 'forwrd') +
f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}')
return
def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str): def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str):
if self.state != State.up: if self.state != State.up:
@@ -177,13 +197,13 @@ class Talent(Message):
self._send_modbus_cmd(func, addr, val, log_lvl) self._send_modbus_cmd(func, addr, val, log_lvl)
def mb_timout_cb(self, exp_cnt): def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.MB_REGULAR_TIMEOUT) self.mb_timer.start(self.mb_timeout)
if 0 == (exp_cnt % 30): if 2 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request") # logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.READ_REGS, 0x2007, 2, logging.DEBUG) self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG)
else: else:
self._send_modbus_cmd(Modbus.READ_REGS, 0x3008, 21, logging.DEBUG) self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
def _init_new_client_conn(self) -> bool: def _init_new_client_conn(self) -> bool:
contact_name = self.contact_name contact_name = self.contact_name
@@ -218,31 +238,43 @@ class Talent(Message):
return switch.get(type, '???') return switch.get(type, '???')
def _timestamp(self): # pragma: no cover def _timestamp(self): # pragma: no cover
if False: '''returns timestamp fo the inverter as localtime
# utc as epoche since 1.1.1970 in msec'''
ts = time.time() # convert localtime in epoche
else: ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
# convert localtime in epoche
ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
return round(ts*1000) return round(ts*1000)
def _utcfromts(self, ts: float):
'''converts inverter timestamp into unix time (epoche)'''
dt = datetime.fromtimestamp(ts/1000, pytz.UTC). \
replace(tzinfo=get_localzone())
return dt.timestamp()
def _utc(self): # pragma: no cover
'''returns unix time (epoche)'''
return datetime.now().timestamp()
def _update_header(self, _forward_buffer): def _update_header(self, _forward_buffer):
'''update header for message before forwarding, '''update header for message before forwarding,
add time offset to timestamp''' add time offset to timestamp'''
_len = len(_forward_buffer) _len = len(_forward_buffer)
result = struct.unpack_from('!lB', _forward_buffer, 0) ofs = 0
id_len = result[1] # len of variable id string while ofs < _len:
if _len < 2*id_len + 21: result = struct.unpack_from('!lB', _forward_buffer, 0)
return msg_len = 4 + result[0]
id_len = result[1] # len of variable id string
if _len < 2*id_len + 21:
return
result = struct.unpack_from('!B', _forward_buffer, id_len+6) result = struct.unpack_from('!B', _forward_buffer, id_len+6)
msg_code = result[0] msg_code = result[0]
if msg_code == 0x71 or msg_code == 0x04: if msg_code == 0x71 or msg_code == 0x04:
result = struct.unpack_from('!q', _forward_buffer, 13+2*id_len) result = struct.unpack_from('!q', _forward_buffer, 13+2*id_len)
ts = result[0] + self.ts_offset ts = result[0] + self.ts_offset
logger.debug(f'offset: {self.ts_offset:08x}' logger.debug(f'offset: {self.ts_offset:08x}'
f' proxy-time: {ts:08x}') f' proxy-time: {ts:08x}')
struct.pack_into('!q', _forward_buffer, 13+2*id_len, ts) struct.pack_into('!q', _forward_buffer, 13+2*id_len, ts)
ofs += msg_len
# check if there is a complete header in the buffer, parse it # check if there is a complete header in the buffer, parse it
# and set # and set
@@ -259,7 +291,7 @@ class Talent(Message):
if (buf_len < 5): # enough bytes to read len and id_len? if (buf_len < 5): # enough bytes to read len and id_len?
return return
result = struct.unpack_from('!lB', buf, 0) result = struct.unpack_from('!lB', buf, 0)
len = result[0] # len of complete message msg_len = result[0] # len of complete message
id_len = result[1] # len of variable id string id_len = result[1] # len of variable id string
hdr_len = 5+id_len+2 hdr_len = 5+id_len+2
@@ -273,10 +305,9 @@ class Talent(Message):
self.id_str = result[0] self.id_str = result[0]
self.ctrl = Control(result[1]) self.ctrl = Control(result[1])
self.msg_id = result[2] self.msg_id = result[2]
self.data_len = len-id_len-3 self.data_len = msg_len-id_len-3
self.header_len = hdr_len self.header_len = hdr_len
self.header_valid = True self.header_valid = True
return
def __build_header(self, ctrl, msg_id=None) -> None: def __build_header(self, ctrl, msg_id=None) -> None:
if not msg_id: if not msg_id:
@@ -321,12 +352,11 @@ class Talent(Message):
elif self.await_conn_resp_cnt > 0: elif self.await_conn_resp_cnt > 0:
self.await_conn_resp_cnt -= 1 self.await_conn_resp_cnt -= 1
else: else:
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
return
else: else:
logger.warning('Unknown Ctrl') logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
def __process_contact_info(self) -> bool: def __process_contact_info(self) -> bool:
result = struct.unpack_from('!B', self._recv_buffer, self.header_len) result = struct.unpack_from('!B', self._recv_buffer, self.header_len)
@@ -348,8 +378,9 @@ class Talent(Message):
def msg_get_time(self): def msg_get_time(self):
if self.ctrl.is_ind(): if self.ctrl.is_ind():
if self.data_len == 0: if self.data_len == 0:
self.state = State.pend # block MODBUS cmds if self.state == State.up:
self.mb_timer.start(self.MB_START_TIMEOUT) self.state = State.pend # block MODBUS cmds
ts = self._timestamp() ts = self._timestamp()
logger.debug(f'time: {ts:08x}') logger.debug(f'time: {ts:08x}')
self.__build_header(0x91) self.__build_header(0x91)
@@ -369,7 +400,7 @@ class Talent(Message):
logger.warning('Unknown Ctrl') logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
def parse_msg_header(self): def parse_msg_header(self):
result = struct.unpack_from('!lB', self._recv_buffer, self.header_len) result = struct.unpack_from('!lB', self._recv_buffer, self.header_len)
@@ -383,11 +414,12 @@ class Talent(Message):
result = struct.unpack_from(f'!{id_len+1}pBq', self._recv_buffer, result = struct.unpack_from(f'!{id_len+1}pBq', self._recv_buffer,
self.header_len + 4) self.header_len + 4)
timestamp = result[2]
logger.debug(f'ID: {result[0]} B: {result[1]}') logger.debug(f'ID: {result[0]} B: {result[1]}')
logger.debug(f'time: {result[2]:08x}') logger.debug(f'time: {timestamp:08x}')
# logger.info(f'time: {datetime.utcfromtimestamp(result[2]).strftime( # logger.info(f'time: {datetime.utcfromtimestamp(result[2]).strftime(
# "%Y-%m-%d %H:%M:%S")}') # "%Y-%m-%d %H:%M:%S")}')
return msg_hdr_len return msg_hdr_len, timestamp
def msg_collector_data(self): def msg_collector_data(self):
if self.ctrl.is_ind(): if self.ctrl.is_ind():
@@ -402,7 +434,7 @@ class Talent(Message):
logger.warning('Unknown Ctrl') logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
def msg_inverter_data(self): def msg_inverter_data(self):
if self.ctrl.is_ind(): if self.ctrl.is_ind():
@@ -411,6 +443,10 @@ class Talent(Message):
self.__finish_send_msg() self.__finish_send_msg()
self.__process_data() self.__process_data()
self.state = State.up # allow MODBUS cmds self.state = State.up # allow MODBUS cmds
if (self.modbus_polling):
self.mb_timer.start(self.mb_start_timeout)
self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout)
elif self.ctrl.is_resp(): elif self.ctrl.is_resp():
return # ignore received response return # ignore received response
@@ -418,25 +454,26 @@ class Talent(Message):
logger.warning('Unknown Ctrl') logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
def __process_data(self): def __process_data(self):
msg_hdr_len = self.parse_msg_header() msg_hdr_len, ts = self.parse_msg_header()
for key, update in self.db.parse(self._recv_buffer, self.header_len for key, update in self.db.parse(self._recv_buffer, self.header_len
+ msg_hdr_len, self.node_id): + msg_hdr_len, self.node_id):
if update: if update:
self._set_mqtt_timestamp(key, self._utcfromts(ts))
self.new_data[key] = True self.new_data[key] = True
def msg_ota_update(self): def msg_ota_update(self):
if self.ctrl.is_req(): if self.ctrl.is_req():
self.inc_counter('OTA_Start_Msg') self.inc_counter('OTA_Start_Msg')
elif self.ctrl.is_ind(): elif self.ctrl.is_ind():
pass pass # Ok, nothing to do
else: else:
logger.warning('Unknown Ctrl') logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
def parse_modbus_header(self): def parse_modbus_header(self):
@@ -445,27 +482,24 @@ class Talent(Message):
result = struct.unpack_from('!lBB', self._recv_buffer, result = struct.unpack_from('!lBB', self._recv_buffer,
self.header_len) self.header_len)
modbus_len = result[1] 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 return msg_hdr_len, modbus_len
def get_modbus_log_lvl(self) -> int: def get_modbus_log_lvl(self) -> int:
if self.ctrl.is_req(): if self.ctrl.is_req():
return logging.INFO return logging.INFO
elif self.ctrl.is_ind(): elif self.ctrl.is_ind() and self.server_side:
if self.server_side: return self.mb.last_log_lvl
return self.mb.last_log_lvl
return logging.WARNING return logging.WARNING
def msg_modbus(self): def msg_modbus(self):
hdr_len, modbus_len = self.parse_modbus_header() hdr_len, _ = self.parse_modbus_header()
data = self._recv_buffer[self.header_len: data = self._recv_buffer[self.header_len:
self.header_len+self.data_len] self.header_len+self.data_len]
if self.ctrl.is_req(): if self.ctrl.is_req():
if self.remoteStream.mb.recv_req(data[hdr_len:], if self.remote_stream.mb.recv_req(data[hdr_len:],
self.remoteStream. self.remote_stream.
msg_forward): msg_forward):
self.inc_counter('Modbus_Command') self.inc_counter('Modbus_Command')
else: else:
self.inc_counter('Invalid_Msg_Format') self.inc_counter('Invalid_Msg_Format')
@@ -481,17 +515,18 @@ class Talent(Message):
hdr_len:], hdr_len:],
self.node_id): self.node_id):
if update: if update:
self._set_mqtt_timestamp(key, self._utc())
self.new_data[key] = True self.new_data[key] = True
self.modbus_elms += 1 # count for unit tests self.modbus_elms += 1 # count for unit tests
else: else:
logger.warning('Unknown Ctrl') logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
def msg_forward(self): def msg_forward(self):
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()
def msg_unknown(self): def msg_unknown(self):
logger.warning(f"Unknow Msg: ID:{self.msg_id}") logger.warning(f"Unknow Msg: ID:{self.msg_id}")
self.inc_counter('Unknown_Msg') self.inc_counter('Unknown_Msg')
self.forward(self._recv_buffer, self.header_len+self.data_len) self.forward()

View File

@@ -1,5 +1,4 @@
import logging import logging
# import gc
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
from async_stream import AsyncStream from async_stream import AsyncStream
from gen3plus.solarman_v5 import SolarmanV5 from gen3plus.solarman_v5 import SolarmanV5
@@ -11,11 +10,12 @@ class ConnectionG3P(AsyncStream, SolarmanV5):
def __init__(self, reader: StreamReader, writer: StreamWriter, def __init__(self, reader: StreamReader, writer: StreamWriter,
addr, remote_stream: 'ConnectionG3P', addr, remote_stream: 'ConnectionG3P',
server_side: bool) -> None: server_side: bool,
client_mode: bool) -> None:
AsyncStream.__init__(self, reader, writer, addr) AsyncStream.__init__(self, reader, writer, addr)
SolarmanV5.__init__(self, server_side) SolarmanV5.__init__(self, server_side, client_mode)
self.remoteStream: 'ConnectionG3P' = remote_stream self.remote_stream: 'ConnectionG3P' = remote_stream
''' '''
Our puplic methods Our puplic methods
@@ -26,10 +26,10 @@ class ConnectionG3P(AsyncStream, SolarmanV5):
# logger.info(f'AsyncStream refs: {gc.get_referrers(self)}') # logger.info(f'AsyncStream refs: {gc.get_referrers(self)}')
async def async_create_remote(self) -> None: async def async_create_remote(self) -> None:
pass pass # virtual interface
async def async_publ_mqtt(self) -> None: async def async_publ_mqtt(self) -> None:
pass pass # virtual interface
def healthy(self) -> bool: def healthy(self) -> bool:
logger.debug('ConnectionG3P healthy()') logger.debug('ConnectionG3P healthy()')

View File

@@ -3,9 +3,9 @@ import struct
from typing import Generator from typing import Generator
if __name__ == "app.src.gen3plus.infos_g3p": if __name__ == "app.src.gen3plus.infos_g3p":
from app.src.infos import Infos, Register from app.src.infos import Infos, Register, ProxyMode
else: # pragma: no cover else: # pragma: no cover
from infos import Infos, Register from infos import Infos, Register, ProxyMode
class RegisterMap: class RegisterMap:
@@ -14,15 +14,15 @@ class RegisterMap:
__slots__ = () __slots__ = ()
map = { map = {
# 0x41020007: {'reg': Register.DEVICE_SNR, 'fmt': '<L'}, # noqa: E501 # 0x41020007: {'reg': Register.DEVICE_SNR, 'fmt': '<L'}, # noqa: E501
0x41020018: {'reg': Register.DATA_UP_INTERVAL, 'fmt': '<B', 'ratio': 60}, # noqa: E501 0x41020018: {'reg': Register.DATA_UP_INTERVAL, 'fmt': '<B', 'ratio': 60, 'dep': ProxyMode.SERVER}, # noqa: E501
0x41020019: {'reg': Register.COLLECT_INTERVAL, 'fmt': '<B', 'eval': 'round(result/60)'}, # noqa: E501 0x41020019: {'reg': Register.COLLECT_INTERVAL, 'fmt': '<B', 'eval': 'round(result/60)', 'dep': ProxyMode.SERVER}, # noqa: E501
0x4102001a: {'reg': Register.HEARTBEAT_INTERVAL, 'fmt': '<B', 'ratio': 1}, # noqa: E501 0x4102001a: {'reg': Register.HEARTBEAT_INTERVAL, 'fmt': '<B', 'ratio': 1}, # noqa: E501
0x4102001c: {'reg': Register.SIGNAL_STRENGTH, 'fmt': '<B', 'ratio': 1}, # noqa: E501 0x4102001c: {'reg': Register.SIGNAL_STRENGTH, 'fmt': '<B', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501
0x4102001e: {'reg': Register.CHIP_MODEL, 'fmt': '!40s'}, # noqa: E501 0x4102001e: {'reg': Register.CHIP_MODEL, 'fmt': '!40s'}, # noqa: E501
0x4102004c: {'reg': Register.IP_ADDRESS, 'fmt': '!16s'}, # noqa: E501 0x4102004c: {'reg': Register.IP_ADDRESS, 'fmt': '!16s'}, # noqa: E501
0x41020064: {'reg': Register.COLLECTOR_FW_VERSION, 'fmt': '!40s'}, # noqa: E501 0x41020064: {'reg': Register.COLLECTOR_FW_VERSION, 'fmt': '!40s'}, # noqa: E501
0x4201001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1}, # noqa: E501 0x4201001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501
0x42010020: {'reg': Register.SERIAL_NUMBER, 'fmt': '!16s'}, # noqa: E501 0x42010020: {'reg': Register.SERIAL_NUMBER, 'fmt': '!16s'}, # noqa: E501
0x420100c0: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 0x420100c0: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501
0x420100d0: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>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
@@ -56,19 +56,31 @@ class RegisterMap:
0x42010110: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x42010110: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x42010112: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x42010112: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
0x42010126: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 0x42010126: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
0x42010170: {'reg': Register.NO_INPUTS, 'fmt': '!B'}, # noqa: E501
0xffffff01: {'reg': Register.OUTPUT_COEFFICIENT},
0xffffff02: {'reg': Register.POLLING_INTERVAL},
# 0x4281001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1}, # noqa: E501 # 0x4281001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1}, # noqa: E501
} }
class InfosG3P(Infos): class InfosG3P(Infos):
def __init__(self): def __init__(self, client_mode: bool):
super().__init__() super().__init__()
self.client_mode = client_mode
self.set_db_def_value(Register.MANUFACTURER, 'TSUN') self.set_db_def_value(Register.MANUFACTURER, 'TSUN')
self.set_db_def_value(Register.EQUIPMENT_MODEL, 'TSOL-MSxx00') self.set_db_def_value(Register.EQUIPMENT_MODEL, 'TSOL-MSxx00')
self.set_db_def_value(Register.CHIP_TYPE, 'IGEN TECH') self.set_db_def_value(Register.CHIP_TYPE, 'IGEN TECH')
self.set_db_def_value(Register.NO_INPUTS, 4)
def __hide_topic(self, row: dict) -> bool:
if 'dep' in row:
mode = row['dep']
if self.client_mode:
return mode != ProxyMode.CLIENT
else:
return mode != ProxyMode.SERVER
return False
def ha_confs(self, ha_prfx: str, node_id: str, snr: str, def ha_confs(self, ha_prfx: str, node_id: str, snr: str,
sug_area: str = '') \ sug_area: str = '') \
@@ -84,7 +96,10 @@ class InfosG3P(Infos):
# iterate over RegisterMap.map and get the register values # iterate over RegisterMap.map and get the register values
for row in RegisterMap.map.values(): for row in RegisterMap.map.values():
info_id = row['reg'] info_id = row['reg']
res = self.ha_conf(info_id, ha_prfx, node_id, snr, False, sug_area) # noqa: E501 if self.__hide_topic(row):
res = self.ha_remove(info_id, node_id, snr) # noqa: E501
else:
res = self.ha_conf(info_id, ha_prfx, node_id, snr, False, sug_area) # noqa: E501
if res: if res:
yield res yield res

View File

@@ -9,9 +9,7 @@ from gen3plus.connection_g3p import ConnectionG3P
from aiomqtt import MqttCodeError from aiomqtt import MqttCodeError
from infos import Infos from infos import Infos
# import gc
# logger = logging.getLogger('conn')
logger_mqtt = logging.getLogger('mqtt') logger_mqtt = logging.getLogger('mqtt')
@@ -45,8 +43,10 @@ class InverterG3P(Inverter, ConnectionG3P):
destroyed destroyed
''' '''
def __init__(self, reader: StreamReader, writer: StreamWriter, addr): def __init__(self, reader: StreamReader, writer: StreamWriter, addr,
super().__init__(reader, writer, addr, None, True) client_mode: bool = False):
super().__init__(reader, writer, addr, None,
server_side=True, client_mode=client_mode)
self.__ha_restarts = -1 self.__ha_restarts = -1
async def async_create_remote(self) -> None: async def async_create_remote(self) -> None:
@@ -60,10 +60,11 @@ class InverterG3P(Inverter, ConnectionG3P):
logging.info(f'[{self.node_id}] Connect to {addr}') logging.info(f'[{self.node_id}] Connect to {addr}')
connect = asyncio.open_connection(host, port) connect = asyncio.open_connection(host, port)
reader, writer = await connect reader, writer = await connect
self.remoteStream = ConnectionG3P(reader, writer, addr, self, self.remote_stream = ConnectionG3P(reader, writer, addr, self,
False) server_side=False,
logging.info(f'[{self.remoteStream.node_id}:' client_mode=False)
f'{self.remoteStream.conn_no}] ' logging.info(f'[{self.remote_stream.node_id}:'
f'{self.remote_stream.conn_no}] '
f'Connected to {addr}') f'Connected to {addr}')
asyncio.create_task(self.client_loop(addr)) asyncio.create_task(self.client_loop(addr))

View File

@@ -1,5 +1,4 @@
import struct import struct
# import json
import logging import logging
import time import time
import asyncio import asyncio
@@ -19,7 +18,6 @@ else: # pragma: no cover
from my_timer import Timer from my_timer import Timer
from gen3plus.infos_g3p import InfosG3P from gen3plus.infos_g3p import InfosG3P
from infos import Register from infos import Register
# import traceback
logger = logging.getLogger('msg') logger = logging.getLogger('msg')
@@ -54,18 +52,24 @@ class SolarmanV5(Message):
AT_CMD = 1 AT_CMD = 1
MB_RTU_CMD = 2 MB_RTU_CMD = 2
MB_START_TIMEOUT = 40 MB_START_TIMEOUT = 40
'''start delay for Modbus polling in server mode'''
MB_REGULAR_TIMEOUT = 60 MB_REGULAR_TIMEOUT = 60
'''regular Modbus polling time in server mode'''
MB_CLIENT_DATA_UP = 30
'''Data up time in client mode'''
def __init__(self, server_side: bool): def __init__(self, server_side: bool, client_mode: bool):
super().__init__(server_side, self.send_modbus_cb, mb_timeout=5) super().__init__(server_side, self.send_modbus_cb, mb_timeout=5)
self.header_len = 11 # overwrite construcor in class Message self.header_len = 11 # overwrite construcor in class Message
self.control = 0 self.control = 0
self.seq = Sequence(server_side) self.seq = Sequence(server_side)
self.snr = 0 self.snr = 0
self.db = InfosG3P() self.db = InfosG3P(client_mode)
self.time_ofs = 0 self.time_ofs = 0
self.forward_at_cmd_resp = False self.forward_at_cmd_resp = False
self.no_forwarding = False
'''not allowed to connect to TSUN cloud by connection type'''
self.switch = { self.switch = {
0x4210: self.msg_data_ind, # real time data 0x4210: self.msg_data_ind, # real time data
@@ -128,12 +132,24 @@ class SolarmanV5(Message):
self.node_id = 'G3P' # will be overwritten in __set_serial_no self.node_id = 'G3P' # will be overwritten in __set_serial_no
self.mb_timer = Timer(self.mb_timout_cb, self.node_id) self.mb_timer = Timer(self.mb_timout_cb, self.node_id)
self.mb_timeout = self.MB_REGULAR_TIMEOUT
self.mb_start_timeout = self.MB_START_TIMEOUT
'''timer value for next Modbus polling request'''
self.modbus_polling = False
''' '''
Our puplic methods Our puplic methods
''' '''
def close(self) -> None: def close(self) -> None:
logging.debug('Solarman.close()') logging.debug('Solarman.close()')
if self.server_side:
# set inverter state to offline, if output power is very low
logging.debug('close power: '
f'{self.db.get_db_value(Register.OUTPUT_POWER, -1)}')
if self.db.get_db_value(Register.OUTPUT_POWER, 999) < 2:
self.db.set_db_def_value(Register.INVERTER_STATUS, 0)
self.new_data['env'] = True
# we have references to methods of this class in self.switch # we have references to methods of this class in self.switch
# so we have to erase self.switch, otherwise this instance can't be # so we have to erase self.switch, otherwise this instance can't be
# deallocated by the garbage collector ==> we get a memory leak # deallocated by the garbage collector ==> we get a memory leak
@@ -143,6 +159,31 @@ class SolarmanV5(Message):
self.mb_timer.close() self.mb_timer.close()
super().close() super().close()
async def send_start_cmd(self, snr: int, host: str,
start_timeout=MB_CLIENT_DATA_UP):
self.no_forwarding = True
self.snr = snr
self.__set_serial_no(snr)
self.mb_timeout = start_timeout
self.db.set_db_def_value(Register.IP_ADDRESS, host)
self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout)
self.db.set_db_def_value(Register.HEARTBEAT_INTERVAL,
120) # fixme
self.new_data['controller'] = True
self.state = State.up
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
self.mb_timer.start(self.mb_timeout)
def new_state_up(self):
if self.state is not State.up:
self.state = State.up
if (self.modbus_polling):
self.mb_timer.start(self.mb_start_timeout)
self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout)
def __set_serial_no(self, snr: int): def __set_serial_no(self, snr: int):
serial_no = str(snr) serial_no = str(snr)
if self.unique_id == serial_no: if self.unique_id == serial_no:
@@ -159,6 +200,7 @@ class SolarmanV5(Message):
found = True found = True
self.node_id = inv['node_id'] self.node_id = inv['node_id']
self.sug_area = inv['suggested_area'] self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
self.db.set_pv_module_details(inv) self.db.set_pv_module_details(inv)
@@ -175,50 +217,47 @@ class SolarmanV5(Message):
self.unique_id = serial_no self.unique_id = serial_no
def read(self) -> float: def read(self) -> float:
'''process all received messages in the _recv_buffer'''
self._read() self._read()
while True:
if not self.header_valid:
self.__parse_header(self._recv_buffer, len(self._recv_buffer))
if not self.header_valid: if self.header_valid and len(self._recv_buffer) >= \
self.__parse_header(self._recv_buffer, len(self._recv_buffer)) (self.header_len + self.data_len+2):
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):
if self.state == State.init:
self.state = State.received
if self.header_valid and len(self._recv_buffer) >= (self.header_len + self.__set_serial_no(self.snr)
self.data_len+2): self.__dispatch_msg()
log_lvl = self.log_lvl.get(self.control, logging.WARNING) self.__flush_recv_msg()
if callable(log_lvl): else:
log_lvl = log_lvl() return 0 # wait 0s before sending a response
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):
if self.state == State.init:
self.state = State.received
self.__set_serial_no(self.snr)
self.__dispatch_msg()
self.__flush_recv_msg()
return 0 # wait 0s before sending a response
def forward(self, buffer, buflen) -> None: def forward(self, buffer, buflen) -> None:
'''add the actual receive msg to the forwarding queue'''
if self.no_forwarding:
return
tsun = Config.get('solarman') tsun = Config.get('solarman')
if tsun['enabled']: if tsun['enabled']:
self._forward_buffer = buffer[:buflen] self._forward_buffer += buffer[:buflen]
hex_dump_memory(logging.DEBUG, 'Store for forwarding:', hex_dump_memory(logging.DEBUG, 'Store for forwarding:',
buffer, buflen) buffer, buflen)
self.__parse_header(self._forward_buffer,
len(self._forward_buffer))
fnc = self.switch.get(self.control, self.msg_unknown) fnc = self.switch.get(self.control, self.msg_unknown)
logger.info(self.__flow_str(self.server_side, 'forwrd') + logger.info(self.__flow_str(self.server_side, 'forwrd') +
f' Ctl: {int(self.control):#04x}' f' Ctl: {int(self.control):#04x}'
f' Msg: {fnc.__name__!r}') f' Msg: {fnc.__name__!r}')
return
def _init_new_client_conn(self) -> bool: def _init_new_client_conn(self) -> bool:
# self.__build_header(0x91)
# self._send_buffer += struct.pack(f'!{len(contact_name)+1}p'
# f'{len(contact_mail)+1}p',
# contact_name, contact_mail)
# self.__finish_send_msg()
return False return False
''' '''
@@ -270,7 +309,6 @@ class SolarmanV5(Message):
self._recv_buffer = bytearray() self._recv_buffer = bytearray()
return return
self.header_valid = True self.header_valid = True
return
def __trailer_is_ok(self, buf: bytes, buf_len: int) -> bool: def __trailer_is_ok(self, buf: bytes, buf_len: int) -> bool:
crc = buf[self.data_len+11] crc = buf[self.data_len+11]
@@ -320,13 +358,17 @@ class SolarmanV5(Message):
'''update header for message before forwarding, '''update header for message before forwarding,
set sequence and checksum''' set sequence and checksum'''
_len = len(_forward_buffer) _len = len(_forward_buffer)
struct.pack_into('<H', _forward_buffer, 1, ofs = 0
_len-13) while ofs < _len:
struct.pack_into('<H', _forward_buffer, 5, result = struct.unpack_from('<BH', _forward_buffer, ofs)
self.seq.get_send()) data_len = result[1] # len of variable id string
check = sum(_forward_buffer[1:_len-2]) & 0xff struct.pack_into('<H', _forward_buffer, ofs+5,
struct.pack_into('<B', _forward_buffer, _len-2, check) self.seq.get_send())
check = sum(_forward_buffer[ofs+1:ofs+data_len+11]) & 0xff
struct.pack_into('<B', _forward_buffer, ofs+data_len+11, check)
ofs += (13 + data_len)
def __dispatch_msg(self) -> None: def __dispatch_msg(self) -> None:
fnc = self.switch.get(self.control, self.msg_unknown) fnc = self.switch.get(self.control, self.msg_unknown)
@@ -378,27 +420,27 @@ class SolarmanV5(Message):
self._send_modbus_cmd(func, addr, val, log_lvl) self._send_modbus_cmd(func, addr, val, log_lvl)
def mb_timout_cb(self, exp_cnt): def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.MB_REGULAR_TIMEOUT) self.mb_timer.start(self.mb_timeout)
self._send_modbus_cmd(Modbus.READ_REGS, 0x3008, 21, logging.DEBUG) self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
if 0 == (exp_cnt % 30): if 1 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request") # logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.READ_REGS, 0x2007, 2, logging.DEBUG) self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG)
def at_cmd_forbidden(self, cmd: str, connection: str) -> bool: def at_cmd_forbidden(self, cmd: str, connection: str) -> bool:
return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \ return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \
cmd.startswith(tuple(self.at_acl[connection]['block'])) cmd.startswith(tuple(self.at_acl[connection]['block']))
async def send_at_cmd(self, AT_cmd: str) -> None: async def send_at_cmd(self, at_cmd: str) -> None:
if self.state != State.up: if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore AT+ cmd,' logger.warning(f'[{self.node_id}] ignore AT+ cmd,'
' as the state is not UP') ' as the state is not UP')
return return
AT_cmd = AT_cmd.strip() at_cmd = at_cmd.strip()
if self.at_cmd_forbidden(cmd=AT_cmd, connection='mqtt'): if self.at_cmd_forbidden(cmd=at_cmd, connection='mqtt'):
data_json = f'\'{AT_cmd}\' is forbidden' data_json = f'\'{at_cmd}\' is forbidden'
node_id = self.node_id node_id = self.node_id
key = 'at_resp' key = 'at_resp'
logger.info(f'{key}: {data_json}') logger.info(f'{key}: {data_json}')
@@ -407,8 +449,8 @@ class SolarmanV5(Message):
self.forward_at_cmd_resp = False self.forward_at_cmd_resp = False
self.__build_header(0x4510) self.__build_header(0x4510)
self._send_buffer += struct.pack(f'<BHLLL{len(AT_cmd)}sc', self.AT_CMD, self._send_buffer += struct.pack(f'<BHLLL{len(at_cmd)}sc', self.AT_CMD,
2, 0, 0, 0, AT_cmd.encode('utf-8'), 2, 0, 0, 0, at_cmd.encode('utf-8'),
b'\r') b'\r')
self.__finish_send_msg() self.__finish_send_msg()
try: try:
@@ -421,21 +463,21 @@ class SolarmanV5(Message):
def __build_model_name(self): def __build_model_name(self):
db = self.db db = self.db
MaxPow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0) max_pow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
Rated = db.get_db_value(Register.RATED_POWER, 0) rated = db.get_db_value(Register.RATED_POWER, 0)
Model = None model = None
if MaxPow == 2000: if max_pow == 2000:
if Rated == 800 or Rated == 600: if rated == 800 or rated == 600:
Model = f'TSOL-MS{MaxPow}({Rated})' model = f'TSOL-MS{max_pow}({rated})'
else: else:
Model = f'TSOL-MS{MaxPow}' model = f'TSOL-MS{max_pow}'
elif MaxPow == 1800 or MaxPow == 1600: elif max_pow == 1800 or max_pow == 1600:
Model = f'TSOL-MS{MaxPow}' model = f'TSOL-MS{max_pow}'
if Model: if model:
logger.info(f'Model: {Model}') logger.info(f'Model: {model}')
self.db.set_db_def_value(Register.EQUIPMENT_MODEL, Model) self.db.set_db_def_value(Register.EQUIPMENT_MODEL, model)
def __process_data(self, ftype): def __process_data(self, ftype, ts):
inv_update = False inv_update = False
msg_type = self.control >> 8 msg_type = self.control >> 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,
@@ -443,6 +485,7 @@ class SolarmanV5(Message):
if update: if update:
if key == 'inverter': if key == 'inverter':
inv_update = True inv_update = True
self._set_mqtt_timestamp(key, ts)
self.new_data[key] = True self.new_data[key] = True
if inv_update: if inv_update:
@@ -459,16 +502,18 @@ class SolarmanV5(Message):
data = self._recv_buffer[self.header_len:] data = self._recv_buffer[self.header_len:]
result = struct.unpack_from('<BLLL', data, 0) result = struct.unpack_from('<BLLL', data, 0)
ftype = result[0] # always 2 ftype = result[0] # always 2
# total = result[1] total = result[1]
tim = result[2] tim = result[2]
res = result[3] # always zero res = result[3] # always zero
logger.info(f'frame type:{ftype:02x}' logger.info(f'frame type:{ftype:02x}'
f' timer:{tim:08x}s null:{res}') f' timer:{tim:08x}s null:{res}')
# if self.time_ofs: if self.time_ofs:
# dt = datetime.fromtimestamp(total + self.time_ofs) # dt = datetime.fromtimestamp(total + self.time_ofs)
# logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}') # logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
ts = total + self.time_ofs
self.__process_data(ftype) else:
ts = None
self.__process_data(ftype, ts)
self.__forward_msg() self.__forward_msg()
self.__send_ack_rsp(0x1110, ftype) self.__send_ack_rsp(0x1110, ftype)
@@ -476,7 +521,7 @@ class SolarmanV5(Message):
data = self._recv_buffer data = self._recv_buffer
result = struct.unpack_from('<BHLLLHL', data, self.header_len) result = struct.unpack_from('<BHLLLHL', data, self.header_len)
ftype = result[0] # 1 or 0x81 ftype = result[0] # 1 or 0x81
# total = result[2] total = result[2]
tim = result[3] tim = result[3]
if 1 == ftype: if 1 == ftype:
self.time_ofs = result[4] self.time_ofs = result[4]
@@ -484,16 +529,17 @@ class SolarmanV5(Message):
cnt = result[6] cnt = result[6]
logger.info(f'ftype:{ftype:02x} timer:{tim:08x}s' logger.info(f'ftype:{ftype:02x} timer:{tim:08x}s'
f' ??: {unkn:04x} cnt:{cnt}') f' ??: {unkn:04x} cnt:{cnt}')
# if self.time_ofs: if self.time_ofs:
# dt = datetime.fromtimestamp(total + self.time_ofs) # dt = datetime.fromtimestamp(total + self.time_ofs)
# logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}') # logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
ts = total + self.time_ofs
else:
ts = None
self.__process_data(ftype) self.__process_data(ftype, ts)
self.__forward_msg() self.__forward_msg()
self.__send_ack_rsp(0x1210, ftype) self.__send_ack_rsp(0x1210, ftype)
if self.state is not State.up: self.new_state_up()
self.state = State.up
self.mb_timer.start(self.MB_START_TIMEOUT)
def msg_sync_start(self): def msg_sync_start(self):
data = self._recv_buffer[self.header_len:] data = self._recv_buffer[self.header_len:]
@@ -514,17 +560,17 @@ class SolarmanV5(Message):
result = struct.unpack_from('<B', data, 0) result = struct.unpack_from('<B', data, 0)
ftype = result[0] ftype = result[0]
if ftype == self.AT_CMD: if ftype == self.AT_CMD:
AT_cmd = data[15:].decode() at_cmd = data[15:].decode()
if self.at_cmd_forbidden(cmd=AT_cmd, connection='tsun'): if self.at_cmd_forbidden(cmd=at_cmd, connection='tsun'):
self.inc_counter('AT_Command_Blocked') self.inc_counter('AT_Command_Blocked')
return return
self.inc_counter('AT_Command') self.inc_counter('AT_Command')
self.forward_at_cmd_resp = True self.forward_at_cmd_resp = True
elif ftype == self.MB_RTU_CMD: elif ftype == self.MB_RTU_CMD:
if self.remoteStream.mb.recv_req(data[15:], if self.remote_stream.mb.recv_req(data[15:],
self.remoteStream. self.remote_stream.
__forward_msg): __forward_msg):
self.inc_counter('Modbus_Command') self.inc_counter('Modbus_Command')
else: else:
logger.error('Invalid Modbus Msg') logger.error('Invalid Modbus Msg')
@@ -533,7 +579,7 @@ class SolarmanV5(Message):
self.__forward_msg() self.__forward_msg()
def publish_mqtt(self, key, data): def publish_mqtt(self, key, data): # pragma: no cover
asyncio.ensure_future( asyncio.ensure_future(
self.mqtt.publish(key, data)) self.mqtt.publish(key, data))
@@ -543,9 +589,9 @@ class SolarmanV5(Message):
if self.forward_at_cmd_resp: if self.forward_at_cmd_resp:
return logging.INFO return logging.INFO
return logging.DEBUG return logging.DEBUG
elif ftype == self.MB_RTU_CMD: elif ftype == self.MB_RTU_CMD \
if self.server_side: and self.server_side:
return self.mb.last_log_lvl return self.mb.last_log_lvl
return logging.WARNING return logging.WARNING
@@ -576,6 +622,7 @@ class SolarmanV5(Message):
if update: if update:
if key == 'inverter': if key == 'inverter':
inv_update = True inv_update = True
self._set_mqtt_timestamp(key, self._timestamp())
self.new_data[key] = True self.new_data[key] = True
if inv_update: if inv_update:
@@ -590,9 +637,7 @@ class SolarmanV5(Message):
self.__forward_msg() self.__forward_msg()
self.__send_ack_rsp(0x1710, ftype) self.__send_ack_rsp(0x1710, ftype)
if self.state is not State.up: self.new_state_up()
self.state = State.up
self.mb_timer.start(self.MB_START_TIMEOUT)
def msg_sync_end(self): def msg_sync_end(self):
data = self._recv_buffer[self.header_len:] data = self._recv_buffer[self.header_len:]

View File

@@ -5,6 +5,11 @@ from enum import Enum
from typing import Generator from typing import Generator
class ProxyMode(Enum):
SERVER = 1
CLIENT = 2
class Register(Enum): class Register(Enum):
COLLECTOR_FW_VERSION = 1 COLLECTOR_FW_VERSION = 1
CHIP_TYPE = 2 CHIP_TYPE = 2
@@ -18,6 +23,7 @@ class Register(Enum):
EQUIPMENT_MODEL = 24 EQUIPMENT_MODEL = 24
NO_INPUTS = 25 NO_INPUTS = 25
MAX_DESIGNED_POWER = 26 MAX_DESIGNED_POWER = 26
OUTPUT_COEFFICIENT = 27
INVERTER_CNT = 50 INVERTER_CNT = 50
UNKNOWN_SNR = 51 UNKNOWN_SNR = 51
UNKNOWN_MSG = 52 UNKNOWN_MSG = 52
@@ -89,6 +95,7 @@ class Register(Enum):
CONNECT_COUNT = 405 CONNECT_COUNT = 405
HEARTBEAT_INTERVAL = 406 HEARTBEAT_INTERVAL = 406
IP_ADDRESS = 407 IP_ADDRESS = 407
POLLING_INTERVAL = 408
EVENT_401 = 500 EVENT_401 = 500
EVENT_402 = 501 EVENT_402 = 501
EVENT_403 = 502 EVENT_403 = 502
@@ -105,6 +112,9 @@ class Register(Enum):
EVENT_414 = 513 EVENT_414 = 513
EVENT_415 = 514 EVENT_415 = 514
EVENT_416 = 515 EVENT_416 = 515
TS_INPUT = 600
TS_GRID = 601
TS_TOTAL = 602
VALUE_1 = 9000 VALUE_1 = 9000
TEST_REG1 = 10000 TEST_REG1 = 10000
TEST_REG2 = 10001 TEST_REG2 = 10001
@@ -120,16 +130,16 @@ class ClrAtMidnight:
return return
prfx += f'{keys[0]}' prfx += f'{keys[0]}'
dict = cls.db db_dict = cls.db
if prfx not in dict: if prfx not in db_dict:
dict[prfx] = {} db_dict[prfx] = {}
dict = dict[prfx] db_dict = db_dict[prfx]
for key in keys[1:-1]: for key in keys[1:-1]:
if key not in dict: if key not in db_dict:
dict[key] = {} db_dict[key] = {}
dict = dict[key] db_dict = db_dict[key]
dict[keys[-1]] = 0 db_dict[keys[-1]] = 0
@classmethod @classmethod
def elm(cls) -> Generator[tuple[str, dict], None, None]: def elm(cls) -> Generator[tuple[str, dict], None, None]:
@@ -177,15 +187,29 @@ class Infos:
} }
__comm_type_val_tpl = "{%set com_types = ['n/a','Wi-Fi', 'G4', 'G5', 'GPRS'] %}{{com_types[value_json['Communication_Type']|int(0)]|default(value_json['Communication_Type'])}}" # noqa: E501 __comm_type_val_tpl = "{%set com_types = ['n/a','Wi-Fi', 'G4', 'G5', 'GPRS'] %}{{com_types[value_json['Communication_Type']|int(0)]|default(value_json['Communication_Type'])}}" # noqa: E501
__status_type_val_tpl = "{%set inv_status = ['n/a', 'Online', 'Offline'] %}{{inv_status[value_json['Inverter_Status']|int(0)]|default(value_json['Inverter_Status'])}}" # noqa: E501 __status_type_val_tpl = "{%set inv_status = ['Off-line', 'On-grid', 'Off-grid'] %}{{inv_status[value_json['Inverter_Status']|int(0)]|default(value_json['Inverter_Status'])}}" # noqa: E501
__rated_power_val_tpl = "{% if 'Rated_Power' in value_json and value_json['Rated_Power'] != None %}{{value_json['Rated_Power']|string() +' W'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501
__designed_power_val_tpl = '''
{% if 'Max_Designed_Power' in value_json and
value_json['Max_Designed_Power'] != None %}
{% if value_json['Max_Designed_Power'] | int(0xffff) < 0x8000 %}
{{value_json['Max_Designed_Power']|string() +' W'}}
{% else %}
n/a
{% endif %}
{% else %}
{{ this.state }}
{% endif %}
'''
__output_coef_val_tpl = "{% if 'Output_Coefficient' in value_json and value_json['Output_Coefficient'] != None %}{{value_json['Output_Coefficient']|string() +' %'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501
__info_defs = { __info_defs = {
# collector values used for device registration: # collector values used for device registration:
Register.COLLECTOR_FW_VERSION: {'name': ['collector', 'Collector_Fw_Version'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 Register.COLLECTOR_FW_VERSION: {'name': ['collector', 'Collector_Fw_Version'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
Register.CHIP_TYPE: {'name': ['collector', 'Chip_Type'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.CHIP_TYPE: {'name': ['collector', 'Chip_Type'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.CHIP_MODEL: {'name': ['collector', 'Chip_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.CHIP_MODEL: {'name': ['collector', 'Chip_Model'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.TRACE_URL: {'name': ['collector', 'Trace_URL'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.TRACE_URL: {'name': ['collector', 'Trace_URL'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.LOGGER_URL: {'name': ['collector', 'Logger_URL'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.LOGGER_URL: {'name': ['collector', 'Logger_URL'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
# inverter values used for device registration: # inverter values used for device registration:
Register.PRODUCT_NAME: {'name': ['inverter', 'Product_Name'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PRODUCT_NAME: {'name': ['inverter', 'Product_Name'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
@@ -194,9 +218,9 @@ class Infos:
Register.SERIAL_NUMBER: {'name': ['inverter', 'Serial_Number'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 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.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.NO_INPUTS: {'name': ['inverter', 'No_Inputs'], 'level': logging.DEBUG, 'unit': ''}, # 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.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_', 'val_tpl': __designed_power_val_tpl, '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.RATED_POWER: {'name': ['inverter', 'Rated_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'rated_power_', 'val_tpl': __rated_power_val_tpl, 'name': 'Rated Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.OUTPUT_COEFFICIENT: {'name': ['inverter', 'Output_Coefficient'], 'level': logging.INFO, 'unit': '%', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'output_coef_', 'val_tpl': __output_coef_val_tpl, 'name': 'Output Coefficient', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV1_MANUFACTURER: {'name': ['inverter', 'PV1_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PV1_MANUFACTURER: {'name': ['inverter', 'PV1_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.PV1_MODEL: {'name': ['inverter', 'PV1_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PV1_MODEL: {'name': ['inverter', 'PV1_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.PV2_MANUFACTURER: {'name': ['inverter', 'PV2_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PV2_MANUFACTURER: {'name': ['inverter', 'PV2_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
@@ -244,14 +268,16 @@ class Infos:
Register.EVENT_416: {'name': ['events', '416_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EVENT_416: {'name': ['events', '416_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
# grid measures: # grid measures:
Register.TS_GRID: {'name': ['grid', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
Register.GRID_VOLTAGE: {'name': ['grid', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'inverter', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'out_volt_', 'fmt': '| float', 'name': 'Grid Voltage', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.GRID_VOLTAGE: {'name': ['grid', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'inverter', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'out_volt_', 'fmt': '| float', 'name': 'Grid Voltage', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.GRID_CURRENT: {'name': ['grid', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'inverter', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'out_cur_', 'fmt': '| float', 'name': 'Grid Current', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.GRID_CURRENT: {'name': ['grid', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'inverter', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'out_cur_', 'fmt': '| float', 'name': 'Grid Current', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.GRID_FREQUENCY: {'name': ['grid', 'Frequency'], 'level': logging.DEBUG, 'unit': 'Hz', 'ha': {'dev': 'inverter', 'dev_cla': 'frequency', 'stat_cla': 'measurement', 'id': 'out_freq_', 'fmt': '| float', 'name': 'Grid Frequency', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.GRID_FREQUENCY: {'name': ['grid', 'Frequency'], 'level': logging.DEBUG, 'unit': 'Hz', 'ha': {'dev': 'inverter', 'dev_cla': 'frequency', 'stat_cla': 'measurement', 'id': 'out_freq_', 'fmt': '| float', 'name': 'Grid Frequency', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.OUTPUT_POWER: {'name': ['grid', 'Output_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'out_power_', 'fmt': '| float', 'name': 'Power'}}, # noqa: E501 Register.OUTPUT_POWER: {'name': ['grid', 'Output_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'out_power_', 'fmt': '| float', 'name': 'Power'}}, # noqa: E501
Register.INVERTER_TEMP: {'name': ['env', 'Inverter_Temp'], 'level': logging.DEBUG, 'unit': '°C', 'ha': {'dev': 'inverter', 'dev_cla': 'temperature', 'stat_cla': 'measurement', 'id': 'temp_', 'fmt': '| int', 'name': 'Temperature'}}, # noqa: E501 Register.INVERTER_TEMP: {'name': ['env', 'Inverter_Temp'], 'level': logging.DEBUG, 'unit': '°C', 'ha': {'dev': 'inverter', 'dev_cla': 'temperature', 'stat_cla': 'measurement', 'id': 'temp_', 'fmt': '| int', 'name': 'Temperature'}}, # noqa: E501
Register.INVERTER_STATUS: {'name': ['env', 'Inverter_Status'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_status_', 'name': 'Inverter Status', 'val_tpl': __status_type_val_tpl, 'icon': 'mdi:counter'}}, # noqa: E501 Register.INVERTER_STATUS: {'name': ['env', 'Inverter_Status'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_status_', 'name': 'Inverter Status', 'val_tpl': __status_type_val_tpl, 'icon': 'mdi:power'}}, # noqa: E501
# input measures: # input measures:
Register.TS_INPUT: {'name': ['input', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
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_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_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.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.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
@@ -283,6 +309,7 @@ class Infos:
Register.PV6_DAILY_GENERATION: {'name': ['input', 'pv6', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv6_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv6']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501 Register.PV6_DAILY_GENERATION: {'name': ['input', 'pv6', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv6_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv6']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV6_TOTAL_GENERATION: {'name': ['input', 'pv6', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv6_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv6']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501 Register.PV6_TOTAL_GENERATION: {'name': ['input', 'pv6', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv6_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv6']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
# total: # total:
Register.TS_TOTAL: {'name': ['total', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
Register.DAILY_GENERATION: {'name': ['total', 'Daily_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_', 'fmt': '| float', 'name': 'Daily Generation', 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501 Register.DAILY_GENERATION: {'name': ['total', 'Daily_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_', 'fmt': '| float', 'name': 'Daily Generation', 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.TOTAL_GENERATION: {'name': ['total', 'Total_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_', 'fmt': '| float', 'name': 'Total Generation', 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501 Register.TOTAL_GENERATION: {'name': ['total', 'Total_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_', 'fmt': '| float', 'name': 'Total Generation', 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
@@ -295,6 +322,7 @@ class Infos:
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.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.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_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 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
Register.POLLING_INTERVAL: {'name': ['controller', 'Polling_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'polling_intval_', 'fmt': '| string + " s"', 'name': 'Polling Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
} }
@property @property
@@ -305,7 +333,7 @@ class Infos:
def info_defs(self) -> dict: def info_defs(self) -> dict:
return self.__info_defs return self.__info_defs
def dev_value(self, idx: str | int) -> str | int | float | None: def dev_value(self, idx: str | int) -> str | int | float | dict | None:
'''returns the stored device value from our database '''returns the stored device value from our database
idx:int ==> lookup the value in the database and return it as str, idx:int ==> lookup the value in the database and return it as str,
@@ -318,29 +346,29 @@ class Infos:
elif idx in self.info_defs: elif idx in self.info_defs:
row = self.info_defs[idx] row = self.info_defs[idx]
if 'singleton' in row and row['singleton']: if 'singleton' in row and row['singleton']:
dict = self.stat db_dict = self.stat
else: else:
dict = self.db db_dict = self.db
keys = row['name'] keys = row['name']
for key in keys: for key in keys:
if key not in dict: if key not in db_dict:
return None # value not found in the database return None # value not found in the database
dict = dict[key] db_dict = db_dict[key]
return dict # value of the reqeusted entry return db_dict # value of the reqeusted entry
return None # unknwon idx, not in info_defs return None # unknwon idx, not in info_defs
def inc_counter(self, counter: str) -> None: def inc_counter(self, counter: str) -> None:
'''inc proxy statistic counter''' '''inc proxy statistic counter'''
dict = self.stat['proxy'] db_dict = self.stat['proxy']
dict[counter] += 1 db_dict[counter] += 1
def dec_counter(self, counter: str) -> None: def dec_counter(self, counter: str) -> None:
'''dec proxy statistic counter''' '''dec proxy statistic counter'''
dict = self.stat['proxy'] db_dict = self.stat['proxy']
dict[counter] -= 1 db_dict[counter] -= 1
def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \ def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \
-> Generator[tuple[str, str, str, str], None, None]: -> Generator[tuple[str, str, str, str], None, None]:
@@ -363,6 +391,20 @@ class Infos:
def ha_conf(self, key, ha_prfx, node_id, snr, singleton: bool, def ha_conf(self, key, ha_prfx, node_id, snr, singleton: bool,
sug_area: str = '') -> tuple[str, str, str, str] | None: sug_area: str = '') -> tuple[str, str, str, str] | None:
'''Method to build json register struct for home-assistant
auto configuration and the unique entity string, for all proxy
registers
arguments:
key ==> index of info_defs dict which reference the topic
ha_prfx:str ==> MQTT prefix for the home assistant 'stat_t string
node_id:str ==> node id of the inverter, used to build unique entity
snr:str ==> serial number of the inverter, used to build unique
entity strings
singleton ==> bool to allow/disaalow proxy topics which are common
for all invters
sug_area ==> area name for home assistant
'''
if key not in self.info_defs: if key not in self.info_defs:
return None return None
row = self.info_defs[key] row = self.info_defs[key]
@@ -466,7 +508,40 @@ class Infos:
return json.dumps(attr), component, node_id, attr['uniq_id'] return json.dumps(attr), component, node_id, attr['uniq_id']
return None return None
def _key_obj(self, id: Register) -> list: def ha_remove(self, key, node_id, snr) -> tuple[str, str, str, str] | None:
'''Method to build json unregister struct for home-assistant
to remove topics per auto configuration. Only for inverer topics.
arguments:
key ==> index of info_defs dict which reference the topic
node_id:str ==> node id of the inverter, used to build unique entity
snr:str ==> serial number of the inverter, used to build unique
entity strings
hint:
the returned tuple must have the same format as self.ha_conf()
'''
if key not in self.info_defs:
return None
row = self.info_defs[key]
if 'singleton' in row and row['singleton']:
return None
# check if we have details for home assistant
if 'ha' in row:
ha = row['ha']
if 'comp' in ha:
component = ha['comp']
else:
component = 'sensor'
attr = {}
uniq_id = ha['id']+snr
return json.dumps(attr), component, node_id, uniq_id
return None
def _key_obj(self, id: Register) -> tuple:
d = self.info_defs.get(id, {'name': None, 'level': logging.DEBUG, d = self.info_defs.get(id, {'name': None, 'level': logging.DEBUG,
'unit': ''}) 'unit': ''})
if 'ha' in d and 'must_incr' in d['ha']: if 'ha' in d and 'must_incr' in d['ha']:
@@ -478,21 +553,21 @@ class Infos:
def update_db(self, keys: list, must_incr: bool, result): def update_db(self, keys: list, must_incr: bool, result):
name = '' name = ''
dict = self.db db_dict = self.db
for key in keys[:-1]: for key in keys[:-1]:
if key not in dict: if key not in db_dict:
dict[key] = {} db_dict[key] = {}
dict = dict[key] db_dict = db_dict[key]
name += key + '.' name += key + '.'
if keys[-1] not in dict: if keys[-1] not in db_dict:
update = (not must_incr or result > 0) update = (not must_incr or result > 0)
else: else:
if must_incr: if must_incr:
update = dict[keys[-1]] < result update = db_dict[keys[-1]] < result
else: else:
update = dict[keys[-1]] != result update = db_dict[keys[-1]] != result
if update: if update:
dict[keys[-1]] = result db_dict[keys[-1]] = result
name += keys[-1] name += keys[-1]
return name, update return name, update
@@ -546,13 +621,13 @@ class Infos:
return True return True
if 'gte' in dep: if 'gte' in dep:
return not value >= dep['gte'] return value < dep['gte']
elif 'less_eq' in dep: elif 'less_eq' in dep:
return not value <= dep['less_eq'] return value > dep['less_eq']
return True return True
def set_pv_module_details(self, inv: dict) -> None: def set_pv_module_details(self, inv: dict) -> None:
map = {'pv1': {'manufacturer': Register.PV1_MANUFACTURER, 'model': Register.PV1_MODEL}, # noqa: E501 pvs = {'pv1': {'manufacturer': Register.PV1_MANUFACTURER, 'model': Register.PV1_MODEL}, # noqa: E501
'pv2': {'manufacturer': Register.PV2_MANUFACTURER, 'model': Register.PV2_MODEL}, # noqa: E501 'pv2': {'manufacturer': Register.PV2_MANUFACTURER, 'model': Register.PV2_MODEL}, # noqa: E501
'pv3': {'manufacturer': Register.PV3_MANUFACTURER, 'model': Register.PV3_MODEL}, # noqa: E501 'pv3': {'manufacturer': Register.PV3_MANUFACTURER, 'model': Register.PV3_MODEL}, # noqa: E501
'pv4': {'manufacturer': Register.PV4_MANUFACTURER, 'model': Register.PV4_MODEL}, # noqa: E501 'pv4': {'manufacturer': Register.PV4_MANUFACTURER, 'model': Register.PV4_MODEL}, # noqa: E501
@@ -560,7 +635,7 @@ class Infos:
'pv6': {'manufacturer': Register.PV6_MANUFACTURER, 'model': Register.PV6_MODEL} # noqa: E501 'pv6': {'manufacturer': Register.PV6_MANUFACTURER, 'model': Register.PV6_MODEL} # noqa: E501
} }
for key, reg in map.items(): for key, reg in pvs.items():
if key in inv: if key in inv:
if 'manufacturer' in inv[key]: if 'manufacturer' in inv[key]:
self.set_db_def_value(reg['manufacturer'], self.set_db_def_value(reg['manufacturer'],

View File

@@ -5,16 +5,16 @@ from enum import Enum
if __name__ == "app.src.messages": if __name__ == "app.src.messages":
from app.src.infos import Infos from app.src.infos import Infos, Register
from app.src.modbus import Modbus from app.src.modbus import Modbus
else: # pragma: no cover else: # pragma: no cover
from infos import Infos from infos import Infos, Register
from modbus import Modbus from modbus import Modbus
logger = logging.getLogger('msg') logger = logging.getLogger('msg')
def hex_dump_memory(level, info, data, num): def hex_dump_memory(level, info, data, data_len):
n = 0 n = 0
lines = [] lines = []
lines.append(info) lines.append(info)
@@ -22,20 +22,20 @@ def hex_dump_memory(level, info, data, num):
if not tracer.isEnabledFor(level): if not tracer.isEnabledFor(level):
return return
for i in range(0, num, 16): for i in range(0, data_len, 16):
line = ' ' line = ' '
line += '%04x | ' % (i) line += '%04x | ' % (i)
n += 16 n += 16
for j in range(n-16, n): for j in range(n-16, n):
if j >= len(data): if j >= data_len:
break break
line += '%02x ' % abs(data[j]) line += '%02x ' % abs(data[j])
line += ' ' * (3 * 16 + 9 - len(line)) + ' | ' line += ' ' * (3 * 16 + 9 - len(line)) + ' | '
for j in range(n-16, n): for j in range(n-16, n):
if j >= len(data): if j >= data_len:
break break
c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.' c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.'
line += '%c' % c line += '%c' % c
@@ -91,6 +91,7 @@ class Message(metaclass=IterRegistry):
self._forward_buffer = bytearray(0) self._forward_buffer = bytearray(0)
self.new_data = {} self.new_data = {}
self.state = State.init self.state = State.init
self.shutdown_started = False
''' '''
Empty methods, that have to be implemented in any child class which Empty methods, that have to be implemented in any child class which
@@ -102,7 +103,22 @@ class Message(metaclass=IterRegistry):
def _update_header(self, _forward_buffer): def _update_header(self, _forward_buffer):
'''callback for updating the header of the forward buffer''' '''callback for updating the header of the forward buffer'''
return # pragma: no cover pass # pragma: no cover
def _set_mqtt_timestamp(self, key, ts: float | None):
if key not in self.new_data or \
not self.new_data[key]:
if key == 'grid':
info_id = Register.TS_GRID
elif key == 'input':
info_id = Register.TS_INPUT
elif key == 'total':
info_id = Register.TS_TOTAL
else:
return
# tstr = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts))
# logger.info(f'update: key: {key} ts:{tstr}'
self.db.set_db_def_value(info_id, round(ts))
''' '''
Our puplic methods Our puplic methods
@@ -111,7 +127,7 @@ class Message(metaclass=IterRegistry):
if self.mb: if self.mb:
self.mb.close() self.mb.close()
self.mb = None self.mb = None
pass # pragma: no cover # pragma: no cover
def inc_counter(self, counter: str) -> None: def inc_counter(self, counter: str) -> None:
self.db.inc_counter(counter) self.db.inc_counter(counter)

View File

@@ -41,8 +41,10 @@ class Modbus():
__crc_tab = [] __crc_tab = []
map = { map = {
0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
# 0x????: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 0x202c: {'reg': Register.OUTPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501
0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501
0x3000: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501
0x3008: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf:1X}'"}, # noqa: E501
0x3009: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # 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 0x300a: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501 0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
@@ -104,6 +106,7 @@ class Modbus():
self.loop = asyncio.get_event_loop() self.loop = asyncio.get_event_loop()
self.req_pend = False self.req_pend = False
self.tim = None self.tim = None
self.node_id = ''
def close(self): def close(self):
"""free the queue and erase the callback handlers""" """free the queue and erase the callback handlers"""
@@ -111,7 +114,7 @@ class Modbus():
self.__stop_timer() self.__stop_timer()
self.rsp_handler = None self.rsp_handler = None
self.snd_handler = None self.snd_handler = None
while not self.que.empty: while not self.que.empty():
self.que.get_nowait() self.que.get_nowait()
def __del__(self): def __del__(self):
@@ -178,6 +181,8 @@ class Modbus():
5: No MODBUS request pending 5: No MODBUS request pending
""" """
# logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}')
self.node_id = node_id
if not self.req_pend: if not self.req_pend:
self.err = 5 self.err = 5
return return
@@ -265,7 +270,10 @@ class Modbus():
self.__start_timer() self.__start_timer()
self.snd_handler(self.last_req, self.last_log_lvl, state='Retrans') self.snd_handler(self.last_req, self.last_log_lvl, state='Retrans')
else: else:
logger.info(f'Modbus timeout {self}') logger.info(f'[{self.node_id}] Modbus timeout '
f'(FCode: {self.last_fcode} '
f'Reg: 0x{self.last_reg:04x}, '
f'{self.last_len})')
self.counter['timeouts'] += 1 self.counter['timeouts'] += 1
self.__send_next_from_que() self.__send_next_from_que()

76
app/src/modbus_tcp.py Normal file
View File

@@ -0,0 +1,76 @@
import logging
import traceback
import asyncio
from config import Config
# import gc
from gen3plus.inverter_g3p import InverterG3P
logger = logging.getLogger('conn')
class ModbusConn():
def __init__(self, host, port):
self.host = host
self.port = port
self.addr = (host, port)
self.stream = None
async def __aenter__(self) -> 'InverterG3P':
'''Establish a client connection to the TSUN cloud'''
connection = asyncio.open_connection(self.host, self.port)
reader, writer = await connection
self.stream = InverterG3P(reader, writer, self.addr,
client_mode=True)
logging.info(f'[{self.stream.node_id}:{self.stream.conn_no}] '
f'Connected to {self.addr}')
self.stream.inc_counter('Inverter_Cnt')
await self.stream.publish_outstanding_mqtt()
return self.stream
async def __aexit__(self, exc_type, exc, tb):
self.stream.dec_counter('Inverter_Cnt')
await self.stream.publish_outstanding_mqtt()
class ModbusTcp():
def __init__(self, loop) -> None:
inverters = Config.get('inverters')
# logging.info(f'Inverters: {inverters}')
for inv in inverters.values():
if (type(inv) is dict
and 'monitor_sn' in inv
and 'client_mode' in inv):
client = inv['client_mode']
# logging.info(f"SerialNo:{inv['monitor_sn']} host:{client['host']} port:{client['port']}") # noqa: E501
loop.create_task(self.modbus_loop(client['host'],
client['port'],
inv['monitor_sn']))
async def modbus_loop(self, host, port, snr: int) -> None:
'''Loop for receiving messages from the TSUN cloud (client-side)'''
while True:
try:
async with ModbusConn(host, port) as stream:
await stream.send_start_cmd(snr, host)
await stream.loop()
logger.info(f'[{stream.node_id}:{stream.conn_no}] '
f'Connection closed - Shutdown: '
f'{stream.shutdown_started}')
if stream.shutdown_started:
return
except (ConnectionRefusedError, TimeoutError) as error:
logging.debug(f'Inv-conn:{error}')
except OSError as error:
logging.info(f'os-error: {error}')
except Exception:
logging.error(
f"ModbusTcpCreate: Exception for {(host,port)}:\n"
f"{traceback.format_exc()}")
await asyncio.sleep(10)

View File

@@ -38,7 +38,8 @@ class Mqtt(metaclass=Singleton):
self.task.cancel() self.task.cancel()
try: try:
await self.task await self.task
except Exception as e:
except (asyncio.CancelledError, Exception) as e:
logging.debug(f"Mqtt.close: exception: {e} ...") logging.debug(f"Mqtt.close: exception: {e} ...")
async def publish(self, topic: str, payload: str | bytes | bytearray async def publish(self, topic: str, payload: str | bytes | bytearray
@@ -60,6 +61,7 @@ class Mqtt(metaclass=Singleton):
interval = 5 # Seconds interval = 5 # Seconds
ha_status_topic = f"{ha['auto_conf_prefix']}/status" ha_status_topic = f"{ha['auto_conf_prefix']}/status"
mb_rated_topic = "tsun/+/rated_load" # fixme mb_rated_topic = "tsun/+/rated_load" # fixme
mb_out_coeff_topic = "tsun/+/out_coeff" # fixme
mb_reads_topic = "tsun/+/modbus_read_regs" # fixme mb_reads_topic = "tsun/+/modbus_read_regs" # fixme
mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme
mb_at_cmd_topic = "tsun/+/at_cmd" # fixme mb_at_cmd_topic = "tsun/+/at_cmd" # fixme
@@ -75,6 +77,7 @@ class Mqtt(metaclass=Singleton):
# async with self.__client.messages() as messages: # async with self.__client.messages() as messages:
await self.__client.subscribe(ha_status_topic) await self.__client.subscribe(ha_status_topic)
await self.__client.subscribe(mb_rated_topic) await self.__client.subscribe(mb_rated_topic)
await self.__client.subscribe(mb_out_coeff_topic)
await self.__client.subscribe(mb_reads_topic) await self.__client.subscribe(mb_reads_topic)
await self.__client.subscribe(mb_inputs_topic) await self.__client.subscribe(mb_inputs_topic)
await self.__client.subscribe(mb_at_cmd_topic) await self.__client.subscribe(mb_at_cmd_topic)
@@ -93,6 +96,19 @@ class Mqtt(metaclass=Singleton):
Modbus.WRITE_SINGLE_REG, Modbus.WRITE_SINGLE_REG,
1, 0x2008) 1, 0x2008)
if message.topic.matches(mb_out_coeff_topic):
payload = message.payload.decode("UTF-8")
val = round(float(payload) * 1024/100)
if val < 0 or val > 1024:
logger_mqtt.error('out_coeff: value must be in'
'the range 0..100,'
f' got: {payload}')
else:
await self.modbus_cmd(message,
Modbus.WRITE_SINGLE_REG,
0, 0x202c, val)
if message.topic.matches(mb_reads_topic): if message.topic.matches(mb_reads_topic):
await self.modbus_cmd(message, await self.modbus_cmd(message,
Modbus.READ_REGS, 2) Modbus.READ_REGS, 2)
@@ -154,7 +170,7 @@ class Mqtt(metaclass=Singleton):
logger_mqtt.debug(f'Found: {node_id}') logger_mqtt.debug(f'Found: {node_id}')
fnc = getattr(m, "send_modbus_cmd", None) fnc = getattr(m, "send_modbus_cmd", None)
res = payload.split(',') res = payload.split(',')
if params != len(res): if params > 0 and params != len(res):
logger_mqtt.error(f'Parameter expected: {params}, ' logger_mqtt.error(f'Parameter expected: {params}, '
f'got: {len(res)}') f'got: {len(res)}')
return return

View File

@@ -11,6 +11,7 @@ from gen3.inverter_g3 import InverterG3
from gen3plus.inverter_g3p import InverterG3P from gen3plus.inverter_g3p import InverterG3P
from scheduler import Schedule from scheduler import Schedule
from config import Config from config import Config
from modbus_tcp import ModbusTcp
routes = web.RouteTableDef() routes = web.RouteTableDef()
proxy_is_up = False proxy_is_up = False
@@ -94,6 +95,7 @@ async def handle_shutdown(web_task):
# first, disc all open TCP connections gracefully # first, disc all open TCP connections gracefully
# #
for stream in Message: for stream in Message:
stream.shutdown_started = True
try: try:
await asyncio.wait_for(stream.disc(), 2) await asyncio.wait_for(stream.disc(), 2)
except Exception: except Exception:
@@ -115,6 +117,13 @@ async def handle_shutdown(web_task):
web_task.cancel() web_task.cancel()
await web_task await web_task
#
# now cancel all remaining (pending) tasks
#
pending = asyncio.all_tasks()
for task in pending:
task.cancel()
# #
# at last, start a coro for stopping the loop # at last, start a coro for stopping the loop
# #
@@ -164,6 +173,7 @@ if __name__ == "__main__":
logging.info(f'ConfigErr: {ConfigErr}') logging.info(f'ConfigErr: {ConfigErr}')
Inverter.class_init() Inverter.class_init()
Schedule.start() Schedule.start()
mb_tcp = ModbusTcp(loop)
# #
# Create tasks for our listening servers. These must be tasks! If we call # Create tasks for our listening servers. These must be tasks! If we call

View File

@@ -20,7 +20,7 @@ def test_empty_config():
Config.conf_schema.validate(cnf) Config.conf_schema.validate(cnf)
assert False assert False
except SchemaMissingKeyError: except SchemaMissingKeyError:
assert True pass
def test_default_config(): def test_default_config():
with open("app/config/default_config.toml", "rb") as f: with open("app/config/default_config.toml", "rb") as f:
@@ -28,10 +28,9 @@ def test_default_config():
try: try:
validated = Config.conf_schema.validate(cnf) validated = Config.conf_schema.validate(cnf)
assert True except Exception:
except:
assert False assert False
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': ''}}} 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': '', 'modbus_polling': False, 'monitor_sn': 0, 'suggested_area': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
def test_full_config(): def test_full_config():
cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005},
@@ -41,14 +40,13 @@ def test_full_config():
'mqtt': {'host': 'mqtt', 'port': 1883, 'user': '', 'passwd': ''}, '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'}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
'inverters': {'allow_all': True, 'inverters': {'allow_all': True,
'R170000000000001': {'node_id': '', 'suggested_area': '', 'pv1': {'type': 'type1', 'manufacturer': 'man1'}, 'pv2': {'type': 'type2', 'manufacturer': 'man2'}, 'pv3': {'type': 'type3', 'manufacturer': 'man3'}}, 'R170000000000001': {'modbus_polling': True, 'node_id': '', 'suggested_area': '', 'pv1': {'type': 'type1', 'manufacturer': 'man1'}, 'pv2': {'type': 'type2', 'manufacturer': 'man2'}, 'pv3': {'type': 'type3', 'manufacturer': 'man3'}},
'Y170000000000001': {'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}} 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
try: try:
validated = Config.conf_schema.validate(cnf) validated = Config.conf_schema.validate(cnf)
assert True except Exception:
except:
assert False assert False
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': ''}}} 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': '', 'modbus_polling': True, 'monitor_sn': 0, 'pv1': {'manufacturer': 'man1','type': 'type1'},'pv2': {'manufacturer': 'man2','type': 'type2'},'pv3': {'manufacturer': 'man3','type': 'type3'}, 'suggested_area': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
def test_mininum_config(): def test_mininum_config():
cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005},
@@ -63,10 +61,9 @@ def test_mininum_config():
try: try:
validated = Config.conf_schema.validate(cnf) validated = Config.conf_schema.validate(cnf)
assert True except Exception:
except:
assert False assert False
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': ''}}} 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': '', 'modbus_polling': True, 'monitor_sn': 0, 'suggested_area': ''}}}
def test_read_empty(): def test_read_empty():
cnf = {} cnf = {}
@@ -74,7 +71,7 @@ def test_read_empty():
err = TstConfig.read('app/config/') err = TstConfig.read('app/config/')
assert err == None assert err == None
cnf = TstConfig.get() cnf = TstConfig.get()
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 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': '', 'modbus_polling': False, 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
defcnf = TstConfig.def_config.get('solarman') defcnf = TstConfig.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000} assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
@@ -96,7 +93,7 @@ def test_read_cnf1():
err = TstConfig.read('app/config/') err = TstConfig.read('app/config/')
assert err == None assert err == None
cnf = TstConfig.get() cnf = TstConfig.get()
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': ''}}} 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': '', 'modbus_polling': False, 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
cnf = TstConfig.get('solarman') cnf = TstConfig.get('solarman')
assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000} assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}
defcnf = TstConfig.def_config.get('solarman') defcnf = TstConfig.def_config.get('solarman')
@@ -109,7 +106,7 @@ def test_read_cnf2():
err = TstConfig.read('app/config/') err = TstConfig.read('app/config/')
assert err == None assert err == None
cnf = TstConfig.get() cnf = TstConfig.get()
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 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': '', 'modbus_polling': False, 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
assert True == TstConfig.is_default('solarman') assert True == TstConfig.is_default('solarman')
def test_read_cnf3(): def test_read_cnf3():
@@ -126,7 +123,7 @@ def test_read_cnf4():
err = TstConfig.read('app/config/') err = TstConfig.read('app/config/')
assert err == None assert err == None
cnf = TstConfig.get() cnf = TstConfig.get()
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 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': '', 'modbus_polling': False, 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
assert False == TstConfig.is_default('solarman') assert False == TstConfig.is_default('solarman')
def test_read_cnf5(): def test_read_cnf5():

View File

@@ -1,6 +1,6 @@
# test_with_pytest.py # test_with_pytest.py
import pytest import pytest
import json import json, math
import logging import logging
from app.src.infos import Register, ClrAtMidnight from app.src.infos import Register, ClrAtMidnight
from app.src.infos import Infos from app.src.infos import Infos
@@ -77,7 +77,7 @@ def test_table_definition():
for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'):
pass pass # sideeffect is calling generator i.ha_proxy_confs()
val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter
assert val == 0 assert val == 0
@@ -123,6 +123,30 @@ def test_table_definition():
val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter
assert val == 3 assert val == 3
def test_table_remove():
i = Infos()
i.static_init() # initialize counter
val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter
assert val == 0
# for d_json, comp, node_id, id in i.ha_confs(ha_prfx="tsun/", node_id="garagendach/", snr='123', sug_area = 'roof'):
# pass
test = 0
for reg in Register:
res = i.ha_remove(reg, node_id="garagendach/", snr='123') # noqa: E501
if reg == Register.INVERTER_STATUS:
test += 1
assert res == ('{}', 'sensor', 'garagendach/', 'inv_status_123')
elif reg == Register.COLLECT_INTERVAL:
test += 1
assert res == ('{}', 'sensor', 'garagendach/', 'data_collect_intval_123')
assert test == 2
val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter
assert val == 0
def test_clr_at_midnight(): def test_clr_at_midnight():
i = Infos() i = Infos()
i.static_init() # initialize counter i.static_init() # initialize counter
@@ -198,24 +222,24 @@ def test_get_value():
i.set_db_def_value(Register.PV2_VOLTAGE, 30.3) i.set_db_def_value(Register.PV2_VOLTAGE, 30.3)
assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
assert 30.3 == i.get_db_value(Register.PV2_VOLTAGE, None) assert math.isclose(30.3,i.get_db_value(Register.PV2_VOLTAGE, None), rel_tol=1e-09, abs_tol=1e-09)
def test_update_value(): def test_update_value():
i = Infos() i = Infos()
assert None == i.get_db_value(Register.PV1_VOLTAGE, None) assert None == i.get_db_value(Register.PV1_VOLTAGE, None)
keys = i.info_defs[Register.PV1_VOLTAGE]['name'] keys = i.info_defs[Register.PV1_VOLTAGE]['name']
name, update = i.update_db(keys, True, 30) _, update = i.update_db(keys, True, 30)
assert update == True assert update == True
assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
keys = i.info_defs[Register.PV1_VOLTAGE]['name'] keys = i.info_defs[Register.PV1_VOLTAGE]['name']
name, update = i.update_db(keys, True, 30) _, update = i.update_db(keys, True, 30)
assert update == False assert update == False
assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
keys = i.info_defs[Register.PV1_VOLTAGE]['name'] keys = i.info_defs[Register.PV1_VOLTAGE]['name']
name, update = i.update_db(keys, False, 29) _, update = i.update_db(keys, False, 29)
assert update == True assert update == True
assert 29 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 29 == i.get_db_value(Register.PV1_VOLTAGE, None)

View File

@@ -1,12 +1,12 @@
# test_with_pytest.py # test_with_pytest.py
import pytest, json import pytest, json, math
from app.src.infos import Register from app.src.infos import Register
from app.src.gen3plus.infos_g3p import InfosG3P from app.src.gen3plus.infos_g3p import InfosG3P
from app.src.gen3plus.infos_g3p import RegisterMap from app.src.gen3plus.infos_g3p import RegisterMap
@pytest.fixture @pytest.fixture
def DeviceData(): # 0x4110 ftype: 0x02 def device_data(): # 0x4110 ftype: 0x02
msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xba\xd2\x00\x00' msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xba\xd2\x00\x00'
msg += b'\x19\x00\x00\x00\x00\x00\x00\x00\x05\x3c\x78\x01\x64\x01\x4c\x53' msg += b'\x19\x00\x00\x00\x00\x00\x00\x00\x05\x3c\x78\x01\x64\x01\x4c\x53'
msg += b'\x57\x35\x42\x4c\x45\x5f\x31\x37\x5f\x30\x32\x42\x30\x5f\x31\x2e' msg += b'\x57\x35\x42\x4c\x45\x5f\x31\x37\x5f\x30\x32\x42\x30\x5f\x31\x2e'
@@ -24,7 +24,7 @@ def DeviceData(): # 0x4110 ftype: 0x02
return msg return msg
@pytest.fixture @pytest.fixture
def InverterData(): # 0x4210 ftype: 0x01 def inverter_data(): # 0x4210 ftype: 0x01
msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\xb0\x02\xbc\xc8' msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\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'\x24\x32\x6c\x1f\x00\x00\xa0\x47\xe4\x33\x01\x00\x03\x08\x00\x00'
msg += b'\x59\x31\x37\x45\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x45' msg += b'\x59\x31\x37\x45\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x45'
@@ -56,34 +56,34 @@ def InverterData(): # 0x4210 ftype: 0x01
def test_default_db(): def test_default_db():
i = InfosG3P() i = InfosG3P(client_mode=False)
assert json.dumps(i.db) == json.dumps({ assert json.dumps(i.db) == json.dumps({
"inverter": {"Manufacturer": "TSUN", "Equipment_Model": "TSOL-MSxx00"}, "inverter": {"Manufacturer": "TSUN", "Equipment_Model": "TSOL-MSxx00", "No_Inputs": 4},
"collector": {"Chip_Type": "IGEN TECH"}, "collector": {"Chip_Type": "IGEN TECH"},
}) })
def test_parse_4110(DeviceData: bytes): def test_parse_4110(device_data: bytes):
i = InfosG3P() i = InfosG3P(client_mode=False)
i.db.clear() i.db.clear()
for key, update in i.parse (DeviceData, 0x41, 2): for key, update in i.parse (device_data, 0x41, 2):
pass pass # side effect is calling generator i.parse()
assert json.dumps(i.db) == json.dumps({ assert json.dumps(i.db) == json.dumps({
'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": "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"}, 'collector': {"Chip_Model": "LSW5BLE_17_02B0_1.05", "Collector_Fw_Version": "V1.1.00.0B"},
}) })
def test_parse_4210(InverterData: bytes): def test_parse_4210(inverter_data: bytes):
i = InfosG3P() i = InfosG3P(client_mode=False)
i.db.clear() i.db.clear()
for key, update in i.parse (InverterData, 0x42, 1): for key, update in i.parse (inverter_data, 0x42, 1):
pass pass # side effect is calling generator i.parse()
assert json.dumps(i.db) == json.dumps({ assert json.dumps(i.db) == json.dumps({
"controller": {"Power_On_Time": 2051}, "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},
"env": {"Inverter_Status": 1, "Inverter_Temp": 14}, "env": {"Inverter_Status": 1, "Inverter_Temp": 14},
"grid": {"Voltage": 224.8, "Current": 0.73, "Frequency": 50.05, "Output_Power": 165.8}, "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}, "input": {"pv1": {"Voltage": 35.3, "Current": 1.68, "Power": 59.6, "Daily_Generation": 0.04, "Total_Generation": 30.76},
@@ -94,7 +94,7 @@ def test_parse_4210(InverterData: bytes):
}) })
def test_build_ha_conf1(): def test_build_ha_conf1():
i = InfosG3P() i = InfosG3P(client_mode=False)
i.static_init() # initialize counter i.static_init() # initialize counter
tests = 0 tests = 0
@@ -116,8 +116,19 @@ def test_build_ha_conf1():
tests +=1 tests +=1
elif id == 'power_pv2_123': elif id == 'power_pv2_123':
assert False # if we haven't received and parsed a control data msg, we don't know the number of inputs. In this case we only register the first one!! assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/input", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "power_pv2_123", "val_tpl": "{{ (value_json['pv2']['Power'] | float)}}", "unit_of_meas": "W", "dev": {"name": "Module PV2", "sa": "Module PV2", "via_device": "inverter_123", "ids": ["input_pv2_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'power_pv3_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/input", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "power_pv3_123", "val_tpl": "{{ (value_json['pv3']['Power'] | float)}}", "unit_of_meas": "W", "dev": {"name": "Module PV3", "sa": "Module PV3", "via_device": "inverter_123", "ids": ["input_pv3_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'power_pv4_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/input", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "power_pv4_123", "val_tpl": "{{ (value_json['pv4']['Power'] | float)}}", "unit_of_meas": "W", "dev": {"name": "Module PV4", "sa": "Module PV4", "via_device": "inverter_123", "ids": ["input_pv4_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'signal_123': elif id == 'signal_123':
assert comp == 'sensor' assert comp == 'sensor'
@@ -126,7 +137,7 @@ def test_build_ha_conf1():
elif id == 'inv_count_456': elif id == 'inv_count_456':
assert False assert False
assert tests==4 assert tests==7
for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'):
@@ -138,8 +149,11 @@ def test_build_ha_conf1():
elif id == 'power_pv1_123': elif id == 'power_pv1_123':
assert False assert False
elif id == 'power_pv2_123': elif id == 'power_pv2_123':
assert False # if we haven't received and parsed a control data msg, we don't know the number of inputs. In this case we only register the first one!! assert False
elif id == 'power_pv3_123':
assert False
elif id == 'power_pv4_123':
assert False
elif id == 'signal_123': elif id == 'signal_123':
assert False assert False
elif id == 'inv_count_456': elif id == 'inv_count_456':
@@ -147,30 +161,99 @@ def test_build_ha_conf1():
assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}}) assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1 tests +=1
assert tests==5 assert tests==8
def test_exception_and_eval(InverterData: bytes): def test_build_ha_conf2():
i = InfosG3P(client_mode=True)
i.static_init() # initialize counter
tests = 0
for d_json, comp, node_id, id in i.ha_confs(ha_prfx="tsun/", node_id="garagendach/", snr='123'):
if id == 'out_power_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/grid", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "out_power_123", "val_tpl": "{{value_json['Output_Power'] | float}}", "unit_of_meas": "W", "dev": {"name": "Micro Inverter", "sa": "Micro Inverter", "via_device": "controller_123", "mdl": "TSOL-MSxx00", "mf": "TSUN", "ids": ["inverter_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'daily_gen_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Daily Generation", "stat_t": "tsun/garagendach/total", "dev_cla": "energy", "stat_cla": "total_increasing", "uniq_id": "daily_gen_123", "val_tpl": "{{value_json['Daily_Generation'] | float}}", "unit_of_meas": "kWh", "ic": "mdi:solar-power-variant", "dev": {"name": "Micro Inverter", "sa": "Micro Inverter", "via_device": "controller_123", "mdl": "TSOL-MSxx00", "mf": "TSUN", "ids": ["inverter_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'power_pv1_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/input", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "power_pv1_123", "val_tpl": "{{ (value_json['pv1']['Power'] | float)}}", "unit_of_meas": "W", "dev": {"name": "Module PV1", "sa": "Module PV1", "via_device": "inverter_123", "ids": ["input_pv1_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'power_pv2_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/input", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "power_pv2_123", "val_tpl": "{{ (value_json['pv2']['Power'] | float)}}", "unit_of_meas": "W", "dev": {"name": "Module PV2", "sa": "Module PV2", "via_device": "inverter_123", "ids": ["input_pv2_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'power_pv3_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/input", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "power_pv3_123", "val_tpl": "{{ (value_json['pv3']['Power'] | float)}}", "unit_of_meas": "W", "dev": {"name": "Module PV3", "sa": "Module PV3", "via_device": "inverter_123", "ids": ["input_pv3_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'power_pv4_123':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Power", "stat_t": "tsun/garagendach/input", "dev_cla": "power", "stat_cla": "measurement", "uniq_id": "power_pv4_123", "val_tpl": "{{ (value_json['pv4']['Power'] | float)}}", "unit_of_meas": "W", "dev": {"name": "Module PV4", "sa": "Module PV4", "via_device": "inverter_123", "ids": ["input_pv4_123"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
elif id == 'signal_123':
assert comp == 'sensor'
assert d_json == json.dumps({})
tests +=1
elif id == 'inv_count_456':
assert False
assert tests==7
for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'):
if id == 'out_power_123':
assert False
elif id == 'daily_gen_123':
assert False
elif id == 'power_pv1_123':
assert False
elif id == 'power_pv2_123':
assert False
elif id == 'power_pv3_123':
assert False
elif id == 'power_pv4_123':
assert False
elif id == 'signal_123':
assert False
elif id == 'inv_count_456':
assert comp == 'sensor'
assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1
assert tests==8
def test_exception_and_eval(inverter_data: bytes):
# add eval to convert temperature from °F to °C # add eval to convert temperature from °F to °C
RegisterMap.map[0x420100d8]['eval'] = '(result-32)/1.8' RegisterMap.map[0x420100d8]['eval'] = '(result-32)/1.8'
# map PV1_VOLTAGE to invalid register # map PV1_VOLTAGE to invalid register
RegisterMap.map[0x420100e0]['reg'] = Register.TEST_REG2 RegisterMap.map[0x420100e0]['reg'] = Register.TEST_REG2
# set invalid maping entry for OUTPUT_POWER (string instead of dict type) # set invalid maping entry for OUTPUT_POWER (string instead of dict type)
Backup = RegisterMap.map[0x420100de] backup = RegisterMap.map[0x420100de]
RegisterMap.map[0x420100de] = 'invalid_entry' RegisterMap.map[0x420100de] = 'invalid_entry'
i = InfosG3P() i = InfosG3P(client_mode=False)
# i.db.clear() # i.db.clear()
for key, update in i.parse (InverterData, 0x42, 1): for key, update in i.parse (inverter_data, 0x42, 1):
pass pass # side effect is calling generator i.parse()
assert 12.2222 == round (i.get_db_value(Register.INVERTER_TEMP, 0),4) assert math.isclose(12.2222, round (i.get_db_value(Register.INVERTER_TEMP, 0),4), rel_tol=1e-09, abs_tol=1e-09)
del RegisterMap.map[0x420100d8]['eval'] # remove eval del RegisterMap.map[0x420100d8]['eval'] # remove eval
RegisterMap.map[0x420100e0]['reg'] = Register.PV1_VOLTAGE # reset mapping RegisterMap.map[0x420100e0]['reg'] = Register.PV1_VOLTAGE # reset mapping
RegisterMap.map[0x420100de] = Backup # reset mapping RegisterMap.map[0x420100de] = backup # reset mapping
for key, update in i.parse (InverterData, 0x42, 1): for key, update in i.parse (inverter_data, 0x42, 1):
pass pass # side effect is calling generator i.parse()
assert 54 == i.get_db_value(Register.INVERTER_TEMP, 0) assert 54 == i.get_db_value(Register.INVERTER_TEMP, 0)

View File

@@ -5,7 +5,6 @@ from app.src.modbus import Modbus
from app.src.infos import Infos, Register from app.src.infos import Infos, Register
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ('pytest_asyncio',)
# pytestmark = pytest.mark.asyncio(scope="module")
class ModbusTestHelper(Modbus): class ModbusTestHelper(Modbus):
def __init__(self): def __init__(self):
@@ -32,7 +31,12 @@ def test_modbus_crc():
assert mb._Modbus__check_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') assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46')
msg = b'\x01\x03\x28\x51'
msg += b'\x0e\x08\xd3\x00\x29\x13\x87\x00\x3e\x00\x00\x01\x2c\x03\xb4\x00'
msg += b'\x08\x00\x00\x00\x00\x01\x59\x01\x21\x03\xe6\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\xe6\xef'
assert 0 == mb._Modbus__calc_crc(msg)
def test_build_modbus_pdu(): def test_build_modbus_pdu():
'''Check building and sending a MODBUS RTU''' '''Check building and sending a MODBUS RTU'''
mb = ModbusTestHelper() mb = ModbusTestHelper()
@@ -71,8 +75,8 @@ def test_recv_resp_crc_err():
mb.req_pend = True mb.req_pend = True
mb.last_addr = 1 mb.last_addr = 1
mb.last_fcode = 3 mb.last_fcode = 3
mb.last_reg == 0x300e mb.last_reg = 0x300e
mb.last_len == 2 mb.last_len = 2
# check matching response, but with CRC error # check matching response, but with CRC error
call = 0 call = 0
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'): for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'):
@@ -91,8 +95,8 @@ def test_recv_resp_invalid_addr():
# simulate a transmitted request # simulate a transmitted request
mb.last_addr = 1 mb.last_addr = 1
mb.last_fcode = 3 mb.last_fcode = 3
mb.last_reg == 0x300e mb.last_reg = 0x300e
mb.last_len == 2 mb.last_len = 2
# check not matching response, with wrong server addr # check not matching response, with wrong server addr
call = 0 call = 0
@@ -173,7 +177,7 @@ def test_parse_resp():
assert mb.req_pend assert mb.req_pend
call = 0 call = 0
exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] exp_result = ['V0.0.2C', 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'): 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': if key == 'grid':
assert update == True assert update == True
@@ -222,7 +226,7 @@ def test_queue2():
assert mb.send_calls == 1 assert mb.send_calls == 1
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
call = 0 call = 0
exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] exp_result = ['V0.0.2C', 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'): 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': if key == 'grid':
assert update == True assert update == True
@@ -242,7 +246,7 @@ def test_queue2():
assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' 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'): for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
pass pass # call generator mb.recv_resp()
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
assert mb.send_calls == 3 assert mb.send_calls == 3
@@ -272,7 +276,7 @@ def test_queue3():
assert mb.recv_responses == 0 assert mb.recv_responses == 0
call = 0 call = 0
exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] exp_result = ['V0.0.2C', 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'): 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': if key == 'grid':
assert update == True assert update == True
@@ -293,7 +297,7 @@ def test_queue3():
assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' 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'): for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
pass pass # no code in loop is OK; calling the generator is the purpose
assert 0 == mb.err assert 0 == mb.err
assert mb.recv_responses == 2 assert mb.recv_responses == 2
@@ -359,8 +363,6 @@ async def test_timeout():
assert mb.retry_cnt == 0 assert mb.retry_cnt == 0
assert mb.send_calls == 4 assert mb.send_calls == 4
# assert mb.counter == {}
def test_recv_unknown_data(): def test_recv_unknown_data():
'''Receive a response with an unknwon register''' '''Receive a response with an unknwon register'''
mb = ModbusTestHelper() mb = ModbusTestHelper()
@@ -378,3 +380,16 @@ def test_recv_unknown_data():
assert not mb.req_pend assert not mb.req_pend
del mb.map[0x9000] del mb.map[0x9000]
def test_close():
'''Check queue handling for build_msg() calls'''
mb = ModbusTestHelper()
mb.build_msg(1,3,0x3007,6)
mb.build_msg(1,6,0x2008,4)
assert mb.que.qsize() == 1
mb.build_msg(1,3,0x3007,6)
assert mb.que.qsize() == 2
assert mb.que.empty() == False
mb.close()
assert mb.que.qsize() == 0
assert mb.que.empty() == True

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.4 KiB

View File

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

View File

@@ -1,8 +1,6 @@
# test_with_pytest.py and scapy # test_with_pytest.py and scapy
# #
import pytest, socket, time import pytest, socket, time
#from scapy.all import *
#from scapy.layers.inet import IP, TCP, TCP_client
def get_sn() -> bytes: def get_sn() -> bytes:
return b'R170000000000001' return b'R170000000000001'
@@ -120,9 +118,7 @@ def MsgOtaUpdateReq(): # Over the air update request from talent cloud
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def ClientConnection(): def ClientConnection():
#host = '172.16.30.7'
host = 'logger.talent-monitoring.com' host = 'logger.talent-monitoring.com'
#host = '127.0.0.1'
port = 5005 port = 5005
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port)) s.connect((host, port))
@@ -132,9 +128,7 @@ def ClientConnection():
s.close() s.close()
def tempClientConnection(): def tempClientConnection():
#host = '172.16.30.7'
host = 'logger.talent-monitoring.com' host = 'logger.talent-monitoring.com'
#host = '127.0.0.1'
port = 5005 port = 5005
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port)) s.connect((host, port))
@@ -148,7 +142,6 @@ def test_open_close():
pass pass
except: except:
assert False assert False
assert True
def test_send_contact_info1(ClientConnection, MsgContactInfo, MsgContactResp): def test_send_contact_info1(ClientConnection, MsgContactInfo, MsgContactResp):
s = ClientConnection s = ClientConnection
@@ -166,7 +159,7 @@ def test_send_contact_info2(ClientConnection, MsgContactInfo2, MsgContactInfo, M
s.sendall(MsgContactInfo2) s.sendall(MsgContactInfo2)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
assert True pass
else: else:
assert False assert False
@@ -198,7 +191,7 @@ def test_send_contact_resp(ClientConnection, MsgContactResp):
s.sendall(MsgContactResp) s.sendall(MsgContactResp)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
assert True pass
else: else:
assert data == b'' assert data == b''

View File

@@ -3,9 +3,6 @@
import pytest, socket, time, os import pytest, socket, time, os
from dotenv import load_dotenv from dotenv import load_dotenv
#from scapy.all import *
#from scapy.layers.inet import IP, TCP, TCP_client
load_dotenv() load_dotenv()
SOLARMAN_SNR = os.getenv('SOLARMAN_SNR', '00000080') SOLARMAN_SNR = os.getenv('SOLARMAN_SNR', '00000080')
@@ -111,10 +108,7 @@ def MsgInvalidInfo(): # Contact Info message wrong start byte
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def ClientConnection(): def ClientConnection():
#host = '172.16.30.7'
host = 'logger.talent-monitoring.com' host = 'logger.talent-monitoring.com'
#host = 'iot.talent-monitoring.com'
#host = '127.0.0.1'
port = 10000 port = 10000
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port)) s.connect((host, port))
@@ -131,10 +125,7 @@ def checkResponse(data, Msg):
def tempClientConnection(): def tempClientConnection():
#host = '172.16.30.7'
host = 'logger.talent-monitoring.com' host = 'logger.talent-monitoring.com'
#host = 'iot.talent-monitoring.com'
#host = '127.0.0.1'
port = 10000 port = 10000
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port)) s.connect((host, port))
@@ -145,11 +136,10 @@ def tempClientConnection():
def test_open_close(): def test_open_close():
try: try:
for s in tempClientConnection(): for _ in tempClientConnection():
pass pass # test generator tempClientConnection()
except: except:
assert False assert False
assert True
def test_conn_msg(ClientConnection,MsgContactInfo, MsgContactResp): def test_conn_msg(ClientConnection,MsgContactInfo, MsgContactResp):
s = ClientConnection s = ClientConnection