Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b364fb3f8e | ||
|
|
a42ba8a8c6 | ||
|
|
f3e69ff217 | ||
|
|
a3c054d2b1 | ||
|
|
c34b33ed5f | ||
|
|
0a18918326 | ||
|
|
aa3bb4a1fa | ||
|
|
a62864218d | ||
|
|
0b2631c162 | ||
|
|
c59bd16664 | ||
|
|
039a021cda | ||
|
|
49e2dfbd86 | ||
|
|
e6ecf5911b | ||
|
|
6e1ed5d1e7 | ||
|
|
ad885e9644 | ||
|
|
8f81ceda98 | ||
|
|
8204cae2b1 | ||
|
|
8baa68e615 | ||
|
|
56f36e9f3f | ||
|
|
5b60d5dae1 | ||
|
|
c1c38ab5c7 | ||
|
|
ec4261ae84 | ||
|
|
be57d11214 | ||
|
|
685c2dc07b | ||
|
|
d27fe09006 | ||
|
|
e850a8c534 | ||
|
|
33f215def2 | ||
|
|
4be726166e | ||
|
|
20f4fd647c | ||
|
|
407c1ceb2b | ||
|
|
c6eecb4791 | ||
|
|
87d59d046f | ||
|
|
063850c7fb | ||
|
|
17c33601a0 | ||
|
|
3980ac013b | ||
|
|
66657888dd | ||
|
|
ab9e798152 | ||
|
|
fdf3475909 | ||
|
|
edc2c12b5b | ||
|
|
5c6f9e7414 | ||
|
|
0fc74b0d19 | ||
|
|
87cc3fb205 | ||
|
|
8fc5eb3670 | ||
|
|
55fc834a1e | ||
|
|
da2388941e | ||
|
|
9e38cb93ea | ||
|
|
de1c48fa62 | ||
|
|
e432441134 | ||
|
|
98ef252bb0 | ||
|
|
25e3db36c4 | ||
|
|
3ac48dad1f | ||
|
|
eff3e7558b | ||
|
|
6ef6f4cd34 | ||
|
|
177706c3e6 | ||
|
|
9ac1f6f46d | ||
|
|
3cc5f3ec53 | ||
|
|
23ff2bb05c | ||
|
|
c761446c11 | ||
|
|
f30aa07431 | ||
|
|
476c5f0006 | ||
|
|
282a459ef0 | ||
|
|
d25173e591 | ||
|
|
9c39ea27f7 | ||
|
|
766774224b | ||
|
|
f4da16987f | ||
|
|
841877305d | ||
|
|
fb5c6a74cf | ||
|
|
14425da5fa | ||
|
|
6877465915 | ||
|
|
2e214b1e71 | ||
|
|
036af8e127 | ||
|
|
92469456b7 | ||
|
|
1658036a26 | ||
|
|
1ae7784bee | ||
|
|
e43a02c508 | ||
|
|
4ea70dee64 | ||
|
|
6fcf4f47c2 | ||
|
|
73baffe9e0 | ||
|
|
3fda08bd25 | ||
|
|
0e7fbc7820 | ||
|
|
26f108cc51 | ||
|
|
dd438bf201 | ||
|
|
f48596a512 | ||
|
|
6a64484174 | ||
|
|
def5702415 | ||
|
|
b3f0fc97d7 | ||
|
|
65973b2835 | ||
|
|
b240b74994 | ||
|
|
93e82a2284 | ||
|
|
537d81fa19 | ||
|
|
5fe455e42f | ||
|
|
5a0456650f | ||
|
|
41d9a2a1ef | ||
|
|
a869ead89a | ||
|
|
91873d0c34 | ||
|
|
c4b3e1a817 | ||
|
|
0ac4b1f571 | ||
|
|
2ec0a59cd3 | ||
|
|
2d176894d3 | ||
|
|
0ae6dffc6b | ||
|
|
5fc1b16627 | ||
|
|
eab109ddab | ||
|
|
1b6bee12de | ||
|
|
2301511242 | ||
|
|
3fd528bdbe | ||
|
|
e15387b1ff | ||
|
|
02d9f01947 | ||
|
|
39beb0cb44 | ||
|
|
d5010fe053 | ||
|
|
54d2bf4439 | ||
|
|
f804b755a4 | ||
|
|
bf0f152d5a | ||
|
|
29ee540a19 | ||
|
|
5822f5de50 | ||
|
|
283ae31af2 | ||
|
|
808bf2fe87 | ||
|
|
fa2626ec7a | ||
|
|
eda8ef1db6 | ||
|
|
3dbcee63f6 | ||
|
|
f2c4230a49 | ||
|
|
763af8b4cf | ||
|
|
a2f67e7d3e | ||
|
|
f78d4ac310 | ||
|
|
fdedfcbf8e | ||
|
|
494c30e489 | ||
|
|
30dc802fb2 | ||
|
|
5fdad484f4 | ||
|
|
dba3b458ba | ||
|
|
1d9cbf314e | ||
|
|
58c3333fcc | ||
|
|
530687039d | ||
|
|
5d0c95d6e6 | ||
|
|
e603bb9baa | ||
|
|
e8902f7923 | ||
|
|
b1e577d357 | ||
|
|
4e8fd8e2a2 | ||
|
|
d34862260e | ||
|
|
c061d263eb | ||
|
|
ccc7e7959e | ||
|
|
7b4ed406a1 | ||
|
|
549fca8ae5 | ||
|
|
f73376b330 | ||
|
|
220f2cce18 | ||
|
|
e2a5c7e640 | ||
|
|
2e64ae5884 | ||
|
|
95ebb92f05 | ||
|
|
59dabbfa4a | ||
|
|
aa0d432149 | ||
|
|
6dbf259e44 |
@@ -1,2 +1,3 @@
|
||||
[run]
|
||||
branch = True
|
||||
branch = True
|
||||
relative_files = True
|
||||
36
.github/workflows/python-app.yml
vendored
36
.github/workflows/python-app.yml
vendored
@@ -5,7 +5,7 @@ name: Python application
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "dev-*" ]
|
||||
branches: [ "main", "dev-*", "*/issue*" ]
|
||||
paths-ignore:
|
||||
- '**.md' # Do no build on *.md changes
|
||||
- '**.yml' # Do no build on *.yml changes
|
||||
@@ -18,10 +18,11 @@ on:
|
||||
- '**.dockerfile' # Do no build on *.dockerfile changes
|
||||
- '**.sh' # Do no build on *.sh changes
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: [ "main", "dev-*" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # allows SonarCloud to decorate PRs with analysis results
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -29,15 +30,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: "Europe/Berlin"
|
||||
timezoneMacos: "Europe/Berlin"
|
||||
timezoneWindows: "Europe/Berlin"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for all tags and branches
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
pip install flake8 pytest pytest-asyncio
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
@@ -47,4 +56,19 @@ jobs:
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Test with pytest
|
||||
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
|
||||
|
||||
4
.sonarlint/connectedMode.json
Normal file
4
.sonarlint/connectedMode.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sonarCloudOrganization": "s-allius",
|
||||
"projectKey": "s-allius_tsun-gen3-proxy"
|
||||
}
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Aktuelle Datei",
|
||||
"type": "python",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal",
|
||||
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -11,5 +11,9 @@
|
||||
"python.testing.pytestEnabled": true,
|
||||
"flake8.args": [
|
||||
"--extend-exclude=app/tests/*.py system_tests/*.py"
|
||||
]
|
||||
],
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "s-allius",
|
||||
"projectKey": "s-allius_tsun-gen3-proxy"
|
||||
}
|
||||
}
|
||||
83
CHANGELOG.md
83
CHANGELOG.md
@@ -5,7 +5,88 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- fix exception in MODBUS timeout callback
|
||||
|
||||
## [0.9.0-RC1] - 2024-06-29
|
||||
|
||||
- add asyncio log and debug mode
|
||||
- stop the HTTP server on shutdown gracefully
|
||||
- Synchronize regular MODBUS commands with the status of the inverter to prevent the inverter from crashing due to
|
||||
unexpected packets. [#111](https://github.com/s-allius/tsun-gen3-proxy/issues/111)
|
||||
- GEN3: avoid sending MODBUS commands to the inverter during the inverter's reporting phase
|
||||
- GEN3: determine the connection timeout based on the connection state
|
||||
- GEN3: support more data encodings for DSP version V5.0.17 [#108](https://github.com/s-allius/tsun-gen3-proxy/issues/108)
|
||||
- detect dead connections [#100](https://github.com/s-allius/tsun-gen3-proxy/issues/100)
|
||||
- improve connection logging wirt a unique connection id
|
||||
- Add healthcheck, readiness and liveness checks [#91](https://github.com/s-allius/tsun-gen3-proxy/issues/91)
|
||||
- MODBUS close handler releases internal resource [#93](https://github.com/s-allius/tsun-gen3-proxy/issues/93)
|
||||
- add exception handling for message forwarding [#94](https://github.com/s-allius/tsun-gen3-proxy/issues/94)
|
||||
- GEN3: make timestamp handling stateless, to avoid blocking when the TSUN cloud is down [#56](https://github.com/s-allius/tsun-gen3-proxy/issues/56)
|
||||
- GEN3PLUS: dump invalid packages with wrong start or stop byte
|
||||
- label debug imagages als `debug`
|
||||
- print imgae build time during proxy start
|
||||
- add type annotations
|
||||
- improve async unit test and fix pytest warnings
|
||||
- run github tests even for pulls on issue branches
|
||||
|
||||
## [0.8.1] - 2024-06-21
|
||||
|
||||
- Fix MODBUS responses are dropped and not forwarded to the TSUN cloud [#104](https://github.com/s-allius/tsun-gen3-proxy/issues/104)
|
||||
- GEN3: Fix connections losts due MODBUS requests [#102](https://github.com/s-allius/tsun-gen3-proxy/issues/102)
|
||||
|
||||
## [0.8.0] - 2024-06-07
|
||||
|
||||
- improve logging: add protocol or node_id to connection logs
|
||||
- improve logging: log ignored AT+ or MODBUS commands
|
||||
- improve tracelog: log level depends on message type and source
|
||||
- fix typo in docker-compose.yaml and remove the external network definition
|
||||
- trace heartbeat and regular modbus pakets witl log level DEBUG
|
||||
- GEN3PLUS: don't forward ack paket from tsun to the inverter
|
||||
- GEN3PLUS: add allow and block filter for AT+ commands
|
||||
- catch all OSError errors in the read loop
|
||||
- log Modbus traces with different log levels
|
||||
- add Modbus fifo and timeout handler
|
||||
- build version string in the same format as TSUN for GEN3 inverters
|
||||
- add graceful shutdown
|
||||
- parse Modbus values and store them in the database
|
||||
- add cron task to request the output power every minute
|
||||
- GEN3PLUS: add MQTT topics to send AT commands to the inverter
|
||||
- add MQTT topics to send Modbus commands to the inverter
|
||||
- convert data collect interval to minutes
|
||||
- add postfix for rc and dev versions to the version number
|
||||
- change logging level to DEBUG for some logs
|
||||
- remove experimental value Register.VALUE_1
|
||||
- format Register.POWER_ON_TIME as integer
|
||||
- ignore catch-up values from the inverters for now
|
||||
|
||||
## [0.7.0] - 2024-04-20
|
||||
|
||||
- GEN3PLUS: fix temperature values
|
||||
- GEN3PLUS: read corect firmware and logger version
|
||||
|
||||
48
README.md
48
README.md
@@ -7,7 +7,7 @@
|
||||
<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://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://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>
|
||||
@@ -39,12 +39,17 @@ If you use a Pi-hole, you can also store the host entry in the Pi-hole.
|
||||
|
||||
## Features
|
||||
|
||||
- supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600
|
||||
- supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300
|
||||
- Supports TSUN GEN3 PLUS inverters: TSOL-MS2000, MS1800 and MS1600
|
||||
- Supports TSUN GEN3 inverters: TSOL-MS800, MS700, MS600, MS400, MS350 and MS300
|
||||
- `MQTT` support
|
||||
- `Home-Assistant` auto-discovery support
|
||||
- `MODBUS` support via MQTT topics
|
||||
- `AT-Command` support via MQTT topics (GEN3PLUS only)
|
||||
- Faster DataUp interval sends measurement data to the MQTT broker every minute
|
||||
- Self-sufficient island operation without internet
|
||||
- runs in a non-root Docker Container
|
||||
- Security-Features:
|
||||
- control access via `AT-commands`
|
||||
- Runs in a non-root Docker Container
|
||||
|
||||
## Home Assistant Screenshots
|
||||
|
||||
@@ -156,15 +161,24 @@ suggested_area = 'balcony' # Optional, suggested installation area for home-a
|
||||
pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
|
||||
[inverters."Y17xxxxxxxxxxxx1"]
|
||||
[inverters."Y17xxxxxxxxxxxx1"] # This block is also for inverters with a Y47 serial no
|
||||
monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter
|
||||
node_id = 'inv_3' # MQTT replacement for inverters serial number
|
||||
suggested_area = 'garage' # suggested installation place for home-assistant
|
||||
# if your inverter supports SSL connections you must use the client_mode. Pls, uncomment
|
||||
# the next line and configure the fixed IP of your inverter
|
||||
#client_mode = {host = '192.168.0.1', port = 8899}
|
||||
pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
|
||||
[gen3plus.at_acl]
|
||||
tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'] # allow this for TSUN access
|
||||
tsun.block = []
|
||||
mqtt.allow = ['AT+'] # allow all via mqtt
|
||||
mqtt.block = []
|
||||
|
||||
```
|
||||
|
||||
## Inverter Configuration
|
||||
@@ -177,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.
|
||||
|
||||
❗If the IP port is set to 10443 in the inverter configuration, you probably have a firmware with SSL support. In this case, you must use the client-mode configuration.
|
||||
|
||||
If access to the web interface does not work, it can also be redirected via DNS redirection, as is necessary for the GEN3 inverters.
|
||||
|
||||
## Client Mode (GEN3PLUS only)
|
||||
|
||||
Newer GEN3PLUS inverters support SSL encrypted connections over port 10443 to the TSUN cloud. In this case you can't loop the proxy into this connection, since the certicate verification of the inverter don't allow this. You can configure the proxy in client-mode to establish an unencrypted connection to the inverter. For this porpuse the inverter listen on port `8899`.
|
||||
|
||||
There are some requirements to be met:
|
||||
|
||||
- the inverter should have a fixed IP
|
||||
- the proxy must be able to reach the inverter. You must configure a corresponding route in your router if the inverter and the proxy are in different IP networks
|
||||
- add a 'client_mode' line to your config.toml file, to specify the inverter's ip address
|
||||
|
||||
## DNS Settings
|
||||
|
||||
### Loop the proxy into the connection
|
||||
@@ -215,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.
|
||||
|
||||
<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. 1.1.00.0B</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 (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 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>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><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><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><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>
|
||||
|
||||
```txt
|
||||
@@ -230,7 +256,7 @@ Legend
|
||||
🚧: Proxy support in preparation
|
||||
```
|
||||
|
||||
❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` instead of `R17E`
|
||||
❗The new inverters of the GEN3 Plus generation (e.g. MS-2000) use a completely different protocol for data transmission to the TSUN server. These inverters are supported from proxy version 0.6. The serial numbers of these inverters start with `Y17E` or `Y47E` instead of `R17E`
|
||||
|
||||
If you have one of these combinations with a red question mark, it would be very nice if you could send me a proxy trace so that I can carry out the detailed checks and adjust the device and system tests. [Ask here how to send a trace](https://github.com/s-allius/tsun-gen3-proxy/discussions/categories/traces-for-compatibility-check)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ RUN apk upgrade --no-cache && \
|
||||
|
||||
#
|
||||
# second stage for building wheels packages
|
||||
FROM base as builder
|
||||
FROM base AS builder
|
||||
|
||||
# copy the dependencies file to the root dir and install requirements
|
||||
COPY ./requirements.txt /root/
|
||||
@@ -26,7 +26,7 @@ RUN apk add --no-cache build-base && \
|
||||
|
||||
#
|
||||
# third stage for our runtime image
|
||||
FROM base as runtime
|
||||
FROM base AS runtime
|
||||
ARG SERVICE_NAME
|
||||
ARG VERSION
|
||||
ARG UID
|
||||
@@ -63,8 +63,8 @@ RUN python -m pip install --no-cache --no-index /root/wheels/* && \
|
||||
COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh
|
||||
COPY config .
|
||||
COPY src .
|
||||
|
||||
EXPOSE 5005
|
||||
RUN date > /build-date.txt
|
||||
EXPOSE 5005 8127 10000
|
||||
|
||||
# command to run on container start
|
||||
ENTRYPOINT ["/root/entrypoint.sh"]
|
||||
@@ -73,7 +73,7 @@ CMD [ "python3", "./server.py" ]
|
||||
|
||||
LABEL org.opencontainers.image.title="TSUN Gen3 Proxy"
|
||||
LABEL org.opencontainers.image.authors="Stefan Allius"
|
||||
LABEL org.opencontainers.image.source https://github.com/s-allius/tsun-gen3-proxy
|
||||
LABEL org.opencontainers.image.description 'This proxy enables a reliable connection between TSUN third generation inverters (eg. TSOL MS600, MS800, MS2000) and an MQTT broker to integrate the inverter into typical home automations.'
|
||||
LABEL org.opencontainers.image.source=https://github.com/s-allius/tsun-gen3-proxy
|
||||
LABEL org.opencontainers.image.description='This proxy enables a reliable connection between TSUN third generation inverters (eg. TSOL MS600, MS800, MS2000) and an MQTT broker to integrate the inverter into typical home automations.'
|
||||
LABEL org.opencontainers.image.licenses="BSD-3-Clause"
|
||||
LABEL org.opencontainers.image.vendor="Stefan Allius"
|
||||
|
||||
58
app/build.sh
58
app/build.sh
@@ -4,7 +4,7 @@
|
||||
# rc: release candidate build
|
||||
# rel: release build and push to ghcr.io
|
||||
# Note: for release build, you need to set GHCR_TOKEN
|
||||
# export GHCR_TOKEN=<YOUR_GITHUB_TOKEN>
|
||||
# export GHCR_TOKEN=<YOUR_GITHUB_TOKEN> in your .zprofile
|
||||
# see also: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry
|
||||
|
||||
|
||||
@@ -17,28 +17,56 @@ VERSION="${VERSION:1}"
|
||||
arr=(${VERSION//./ })
|
||||
MAJOR=${arr[0]}
|
||||
IMAGE=tsun-gen3-proxy
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
if [[ $1 == dev ]] || [[ $1 == rc ]] ;then
|
||||
if [[ $1 == debug ]] || [[ $1 == dev ]] ;then
|
||||
IMAGE=docker.io/sallius/${IMAGE}
|
||||
VERSION=${VERSION}
|
||||
elif [[ $1 == rel ]];then
|
||||
VERSION=${VERSION}+$1
|
||||
elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then
|
||||
IMAGE=ghcr.io/s-allius/${IMAGE}
|
||||
else
|
||||
echo argument missing!
|
||||
echo try: $0 '[dev|rc|rel]'
|
||||
echo try: $0 '[debug|dev|preview|rc|rel]'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $1 == debug ]] ;then
|
||||
BUILD_ENV="dev"
|
||||
else
|
||||
BUILD_ENV="production"
|
||||
fi
|
||||
|
||||
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"
|
||||
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 == dev ]];then
|
||||
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest app
|
||||
elif [[ $1 == rc ]];then
|
||||
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest app
|
||||
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
|
||||
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 push ghcr.io/s-allius/tsun-gen3-proxy:latest
|
||||
docker push ghcr.io/s-allius/tsun-gen3-proxy:${MAJOR}
|
||||
docker push ghcr.io/s-allius/tsun-gen3-proxy:${VERSION}
|
||||
fi
|
||||
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
|
||||
|
||||
@@ -31,12 +31,14 @@ inverters.allow_all = true # allow inverters, even if we have no inverter mapp
|
||||
[inverters."R170000000000001"]
|
||||
#node_id = '' # Optional, MQTT replacement for inverters serial number
|
||||
#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
|
||||
#pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
|
||||
#[inverters."R17xxxxxxxxxxxx2"]
|
||||
#node_id = '' # Optional, MQTT replacement for inverters serial number
|
||||
#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
|
||||
#pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
|
||||
@@ -44,8 +46,19 @@ inverters.allow_all = true # allow inverters, even if we have no inverter mapp
|
||||
monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter
|
||||
#node_id = '' # Optional, MQTT replacement for inverters serial number
|
||||
#suggested_area = '' # Optional, suggested installation place for home-assistant
|
||||
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
|
||||
#pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
#pv3 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
#pv4 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
|
||||
|
||||
[gen3plus.at_acl]
|
||||
tsun.allow = ['AT+Z', 'AT+UPURL', 'AT+SUPDATE']
|
||||
tsun.block = []
|
||||
mqtt.allow = ['AT+']
|
||||
mqtt.block = []
|
||||
|
||||
@@ -5,6 +5,7 @@ user="$(id -u)"
|
||||
echo "######################################################"
|
||||
echo "# prepare: '$SERVICE_NAME' Version:$VERSION"
|
||||
echo "# for running with UserID:$UID, GroupID:$GID"
|
||||
echo "# Image built: $(cat /build-date.txt) "
|
||||
echo "#"
|
||||
|
||||
if [ "$user" = '0' ]; then
|
||||
|
||||
@@ -17,6 +17,5 @@ if [ "$environment" = "production" ] ; then \
|
||||
-name od -o \
|
||||
-name strings -o \
|
||||
-name su -o \
|
||||
-name wget -o \
|
||||
\) -delete \
|
||||
; fi
|
||||
|
||||
634
app/proxy.svg
634
app/proxy.svg
@@ -4,340 +4,408 @@
|
||||
<!-- Generated by graphviz version 2.40.1 (20161225.0304)
|
||||
-->
|
||||
<!-- Title: G Pages: 1 -->
|
||||
<svg width="511pt" height="1204pt"
|
||||
viewBox="0.00 0.00 511.39 1204.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 1200)">
|
||||
<svg width="720pt" height="1360pt"
|
||||
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 1356)">
|
||||
<title>G</title>
|
||||
<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-1200 507.3928,-1200 507.3928,4 -4,4"/>
|
||||
<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-1356 715.5,-1356 715.5,4 -4,4"/>
|
||||
<!-- A0 -->
|
||||
<g id="node1" class="node">
|
||||
<title>A0</title>
|
||||
<polygon fill="#fff8dc" stroke="#000000" points="148.1964,-1100 39.8036,-1100 39.8036,-1064 154.1964,-1064 154.1964,-1094 148.1964,-1100"/>
|
||||
<polyline fill="none" stroke="#000000" points="148.1964,-1100 148.1964,-1094 "/>
|
||||
<polyline fill="none" stroke="#000000" points="154.1964,-1094 148.1964,-1094 "/>
|
||||
<text text-anchor="middle" x="97" y="-1085" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">You can stick notes</text>
|
||||
<text text-anchor="middle" x="97" y="-1073" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">on diagrams too!</text>
|
||||
<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="153.6964,-1250 153.6964,-1244 "/>
|
||||
<polyline fill="none" stroke="#000000" points="159.6964,-1244 153.6964,-1244 "/>
|
||||
<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="102.5" y="-1223" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">on diagrams too!</text>
|
||||
</g>
|
||||
<!-- A1 -->
|
||||
<g id="node2" class="node">
|
||||
<title>A1</title>
|
||||
<polygon fill="none" stroke="#000000" points="95.6817,-804 26.3183,-804 26.3183,-768 95.6817,-768 95.6817,-804"/>
|
||||
<text text-anchor="middle" x="61" y="-783" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Singleton</text>
|
||||
<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="650.5" y="-921" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Singleton</text>
|
||||
</g>
|
||||
<!-- A2 -->
|
||||
<g id="node3" class="node">
|
||||
<title>A2</title>
|
||||
<polygon fill="none" stroke="#000000" points="0,-518 0,-550 122,-550 122,-518 0,-518"/>
|
||||
<text text-anchor="start" x="51.277" y="-531" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Mqtt</text>
|
||||
<polygon fill="none" stroke="#000000" points="0,-462 0,-518 122,-518 122,-462 0,-462"/>
|
||||
<text text-anchor="start" x="18.4875" y="-499" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><static>ha_restarts</text>
|
||||
<text text-anchor="start" x="26.2665" y="-487" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><static>__client</text>
|
||||
<text text-anchor="start" x="9.8735" y="-475" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><static>__cb_MqttIsUp</text>
|
||||
<polygon fill="none" stroke="#000000" points="0,-418 0,-462 122,-462 122,-418 0,-418"/>
|
||||
<text text-anchor="start" x="22.936" y="-443" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>publish()</text>
|
||||
<text text-anchor="start" x="27.1045" y="-431" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>close()</text>
|
||||
<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="640.777" y="-657" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Mqtt</text>
|
||||
<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="607.9875" y="-625" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><static>ha_restarts</text>
|
||||
<text text-anchor="start" x="615.7665" y="-613" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><static>__client</text>
|
||||
<text text-anchor="start" x="599.3735" y="-601" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><static>__cb_MqttIsUp</text>
|
||||
<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="612.436" y="-569" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>publish()</text>
|
||||
<text text-anchor="start" x="616.6045" y="-557" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>close()</text>
|
||||
</g>
|
||||
<!-- A1->A2 -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>A1->A2</title>
|
||||
<path fill="none" stroke="#000000" d="M61,-757.4632C61,-710.3291 61,-615.0013 61,-550.3153"/>
|
||||
<polygon fill="none" stroke="#000000" points="57.5001,-757.5631 61,-767.5632 64.5001,-757.5632 57.5001,-757.5631"/>
|
||||
</g>
|
||||
<!-- A10 -->
|
||||
<g id="node11" class="node">
|
||||
<title>A10</title>
|
||||
<polygon fill="none" stroke="#000000" points="7,-282 7,-314 115,-314 115,-282 7,-282"/>
|
||||
<text text-anchor="start" x="44.0535" y="-295" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Inverter</text>
|
||||
<polygon fill="none" stroke="#000000" points="7,-190 7,-282 115,-282 115,-190 7,-190"/>
|
||||
<text text-anchor="start" x="37.104" y="-263" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.db_stat</text>
|
||||
<text text-anchor="start" x="30.4405" y="-251" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.entity_prfx</text>
|
||||
<text text-anchor="start" x="21.2755" y="-239" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.discovery_prfx</text>
|
||||
<text text-anchor="start" x="20.7115" y="-227" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.proxy_node_id</text>
|
||||
<text text-anchor="start" x="16.8225" y="-215" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.proxy_unique_id</text>
|
||||
<text text-anchor="start" x="32.6655" y="-203" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.mqtt:Mqtt</text>
|
||||
<polygon fill="none" stroke="#000000" points="7,-170 7,-190 115,-190 115,-170 7,-170"/>
|
||||
</g>
|
||||
<!-- A2->A10 -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>A2->A10</title>
|
||||
<path fill="none" stroke="#000000" d="M61,-417.8724C61,-385.8251 61,-347.2624 61,-314.4235"/>
|
||||
</g>
|
||||
<!-- A3 -->
|
||||
<g id="node4" class="node">
|
||||
<title>A3</title>
|
||||
<polygon fill="none" stroke="#000000" points="173,-1092 173,-1124 244,-1124 244,-1092 173,-1092"/>
|
||||
<text text-anchor="start" x="182.945" y="-1105" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">IterRegistry</text>
|
||||
<polygon fill="none" stroke="#000000" points="173,-1072 173,-1092 244,-1092 244,-1072 173,-1072"/>
|
||||
<polygon fill="none" stroke="#000000" points="173,-1040 173,-1072 244,-1072 244,-1040 173,-1040"/>
|
||||
<text text-anchor="start" x="190.439" y="-1053" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__iter__</text>
|
||||
</g>
|
||||
<!-- A4 -->
|
||||
<g id="node5" class="node">
|
||||
<title>A4</title>
|
||||
<polygon fill="none" stroke="#000000" points="141,-886 141,-918 275,-918 275,-886 141,-886"/>
|
||||
<text text-anchor="start" x="187.7175" y="-899" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Message</text>
|
||||
<polygon fill="none" stroke="#000000" points="141,-722 141,-886 275,-886 275,-722 141,-722"/>
|
||||
<text text-anchor="start" x="171.3265" y="-867" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">server_side:bool</text>
|
||||
<text text-anchor="start" x="168.543" y="-855" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">header_valid:bool</text>
|
||||
<text text-anchor="start" x="161.314" y="-843" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">header_len:unsigned</text>
|
||||
<text text-anchor="start" x="167.148" y="-831" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">data_len:unsigned</text>
|
||||
<text text-anchor="start" x="186.3245" y="-819" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">unique_id</text>
|
||||
<text text-anchor="start" x="190.2135" y="-807" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">node_id</text>
|
||||
<text text-anchor="start" x="187.1585" y="-795" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">sug_area</text>
|
||||
<text text-anchor="start" x="157.989" y="-783" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_recv_buffer:bytearray</text>
|
||||
<text text-anchor="start" x="156.5945" y="-771" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_send_buffer:bytearray</text>
|
||||
<text text-anchor="start" x="150.7665" y="-759" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_forward_buffer:bytearray</text>
|
||||
<text text-anchor="start" x="190.2135" y="-747" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:Infos</text>
|
||||
<text text-anchor="start" x="178.826" y="-735" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">new_data:list</text>
|
||||
<polygon fill="none" stroke="#000000" points="141,-654 141,-722 275,-722 275,-654 141,-654"/>
|
||||
<text text-anchor="start" x="157.7095" y="-703" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_read():void<abstract></text>
|
||||
<text text-anchor="start" x="182.4445" y="-691" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close():void</text>
|
||||
<text text-anchor="start" x="168.2725" y="-679" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_counter():void</text>
|
||||
<text text-anchor="start" x="166.6025" y="-667" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dec_counter():void</text>
|
||||
</g>
|
||||
<!-- A3->A4 -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>A3->A4</title>
|
||||
<path fill="none" stroke="#000000" d="M208,-1029.7414C208,-998.6043 208,-957.5621 208,-918.0536"/>
|
||||
<polygon fill="none" stroke="#000000" points="204.5001,-1029.9047 208,-1039.9048 211.5001,-1029.9048 204.5001,-1029.9047"/>
|
||||
</g>
|
||||
<!-- A5 -->
|
||||
<g id="node6" class="node">
|
||||
<title>A5</title>
|
||||
<polygon fill="none" stroke="#000000" points="145,-566 145,-598 259,-598 259,-566 145,-566"/>
|
||||
<text text-anchor="start" x="188.108" y="-579" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Talent</text>
|
||||
<polygon fill="none" stroke="#000000" points="145,-486 145,-566 259,-566 259,-486 145,-486"/>
|
||||
<text text-anchor="start" x="154.763" y="-547" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">await_conn_resp_cnt</text>
|
||||
<text text-anchor="start" x="189.7775" y="-535" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">id_str</text>
|
||||
<text text-anchor="start" x="170.6" y="-523" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">contact_name</text>
|
||||
<text text-anchor="start" x="173.94" y="-511" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">contact_mail</text>
|
||||
<text text-anchor="start" x="188.112" y="-499" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">switch</text>
|
||||
<polygon fill="none" stroke="#000000" points="145,-370 145,-486 259,-486 259,-370 145,-370"/>
|
||||
<text text-anchor="start" x="159.4925" y="-467" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_contact_info()</text>
|
||||
<text text-anchor="start" x="161.4325" y="-455" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_ota_update()</text>
|
||||
<text text-anchor="start" x="167.2765" y="-443" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_get_time()</text>
|
||||
<text text-anchor="start" x="155.3285" y="-431" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_collector_data()</text>
|
||||
<text text-anchor="start" x="157.2735" y="-419" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_inverter_data()</text>
|
||||
<text text-anchor="start" x="166.4405" y="-407" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_unknown()</text>
|
||||
<text text-anchor="start" x="187.0025" y="-383" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
</g>
|
||||
<!-- A4->A5 -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>A4->A5</title>
|
||||
<path fill="none" stroke="#000000" d="M205.1775,-643.9363C204.8732,-628.6188 204.5665,-613.1783 204.2698,-598.2481"/>
|
||||
<polygon fill="none" stroke="#000000" points="201.679,-644.0493 205.377,-653.9777 208.6776,-643.9102 201.679,-644.0493"/>
|
||||
</g>
|
||||
<!-- A6 -->
|
||||
<g id="node7" class="node">
|
||||
<title>A6</title>
|
||||
<polygon fill="none" stroke="#000000" points="412,-530 412,-562 503,-562 503,-530 412,-530"/>
|
||||
<text text-anchor="start" x="429.995" y="-543" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">SolarmanV5</text>
|
||||
<polygon fill="none" stroke="#000000" points="412,-462 412,-530 503,-530 503,-462 412,-462"/>
|
||||
<text text-anchor="start" x="442.498" y="-511" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">control</text>
|
||||
<text text-anchor="start" x="445.5575" y="-499" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">serial</text>
|
||||
<text text-anchor="start" x="450.556" y="-487" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">snr</text>
|
||||
<text text-anchor="start" x="443.612" y="-475" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">switch</text>
|
||||
<polygon fill="none" stroke="#000000" points="412,-406 412,-462 503,-462 503,-406 412,-406"/>
|
||||
<text text-anchor="start" x="421.9405" y="-443" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_unknown()</text>
|
||||
<text text-anchor="start" x="442.5025" y="-419" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
</g>
|
||||
<!-- A4->A6 -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>A4->A6</title>
|
||||
<path fill="none" stroke="#000000" d="M281.898,-681.5056C290.821,-671.6784 300.2479,-662.304 310,-654 345.4324,-623.8293 370.2318,-638.0075 402,-604 413.2639,-591.9422 422.5424,-577.1747 430.0614,-562.1755"/>
|
||||
<polygon fill="none" stroke="#000000" points="279.184,-679.2912 275.1758,-689.0986 284.4251,-683.9313 279.184,-679.2912"/>
|
||||
</g>
|
||||
<!-- A7 -->
|
||||
<g id="node8" class="node">
|
||||
<title>A7</title>
|
||||
<polygon fill="none" stroke="#000000" points="133,-258 133,-290 283,-290 283,-258 133,-258"/>
|
||||
<text text-anchor="start" x="176.0455" y="-271" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3</text>
|
||||
<polygon fill="none" stroke="#000000" points="133,-226 133,-258 283,-258 283,-226 133,-226"/>
|
||||
<text text-anchor="start" x="142.987" y="-239" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remoteStream:ConnectionG3</text>
|
||||
<polygon fill="none" stroke="#000000" points="133,-194 133,-226 283,-226 283,-194 133,-194"/>
|
||||
<text text-anchor="start" x="193.0025" y="-207" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
</g>
|
||||
<!-- A5->A7 -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>A5->A7</title>
|
||||
<path fill="none" stroke="#000000" d="M205.0858,-359.5407C205.6971,-334.8843 206.3036,-310.4196 206.8038,-290.2462"/>
|
||||
<polygon fill="none" stroke="#000000" points="201.5821,-359.6485 204.8331,-369.7323 208.58,-359.8221 201.5821,-359.6485"/>
|
||||
</g>
|
||||
<!-- A8 -->
|
||||
<g id="node9" class="node">
|
||||
<title>A8</title>
|
||||
<polygon fill="none" stroke="#000000" points="319,-258 319,-290 475,-290 475,-258 319,-258"/>
|
||||
<text text-anchor="start" x="361.711" y="-271" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3P</text>
|
||||
<polygon fill="none" stroke="#000000" points="319,-226 319,-258 475,-258 475,-226 319,-226"/>
|
||||
<text text-anchor="start" x="328.6525" y="-239" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remoteStream:ConnectionG3P</text>
|
||||
<polygon fill="none" stroke="#000000" points="319,-194 319,-226 475,-226 475,-194 319,-194"/>
|
||||
<text text-anchor="start" x="382.0025" y="-207" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
</g>
|
||||
<!-- A6->A8 -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>A6->A8</title>
|
||||
<path fill="none" stroke="#000000" d="M435.1335,-395.8051C426.2731,-360.0682 416.3888,-320.2015 408.9039,-290.0125"/>
|
||||
<polygon fill="none" stroke="#000000" points="431.7989,-396.8999 437.6026,-405.7637 438.5932,-395.2153 431.7989,-396.8999"/>
|
||||
</g>
|
||||
<!-- A7->A7 -->
|
||||
<g id="edge13" class="edge">
|
||||
<title>A7->A7</title>
|
||||
<path fill="none" stroke="#000000" d="M283.1684,-272.6238C293.8394,-267.6708 301,-257.4629 301,-242 301,-231.1277 297.4599,-222.8533 291.6486,-217.1769"/>
|
||||
<polygon fill="#000000" stroke="#000000" points="283.1684,-211.3762 293.9628,-213.3079 287.2953,-214.1991 291.4222,-217.0221 291.4222,-217.0221 291.4222,-217.0221 287.2953,-214.1991 288.8816,-220.7363 283.1684,-211.3762 283.1684,-211.3762"/>
|
||||
<text text-anchor="middle" x="302.9014" y="-211.6335" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</text>
|
||||
<text text-anchor="middle" x="295.2075" y="-253.6532" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text>
|
||||
<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="647.0001,-895.7608 650.5,-905.7608 654.0001,-895.7608 647.0001,-895.7608"/>
|
||||
</g>
|
||||
<!-- A11 -->
|
||||
<g id="node12" class="node">
|
||||
<title>A11</title>
|
||||
<polygon fill="none" stroke="#000000" points="73,-88 73,-120 195,-120 195,-88 73,-88"/>
|
||||
<text text-anchor="start" x="110.3845" y="-101" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3</text>
|
||||
<polygon fill="none" stroke="#000000" points="73,-56 73,-88 195,-88 195,-56 73,-56"/>
|
||||
<text text-anchor="start" x="103.4355" y="-69" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text>
|
||||
<polygon fill="none" stroke="#000000" points="73,0 73,-56 195,-56 195,0 73,0"/>
|
||||
<text text-anchor="start" x="82.6035" y="-37" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote()</text>
|
||||
<text text-anchor="start" x="119.0025" y="-13" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
<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="633.5535" y="-361" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Inverter</text>
|
||||
<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="626.604" y="-329" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.db_stat</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="610.7755" y="-305" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">cls.discovery_prfx</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="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="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="596.5,-236 596.5,-256 704.5,-256 704.5,-236 596.5,-236"/>
|
||||
</g>
|
||||
<!-- A7->A11 -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>A7->A11</title>
|
||||
<path fill="none" stroke="#000000" d="M184.5103,-184.2281C176.2169,-163.8307 166.8737,-140.8515 158.4988,-120.2539"/>
|
||||
<polygon fill="none" stroke="#000000" points="181.3548,-185.7599 188.3636,-193.7052 187.8393,-183.1233 181.3548,-185.7599"/>
|
||||
<!-- A2->A11 -->
|
||||
<g id="edge13" class="edge">
|
||||
<title>A2->A11</title>
|
||||
<path fill="none" stroke="#000000" d="M650.5,-543.7248C650.5,-495.3688 650.5,-429.8734 650.5,-380.1918"/>
|
||||
</g>
|
||||
<!-- A8->A8 -->
|
||||
<g id="edge15" class="edge">
|
||||
<title>A8->A8</title>
|
||||
<path fill="none" stroke="#000000" d="M475.3471,-272.2739C485.9443,-267.1987 493,-257.1074 493,-242 493,-231.3776 489.5118,-223.2351 483.7569,-217.5725"/>
|
||||
<polygon fill="#000000" stroke="#000000" points="475.3471,-211.7261 486.1266,-213.7393 479.4525,-214.5802 483.5579,-217.4342 483.5579,-217.4342 483.5579,-217.4342 479.4525,-214.5802 480.9893,-221.1291 475.3471,-211.7261 475.3471,-211.7261"/>
|
||||
<text text-anchor="middle" x="495.0548" y="-212.1325" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</text>
|
||||
<text text-anchor="middle" x="487.2174" y="-253.1774" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">has</text>
|
||||
<!-- A3 -->
|
||||
<g id="node4" class="node">
|
||||
<title>A3</title>
|
||||
<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="338.2175" y="-415" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Modbus</text>
|
||||
<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="347.6615" y="-383" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">que</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="329.605" y="-347" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">rsp_handler</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="329.8895" y="-323" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">max_retires</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="349.8915" y="-299" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">err</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="334.879" y="-275" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">req_pend</text>
|
||||
<text text-anchor="start" x="349.3365" y="-263" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">tim</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="329.89" y="-231" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">build_msg()</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>
|
||||
<!-- A12 -->
|
||||
<g id="node13" class="node">
|
||||
<title>A12</title>
|
||||
<polygon fill="none" stroke="#000000" points="274,-88 274,-120 396,-120 396,-88 274,-88"/>
|
||||
<text text-anchor="start" x="308.05" y="-101" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3P</text>
|
||||
<polygon fill="none" stroke="#000000" points="274,-56 274,-88 396,-88 396,-56 274,-56"/>
|
||||
<text text-anchor="start" x="304.4355" y="-69" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text>
|
||||
<polygon fill="none" stroke="#000000" points="274,0 274,-56 396,-56 396,0 274,0"/>
|
||||
<text text-anchor="start" x="283.6035" y="-37" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote()</text>
|
||||
<text text-anchor="start" x="320.0025" y="-13" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
<!-- A4 -->
|
||||
<g id="node5" class="node">
|
||||
<title>A4</title>
|
||||
<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="318.445" y="-1255" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">IterRegistry</text>
|
||||
<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="308.5,-1190 308.5,-1222 379.5,-1222 379.5,-1190 308.5,-1190"/>
|
||||
<text text-anchor="start" x="325.939" y="-1203" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__iter__</text>
|
||||
</g>
|
||||
<!-- A8->A12 -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>A8->A12</title>
|
||||
<path fill="none" stroke="#000000" d="M377.3195,-184.2281C370.3709,-163.8307 362.5428,-140.8515 355.526,-120.2539"/>
|
||||
<polygon fill="none" stroke="#000000" points="374.0102,-185.368 380.5479,-193.7052 380.6363,-183.1107 374.0102,-185.368"/>
|
||||
<!-- A5 -->
|
||||
<g id="node6" class="node">
|
||||
<title>A5</title>
|
||||
<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="323.2175" y="-1043" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Message</text>
|
||||
<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="306.8265" y="-1011" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">server_side: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="296.814" y="-987" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">header_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="321.8245" y="-963" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">unique_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="322.6585" y="-939" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">sug_area</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="292.0945" y="-915" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_send_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="325.7135" y="-891" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">db:Infos</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="332.662" y="-867" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">state</text>
|
||||
<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="293.2095" y="-835" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">_read():void<abstract></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="303.7725" y="-811" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_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>
|
||||
<!-- A4->A5 -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>A4->A5</title>
|
||||
<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="340.0001,-1179.8197 343.5,-1189.8197 347.0001,-1179.8198 340.0001,-1179.8197"/>
|
||||
</g>
|
||||
<!-- A6 -->
|
||||
<g id="node7" class="node">
|
||||
<title>A6</title>
|
||||
<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="458.608" y="-717" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Talent</text>
|
||||
<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="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="460.2775" y="-673" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">id_str</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="444.44" y="-649" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">contact_mail</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="446.384" y="-625" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">mb:Modbus</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="415.5,-484 415.5,-600 529.5,-600 529.5,-484 415.5,-484"/>
|
||||
<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="431.9325" y="-569" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_ota_update()</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="425.8285" y="-545" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_collector_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="436.9405" y="-521" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">msg_unknown()</text>
|
||||
<text text-anchor="start" x="457.5025" y="-497" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
</g>
|
||||
<!-- A5->A6 -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>A5->A6</title>
|
||||
<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="400.8317,-775.2382 400.269,-785.8181 407.3066,-777.8983 400.8317,-775.2382"/>
|
||||
</g>
|
||||
<!-- A7 -->
|
||||
<g id="node8" class="node">
|
||||
<title>A7</title>
|
||||
<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="190.495" y="-681" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">SolarmanV5</text>
|
||||
<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="202.998" y="-649" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">control</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="211.056" y="-625" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">snr</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="191.884" y="-601" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">mb:Modbus</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="172.5,-520 172.5,-576 263.5,-576 263.5,-520 172.5,-520"/>
|
||||
<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="203.0025" y="-533" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
</g>
|
||||
<!-- A5->A7 -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>A5->A7</title>
|
||||
<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="281.0788,-777.8409 288.0512,-785.8181 287.5753,-775.2339 281.0788,-777.8409"/>
|
||||
</g>
|
||||
<!-- A6->A3 -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>A6->A3</title>
|
||||
<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="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="407.3001" y="-422.5743" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">1</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>
|
||||
<!-- A8 -->
|
||||
<g id="node9" class="node">
|
||||
<title>A8</title>
|
||||
<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="453.5455" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3</text>
|
||||
<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="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="410.5,-254 410.5,-298 560.5,-298 560.5,-254 410.5,-254"/>
|
||||
<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>
|
||||
<!-- A6->A8 -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>A6->A8</title>
|
||||
<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="474.8712,-473.5333 477.9378,-483.6747 481.8648,-473.8345 474.8712,-473.5333"/>
|
||||
</g>
|
||||
<!-- A7->A3 -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>A7->A3</title>
|
||||
<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="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="317.8629" y="-431.7687" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">1</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>
|
||||
<!-- A9 -->
|
||||
<g id="node10" class="node">
|
||||
<title>A9</title>
|
||||
<polygon fill="none" stroke="#000000" points="277,-572 277,-604 393,-604 393,-572 277,-572"/>
|
||||
<text text-anchor="start" x="305.274" y="-585" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">AsyncStream</text>
|
||||
<polygon fill="none" stroke="#000000" points="277,-492 277,-572 393,-572 393,-492 277,-492"/>
|
||||
<text text-anchor="start" x="320.553" y="-553" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">reader</text>
|
||||
<text text-anchor="start" x="322.783" y="-541" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">writer</text>
|
||||
<text text-anchor="start" x="324.997" y="-529" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">addr</text>
|
||||
<text text-anchor="start" x="320.553" y="-517" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">r_addr</text>
|
||||
<text text-anchor="start" x="321.108" y="-505" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">l_addr</text>
|
||||
<polygon fill="none" stroke="#000000" points="277,-364 277,-492 393,-492 393,-364 277,-364"/>
|
||||
<text text-anchor="start" x="286.6575" y="-473" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>server_loop()</text>
|
||||
<text text-anchor="start" x="288.878" y="-461" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>client_loop()</text>
|
||||
<text text-anchor="start" x="306.654" y="-449" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>loop</text>
|
||||
<text text-anchor="start" x="322.782" y="-437" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">disc()</text>
|
||||
<text text-anchor="start" x="320.0025" y="-425" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
|
||||
<text text-anchor="start" x="300.2705" y="-401" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__async_read()</text>
|
||||
<text text-anchor="start" x="299.721" y="-389" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__async_write()</text>
|
||||
<text text-anchor="start" x="293.607" y="-377" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__async_forward()</text>
|
||||
<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="168.211" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3P</text>
|
||||
<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="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="125.5,-254 125.5,-298 281.5,-298 281.5,-254 125.5,-254"/>
|
||||
<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>
|
||||
<!-- A9->A7 -->
|
||||
<!-- A7->A9 -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>A9->A7</title>
|
||||
<path fill="none" stroke="#000000" d="M272.2034,-364.3403C258.3698,-337.9803 244.4918,-311.5356 233.1958,-290.0108"/>
|
||||
<polygon fill="none" stroke="#000000" points="269.1431,-366.041 276.8893,-373.2693 275.3415,-362.7881 269.1431,-366.041"/>
|
||||
<title>A7->A9</title>
|
||||
<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="209.3591,-509.972 213.3185,-519.7991 216.3516,-509.6477 209.3591,-509.972"/>
|
||||
</g>
|
||||
<!-- A9->A8 -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>A9->A8</title>
|
||||
<path fill="none" stroke="#000000" d="M368.3237,-353.9299C374.1595,-331.1516 379.8848,-308.8044 384.6662,-290.1415"/>
|
||||
<polygon fill="none" stroke="#000000" points="364.9098,-353.1532 365.8184,-363.709 371.6908,-354.8905 364.9098,-353.1532"/>
|
||||
<!-- A8->A8 -->
|
||||
<g id="edge15" class="edge">
|
||||
<title>A8->A8</title>
|
||||
<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="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="579.877" y="-269.8507" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</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>
|
||||
<!-- A10->A11 -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>A10->A11</title>
|
||||
<path fill="none" stroke="#000000" d="M93.7037,-160.4648C99.1515,-146.8826 104.7134,-133.016 109.8967,-120.0931"/>
|
||||
<polygon fill="none" stroke="#000000" points="90.4307,-159.2232 89.9564,-169.8074 96.9276,-161.8291 90.4307,-159.2232"/>
|
||||
<!-- A12 -->
|
||||
<g id="node13" class="node">
|
||||
<title>A12</title>
|
||||
<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="543.8845" y="-113" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3</text>
|
||||
<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="536.9355" y="-81" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text>
|
||||
<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="516.1035" y="-49" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote()</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>
|
||||
<!-- A10->A12 -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>A10->A12</title>
|
||||
<path fill="none" stroke="#000000" d="M122.8753,-170.8927C123.2497,-170.5927 123.6246,-170.2951 124,-170 149.1284,-150.2443 220.9786,-114.1777 273.8825,-88.7388"/>
|
||||
<polygon fill="none" stroke="#000000" points="120.4501,-168.3606 115.1024,-177.5068 124.9865,-173.6918 120.4501,-168.3606"/>
|
||||
<!-- A8->A12 -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>A8->A12</title>
|
||||
<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="503.6947,-243.3974 503.8003,-253.9917 510.3245,-245.6439 503.6947,-243.3974"/>
|
||||
</g>
|
||||
<!-- A9->A9 -->
|
||||
<g id="edge17" class="edge">
|
||||
<title>A9->A9</title>
|
||||
<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="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="301.0069" y="-270.4817" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">0..1</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>
|
||||
<!-- A13 -->
|
||||
<g id="node14" class="node">
|
||||
<title>A13</title>
|
||||
<polygon fill="none" stroke="#000000" points="350,-1164 350,-1196 453,-1196 453,-1164 350,-1164"/>
|
||||
<text text-anchor="start" x="390.662" y="-1177" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Infos</text>
|
||||
<polygon fill="none" stroke="#000000" points="350,-1108 350,-1164 453,-1164 453,-1108 350,-1108"/>
|
||||
<text text-anchor="start" x="393.4415" y="-1145" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">stat</text>
|
||||
<text text-anchor="start" x="368.986" y="-1133" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">new_stat_data</text>
|
||||
<text text-anchor="start" x="382.6035" y="-1121" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">info_dev</text>
|
||||
<polygon fill="none" stroke="#000000" points="350,-968 350,-1108 453,-1108 453,-968 350,-968"/>
|
||||
<text text-anchor="start" x="377.3355" y="-1089" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">static_init()</text>
|
||||
<text text-anchor="start" x="375.3845" y="-1077" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dev_value()</text>
|
||||
<text text-anchor="start" x="372.3305" y="-1065" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_counter()</text>
|
||||
<text text-anchor="start" x="370.6605" y="-1053" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">dec_counter()</text>
|
||||
<text text-anchor="start" x="368.71" y="-1041" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_proxy_conf</text>
|
||||
<text text-anchor="start" x="383.713" y="-1029" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_conf</text>
|
||||
<text text-anchor="start" x="377.8745" y="-1017" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">update_db</text>
|
||||
<text text-anchor="start" x="362.037" y="-1005" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">set_db_def_value</text>
|
||||
<text text-anchor="start" x="371.4855" y="-993" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">get_db_value</text>
|
||||
<text text-anchor="start" x="359.8225" y="-981" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ignore_this_device</text>
|
||||
<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="177.05" y="-107" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InverterG3P</text>
|
||||
<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="173.4355" y="-75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">__ha_restarts</text>
|
||||
<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="154.268" y="-43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_create_remote(</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>
|
||||
<!-- A9->A13 -->
|
||||
<g id="edge16" class="edge">
|
||||
<title>A9->A13</title>
|
||||
<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="200.0001,-243.9917 203.5,-253.9917 207.0001,-243.9917 200.0001,-243.9917"/>
|
||||
</g>
|
||||
<!-- A10 -->
|
||||
<g id="node11" class="node">
|
||||
<title>A10</title>
|
||||
<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="309.774" y="-711" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">AsyncStream</text>
|
||||
<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="325.053" y="-679" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">reader</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="329.497" y="-655" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">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="325.608" y="-631" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">l_addr</text>
|
||||
<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="291.1575" y="-599" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>server_loop()</text>
|
||||
<text text-anchor="start" x="293.378" y="-587" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>client_loop()</text>
|
||||
<text text-anchor="start" x="311.154" y="-575" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000"><async>loop</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="324.5025" y="-551" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</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="309.78" y="-515" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">async_write()</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>
|
||||
<!-- A10->A8 -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>A10->A8</title>
|
||||
<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="398.824,-479.2474 397.6226,-489.7739 405.1262,-482.2941 398.824,-479.2474"/>
|
||||
</g>
|
||||
<!-- A10->A9 -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>A10->A9</title>
|
||||
<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="278.0609,-482.0929 285.3584,-489.7739 284.4435,-479.2186 278.0609,-482.0929"/>
|
||||
</g>
|
||||
<!-- A11->A12 -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>A11->A12</title>
|
||||
<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="619.1547,-227.3962 625.7097,-235.7198 625.7761,-225.1252 619.1547,-227.3962"/>
|
||||
</g>
|
||||
<!-- A11->A13 -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>A11->A13</title>
|
||||
<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="619.1257,-228.6587 626.7743,-235.9901 625.367,-225.4892 619.1257,-228.6587"/>
|
||||
</g>
|
||||
<!-- A14 -->
|
||||
<g id="node15" class="node">
|
||||
<title>A14</title>
|
||||
<polygon fill="none" stroke="#000000" points="319,-802 319,-834 386,-834 386,-802 319,-802"/>
|
||||
<text text-anchor="start" x="334.993" y="-815" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3</text>
|
||||
<polygon fill="none" stroke="#000000" points="319,-782 319,-802 386,-802 386,-782 319,-782"/>
|
||||
<polygon fill="none" stroke="#000000" points="319,-738 319,-782 386,-782 386,-738 319,-738"/>
|
||||
<text text-anchor="start" x="328.884" y="-763" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_confs()</text>
|
||||
<text text-anchor="start" x="336.668" y="-751" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text>
|
||||
</g>
|
||||
<!-- A13->A14 -->
|
||||
<g id="edge16" class="edge">
|
||||
<title>A13->A14</title>
|
||||
<path fill="none" stroke="#000000" d="M380.4486,-957.853C373.2314,-914.2551 365.5447,-867.821 359.9831,-834.2247"/>
|
||||
<polygon fill="none" stroke="#000000" points="377.0391,-958.688 382.1254,-967.9821 383.9452,-957.5447 377.0391,-958.688"/>
|
||||
<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="219.162" y="-1333" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">Infos</text>
|
||||
<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="221.9415" y="-1301" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">stat</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="211.1035" y="-1277" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">info_dev</text>
|
||||
<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="205.8355" y="-1245" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">static_init()</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="200.8305" y="-1221" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">inc_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="197.21" y="-1197" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_proxy_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="204.994" y="-1173" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_remove</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="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="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>
|
||||
<!-- A15 -->
|
||||
<g id="node16" class="node">
|
||||
<title>A15</title>
|
||||
<polygon fill="none" stroke="#000000" points="417,-802 417,-834 484,-834 484,-802 417,-802"/>
|
||||
<text text-anchor="start" x="429.6585" y="-815" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3P</text>
|
||||
<polygon fill="none" stroke="#000000" points="417,-782 417,-802 484,-802 484,-782 417,-782"/>
|
||||
<polygon fill="none" stroke="#000000" points="417,-738 417,-782 484,-782 484,-738 417,-738"/>
|
||||
<text text-anchor="start" x="426.884" y="-763" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ha_confs()</text>
|
||||
<text text-anchor="start" x="434.668" y="-751" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text>
|
||||
<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="447.493" y="-953" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3</text>
|
||||
<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="431.5,-876 431.5,-920 498.5,-920 498.5,-876 431.5,-876"/>
|
||||
<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="449.168" y="-889" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text>
|
||||
</g>
|
||||
<!-- A13->A15 -->
|
||||
<g id="edge17" class="edge">
|
||||
<title>A13->A15</title>
|
||||
<path fill="none" stroke="#000000" d="M421.5514,-957.853C428.7686,-914.2551 436.4553,-867.821 442.0169,-834.2247"/>
|
||||
<polygon fill="none" stroke="#000000" points="418.0548,-957.5447 419.8746,-967.9821 424.9609,-958.688 418.0548,-957.5447"/>
|
||||
<!-- A14->A15 -->
|
||||
<g id="edge18" class="edge">
|
||||
<title>A14->A15</title>
|
||||
<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="284.8741,-1121.5366 281.0238,-1131.4071 290.1891,-1126.0921 284.8741,-1121.5366"/>
|
||||
</g>
|
||||
<!-- A14->A5 -->
|
||||
<!-- A16 -->
|
||||
<g id="node17" class="node">
|
||||
<title>A16</title>
|
||||
<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="200.1585" y="-953" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">InfosG3P</text>
|
||||
<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="187.5,-876 187.5,-920 254.5,-920 254.5,-876 187.5,-876"/>
|
||||
<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="205.168" y="-889" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">parse()</text>
|
||||
</g>
|
||||
<!-- A14->A16 -->
|
||||
<g id="edge19" class="edge">
|
||||
<title>A14->A5</title>
|
||||
<path fill="none" stroke="#000000" d="M328.0662,-737.8133C310.5801,-702.608 286.0413,-653.2031 263.2551,-607.3269"/>
|
||||
<polygon fill="#000000" stroke="#000000" points="258.7238,-598.2039 267.2025,-605.1583 260.948,-602.682 263.1723,-607.1601 263.1723,-607.1601 263.1723,-607.1601 260.948,-602.682 259.142,-609.1619 258.7238,-598.2039 258.7238,-598.2039"/>
|
||||
<title>A14->A16</title>
|
||||
<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="222.191,-1101.7024 225.9817,-1111.5959 229.188,-1101.4979 222.191,-1101.7024"/>
|
||||
</g>
|
||||
<!-- A15->A6 -->
|
||||
<g id="edge18" class="edge">
|
||||
<g id="edge21" class="edge">
|
||||
<title>A15->A6</title>
|
||||
<path fill="none" stroke="#000000" d="M451.1169,-737.8133C452.1468,-693.3826 453.7008,-626.3353 454.9531,-572.3076"/>
|
||||
<polygon fill="#000000" stroke="#000000" points="455.1913,-562.0332 459.4583,-572.1349 455.0754,-567.0319 454.9595,-572.0306 454.9595,-572.0306 454.9595,-572.0306 455.0754,-567.0319 450.4607,-571.9262 455.1913,-562.0332 455.1913,-562.0332"/>
|
||||
<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="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>
|
||||
<!-- A16->A7 -->
|
||||
<g id="edge20" class="edge">
|
||||
<title>A16->A7</title>
|
||||
<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="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->A13 -->
|
||||
<g id="edge22" class="edge">
|
||||
<title>A17->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>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 34 KiB |
@@ -4,23 +4,27 @@
|
||||
|
||||
[note: You can stick notes on diagrams too!{bg:cornsilk}]
|
||||
[Singleton]^[Mqtt|<static>ha_restarts;<static>__client;<static>__cb_MqttIsUp|<async>publish();<async>close()]
|
||||
|
||||
[IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list|_read():void<abstract>;close():void;inc_counter():void;dec_counter():void]
|
||||
[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()]
|
||||
[Message]^[SolarmanV5|control;serial;snr;switch|msg_unknown();;close()]
|
||||
[Talent]^[ConnectionG3|remoteStream:ConnectionG3|close()]
|
||||
[SolarmanV5]^[ConnectionG3P|remoteStream:ConnectionG3P|close()]
|
||||
[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]
|
||||
[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]
|
||||
[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()]
|
||||
[Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()]
|
||||
[Talent]^[ConnectionG3|remote_stream:ConnectionG3|healthy();close()]
|
||||
[Talent]has-1>[Modbus]
|
||||
[SolarmanV5]^[ConnectionG3P|remote_stream:ConnectionG3P|healthy();close()]
|
||||
[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]^[ConnectionG3P]
|
||||
[Inverter|cls.db_stat;cls.entity_prfx;cls.discovery_prfx;cls.proxy_node_id;cls.proxy_unique_id;cls.mqtt:Mqtt|]^[InverterG3|__ha_restarts|async_create_remote();;close()]
|
||||
[Inverter]^[InverterG3P|__ha_restarts|async_create_remote();;close()]
|
||||
[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(;)async_publ_mqtt();close()]
|
||||
[Mqtt]-[Inverter]
|
||||
[ConnectionG3]^[InverterG3]
|
||||
[ConnectionG3]has-0..1>[ConnectionG3]
|
||||
[ConnectionG3P]^[InverterG3P]
|
||||
[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()]
|
||||
[InfosG3P]->[SolarmanV5]
|
||||
[InfosG3]->[Talent]
|
||||
[ModbusConn|host;port;addr;stream:InverterG3P;|]has-1>[InverterG3P]
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
aiomqtt==2.0.1
|
||||
schema==0.7.5
|
||||
aiocron==1.8
|
||||
aiomqtt==2.2.0
|
||||
schema==0.7.7
|
||||
aiocron==1.8
|
||||
aiohttp==3.10.2
|
||||
@@ -1,42 +1,84 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from messages import hex_dump_memory
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from messages import hex_dump_memory, State
|
||||
from typing import Self
|
||||
from itertools import count
|
||||
|
||||
import gc
|
||||
logger = logging.getLogger('conn')
|
||||
|
||||
|
||||
class AsyncStream():
|
||||
_ids = count(0)
|
||||
MAX_PROC_TIME = 2
|
||||
'''maximum processing time for a received msg in sec'''
|
||||
MAX_START_TIME = 400
|
||||
'''maximum time without a received msg in sec'''
|
||||
MAX_INV_IDLE_TIME = 120
|
||||
'''maximum time without a received msg from the inverter in sec'''
|
||||
MAX_DEF_IDLE_TIME = 360
|
||||
'''maximum default time without a received msg in sec'''
|
||||
|
||||
def __init__(self, reader, writer, addr) -> None:
|
||||
def __init__(self, reader: StreamReader, writer: StreamWriter,
|
||||
addr) -> None:
|
||||
logger.debug('AsyncStream.__init__')
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.addr = addr
|
||||
self.r_addr = ''
|
||||
self.l_addr = ''
|
||||
self.conn_no = next(self._ids)
|
||||
self.proc_start = None # start processing start timestamp
|
||||
self.proc_max = 0
|
||||
|
||||
async def server_loop(self, addr):
|
||||
'''Loop for receiving messages from the inverter (server-side)'''
|
||||
logging.info(f'Accept connection from {addr}')
|
||||
self.inc_counter('Inverter_Cnt')
|
||||
await self.loop()
|
||||
self.dec_counter('Inverter_Cnt')
|
||||
logging.info(f'Server loop stopped for r{self.r_addr}')
|
||||
def __timeout(self) -> int:
|
||||
if self.state == State.init or self.state == State.received:
|
||||
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:
|
||||
to = self.MAX_DEF_IDLE_TIME
|
||||
return to
|
||||
|
||||
# if the server connection closes, we also have to disconnect
|
||||
# the connection to te TSUN cloud
|
||||
if self.remoteStream:
|
||||
logging.debug("disconnect client connection")
|
||||
self.remoteStream.disc()
|
||||
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 client_loop(self, addr):
|
||||
async def server_loop(self, addr: str) -> None:
|
||||
'''Loop for receiving messages from the inverter (server-side)'''
|
||||
logger.info(f'[{self.node_id}:{self.conn_no}] '
|
||||
f'Accept connection from {addr}')
|
||||
self.inc_counter('Inverter_Cnt')
|
||||
await self.publish_outstanding_mqtt()
|
||||
await self.loop()
|
||||
self.dec_counter('Inverter_Cnt')
|
||||
await self.publish_outstanding_mqtt()
|
||||
logger.info(f'[{self.node_id}:{self.conn_no}] Server loop stopped for'
|
||||
f' r{self.r_addr}')
|
||||
|
||||
# if the server connection closes, we also have to disconnect
|
||||
# the connection to te TSUN cloud
|
||||
if self.remote_stream:
|
||||
logger.info(f'[{self.node_id}:{self.conn_no}] disc client '
|
||||
f'connection: [{self.remote_stream.node_id}:'
|
||||
f'{self.remote_stream.conn_no}]')
|
||||
await self.remote_stream.disc()
|
||||
|
||||
async def client_loop(self, _: str) -> None:
|
||||
'''Loop for receiving messages from the TSUN cloud (client-side)'''
|
||||
clientStream = await self.remoteStream.loop()
|
||||
logging.info(f'Client loop stopped for l{clientStream.l_addr}')
|
||||
client_stream = await self.remote_stream.loop()
|
||||
logger.info(f'[{client_stream.node_id}:{client_stream.conn_no}] '
|
||||
'Client loop stopped for'
|
||||
f' l{client_stream.l_addr}')
|
||||
|
||||
# if the client connection closes, we don't touch the server
|
||||
# connection. Instead we erase the client connection stream,
|
||||
@@ -44,88 +86,163 @@ class AsyncStream():
|
||||
# establish a new connection to the TSUN cloud
|
||||
|
||||
# erase backlink to inverter
|
||||
clientStream.remoteStream = None
|
||||
client_stream.remote_stream = None
|
||||
|
||||
if self.remoteStream == clientStream:
|
||||
# logging.debug(f'Client l{clientStream.l_addr} refs:'
|
||||
# f' {gc.get_referrers(clientStream)}')
|
||||
if self.remote_stream == client_stream:
|
||||
# logging.debug(f'Client l{client_stream.l_addr} refs:'
|
||||
# f' {gc.get_referrers(client_stream)}')
|
||||
# than erase client connection
|
||||
self.remoteStream = None
|
||||
self.remote_stream = None
|
||||
|
||||
async def loop(self):
|
||||
async def loop(self) -> Self:
|
||||
"""Async loop handler for precessing all received messages"""
|
||||
self.r_addr = self.writer.get_extra_info('peername')
|
||||
self.l_addr = self.writer.get_extra_info('sockname')
|
||||
|
||||
self.proc_start = time.time()
|
||||
while True:
|
||||
try:
|
||||
await self.__async_read()
|
||||
proc = time.time() - self.proc_start
|
||||
if proc > self.proc_max:
|
||||
self.proc_max = proc
|
||||
self.proc_start = None
|
||||
dead_conn_to = self.__timeout()
|
||||
await asyncio.wait_for(self.__async_read(),
|
||||
dead_conn_to)
|
||||
|
||||
if self.unique_id:
|
||||
await self.__async_write()
|
||||
await self.async_write()
|
||||
await self.__async_forward()
|
||||
await self.async_publ_mqtt()
|
||||
|
||||
except (ConnectionResetError,
|
||||
ConnectionAbortedError,
|
||||
BrokenPipeError,
|
||||
RuntimeError) as error:
|
||||
logger.warning(f'In loop for l{self.l_addr} | '
|
||||
f'r{self.r_addr}: {error}')
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f'[{self.node_id}:{self.conn_no}] Dead '
|
||||
f'connection timeout ({dead_conn_to}s) '
|
||||
f'for {self.l_addr}')
|
||||
await self.disc()
|
||||
self.close()
|
||||
return self
|
||||
|
||||
except OSError as error:
|
||||
logger.error(f'[{self.node_id}:{self.conn_no}] '
|
||||
f'{error} for l{self.l_addr} | '
|
||||
f'r{self.r_addr}')
|
||||
await self.disc()
|
||||
self.close()
|
||||
return self
|
||||
|
||||
except RuntimeError as error:
|
||||
logger.info(f'[{self.node_id}:{self.conn_no}] '
|
||||
f'{error} for {self.l_addr}')
|
||||
await self.disc()
|
||||
self.close()
|
||||
return self
|
||||
|
||||
except Exception:
|
||||
self.inc_counter('SW_Exception')
|
||||
logger.error(
|
||||
f"Exception for {self.addr}:\n"
|
||||
f"{traceback.format_exc()}")
|
||||
self.close()
|
||||
return self
|
||||
|
||||
def disc(self) -> None:
|
||||
logger.debug(f'AsyncStream.disc() l{self.l_addr} | r{self.r_addr}')
|
||||
self.writer.close()
|
||||
|
||||
def close(self):
|
||||
logger.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}')
|
||||
self.writer.close()
|
||||
|
||||
'''
|
||||
Our private methods
|
||||
'''
|
||||
async def __async_read(self) -> None:
|
||||
data = await self.reader.read(4096)
|
||||
if data:
|
||||
self._recv_buffer += data
|
||||
self.read() # call read in parent class
|
||||
else:
|
||||
raise RuntimeError("Peer closed.")
|
||||
|
||||
async def __async_write(self) -> None:
|
||||
async def async_write(self, headline: str = 'Transmit to ') -> None:
|
||||
"""Async write handler to transmit the send_buffer"""
|
||||
if self._send_buffer:
|
||||
hex_dump_memory(logging.INFO, f'Transmit to {self.addr}:',
|
||||
hex_dump_memory(logging.INFO, f'{headline}{self.addr}:',
|
||||
self._send_buffer, len(self._send_buffer))
|
||||
self.writer.write(self._send_buffer)
|
||||
await self.writer.drain()
|
||||
self._send_buffer = bytearray(0) # self._send_buffer[sent:]
|
||||
|
||||
async def __async_forward(self) -> None:
|
||||
if self._forward_buffer:
|
||||
if not self.remoteStream:
|
||||
await self.async_create_remote()
|
||||
if self.remoteStream:
|
||||
if self.remoteStream._init_new_client_conn():
|
||||
await self.remoteStream.__async_write()
|
||||
async def disc(self) -> None:
|
||||
"""Async disc handler for graceful disconnect"""
|
||||
if self.writer.is_closing():
|
||||
return
|
||||
logger.debug(f'AsyncStream.disc() l{self.l_addr} | r{self.r_addr}')
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
|
||||
if self.remoteStream:
|
||||
self.remoteStream._update_header(self._forward_buffer)
|
||||
def close(self) -> None:
|
||||
"""close handler for a no waiting disconnect
|
||||
|
||||
hint: must be called before releasing the connection instance
|
||||
"""
|
||||
self.reader.feed_eof() # abort awaited read
|
||||
if self.writer.is_closing():
|
||||
return
|
||||
logger.debug(f'AsyncStream.close() l{self.l_addr} | r{self.r_addr}')
|
||||
self.writer.close()
|
||||
|
||||
def healthy(self) -> bool:
|
||||
elapsed = 0
|
||||
if self.proc_start is not None:
|
||||
elapsed = time.time() - self.proc_start
|
||||
if self.state == State.closed or elapsed > self.MAX_PROC_TIME:
|
||||
logging.debug(f'[{self.node_id}:{self.conn_no}:'
|
||||
f'{type(self).__name__}]'
|
||||
f' act:{round(1000*elapsed)}ms'
|
||||
f' max:{round(1000*self.proc_max)}ms')
|
||||
logging.debug(f'Healthy()) refs: {gc.get_referrers(self)}')
|
||||
return elapsed < 5
|
||||
|
||||
'''
|
||||
Our private methods
|
||||
'''
|
||||
async def __async_read(self) -> None:
|
||||
"""Async read handler to read received data from TCP stream"""
|
||||
data = await self.reader.read(4096)
|
||||
if data:
|
||||
self.proc_start = time.time()
|
||||
self._recv_buffer += data
|
||||
wait = self.read() # call read in parent class
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
raise RuntimeError("Peer closed.")
|
||||
|
||||
async def __async_forward(self) -> None:
|
||||
"""forward handler transmits data over the remote connection"""
|
||||
if not self._forward_buffer:
|
||||
return
|
||||
try:
|
||||
if not self.remote_stream:
|
||||
await self.async_create_remote()
|
||||
if self.remote_stream:
|
||||
if self.remote_stream._init_new_client_conn():
|
||||
await self.remote_stream.async_write()
|
||||
|
||||
if self.remote_stream:
|
||||
self.remote_stream._update_header(self._forward_buffer)
|
||||
hex_dump_memory(logging.INFO,
|
||||
f'Forward to {self.remoteStream.addr}:',
|
||||
f'Forward to {self.remote_stream.addr}:',
|
||||
self._forward_buffer,
|
||||
len(self._forward_buffer))
|
||||
self.remoteStream.writer.write(self._forward_buffer)
|
||||
await self.remoteStream.writer.drain()
|
||||
self.remote_stream.writer.write(self._forward_buffer)
|
||||
await self.remote_stream.writer.drain()
|
||||
self._forward_buffer = bytearray(0)
|
||||
|
||||
except OSError as error:
|
||||
if self.remote_stream:
|
||||
rmt = self.remote_stream
|
||||
self.remote_stream = None
|
||||
logger.error(f'[{rmt.node_id}:{rmt.conn_no}] Fwd: {error} for '
|
||||
f'l{rmt.l_addr} | r{rmt.r_addr}')
|
||||
await rmt.disc()
|
||||
rmt.close()
|
||||
|
||||
except RuntimeError as error:
|
||||
if self.remote_stream:
|
||||
rmt = self.remote_stream
|
||||
self.remote_stream = None
|
||||
logger.info(f'[{rmt.node_id}:{rmt.conn_no}] '
|
||||
f'Fwd: {error} for {rmt.l_addr}')
|
||||
await rmt.disc()
|
||||
rmt.close()
|
||||
|
||||
except Exception:
|
||||
self.inc_counter('SW_Exception')
|
||||
logger.error(
|
||||
f"Fwd Exception for {self.addr}:\n"
|
||||
f"{traceback.format_exc()}")
|
||||
|
||||
def __del__(self):
|
||||
logger.debug(
|
||||
f"AsyncStream.__del__ l{self.l_addr} | r{self.r_addr}")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import shutil
|
||||
import tomllib
|
||||
import logging
|
||||
from schema import Schema, And, Use, Optional
|
||||
from schema import Schema, And, Or, Use, Optional
|
||||
|
||||
|
||||
class Config():
|
||||
@@ -38,6 +38,14 @@ class Config():
|
||||
'proxy_node_id': Use(str),
|
||||
'proxy_unique_id': Use(str)
|
||||
},
|
||||
'gen3plus': {
|
||||
'at_acl': {
|
||||
Or('mqtt', 'tsun'): {
|
||||
'allow': [str],
|
||||
Optional('block', default=[]): [str]
|
||||
}
|
||||
}
|
||||
},
|
||||
'inverters': {
|
||||
'allow_all': Use(bool), And(Use(str), lambda s: len(s) == 16): {
|
||||
Optional('monitor_sn', default=0): Use(int),
|
||||
@@ -45,7 +53,12 @@ class Config():
|
||||
Use(lambda s: s + '/'
|
||||
if len(s) > 0 and
|
||||
s[-1] != '/' else s)),
|
||||
|
||||
Optional('client_mode'): {
|
||||
'host': Use(str),
|
||||
Optional('port', default=8899):
|
||||
And(Use(int), lambda n: 1024 <= n <= 65535)
|
||||
},
|
||||
Optional('modbus_polling', default=True): Use(bool),
|
||||
Optional('suggested_area', default=""): Use(str),
|
||||
Optional('pv1'): {
|
||||
Optional('type'): Use(str),
|
||||
@@ -76,51 +89,77 @@ class Config():
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def read(cls) -> None:
|
||||
def class_init(cls) -> None | str: # pragma: no cover
|
||||
try:
|
||||
# make the default config transparaent by copying it
|
||||
# in the config.example file
|
||||
logging.debug('Copy Default Config to config.example.toml')
|
||||
|
||||
shutil.copy2("default_config.toml",
|
||||
"config/config.example.toml")
|
||||
except Exception:
|
||||
pass
|
||||
err_str = cls.read()
|
||||
del cls.conf_schema
|
||||
return err_str
|
||||
|
||||
@classmethod
|
||||
def _read_config_file(cls) -> dict: # pragma: no cover
|
||||
usr_config = {}
|
||||
|
||||
try:
|
||||
with open("config/config.toml", "rb") as f:
|
||||
usr_config = tomllib.load(f)
|
||||
except Exception as error:
|
||||
err = f'Config.read: {error}'
|
||||
logging.error(err)
|
||||
logging.info(
|
||||
'\n To create the missing config.toml file, '
|
||||
'you can rename the template config.example.toml\n'
|
||||
' and customize it for your scenario.\n')
|
||||
return usr_config
|
||||
|
||||
@classmethod
|
||||
def read(cls, path='') -> None | str:
|
||||
'''Read config file, merge it with the default config
|
||||
and sanitize the result'''
|
||||
|
||||
err = None
|
||||
config = {}
|
||||
logger = logging.getLogger('data')
|
||||
|
||||
try:
|
||||
# make the default config transparaent by copying it
|
||||
# in the config.example file
|
||||
shutil.copy2("default_config.toml", "config/config.example.toml")
|
||||
|
||||
# read example config file as default configuration
|
||||
with open("default_config.toml", "rb") as f:
|
||||
cls.def_config = {}
|
||||
with open(f"{path}default_config.toml", "rb") as f:
|
||||
def_config = tomllib.load(f)
|
||||
cls.def_config = cls.conf_schema.validate(def_config)
|
||||
|
||||
# overwrite the default values, with values from
|
||||
# the config.toml file
|
||||
usr_config = cls._read_config_file()
|
||||
|
||||
# merge the default and the user config
|
||||
config = def_config.copy()
|
||||
for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters',
|
||||
'gen3plus']:
|
||||
if key in usr_config:
|
||||
config[key] |= usr_config[key]
|
||||
|
||||
try:
|
||||
with open("config/config.toml", "rb") as f:
|
||||
usr_config = tomllib.load(f)
|
||||
cls.config = cls.conf_schema.validate(config)
|
||||
except Exception as error:
|
||||
logging.error(f'Config.read: {error}')
|
||||
logging.info(
|
||||
'\n To create the missing config.toml file, '
|
||||
'you can rename the template config.example.toml\n'
|
||||
' and customize it for your scenario.\n')
|
||||
usr_config = def_config
|
||||
err = f'Config.read: {error}'
|
||||
logging.error(err)
|
||||
|
||||
config['tsun'] = def_config['tsun'] | usr_config['tsun']
|
||||
config['solarman'] = def_config['solarman'] | \
|
||||
usr_config['solarman']
|
||||
config['mqtt'] = def_config['mqtt'] | usr_config['mqtt']
|
||||
config['ha'] = def_config['ha'] | usr_config['ha']
|
||||
config['inverters'] = def_config['inverters'] | \
|
||||
usr_config['inverters']
|
||||
|
||||
cls.config = cls.conf_schema.validate(config)
|
||||
cls.def_config = cls.conf_schema.validate(def_config)
|
||||
# logging.debug(f'Readed config: "{cls.config}" ')
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Config.read: {error}')
|
||||
err = f'Config.read: {error}'
|
||||
logger.error(err)
|
||||
cls.config = {}
|
||||
|
||||
return err
|
||||
|
||||
@classmethod
|
||||
def get(cls, member: str = None):
|
||||
'''Get a named attribute from the proxy config. If member ==
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
# import gc
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from async_stream import AsyncStream
|
||||
from gen3.talent import Talent
|
||||
|
||||
@@ -8,12 +8,13 @@ logger = logging.getLogger('conn')
|
||||
|
||||
class ConnectionG3(AsyncStream, Talent):
|
||||
|
||||
def __init__(self, reader, writer, addr, remote_stream, server_side: bool,
|
||||
def __init__(self, reader: StreamReader, writer: StreamWriter,
|
||||
addr, remote_stream: 'ConnectionG3', server_side: bool,
|
||||
id_str=b'') -> None:
|
||||
AsyncStream.__init__(self, reader, writer, addr)
|
||||
Talent.__init__(self, server_side, id_str)
|
||||
|
||||
self.remoteStream = remote_stream
|
||||
self.remote_stream: 'ConnectionG3' = remote_stream
|
||||
|
||||
'''
|
||||
Our puplic methods
|
||||
@@ -24,10 +25,14 @@ class ConnectionG3(AsyncStream, Talent):
|
||||
# logger.info(f'AsyncStream refs: {gc.get_referrers(self)}')
|
||||
|
||||
async def async_create_remote(self) -> None:
|
||||
pass
|
||||
pass # virtual interface
|
||||
|
||||
async def async_publ_mqtt(self) -> None:
|
||||
pass
|
||||
pass # virtual interface
|
||||
|
||||
def healthy(self) -> bool:
|
||||
logger.debug('ConnectionG3 healthy()')
|
||||
return AsyncStream.healthy(self)
|
||||
|
||||
'''
|
||||
Our private methods
|
||||
|
||||
@@ -30,6 +30,10 @@ class RegisterMap:
|
||||
0xffffff05: Register.UNKNOWN_CTRL,
|
||||
0xffffff06: Register.OTA_START_MSG,
|
||||
0xffffff07: Register.SW_EXCEPTION,
|
||||
0xffffff08: Register.MAX_DESIGNED_POWER,
|
||||
0xffffff09: Register.OUTPUT_COEFFICIENT,
|
||||
0xffffff0a: Register.INVERTER_STATUS,
|
||||
0xffffff0b: Register.POLLING_INTERVAL,
|
||||
0xfffffffe: Register.TEST_REG1,
|
||||
0xffffffff: Register.TEST_REG2,
|
||||
0x00000640: Register.OUTPUT_POWER,
|
||||
@@ -104,7 +108,8 @@ class InfosG3(Infos):
|
||||
if res:
|
||||
yield res
|
||||
|
||||
def parse(self, buf, ind=0) -> Generator[tuple[str, bool], None, None]:
|
||||
def parse(self, buf, ind=0, node_id: str = '') -> \
|
||||
Generator[tuple[str, bool], None, None]:
|
||||
'''parse a data sequence received from the inverter and
|
||||
stores the values in Infos.db
|
||||
|
||||
@@ -130,11 +135,24 @@ class InfosG3(Infos):
|
||||
errors='replace')
|
||||
ind += str_len+1
|
||||
|
||||
elif data_type == 0x00: # 'Nul' -> end
|
||||
i = elms # abort the loop
|
||||
|
||||
elif data_type == 0x41: # 'A' -> Nop ??
|
||||
# result = struct.unpack_from('!l', buf, ind)[0]
|
||||
ind += 0
|
||||
i += 1
|
||||
continue
|
||||
|
||||
elif data_type == 0x42: # 'B' -> byte, int8
|
||||
result = struct.unpack_from('!B', buf, ind)[0]
|
||||
ind += 1
|
||||
|
||||
elif data_type == 0x49: # 'I' -> int32
|
||||
result = struct.unpack_from('!l', buf, ind)[0]
|
||||
ind += 4
|
||||
|
||||
elif data_type == 0x53: # 'S' -> short
|
||||
elif data_type == 0x53: # 'S' -> short, int16
|
||||
result = struct.unpack_from('!h', buf, ind)[0]
|
||||
ind += 2
|
||||
|
||||
@@ -142,13 +160,14 @@ class InfosG3(Infos):
|
||||
result = round(struct.unpack_from('!f', buf, ind)[0], 2)
|
||||
ind += 4
|
||||
|
||||
elif data_type == 0x4c: # 'L' -> int64
|
||||
elif data_type == 0x4c: # 'L' -> long, int64
|
||||
result = struct.unpack_from('!q', buf, ind)[0]
|
||||
ind += 8
|
||||
|
||||
else:
|
||||
self.inc_counter('Invalid_Data_Type')
|
||||
logging.error(f"Infos.parse: data_type: {data_type}"
|
||||
f" @0x{addr:04x} No:{i}"
|
||||
" not supported")
|
||||
return
|
||||
|
||||
@@ -161,7 +180,8 @@ class InfosG3(Infos):
|
||||
update = False
|
||||
name = str(f'info-id.0x{addr:x}')
|
||||
|
||||
self.tracer.log(level, f'GEN3: {name} : {result}{unit}'
|
||||
f' update: {update}')
|
||||
if update:
|
||||
self.tracer.log(level, f'[{node_id}] GEN3: {name} :'
|
||||
f' {result}{unit}')
|
||||
|
||||
i += 1
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
import json
|
||||
import asyncio
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from config import Config
|
||||
from inverter import Inverter
|
||||
from gen3.connection_g3 import ConnectionG3
|
||||
from aiomqtt import MqttCodeError
|
||||
from infos import Infos
|
||||
|
||||
# import gc
|
||||
|
||||
# logger = logging.getLogger('conn')
|
||||
logger_mqtt = logging.getLogger('mqtt')
|
||||
|
||||
|
||||
@@ -44,7 +43,7 @@ class InverterG3(Inverter, ConnectionG3):
|
||||
destroyed
|
||||
'''
|
||||
|
||||
def __init__(self, reader, writer, addr):
|
||||
def __init__(self, reader: StreamReader, writer: StreamWriter, addr):
|
||||
super().__init__(reader, writer, addr, None, True)
|
||||
self.__ha_restarts = -1
|
||||
|
||||
@@ -56,11 +55,14 @@ class InverterG3(Inverter, ConnectionG3):
|
||||
addr = (host, port)
|
||||
|
||||
try:
|
||||
logging.info(f'Connected to {addr}')
|
||||
logging.info(f'[{self.node_id}] Connect to {addr}')
|
||||
connect = asyncio.open_connection(host, port)
|
||||
reader, writer = await connect
|
||||
self.remoteStream = ConnectionG3(reader, writer, addr, self,
|
||||
False, self.id_str)
|
||||
self.remote_stream = ConnectionG3(reader, writer, addr, self,
|
||||
False, self.id_str)
|
||||
logging.info(f'[{self.remote_stream.node_id}:'
|
||||
f'{self.remote_stream.conn_no}] '
|
||||
f'Connected to {addr}')
|
||||
asyncio.create_task(self.client_loop(addr))
|
||||
|
||||
except (ConnectionRefusedError, TimeoutError) as error:
|
||||
@@ -121,7 +123,7 @@ class InverterG3(Inverter, ConnectionG3):
|
||||
def close(self) -> None:
|
||||
logging.debug(f'InverterG3.close() l{self.l_addr} | r{self.r_addr}')
|
||||
super().close() # call close handler in the parent class
|
||||
# logger.debug (f'Inverter refs: {gc.get_referrers(self)}')
|
||||
# logging.info(f'Inverter refs: {gc.get_referrers(self)}')
|
||||
|
||||
def __del__(self):
|
||||
logging.debug("InverterG3.__del__")
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import struct
|
||||
import logging
|
||||
import time
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
from tzlocal import get_localzone
|
||||
|
||||
if __name__ == "app.src.gen3.talent":
|
||||
from app.src.messages import hex_dump_memory, Message
|
||||
from app.src.messages import hex_dump_memory, Message, State
|
||||
from app.src.modbus import Modbus
|
||||
from app.src.my_timer import Timer
|
||||
from app.src.config import Config
|
||||
from app.src.gen3.infos_g3 import InfosG3
|
||||
from app.src.infos import Register
|
||||
else: # pragma: no cover
|
||||
from messages import hex_dump_memory, Message
|
||||
from messages import hex_dump_memory, Message, State
|
||||
from modbus import Modbus
|
||||
from my_timer import Timer
|
||||
from config import Config
|
||||
from gen3.infos_g3 import InfosG3
|
||||
from infos import Register
|
||||
|
||||
logger = logging.getLogger('msg')
|
||||
|
||||
@@ -33,31 +40,65 @@ class Control:
|
||||
|
||||
|
||||
class Talent(Message):
|
||||
MB_START_TIMEOUT = 40
|
||||
MB_REGULAR_TIMEOUT = 60
|
||||
|
||||
def __init__(self, server_side: bool, id_str=b''):
|
||||
super().__init__(server_side)
|
||||
super().__init__(server_side, self.send_modbus_cb, mb_timeout=15)
|
||||
self.await_conn_resp_cnt = 0
|
||||
self.id_str = id_str
|
||||
self.contact_name = b''
|
||||
self.contact_mail = b''
|
||||
self.ts_offset = 0 # time offset between tsun cloud and local
|
||||
self.db = InfosG3()
|
||||
self.switch = {
|
||||
0x00: self.msg_contact_info,
|
||||
0x13: self.msg_ota_update,
|
||||
0x22: self.msg_get_time,
|
||||
0x71: self.msg_collector_data,
|
||||
# 0x76:
|
||||
0x77: self.msg_modbus,
|
||||
# 0x78:
|
||||
0x04: self.msg_inverter_data,
|
||||
}
|
||||
self.log_lvl = {
|
||||
0x00: logging.INFO,
|
||||
0x13: logging.INFO,
|
||||
0x22: logging.INFO,
|
||||
0x71: logging.INFO,
|
||||
# 0x76:
|
||||
0x77: self.get_modbus_log_lvl,
|
||||
# 0x78:
|
||||
0x04: logging.INFO,
|
||||
}
|
||||
self.modbus_elms = 0 # for unit tests
|
||||
self.node_id = 'G3' # will be overwritten in __set_serial_no
|
||||
self.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
|
||||
'''
|
||||
def close(self) -> None:
|
||||
logging.debug('Talent.close()')
|
||||
# we have refernces to methods of this class in self.switch
|
||||
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
|
||||
# so we have to erase self.switch, otherwise this instance can't be
|
||||
# deallocated by the garbage collector ==> we get a memory leak
|
||||
self.switch.clear()
|
||||
self.log_lvl.clear()
|
||||
self.state = State.closed
|
||||
self.mb_timer.close()
|
||||
super().close()
|
||||
|
||||
def __set_serial_no(self, serial_no: str):
|
||||
|
||||
@@ -71,6 +112,7 @@ class Talent(Message):
|
||||
inv = inverters[serial_no]
|
||||
self.node_id = inv['node_id']
|
||||
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
|
||||
self.db.set_pv_module_details(inv)
|
||||
else:
|
||||
@@ -85,35 +127,83 @@ class Talent(Message):
|
||||
|
||||
self.unique_id = serial_no
|
||||
|
||||
def read(self) -> None:
|
||||
def read(self) -> float:
|
||||
'''process all received messages in the _recv_buffer'''
|
||||
self._read()
|
||||
while True:
|
||||
if not self.header_valid:
|
||||
self.__parse_header(self._recv_buffer, len(self._recv_buffer))
|
||||
|
||||
if not self.header_valid:
|
||||
self.__parse_header(self._recv_buffer, len(self._recv_buffer))
|
||||
if self.header_valid and \
|
||||
len(self._recv_buffer) >= (self.header_len + self.data_len):
|
||||
if self.state == State.init:
|
||||
self.state = State.received # received 1st package
|
||||
|
||||
if self.header_valid and len(self._recv_buffer) >= (self.header_len +
|
||||
self.data_len):
|
||||
hex_dump_memory(logging.INFO, f'Received from {self.addr}:',
|
||||
self._recv_buffer, self.header_len+self.data_len)
|
||||
log_lvl = self.log_lvl.get(self.msg_id, logging.WARNING)
|
||||
if callable(log_lvl):
|
||||
log_lvl = log_lvl()
|
||||
|
||||
self.__set_serial_no(self.id_str.decode("utf-8"))
|
||||
self.__dispatch_msg()
|
||||
self.__flush_recv_msg()
|
||||
return
|
||||
hex_dump_memory(log_lvl, f'Received from {self.addr}:'
|
||||
f' BufLen: {len(self._recv_buffer)}'
|
||||
f' HdrLen: {self.header_len}'
|
||||
f' DtaLen: {self.data_len}',
|
||||
self._recv_buffer, len(self._recv_buffer))
|
||||
|
||||
def forward(self, buffer, buflen) -> None:
|
||||
self.__set_serial_no(self.id_str.decode("utf-8"))
|
||||
self.__dispatch_msg()
|
||||
self.__flush_recv_msg()
|
||||
else:
|
||||
return 0 # don not wait before sending a response
|
||||
|
||||
def forward(self) -> None:
|
||||
'''add the actual receive msg to the forwarding queue'''
|
||||
tsun = Config.get('tsun')
|
||||
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:',
|
||||
buffer, buflen)
|
||||
|
||||
self.__parse_header(self._forward_buffer,
|
||||
len(self._forward_buffer))
|
||||
fnc = self.switch.get(self.msg_id, self.msg_unknown)
|
||||
logger.info(self.__flow_str(self.server_side, 'forwrd') +
|
||||
f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}')
|
||||
return
|
||||
|
||||
def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str):
|
||||
if self.state != State.up:
|
||||
logger.warning(f'[{self.node_id}] ignore MODBUS cmd,'
|
||||
' cause the state is not UP anymore')
|
||||
return
|
||||
|
||||
self.__build_header(0x70, 0x77)
|
||||
self._send_buffer += b'\x00\x01\xa3\x28' # fixme
|
||||
self._send_buffer += struct.pack('!B', len(modbus_pdu))
|
||||
self._send_buffer += modbus_pdu
|
||||
self.__finish_send_msg()
|
||||
|
||||
hex_dump_memory(log_lvl, f'Send Modbus {state}:{self.addr}:',
|
||||
self._send_buffer, len(self._send_buffer))
|
||||
self.writer.write(self._send_buffer)
|
||||
self._send_buffer = bytearray(0) # self._send_buffer[sent:]
|
||||
|
||||
def _send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
|
||||
if self.state != State.up:
|
||||
logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,'
|
||||
' as the state is not UP')
|
||||
return
|
||||
self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl)
|
||||
|
||||
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
|
||||
self._send_modbus_cmd(func, addr, val, log_lvl)
|
||||
|
||||
def mb_timout_cb(self, exp_cnt):
|
||||
self.mb_timer.start(self.mb_timeout)
|
||||
|
||||
if 2 == (exp_cnt % 30):
|
||||
# logging.info("Regular Modbus Status request")
|
||||
self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG)
|
||||
else:
|
||||
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
|
||||
|
||||
def _init_new_client_conn(self) -> bool:
|
||||
contact_name = self.contact_name
|
||||
@@ -148,14 +238,44 @@ class Talent(Message):
|
||||
return switch.get(type, '???')
|
||||
|
||||
def _timestamp(self): # pragma: no cover
|
||||
if False:
|
||||
# utc as epoche
|
||||
ts = time.time()
|
||||
else:
|
||||
# convert localtime in epoche
|
||||
ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
|
||||
'''returns timestamp fo the inverter as localtime
|
||||
since 1.1.1970 in msec'''
|
||||
# convert localtime in epoche
|
||||
ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
|
||||
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):
|
||||
'''update header for message before forwarding,
|
||||
add time offset to timestamp'''
|
||||
_len = len(_forward_buffer)
|
||||
ofs = 0
|
||||
while ofs < _len:
|
||||
result = struct.unpack_from('!lB', _forward_buffer, 0)
|
||||
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)
|
||||
msg_code = result[0]
|
||||
if msg_code == 0x71 or msg_code == 0x04:
|
||||
result = struct.unpack_from('!q', _forward_buffer, 13+2*id_len)
|
||||
ts = result[0] + self.ts_offset
|
||||
logger.debug(f'offset: {self.ts_offset:08x}'
|
||||
f' proxy-time: {ts:08x}')
|
||||
struct.pack_into('!q', _forward_buffer, 13+2*id_len, ts)
|
||||
ofs += msg_len
|
||||
|
||||
# check if there is a complete header in the buffer, parse it
|
||||
# and set
|
||||
# self.header_len
|
||||
@@ -171,7 +291,7 @@ class Talent(Message):
|
||||
if (buf_len < 5): # enough bytes to read len and id_len?
|
||||
return
|
||||
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
|
||||
|
||||
hdr_len = 5+id_len+2
|
||||
@@ -185,16 +305,17 @@ class Talent(Message):
|
||||
self.id_str = result[0]
|
||||
self.ctrl = Control(result[1])
|
||||
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_valid = True
|
||||
return
|
||||
|
||||
def __build_header(self, ctrl) -> None:
|
||||
def __build_header(self, ctrl, msg_id=None) -> None:
|
||||
if not msg_id:
|
||||
msg_id = self.msg_id
|
||||
self.send_msg_ofs = len(self._send_buffer)
|
||||
self._send_buffer += struct.pack(f'!l{len(self.id_str)+1}pBB',
|
||||
0, self.id_str, ctrl, self.msg_id)
|
||||
fnc = self.switch.get(self.msg_id, self.msg_unknown)
|
||||
0, self.id_str, ctrl, msg_id)
|
||||
fnc = self.switch.get(msg_id, self.msg_unknown)
|
||||
logger.info(self.__flow_str(self.server_side, 'tx') +
|
||||
f' Ctl: {int(ctrl):#02x} Msg: {fnc.__name__!r}')
|
||||
|
||||
@@ -206,7 +327,8 @@ class Talent(Message):
|
||||
fnc = self.switch.get(self.msg_id, self.msg_unknown)
|
||||
if self.unique_id:
|
||||
logger.info(self.__flow_str(self.server_side, 'rx') +
|
||||
f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}')
|
||||
f' Ctl: {int(self.ctrl):#02x} ({self.state}) '
|
||||
f'Msg: {fnc.__name__!r}')
|
||||
fnc()
|
||||
else:
|
||||
logger.info(self.__flow_str(self.server_side, 'drop') +
|
||||
@@ -230,12 +352,11 @@ class Talent(Message):
|
||||
elif self.await_conn_resp_cnt > 0:
|
||||
self.await_conn_resp_cnt -= 1
|
||||
else:
|
||||
self.forward(self._recv_buffer, self.header_len+self.data_len)
|
||||
return
|
||||
self.forward()
|
||||
else:
|
||||
logger.warning('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:
|
||||
result = struct.unpack_from('!B', self._recv_buffer, self.header_len)
|
||||
@@ -255,50 +376,50 @@ class Talent(Message):
|
||||
return True
|
||||
|
||||
def msg_get_time(self):
|
||||
tsun = Config.get('tsun')
|
||||
if tsun['enabled']:
|
||||
if self.ctrl.is_ind():
|
||||
if self.data_len >= 8:
|
||||
ts = self._timestamp()
|
||||
result = struct.unpack_from('!q', self._recv_buffer,
|
||||
self.header_len)
|
||||
logger.debug(f'tsun-time: {result[0]:08x}'
|
||||
f' proxy-time: {ts:08x}')
|
||||
else:
|
||||
logger.warning('Unknown Ctrl')
|
||||
self.inc_counter('Unknown_Ctrl')
|
||||
self.forward(self._recv_buffer, self.header_len+self.data_len)
|
||||
if self.ctrl.is_ind():
|
||||
if self.data_len == 0:
|
||||
if self.state == State.up:
|
||||
self.state = State.pend # block MODBUS cmds
|
||||
|
||||
ts = self._timestamp()
|
||||
logger.debug(f'time: {ts:08x}')
|
||||
self.__build_header(0x91)
|
||||
self._send_buffer += struct.pack('!q', ts)
|
||||
self.__finish_send_msg()
|
||||
|
||||
elif self.data_len >= 8:
|
||||
ts = self._timestamp()
|
||||
result = struct.unpack_from('!q', self._recv_buffer,
|
||||
self.header_len)
|
||||
self.ts_offset = result[0]-ts
|
||||
logger.debug(f'tsun-time: {int(result[0]):08x}'
|
||||
f' proxy-time: {ts:08x}'
|
||||
f' offset: {self.ts_offset}')
|
||||
return # ignore received response
|
||||
else:
|
||||
if self.ctrl.is_ind():
|
||||
if self.data_len == 0:
|
||||
ts = self._timestamp()
|
||||
logger.debug(f'time: {ts:08x}')
|
||||
logger.warning('Unknown Ctrl')
|
||||
self.inc_counter('Unknown_Ctrl')
|
||||
|
||||
self.__build_header(0x91)
|
||||
self._send_buffer += struct.pack('!q', ts)
|
||||
self.__finish_send_msg()
|
||||
|
||||
else:
|
||||
logger.warning('Unknown Ctrl')
|
||||
self.inc_counter('Unknown_Ctrl')
|
||||
self.forward()
|
||||
|
||||
def parse_msg_header(self):
|
||||
result = struct.unpack_from('!lB', self._recv_buffer, self.header_len)
|
||||
|
||||
data_id = result[0] # len of complete message
|
||||
id_len = result[1] # len of variable id string
|
||||
logger.debug(f'Data_ID: {data_id} id_len: {id_len}')
|
||||
logger.debug(f'Data_ID: 0x{data_id:08x} id_len: {id_len}')
|
||||
|
||||
msg_hdr_len = 5+id_len+9
|
||||
|
||||
result = struct.unpack_from(f'!{id_len+1}pBq', self._recv_buffer,
|
||||
self.header_len + 4)
|
||||
|
||||
timestamp = result[2]
|
||||
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(
|
||||
# "%Y-%m-%d %H:%M:%S")}')
|
||||
return msg_hdr_len
|
||||
return msg_hdr_len, timestamp
|
||||
|
||||
def msg_collector_data(self):
|
||||
if self.ctrl.is_ind():
|
||||
@@ -313,7 +434,7 @@ class Talent(Message):
|
||||
logger.warning('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):
|
||||
if self.ctrl.is_ind():
|
||||
@@ -321,6 +442,11 @@ class Talent(Message):
|
||||
self._send_buffer += b'\x01'
|
||||
self.__finish_send_msg()
|
||||
self.__process_data()
|
||||
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():
|
||||
return # ignore received response
|
||||
@@ -328,27 +454,79 @@ class Talent(Message):
|
||||
logger.warning('Unknown Ctrl')
|
||||
self.inc_counter('Unknown_Ctrl')
|
||||
|
||||
self.forward(self._recv_buffer, self.header_len+self.data_len)
|
||||
self.forward()
|
||||
|
||||
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
|
||||
+ msg_hdr_len):
|
||||
+ msg_hdr_len, self.node_id):
|
||||
if update:
|
||||
self._set_mqtt_timestamp(key, self._utcfromts(ts))
|
||||
self.new_data[key] = True
|
||||
|
||||
def msg_ota_update(self):
|
||||
if self.ctrl.is_req():
|
||||
self.inc_counter('OTA_Start_Msg')
|
||||
elif self.ctrl.is_ind():
|
||||
pass
|
||||
pass # Ok, nothing to do
|
||||
else:
|
||||
logger.warning('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):
|
||||
|
||||
msg_hdr_len = 5
|
||||
|
||||
result = struct.unpack_from('!lBB', self._recv_buffer,
|
||||
self.header_len)
|
||||
modbus_len = result[1]
|
||||
return msg_hdr_len, modbus_len
|
||||
|
||||
def get_modbus_log_lvl(self) -> int:
|
||||
if self.ctrl.is_req():
|
||||
return logging.INFO
|
||||
elif self.ctrl.is_ind() and self.server_side:
|
||||
return self.mb.last_log_lvl
|
||||
return logging.WARNING
|
||||
|
||||
def msg_modbus(self):
|
||||
hdr_len, _ = self.parse_modbus_header()
|
||||
data = self._recv_buffer[self.header_len:
|
||||
self.header_len+self.data_len]
|
||||
|
||||
if self.ctrl.is_req():
|
||||
if self.remote_stream.mb.recv_req(data[hdr_len:],
|
||||
self.remote_stream.
|
||||
msg_forward):
|
||||
self.inc_counter('Modbus_Command')
|
||||
else:
|
||||
self.inc_counter('Invalid_Msg_Format')
|
||||
elif self.ctrl.is_ind():
|
||||
self.modbus_elms = 0
|
||||
# logger.debug(f'Modbus Ind MsgLen: {modbus_len}')
|
||||
if not self.server_side:
|
||||
logger.warning('Unknown Message')
|
||||
self.inc_counter('Unknown_Msg')
|
||||
return
|
||||
|
||||
for key, update, _ in self.mb.recv_resp(self.db, data[
|
||||
hdr_len:],
|
||||
self.node_id):
|
||||
if update:
|
||||
self._set_mqtt_timestamp(key, self._utc())
|
||||
self.new_data[key] = True
|
||||
self.modbus_elms += 1 # count for unit tests
|
||||
else:
|
||||
logger.warning('Unknown Ctrl')
|
||||
self.inc_counter('Unknown_Ctrl')
|
||||
self.forward()
|
||||
|
||||
def msg_forward(self):
|
||||
self.forward()
|
||||
|
||||
def msg_unknown(self):
|
||||
logger.warning(f"Unknow Msg: ID:{self.msg_id}")
|
||||
self.inc_counter('Unknown_Msg')
|
||||
self.forward(self._recv_buffer, self.header_len+self.data_len)
|
||||
self.forward()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
# import gc
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from async_stream import AsyncStream
|
||||
from gen3plus.solarman_v5 import SolarmanV5
|
||||
|
||||
@@ -8,12 +8,14 @@ logger = logging.getLogger('conn')
|
||||
|
||||
class ConnectionG3P(AsyncStream, SolarmanV5):
|
||||
|
||||
def __init__(self, reader, writer, addr, remote_stream,
|
||||
server_side: bool) -> None:
|
||||
def __init__(self, reader: StreamReader, writer: StreamWriter,
|
||||
addr, remote_stream: 'ConnectionG3P',
|
||||
server_side: bool,
|
||||
client_mode: bool) -> None:
|
||||
AsyncStream.__init__(self, reader, writer, addr)
|
||||
SolarmanV5.__init__(self, server_side)
|
||||
SolarmanV5.__init__(self, server_side, client_mode)
|
||||
|
||||
self.remoteStream = remote_stream
|
||||
self.remote_stream: 'ConnectionG3P' = remote_stream
|
||||
|
||||
'''
|
||||
Our puplic methods
|
||||
@@ -24,10 +26,14 @@ class ConnectionG3P(AsyncStream, SolarmanV5):
|
||||
# logger.info(f'AsyncStream refs: {gc.get_referrers(self)}')
|
||||
|
||||
async def async_create_remote(self) -> None:
|
||||
pass
|
||||
pass # virtual interface
|
||||
|
||||
async def async_publ_mqtt(self) -> None:
|
||||
pass
|
||||
pass # virtual interface
|
||||
|
||||
def healthy(self) -> bool:
|
||||
logger.debug('ConnectionG3P healthy()')
|
||||
return AsyncStream.healthy(self)
|
||||
|
||||
'''
|
||||
Our private methods
|
||||
|
||||
@@ -3,9 +3,9 @@ import struct
|
||||
from typing import Generator
|
||||
|
||||
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
|
||||
from infos import Infos, Register
|
||||
from infos import Infos, Register, ProxyMode
|
||||
|
||||
|
||||
class RegisterMap:
|
||||
@@ -14,18 +14,18 @@ class RegisterMap:
|
||||
__slots__ = ()
|
||||
map = {
|
||||
# 0x41020007: {'reg': Register.DEVICE_SNR, 'fmt': '<L'}, # noqa: E501
|
||||
0x41020018: {'reg': Register.DATA_UP_INTERVAL, 'fmt': '<B', 'ratio': 60}, # noqa: E501
|
||||
0x41020019: {'reg': Register.COLLECT_INTERVAL, 'fmt': '<B', 'ratio': 1}, # 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)', 'dep': ProxyMode.SERVER}, # 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
|
||||
0x4102004c: {'reg': Register.IP_ADRESS, 'fmt': '!16s'}, # noqa: E501
|
||||
0x4102004c: {'reg': Register.IP_ADDRESS, 'fmt': '!16s'}, # 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
|
||||
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
|
||||
0x420100d2: {'reg': Register.GRID_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x420100d4: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x420100d6: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
@@ -56,19 +56,31 @@ class RegisterMap:
|
||||
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
|
||||
0x42010126: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
|
||||
0x42010170: {'reg': Register.NO_INPUTS, 'fmt': '!B'}, # noqa: E501
|
||||
|
||||
0x4281001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1}, # noqa: E501
|
||||
0xffffff01: {'reg': Register.OUTPUT_COEFFICIENT},
|
||||
0xffffff02: {'reg': Register.POLLING_INTERVAL},
|
||||
# 0x4281001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1}, # noqa: E501
|
||||
|
||||
}
|
||||
|
||||
|
||||
class InfosG3P(Infos):
|
||||
def __init__(self):
|
||||
def __init__(self, client_mode: bool):
|
||||
super().__init__()
|
||||
self.client_mode = client_mode
|
||||
self.set_db_def_value(Register.MANUFACTURER, 'TSUN')
|
||||
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.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,
|
||||
sug_area: str = '') \
|
||||
@@ -84,11 +96,14 @@ class InfosG3P(Infos):
|
||||
# iterate over RegisterMap.map and get the register values
|
||||
for row in RegisterMap.map.values():
|
||||
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:
|
||||
yield res
|
||||
|
||||
def parse(self, buf, msg_type: int, rcv_ftype: int) \
|
||||
def parse(self, buf, msg_type: int, rcv_ftype: int, node_id: str = '') \
|
||||
-> Generator[tuple[str, bool], None, None]:
|
||||
'''parse a data sequence received from the inverter and
|
||||
stores the values in Infos.db
|
||||
@@ -122,5 +137,6 @@ class InfosG3P(Infos):
|
||||
name = str(f'info-id.0x{addr:x}')
|
||||
update = False
|
||||
|
||||
self.tracer.log(level, f'GEN3PLUS: {name} : {result}{unit}'
|
||||
f' update: {update}')
|
||||
if update:
|
||||
self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}'
|
||||
f' : {result}{unit}')
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
import json
|
||||
import asyncio
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from config import Config
|
||||
from inverter import Inverter
|
||||
from gen3plus.connection_g3p import ConnectionG3P
|
||||
from aiomqtt import MqttCodeError
|
||||
from infos import Infos
|
||||
|
||||
# import gc
|
||||
|
||||
# logger = logging.getLogger('conn')
|
||||
logger_mqtt = logging.getLogger('mqtt')
|
||||
|
||||
|
||||
@@ -44,8 +43,10 @@ class InverterG3P(Inverter, ConnectionG3P):
|
||||
destroyed
|
||||
'''
|
||||
|
||||
def __init__(self, reader, writer, addr):
|
||||
super().__init__(reader, writer, addr, None, True)
|
||||
def __init__(self, reader: StreamReader, writer: StreamWriter, addr,
|
||||
client_mode: bool = False):
|
||||
super().__init__(reader, writer, addr, None,
|
||||
server_side=True, client_mode=client_mode)
|
||||
self.__ha_restarts = -1
|
||||
|
||||
async def async_create_remote(self) -> None:
|
||||
@@ -56,11 +57,15 @@ class InverterG3P(Inverter, ConnectionG3P):
|
||||
addr = (host, port)
|
||||
|
||||
try:
|
||||
logging.info(f'Connected to {addr}')
|
||||
logging.info(f'[{self.node_id}] Connect to {addr}')
|
||||
connect = asyncio.open_connection(host, port)
|
||||
reader, writer = await connect
|
||||
self.remoteStream = ConnectionG3P(reader, writer, addr, self,
|
||||
False)
|
||||
self.remote_stream = ConnectionG3P(reader, writer, addr, self,
|
||||
server_side=False,
|
||||
client_mode=False)
|
||||
logging.info(f'[{self.remote_stream.node_id}:'
|
||||
f'{self.remote_stream.conn_no}] '
|
||||
f'Connected to {addr}')
|
||||
asyncio.create_task(self.client_loop(addr))
|
||||
|
||||
except (ConnectionRefusedError, TimeoutError) as error:
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import struct
|
||||
# import json
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
if __name__ == "app.src.gen3plus.solarman_v5":
|
||||
from app.src.messages import hex_dump_memory, Message
|
||||
from app.src.messages import hex_dump_memory, Message, State
|
||||
from app.src.modbus import Modbus
|
||||
from app.src.my_timer import Timer
|
||||
from app.src.config import Config
|
||||
from app.src.gen3plus.infos_g3p import InfosG3P
|
||||
from app.src.infos import Register
|
||||
else: # pragma: no cover
|
||||
from messages import hex_dump_memory, Message
|
||||
from messages import hex_dump_memory, Message, State
|
||||
from config import Config
|
||||
from modbus import Modbus
|
||||
from my_timer import Timer
|
||||
from gen3plus.infos_g3p import InfosG3P
|
||||
from infos import Register
|
||||
# import traceback
|
||||
|
||||
logger = logging.getLogger('msg')
|
||||
|
||||
@@ -46,16 +49,27 @@ class Sequence():
|
||||
|
||||
|
||||
class SolarmanV5(Message):
|
||||
AT_CMD = 1
|
||||
MB_RTU_CMD = 2
|
||||
MB_START_TIMEOUT = 40
|
||||
'''start delay for Modbus polling in server mode'''
|
||||
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):
|
||||
super().__init__(server_side)
|
||||
def __init__(self, server_side: bool, client_mode: bool):
|
||||
super().__init__(server_side, self.send_modbus_cb, mb_timeout=5)
|
||||
|
||||
self.header_len = 11 # overwrite construcor in class Message
|
||||
self.control = 0
|
||||
self.seq = Sequence(server_side)
|
||||
self.snr = 0
|
||||
self.db = InfosG3P()
|
||||
self.db = InfosG3P(client_mode)
|
||||
self.time_ofs = 0
|
||||
self.forward_at_cmd_resp = False
|
||||
self.no_forwarding = False
|
||||
'''not allowed to connect to TSUN cloud by connection type'''
|
||||
self.switch = {
|
||||
|
||||
0x4210: self.msg_data_ind, # real time data
|
||||
@@ -84,18 +98,91 @@ class SolarmanV5(Message):
|
||||
#
|
||||
# MODbus or AT cmd
|
||||
0x4510: self.msg_command_req, # from server
|
||||
0x1510: self.msg_response, # from inverter
|
||||
0x1510: self.msg_command_rsp, # from inverter
|
||||
# 0x0510: self.msg_command_rsp, # from inverter
|
||||
}
|
||||
|
||||
self.log_lvl = {
|
||||
|
||||
0x4210: logging.INFO, # real time data
|
||||
0x1210: logging.INFO, # at least every 5 minutes
|
||||
|
||||
0x4710: logging.DEBUG, # heatbeat
|
||||
0x1710: logging.DEBUG, # every 2 minutes
|
||||
|
||||
0x4110: logging.INFO, # device data, sync start
|
||||
0x1110: logging.INFO, # every 3 hours
|
||||
|
||||
0x4310: logging.INFO, # regulary after 3-6 hours
|
||||
0x1310: logging.INFO,
|
||||
|
||||
0x4810: logging.INFO, # sync end
|
||||
0x1810: logging.INFO,
|
||||
|
||||
#
|
||||
# MODbus or AT cmd
|
||||
0x4510: logging.INFO, # from server
|
||||
0x1510: self.get_cmd_rsp_log_lvl,
|
||||
}
|
||||
self.modbus_elms = 0 # for unit tests
|
||||
g3p_cnf = Config.get('gen3plus')
|
||||
|
||||
if 'at_acl' in g3p_cnf: # pragma: no cover
|
||||
self.at_acl = g3p_cnf['at_acl']
|
||||
|
||||
self.node_id = 'G3P' # will be overwritten in __set_serial_no
|
||||
self.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
|
||||
'''
|
||||
def close(self) -> None:
|
||||
logging.debug('Solarman.close()')
|
||||
# we have refernces to methods of this class in self.switch
|
||||
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
|
||||
# so we have to erase self.switch, otherwise this instance can't be
|
||||
# deallocated by the garbage collector ==> we get a memory leak
|
||||
self.switch.clear()
|
||||
self.log_lvl.clear()
|
||||
self.state = State.closed
|
||||
self.mb_timer.close()
|
||||
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):
|
||||
serial_no = str(snr)
|
||||
@@ -113,6 +200,7 @@ class SolarmanV5(Message):
|
||||
found = True
|
||||
self.node_id = inv['node_id']
|
||||
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
|
||||
self.db.set_pv_module_details(inv)
|
||||
|
||||
@@ -128,45 +216,48 @@ class SolarmanV5(Message):
|
||||
|
||||
self.unique_id = serial_no
|
||||
|
||||
def read(self) -> None:
|
||||
def read(self) -> float:
|
||||
'''process all received messages in the _recv_buffer'''
|
||||
self._read()
|
||||
while True:
|
||||
if not self.header_valid:
|
||||
self.__parse_header(self._recv_buffer, len(self._recv_buffer))
|
||||
|
||||
if not self.header_valid:
|
||||
self.__parse_header(self._recv_buffer, len(self._recv_buffer))
|
||||
if self.header_valid and 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.data_len+2):
|
||||
hex_dump_memory(logging.INFO, 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):
|
||||
self.__set_serial_no(self.snr)
|
||||
self.__dispatch_msg()
|
||||
self.__flush_recv_msg()
|
||||
return
|
||||
self.__set_serial_no(self.snr)
|
||||
self.__dispatch_msg()
|
||||
self.__flush_recv_msg()
|
||||
else:
|
||||
return 0 # wait 0s before sending a response
|
||||
|
||||
def forward(self, buffer, buflen) -> None:
|
||||
'''add the actual receive msg to the forwarding queue'''
|
||||
if self.no_forwarding:
|
||||
return
|
||||
tsun = Config.get('solarman')
|
||||
if tsun['enabled']:
|
||||
self._forward_buffer = buffer[:buflen]
|
||||
self._forward_buffer += buffer[:buflen]
|
||||
hex_dump_memory(logging.DEBUG, 'Store for forwarding:',
|
||||
buffer, buflen)
|
||||
|
||||
self.__parse_header(self._forward_buffer,
|
||||
len(self._forward_buffer))
|
||||
fnc = self.switch.get(self.control, self.msg_unknown)
|
||||
logger.info(self.__flow_str(self.server_side, 'forwrd') +
|
||||
f' Ctl: {int(self.control):#04x}'
|
||||
f' Msg: {fnc.__name__!r}')
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
'''
|
||||
@@ -209,17 +300,23 @@ class SolarmanV5(Message):
|
||||
self.snr = result[4]
|
||||
|
||||
if start != 0xA5:
|
||||
hex_dump_memory(logging.ERROR,
|
||||
'Drop packet w invalid start byte from'
|
||||
f' {self.addr}:', buf, buf_len)
|
||||
|
||||
self.inc_counter('Invalid_Msg_Format')
|
||||
# erase broken recv buffer
|
||||
self._recv_buffer = bytearray()
|
||||
return
|
||||
self.header_valid = True
|
||||
return
|
||||
|
||||
def __trailer_is_ok(self, buf: bytes, buf_len: int) -> bool:
|
||||
crc = buf[self.data_len+11]
|
||||
stop = buf[self.data_len+12]
|
||||
if stop != 0x15:
|
||||
hex_dump_memory(logging.ERROR,
|
||||
'Drop packet w invalid stop byte from '
|
||||
f'{self.addr}:', buf, buf_len)
|
||||
self.inc_counter('Invalid_Msg_Format')
|
||||
if len(self._recv_buffer) > (self.data_len+13):
|
||||
next_start = buf[self.data_len+13]
|
||||
@@ -261,13 +358,17 @@ class SolarmanV5(Message):
|
||||
'''update header for message before forwarding,
|
||||
set sequence and checksum'''
|
||||
_len = len(_forward_buffer)
|
||||
struct.pack_into('<H', _forward_buffer, 1,
|
||||
_len-13)
|
||||
struct.pack_into('<H', _forward_buffer, 5,
|
||||
self.seq.get_send())
|
||||
ofs = 0
|
||||
while ofs < _len:
|
||||
result = struct.unpack_from('<BH', _forward_buffer, ofs)
|
||||
data_len = result[1] # len of variable id string
|
||||
|
||||
check = sum(_forward_buffer[1:_len-2]) & 0xff
|
||||
struct.pack_into('<B', _forward_buffer, _len-2, check)
|
||||
struct.pack_into('<H', _forward_buffer, ofs+5,
|
||||
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:
|
||||
fnc = self.switch.get(self.control, self.msg_unknown)
|
||||
@@ -293,41 +394,102 @@ class SolarmanV5(Message):
|
||||
self._heartbeat())
|
||||
self.__finish_send_msg()
|
||||
|
||||
def send_at_cmd(self, AT_cmd: str) -> None:
|
||||
def send_modbus_cb(self, pdu: bytearray, log_lvl: int, state: str):
|
||||
if self.state != State.up:
|
||||
logger.warning(f'[{self.node_id}] ignore MODBUS cmd,'
|
||||
' cause the state is not UP anymore')
|
||||
return
|
||||
self.__build_header(0x4510)
|
||||
self._send_buffer += struct.pack(f'<BHLLL{len(AT_cmd)}sc', 1, 2,
|
||||
0, 0, 0, AT_cmd.encode('utf-8'),
|
||||
self._send_buffer += struct.pack('<BHLLL', self.MB_RTU_CMD,
|
||||
0x2b0, 0, 0, 0)
|
||||
self._send_buffer += pdu
|
||||
self.__finish_send_msg()
|
||||
hex_dump_memory(log_lvl, f'Send Modbus {state}:{self.addr}:',
|
||||
self._send_buffer, len(self._send_buffer))
|
||||
self.writer.write(self._send_buffer)
|
||||
self._send_buffer = bytearray(0) # self._send_buffer[sent:]
|
||||
|
||||
def _send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
|
||||
if self.state != State.up:
|
||||
logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,'
|
||||
' as the state is not UP')
|
||||
return
|
||||
self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl)
|
||||
|
||||
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
|
||||
self._send_modbus_cmd(func, addr, val, log_lvl)
|
||||
|
||||
def mb_timout_cb(self, exp_cnt):
|
||||
self.mb_timer.start(self.mb_timeout)
|
||||
|
||||
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
|
||||
|
||||
if 1 == (exp_cnt % 30):
|
||||
# logging.info("Regular Modbus Status request")
|
||||
self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG)
|
||||
|
||||
def at_cmd_forbidden(self, cmd: str, connection: str) -> bool:
|
||||
return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \
|
||||
cmd.startswith(tuple(self.at_acl[connection]['block']))
|
||||
|
||||
async def send_at_cmd(self, at_cmd: str) -> None:
|
||||
if self.state != State.up:
|
||||
logger.warning(f'[{self.node_id}] ignore AT+ cmd,'
|
||||
' as the state is not UP')
|
||||
return
|
||||
at_cmd = at_cmd.strip()
|
||||
|
||||
if self.at_cmd_forbidden(cmd=at_cmd, connection='mqtt'):
|
||||
data_json = f'\'{at_cmd}\' is forbidden'
|
||||
node_id = self.node_id
|
||||
key = 'at_resp'
|
||||
logger.info(f'{key}: {data_json}')
|
||||
await self.mqtt.publish(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501
|
||||
return
|
||||
|
||||
self.forward_at_cmd_resp = False
|
||||
self.__build_header(0x4510)
|
||||
self._send_buffer += struct.pack(f'<BHLLL{len(at_cmd)}sc', self.AT_CMD,
|
||||
2, 0, 0, 0, at_cmd.encode('utf-8'),
|
||||
b'\r')
|
||||
self.__finish_send_msg()
|
||||
try:
|
||||
await self.async_write('Send AT Command:')
|
||||
except Exception:
|
||||
self._send_buffer = bytearray(0)
|
||||
|
||||
def __forward_msg(self):
|
||||
self.forward(self._recv_buffer, self.header_len+self.data_len+2)
|
||||
|
||||
def __process_data(self, ftype):
|
||||
def __build_model_name(self):
|
||||
db = self.db
|
||||
max_pow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
|
||||
rated = db.get_db_value(Register.RATED_POWER, 0)
|
||||
model = None
|
||||
if max_pow == 2000:
|
||||
if rated == 800 or rated == 600:
|
||||
model = f'TSOL-MS{max_pow}({rated})'
|
||||
else:
|
||||
model = f'TSOL-MS{max_pow}'
|
||||
elif max_pow == 1800 or max_pow == 1600:
|
||||
model = f'TSOL-MS{max_pow}'
|
||||
if model:
|
||||
logger.info(f'Model: {model}')
|
||||
self.db.set_db_def_value(Register.EQUIPMENT_MODEL, model)
|
||||
|
||||
def __process_data(self, ftype, ts):
|
||||
inv_update = False
|
||||
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,
|
||||
self.node_id):
|
||||
if update:
|
||||
if key == 'inverter':
|
||||
inv_update = True
|
||||
self._set_mqtt_timestamp(key, ts)
|
||||
self.new_data[key] = True
|
||||
|
||||
if inv_update:
|
||||
db = self.db
|
||||
MaxPow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
|
||||
Rated = db.get_db_value(Register.RATED_POWER, 0)
|
||||
Model = None
|
||||
if MaxPow == 2000:
|
||||
if Rated == 800 or Rated == 600:
|
||||
Model = f'TSOL-MS{MaxPow}({Rated})'
|
||||
else:
|
||||
Model = f'TSOL-MS{MaxPow}'
|
||||
elif MaxPow == 1800 or MaxPow == 1600:
|
||||
Model = f'TSOL-MS{MaxPow}'
|
||||
if Model:
|
||||
logger.info(f'Model: {Model}')
|
||||
self.db.set_db_def_value(Register.EQUIPMENT_MODEL, Model)
|
||||
|
||||
self.__build_model_name()
|
||||
'''
|
||||
Message handler methods
|
||||
'''
|
||||
@@ -346,10 +508,12 @@ class SolarmanV5(Message):
|
||||
logger.info(f'frame type:{ftype:02x}'
|
||||
f' timer:{tim:08x}s null:{res}')
|
||||
if self.time_ofs:
|
||||
dt = datetime.fromtimestamp(total + self.time_ofs)
|
||||
logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
|
||||
self.__process_data(ftype)
|
||||
# dt = datetime.fromtimestamp(total + self.time_ofs)
|
||||
# logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
ts = total + self.time_ofs
|
||||
else:
|
||||
ts = None
|
||||
self.__process_data(ftype, ts)
|
||||
self.__forward_msg()
|
||||
self.__send_ack_rsp(0x1110, ftype)
|
||||
|
||||
@@ -366,12 +530,16 @@ class SolarmanV5(Message):
|
||||
logger.info(f'ftype:{ftype:02x} timer:{tim:08x}s'
|
||||
f' ??: {unkn:04x} cnt:{cnt}')
|
||||
if self.time_ofs:
|
||||
dt = datetime.fromtimestamp(total + self.time_ofs)
|
||||
logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
# dt = datetime.fromtimestamp(total + self.time_ofs)
|
||||
# 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.__send_ack_rsp(0x1210, ftype)
|
||||
self.new_state_up()
|
||||
|
||||
def msg_sync_start(self):
|
||||
data = self._recv_buffer[self.header_len:]
|
||||
@@ -387,13 +555,80 @@ class SolarmanV5(Message):
|
||||
self.__send_ack_rsp(0x1310, ftype)
|
||||
|
||||
def msg_command_req(self):
|
||||
data = self._recv_buffer[self.header_len:]
|
||||
data = self._recv_buffer[self.header_len:
|
||||
self.header_len+self.data_len]
|
||||
result = struct.unpack_from('<B', data, 0)
|
||||
ftype = result[0]
|
||||
if ftype == self.AT_CMD:
|
||||
at_cmd = data[15:].decode()
|
||||
if self.at_cmd_forbidden(cmd=at_cmd, connection='tsun'):
|
||||
self.inc_counter('AT_Command_Blocked')
|
||||
return
|
||||
self.inc_counter('AT_Command')
|
||||
self.forward_at_cmd_resp = True
|
||||
|
||||
elif ftype == self.MB_RTU_CMD:
|
||||
if self.remote_stream.mb.recv_req(data[15:],
|
||||
self.remote_stream.
|
||||
__forward_msg):
|
||||
self.inc_counter('Modbus_Command')
|
||||
else:
|
||||
logger.error('Invalid Modbus Msg')
|
||||
self.inc_counter('Invalid_Msg_Format')
|
||||
return
|
||||
|
||||
self.inc_counter('AT_Command')
|
||||
self.__forward_msg()
|
||||
self.__send_ack_rsp(0x1510, ftype)
|
||||
|
||||
def publish_mqtt(self, key, data): # pragma: no cover
|
||||
asyncio.ensure_future(
|
||||
self.mqtt.publish(key, data))
|
||||
|
||||
def get_cmd_rsp_log_lvl(self) -> int:
|
||||
ftype = self._recv_buffer[self.header_len]
|
||||
if ftype == self.AT_CMD:
|
||||
if self.forward_at_cmd_resp:
|
||||
return logging.INFO
|
||||
return logging.DEBUG
|
||||
elif ftype == self.MB_RTU_CMD \
|
||||
and self.server_side:
|
||||
return self.mb.last_log_lvl
|
||||
|
||||
return logging.WARNING
|
||||
|
||||
def msg_command_rsp(self):
|
||||
data = self._recv_buffer[self.header_len:
|
||||
self.header_len+self.data_len]
|
||||
ftype = data[0]
|
||||
if ftype == self.AT_CMD:
|
||||
if not self.forward_at_cmd_resp:
|
||||
data_json = data[14:].decode("utf-8")
|
||||
node_id = self.node_id
|
||||
key = 'at_resp'
|
||||
logger.info(f'{key}: {data_json}')
|
||||
self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501
|
||||
return
|
||||
elif ftype == self.MB_RTU_CMD:
|
||||
valid = data[1]
|
||||
modbus_msg_len = self.data_len - 14
|
||||
# logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}')
|
||||
if valid == 1 and modbus_msg_len > 4:
|
||||
# logger.info(f'first byte modbus:{data[14]}')
|
||||
inv_update = False
|
||||
self.modbus_elms = 0
|
||||
|
||||
for key, update, _ in self.mb.recv_resp(self.db, data[14:],
|
||||
self.node_id):
|
||||
self.modbus_elms += 1
|
||||
if update:
|
||||
if key == 'inverter':
|
||||
inv_update = True
|
||||
self._set_mqtt_timestamp(key, self._timestamp())
|
||||
self.new_data[key] = True
|
||||
|
||||
if inv_update:
|
||||
self.__build_model_name()
|
||||
return
|
||||
self.__forward_msg()
|
||||
|
||||
def msg_hbeat_ind(self):
|
||||
data = self._recv_buffer[self.header_len:]
|
||||
@@ -402,6 +637,7 @@ class SolarmanV5(Message):
|
||||
|
||||
self.__forward_msg()
|
||||
self.__send_ack_rsp(0x1710, ftype)
|
||||
self.new_state_up()
|
||||
|
||||
def msg_sync_end(self):
|
||||
data = self._recv_buffer[self.header_len:]
|
||||
@@ -423,8 +659,8 @@ class SolarmanV5(Message):
|
||||
valid = result[1] == 1 # status
|
||||
ts = result[2]
|
||||
set_hb = result[3] # always 60 or 120
|
||||
logger.info(f'ftype:{ftype} accepted:{valid}'
|
||||
f' ts:{ts:08x} nextHeartbeat: {set_hb}s')
|
||||
logger.debug(f'ftype:{ftype} accepted:{valid}'
|
||||
f' ts:{ts:08x} nextHeartbeat: {set_hb}s')
|
||||
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
logger.info(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
logger.debug(f'ts: {dt.strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
|
||||
208
app/src/infos.py
208
app/src/infos.py
@@ -5,6 +5,11 @@ from enum import Enum
|
||||
from typing import Generator
|
||||
|
||||
|
||||
class ProxyMode(Enum):
|
||||
SERVER = 1
|
||||
CLIENT = 2
|
||||
|
||||
|
||||
class Register(Enum):
|
||||
COLLECTOR_FW_VERSION = 1
|
||||
CHIP_TYPE = 2
|
||||
@@ -18,6 +23,7 @@ class Register(Enum):
|
||||
EQUIPMENT_MODEL = 24
|
||||
NO_INPUTS = 25
|
||||
MAX_DESIGNED_POWER = 26
|
||||
OUTPUT_COEFFICIENT = 27
|
||||
INVERTER_CNT = 50
|
||||
UNKNOWN_SNR = 51
|
||||
UNKNOWN_MSG = 52
|
||||
@@ -28,6 +34,8 @@ class Register(Enum):
|
||||
SW_EXCEPTION = 57
|
||||
INVALID_MSG_FMT = 58
|
||||
AT_COMMAND = 59
|
||||
MODBUS_COMMAND = 60
|
||||
AT_COMMAND_BLOCKED = 61
|
||||
OUTPUT_POWER = 83
|
||||
RATED_POWER = 84
|
||||
INVERTER_TEMP = 85
|
||||
@@ -86,7 +94,8 @@ class Register(Enum):
|
||||
DATA_UP_INTERVAL = 404
|
||||
CONNECT_COUNT = 405
|
||||
HEARTBEAT_INTERVAL = 406
|
||||
IP_ADRESS = 407
|
||||
IP_ADDRESS = 407
|
||||
POLLING_INTERVAL = 408
|
||||
EVENT_401 = 500
|
||||
EVENT_402 = 501
|
||||
EVENT_403 = 502
|
||||
@@ -103,6 +112,9 @@ class Register(Enum):
|
||||
EVENT_414 = 513
|
||||
EVENT_415 = 514
|
||||
EVENT_416 = 515
|
||||
TS_INPUT = 600
|
||||
TS_GRID = 601
|
||||
TS_TOTAL = 602
|
||||
VALUE_1 = 9000
|
||||
TEST_REG1 = 10000
|
||||
TEST_REG2 = 10001
|
||||
@@ -118,16 +130,16 @@ class ClrAtMidnight:
|
||||
return
|
||||
|
||||
prfx += f'{keys[0]}'
|
||||
dict = cls.db
|
||||
if prfx not in dict:
|
||||
dict[prfx] = {}
|
||||
dict = dict[prfx]
|
||||
db_dict = cls.db
|
||||
if prfx not in db_dict:
|
||||
db_dict[prfx] = {}
|
||||
db_dict = db_dict[prfx]
|
||||
|
||||
for key in keys[1:-1]:
|
||||
if key not in dict:
|
||||
dict[key] = {}
|
||||
dict = dict[key]
|
||||
dict[keys[-1]] = 0
|
||||
if key not in db_dict:
|
||||
db_dict[key] = {}
|
||||
db_dict = db_dict[key]
|
||||
db_dict[keys[-1]] = 0
|
||||
|
||||
@classmethod
|
||||
def elm(cls) -> Generator[tuple[str, dict], None, None]:
|
||||
@@ -145,7 +157,7 @@ class Infos:
|
||||
|
||||
@classmethod
|
||||
def static_init(cls):
|
||||
logging.info('Initialize proxy statistics')
|
||||
logging.debug('Initialize proxy statistics')
|
||||
# init proxy counter in the class.stat dictionary
|
||||
cls.stat['proxy'] = {}
|
||||
for key in cls.__info_defs:
|
||||
@@ -175,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
|
||||
__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 = {
|
||||
# collector values used for device registration:
|
||||
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_MODEL: {'name': ['collector', 'Chip_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.TRACE_URL: {'name': ['collector', 'Trace_URL'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.LOGGER_URL: {'name': ['collector', 'Logger_URL'], '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'], 'singleton': False, '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'], 'singleton': False, 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
|
||||
# inverter values used for device registration:
|
||||
Register.PRODUCT_NAME: {'name': ['inverter', 'Product_Name'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
@@ -192,9 +218,9 @@ class Infos:
|
||||
Register.SERIAL_NUMBER: {'name': ['inverter', 'Serial_Number'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EQUIPMENT_MODEL: {'name': ['inverter', 'Equipment_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.NO_INPUTS: {'name': ['inverter', 'No_Inputs'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'fmt': '| string + " W"', 'name': 'Max Designed Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.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.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_', '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_MODEL: {'name': ['inverter', 'PV1_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.PV2_MANUFACTURER: {'name': ['inverter', 'PV2_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
@@ -209,16 +235,18 @@ class Infos:
|
||||
Register.PV6_MODEL: {'name': ['inverter', 'PV6_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
|
||||
# proxy:
|
||||
Register.INVERTER_CNT: {'name': ['proxy', 'Inverter_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_count_', 'fmt': '| int', 'name': 'Active Inverter Connections', 'icon': 'mdi:counter'}}, # noqa: E501
|
||||
Register.UNKNOWN_SNR: {'name': ['proxy', 'Unknown_SNR'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_snr_', 'fmt': '| int', 'name': 'Unknown Serial No', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': '| int', 'name': 'Unknown Msg Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.INVALID_DATA_TYPE: {'name': ['proxy', 'Invalid_Data_Type'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_data_type_', 'fmt': '| int', 'name': 'Invalid Data Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.INTERNAL_ERROR: {'name': ['proxy', 'Internal_Error'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'intern_err_', 'fmt': '| int', 'name': 'Internal Error', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic', 'en': False}}, # noqa: E501
|
||||
Register.UNKNOWN_CTRL: {'name': ['proxy', 'Unknown_Ctrl'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_ctrl_', 'fmt': '| int', 'name': 'Unknown Control Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.OTA_START_MSG: {'name': ['proxy', 'OTA_Start_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'ota_start_cmd_', 'fmt': '| int', 'name': 'OTA Start Cmd', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.SW_EXCEPTION: {'name': ['proxy', 'SW_Exception'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'sw_exception_', 'fmt': '| int', 'name': 'Internal SW Exception', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.INVALID_MSG_FMT: {'name': ['proxy', 'Invalid_Msg_Format'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_msg_fmt_', 'fmt': '| int', 'name': 'Invalid Message Format', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.AT_COMMAND: {'name': ['proxy', 'AT_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_', 'fmt': '| int', 'name': 'AT Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.INVERTER_CNT: {'name': ['proxy', 'Inverter_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_count_', 'fmt': '| int', 'name': 'Active Inverter Connections', 'icon': 'mdi:counter'}}, # noqa: E501
|
||||
Register.UNKNOWN_SNR: {'name': ['proxy', 'Unknown_SNR'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_snr_', 'fmt': '| int', 'name': 'Unknown Serial No', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': '| int', 'name': 'Unknown Msg Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.INVALID_DATA_TYPE: {'name': ['proxy', 'Invalid_Data_Type'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_data_type_', 'fmt': '| int', 'name': 'Invalid Data Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.INTERNAL_ERROR: {'name': ['proxy', 'Internal_Error'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'intern_err_', 'fmt': '| int', 'name': 'Internal Error', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic', 'en': False}}, # noqa: E501
|
||||
Register.UNKNOWN_CTRL: {'name': ['proxy', 'Unknown_Ctrl'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_ctrl_', 'fmt': '| int', 'name': 'Unknown Control Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.OTA_START_MSG: {'name': ['proxy', 'OTA_Start_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'ota_start_cmd_', 'fmt': '| int', 'name': 'OTA Start Cmd', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.SW_EXCEPTION: {'name': ['proxy', 'SW_Exception'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'sw_exception_', 'fmt': '| int', 'name': 'Internal SW Exception', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.INVALID_MSG_FMT: {'name': ['proxy', 'Invalid_Msg_Format'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_msg_fmt_', 'fmt': '| int', 'name': 'Invalid Message Format', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.AT_COMMAND: {'name': ['proxy', 'AT_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_', 'fmt': '| int', 'name': 'AT Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.AT_COMMAND_BLOCKED: {'name': ['proxy', 'AT_Command_Blocked'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_blocked_', 'fmt': '| int', 'name': 'AT Command Blocked', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.MODBUS_COMMAND: {'name': ['proxy', 'Modbus_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'modbus_cmd_', 'fmt': '| int', 'name': 'Modbus Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
# 0xffffff03: {'name':['proxy', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha':{'dev':'proxy', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id':'proxy_volt_', 'fmt':'| float','name': 'Grid Voltage'}}, # noqa: E501
|
||||
|
||||
# events
|
||||
@@ -230,7 +258,7 @@ class Infos:
|
||||
Register.EVENT_406: {'name': ['events', '406_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EVENT_407: {'name': ['events', '407_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EVENT_408: {'name': ['events', '408_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EVENT_409: {'name': ['events', '409_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EVENT_409: {'name': ['events', '409_No_Utility'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EVENT_410: {'name': ['events', '410_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EVENT_411: {'name': ['events', '411_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
Register.EVENT_412: {'name': ['events', '412_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
@@ -240,33 +268,34 @@ class Infos:
|
||||
Register.EVENT_416: {'name': ['events', '416_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
|
||||
|
||||
# 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_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.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.VALUE_1: {'name': ['env', 'Value_1'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': 'measurement', 'id': 'value_1_', 'fmt': '| int', 'name': 'Value 1', 'ent_cat': 'diagnostic'}}, # 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:
|
||||
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_CURRENT: {'name': ['input', 'pv1', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv1', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv1_', 'val_tpl': "{{ (value_json['pv1']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV2_VOLTAGE: {'name': ['input', 'pv2', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv2', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv2_', 'val_tpl': "{{ (value_json['pv2']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV2_CURRENT: {'name': ['input', 'pv2', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv2', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv2_', 'val_tpl': "{{ (value_json['pv2']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV3_VOLTAGE: {'name': ['input', 'pv3', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv3', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv3_', 'val_tpl': "{{ (value_json['pv3']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV3_CURRENT: {'name': ['input', 'pv3', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv3', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv3_', 'val_tpl': "{{ (value_json['pv3']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV4_VOLTAGE: {'name': ['input', 'pv4', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv4', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv4_', 'val_tpl': "{{ (value_json['pv4']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV4_CURRENT: {'name': ['input', 'pv4', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv4', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv4_', 'val_tpl': "{{ (value_json['pv4']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV5_VOLTAGE: {'name': ['input', 'pv5', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv5', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv5_', 'val_tpl': "{{ (value_json['pv5']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV5_CURRENT: {'name': ['input', 'pv5', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv5', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv5_', 'val_tpl': "{{ (value_json['pv5']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV6_VOLTAGE: {'name': ['input', 'pv6', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv6', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv6_', 'val_tpl': "{{ (value_json['pv6']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV6_CURRENT: {'name': ['input', 'pv6', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv6', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv6_', 'val_tpl': "{{ (value_json['pv6']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501
|
||||
Register.PV1_DAILY_GENERATION: {'name': ['input', 'pv1', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv1_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv1']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
|
||||
Register.PV1_TOTAL_GENERATION: {'name': ['input', 'pv1', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv1_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv1']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
|
||||
Register.PV2_DAILY_GENERATION: {'name': ['input', 'pv2', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv2_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv2']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
|
||||
@@ -280,18 +309,20 @@ 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_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:
|
||||
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.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
|
||||
|
||||
# controller:
|
||||
Register.SIGNAL_STRENGTH: {'name': ['controller', 'Signal_Strength'], 'level': logging.DEBUG, 'unit': '%', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': 'measurement', 'id': 'signal_', 'fmt': '| int', 'name': 'Signal Strength', 'icon': 'mdi:wifi'}}, # noqa: E501
|
||||
Register.POWER_ON_TIME: {'name': ['controller', 'Power_On_Time'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': 'duration', 'stat_cla': 'measurement', 'id': 'power_on_time_', 'fmt': '| float', 'name': 'Power on Time', 'nat_prc': '3', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.COLLECT_INTERVAL: {'name': ['controller', 'Collect_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_collect_intval_', 'fmt': '| string + " s"', 'name': 'Data Collect Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.POWER_ON_TIME: {'name': ['controller', 'Power_On_Time'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': 'duration', 'stat_cla': 'measurement', 'id': 'power_on_time_', 'fmt': '| int', 'name': 'Power on Time', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.COLLECT_INTERVAL: {'name': ['controller', 'Collect_Interval'], 'level': logging.DEBUG, 'unit': 'min', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_collect_intval_', 'fmt': '| string + " min"', 'name': 'Data Collect Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.CONNECT_COUNT: {'name': ['controller', 'Connect_Count'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'connect_count_', 'fmt': '| int', 'name': 'Connect Count', 'icon': 'mdi:counter', 'comp': 'sensor', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.COMMUNICATION_TYPE: {'name': ['controller', 'Communication_Type'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'comm_type_', 'name': 'Communication Type', 'val_tpl': __comm_type_val_tpl, 'comp': 'sensor', 'icon': 'mdi:wifi'}}, # noqa: E501
|
||||
Register.DATA_UP_INTERVAL: {'name': ['controller', 'Data_Up_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_up_intval_', 'fmt': '| string + " s"', 'name': 'Data Up Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.HEARTBEAT_INTERVAL: {'name': ['controller', 'Heartbeat_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'heartbeat_intval_', 'fmt': '| string + " s"', 'name': 'Heartbeat Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.IP_ADRESS: {'name': ['controller', 'IP_Adress'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_adress_', 'fmt': '| string', 'name': 'IP Adress', 'icon': 'mdi:wifi', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
Register.IP_ADDRESS: {'name': ['controller', 'IP_Address'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_address_', 'fmt': '| string', 'name': 'IP Address', 'icon': 'mdi:wifi', 'ent_cat': 'diagnostic'}}, # noqa: E501
|
||||
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
|
||||
@@ -302,7 +333,7 @@ class Infos:
|
||||
def info_defs(self) -> dict:
|
||||
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
|
||||
|
||||
idx:int ==> lookup the value in the database and return it as str,
|
||||
@@ -315,32 +346,32 @@ class Infos:
|
||||
elif idx in self.info_defs:
|
||||
row = self.info_defs[idx]
|
||||
if 'singleton' in row and row['singleton']:
|
||||
dict = self.stat
|
||||
db_dict = self.stat
|
||||
else:
|
||||
dict = self.db
|
||||
db_dict = self.db
|
||||
|
||||
keys = row['name']
|
||||
|
||||
for key in keys:
|
||||
if key not in dict:
|
||||
if key not in db_dict:
|
||||
return None # value not found in the database
|
||||
dict = dict[key]
|
||||
return dict # value of the reqeusted entry
|
||||
db_dict = db_dict[key]
|
||||
return db_dict # value of the reqeusted entry
|
||||
|
||||
return None # unknwon idx, not in info_defs
|
||||
|
||||
def inc_counter(self, counter: str) -> None:
|
||||
'''inc proxy statistic counter'''
|
||||
dict = self.stat['proxy']
|
||||
dict[counter] += 1
|
||||
db_dict = self.stat['proxy']
|
||||
db_dict[counter] += 1
|
||||
|
||||
def dec_counter(self, counter: str) -> None:
|
||||
'''dec proxy statistic counter'''
|
||||
dict = self.stat['proxy']
|
||||
dict[counter] -= 1
|
||||
db_dict = self.stat['proxy']
|
||||
db_dict[counter] -= 1
|
||||
|
||||
def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \
|
||||
-> Generator[tuple[dict, str], None, None]:
|
||||
-> Generator[tuple[str, str, str, str], None, None]:
|
||||
'''Generator function yields json register struct for home-assistant
|
||||
auto configuration and the unique entity string, for all proxy
|
||||
registers
|
||||
@@ -360,6 +391,20 @@ class Infos:
|
||||
|
||||
def ha_conf(self, key, ha_prfx, node_id, snr, singleton: bool,
|
||||
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:
|
||||
return None
|
||||
row = self.info_defs[key]
|
||||
@@ -403,7 +448,7 @@ class Infos:
|
||||
attr['unit_of_meas'] = row['unit'] # 'unit_of_meas'
|
||||
if 'icon' in ha:
|
||||
attr['ic'] = ha['icon'] # icon for the entity
|
||||
if 'nat_prc' in ha:
|
||||
if 'nat_prc' in ha: # pragma: no cover
|
||||
attr['sug_dsp_prc'] = ha['nat_prc'] # precison of floats
|
||||
if 'ent_cat' in ha:
|
||||
attr['ent_cat'] = ha['ent_cat'] # diagnostic, config
|
||||
@@ -463,7 +508,40 @@ class Infos:
|
||||
return json.dumps(attr), component, node_id, attr['uniq_id']
|
||||
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,
|
||||
'unit': ''})
|
||||
if 'ha' in d and 'must_incr' in d['ha']:
|
||||
@@ -475,21 +553,21 @@ class Infos:
|
||||
|
||||
def update_db(self, keys: list, must_incr: bool, result):
|
||||
name = ''
|
||||
dict = self.db
|
||||
db_dict = self.db
|
||||
for key in keys[:-1]:
|
||||
if key not in dict:
|
||||
dict[key] = {}
|
||||
dict = dict[key]
|
||||
if key not in db_dict:
|
||||
db_dict[key] = {}
|
||||
db_dict = db_dict[key]
|
||||
name += key + '.'
|
||||
if keys[-1] not in dict:
|
||||
if keys[-1] not in db_dict:
|
||||
update = (not must_incr or result > 0)
|
||||
else:
|
||||
if must_incr:
|
||||
update = dict[keys[-1]] < result
|
||||
update = db_dict[keys[-1]] < result
|
||||
else:
|
||||
update = dict[keys[-1]] != result
|
||||
update = db_dict[keys[-1]] != result
|
||||
if update:
|
||||
dict[keys[-1]] = result
|
||||
db_dict[keys[-1]] = result
|
||||
name += keys[-1]
|
||||
return name, update
|
||||
|
||||
@@ -543,13 +621,13 @@ class Infos:
|
||||
return True
|
||||
|
||||
if 'gte' in dep:
|
||||
return not value >= dep['gte']
|
||||
return value < dep['gte']
|
||||
elif 'less_eq' in dep:
|
||||
return not value <= dep['less_eq']
|
||||
return value > dep['less_eq']
|
||||
return True
|
||||
|
||||
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
|
||||
'pv3': {'manufacturer': Register.PV3_MANUFACTURER, 'model': Register.PV3_MODEL}, # noqa: E501
|
||||
'pv4': {'manufacturer': Register.PV4_MANUFACTURER, 'model': Register.PV4_MODEL}, # noqa: E501
|
||||
@@ -557,7 +635,7 @@ class Infos:
|
||||
'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 'manufacturer' in inv[key]:
|
||||
self.set_db_def_value(reg['manufacturer'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[loggers]
|
||||
keys=root,tracer,mesg,conn,data,mqtt
|
||||
keys=root,tracer,mesg,conn,data,mqtt,asyncio
|
||||
|
||||
[handlers]
|
||||
keys=console_handler,file_handler_name1,file_handler_name2
|
||||
@@ -24,6 +24,12 @@ handlers=console_handler,file_handler_name1
|
||||
propagate=0
|
||||
qualname=mqtt
|
||||
|
||||
[logger_asyncio]
|
||||
level=INFO
|
||||
handlers=console_handler,file_handler_name1
|
||||
propagate=0
|
||||
qualname=asyncio
|
||||
|
||||
[logger_data]
|
||||
level=DEBUG
|
||||
handlers=file_handler_name1
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import logging
|
||||
import weakref
|
||||
from typing import Callable, Generator
|
||||
from enum import Enum
|
||||
|
||||
|
||||
if __name__ == "app.src.messages":
|
||||
from app.src.infos import Infos
|
||||
from app.src.infos import Infos, Register
|
||||
from app.src.modbus import Modbus
|
||||
else: # pragma: no cover
|
||||
from infos import Infos
|
||||
from infos import Infos, Register
|
||||
from modbus import Modbus
|
||||
|
||||
logger = logging.getLogger('msg')
|
||||
|
||||
|
||||
def hex_dump_memory(level, info, data, num):
|
||||
def hex_dump_memory(level, info, data, data_len):
|
||||
n = 0
|
||||
lines = []
|
||||
lines.append(info)
|
||||
@@ -17,20 +22,20 @@ def hex_dump_memory(level, info, data, num):
|
||||
if not tracer.isEnabledFor(level):
|
||||
return
|
||||
|
||||
for i in range(0, num, 16):
|
||||
for i in range(0, data_len, 16):
|
||||
line = ' '
|
||||
line += '%04x | ' % (i)
|
||||
n += 16
|
||||
|
||||
for j in range(n-16, n):
|
||||
if j >= len(data):
|
||||
if j >= data_len:
|
||||
break
|
||||
line += '%02x ' % abs(data[j])
|
||||
|
||||
line += ' ' * (3 * 16 + 9 - len(line)) + ' | '
|
||||
|
||||
for j in range(n-16, n):
|
||||
if j >= len(data):
|
||||
if j >= data_len:
|
||||
break
|
||||
c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.'
|
||||
line += '%c' % c
|
||||
@@ -41,30 +46,52 @@ def hex_dump_memory(level, info, data, num):
|
||||
|
||||
|
||||
class IterRegistry(type):
|
||||
def __iter__(cls):
|
||||
def __iter__(cls) -> Generator['Message', None, None]:
|
||||
for ref in cls._registry:
|
||||
obj = ref()
|
||||
if obj is not None:
|
||||
yield obj
|
||||
|
||||
|
||||
class State(Enum):
|
||||
'''state of the logical connection'''
|
||||
init = 0
|
||||
'''just created'''
|
||||
received = 1
|
||||
'''at least one packet received'''
|
||||
up = 2
|
||||
'''at least one cmd-rsp transaction'''
|
||||
pend = 3
|
||||
'''inverter transaction pending, don't send MODBUS cmds'''
|
||||
closed = 4
|
||||
'''connection closed'''
|
||||
|
||||
|
||||
class Message(metaclass=IterRegistry):
|
||||
_registry = []
|
||||
|
||||
def __init__(self, server_side: bool):
|
||||
def __init__(self, server_side: bool, send_modbus_cb:
|
||||
Callable[[bytes, int, str], None], mb_timeout: int):
|
||||
self._registry.append(weakref.ref(self))
|
||||
|
||||
self.server_side = server_side
|
||||
if server_side:
|
||||
self.mb = Modbus(send_modbus_cb, mb_timeout)
|
||||
else:
|
||||
self.mb = None
|
||||
|
||||
self.header_valid = False
|
||||
self.header_len = 0
|
||||
self.data_len = 0
|
||||
self.unique_id = 0
|
||||
self.node_id = ''
|
||||
self.node_id = '' # will be overwritten in the child class's __init__
|
||||
self.sug_area = ''
|
||||
self._recv_buffer = bytearray(0)
|
||||
self._send_buffer = bytearray(0)
|
||||
self._forward_buffer = bytearray(0)
|
||||
self.new_data = {}
|
||||
self.state = State.init
|
||||
self.shutdown_started = False
|
||||
|
||||
'''
|
||||
Empty methods, that have to be implemented in any child class which
|
||||
@@ -76,13 +103,31 @@ class Message(metaclass=IterRegistry):
|
||||
|
||||
def _update_header(self, _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
|
||||
'''
|
||||
def close(self) -> None:
|
||||
pass # pragma: no cover
|
||||
if self.mb:
|
||||
self.mb.close()
|
||||
self.mb = None
|
||||
# pragma: no cover
|
||||
|
||||
def inc_counter(self, counter: str) -> None:
|
||||
self.db.inc_counter(counter)
|
||||
|
||||
328
app/src/modbus.py
Normal file
328
app/src/modbus.py
Normal file
@@ -0,0 +1,328 @@
|
||||
'''MODBUS module for TSUN inverter support
|
||||
|
||||
TSUN uses the MODBUS in the RTU transmission mode over serial line.
|
||||
see: https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
|
||||
see: https://modbus.org/docs/Modbus_over_serial_line_V1_02.pdf
|
||||
|
||||
A Modbus PDU consists of: 'Function-Code' + 'Data'
|
||||
A Modbus RTU message consists of: 'Addr' + 'Modbus-PDU' + 'CRC-16'
|
||||
The inverter is a MODBUS server and the proxy the MODBUS client.
|
||||
|
||||
The 16-bit CRC is known as CRC-16-ANSI(reverse)
|
||||
see: https://en.wikipedia.org/wiki/Computation_of_cyclic_redundancy_checks
|
||||
'''
|
||||
import struct
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Generator, Callable
|
||||
|
||||
if __name__ == "app.src.modbus":
|
||||
from app.src.infos import Register
|
||||
else: # pragma: no cover
|
||||
from infos import Register
|
||||
|
||||
logger = logging.getLogger('data')
|
||||
|
||||
CRC_POLY = 0xA001 # (LSBF/reverse)
|
||||
CRC_INIT = 0xFFFF
|
||||
|
||||
|
||||
class Modbus():
|
||||
'''Simple MODBUS implementation with TX queue and retransmit timer'''
|
||||
INV_ADDR = 1
|
||||
'''MODBUS server address of the TSUN inverter'''
|
||||
READ_REGS = 3
|
||||
'''MODBUS function code: Read Holding Register'''
|
||||
READ_INPUTS = 4
|
||||
'''MODBUS function code: Read Input Register'''
|
||||
WRITE_SINGLE_REG = 6
|
||||
'''Modbus function code: Write Single Register'''
|
||||
|
||||
__crc_tab = []
|
||||
map = {
|
||||
0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
|
||||
0x202c: {'reg': Register.OUTPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # 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
|
||||
0x300a: {'reg': Register.GRID_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x300b: {'reg': Register.GRID_FREQUENCY, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x300c: {'reg': Register.INVERTER_TEMP, 'fmt': '!H', 'eval': 'result-40'}, # noqa: E501
|
||||
# 0x300d
|
||||
0x300e: {'reg': Register.RATED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
|
||||
0x300f: {'reg': Register.OUTPUT_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x3010: {'reg': Register.PV1_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x3011: {'reg': Register.PV1_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x3012: {'reg': Register.PV1_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x3013: {'reg': Register.PV2_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x3014: {'reg': Register.PV2_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x3015: {'reg': Register.PV2_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x3016: {'reg': Register.PV3_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x3017: {'reg': Register.PV3_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x3018: {'reg': Register.PV3_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x3019: {'reg': Register.PV4_VOLTAGE, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x301a: {'reg': Register.PV4_CURRENT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x301b: {'reg': Register.PV4_POWER, 'fmt': '!H', 'ratio': 0.1}, # noqa: E501
|
||||
0x301c: {'reg': Register.DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x301d: {'reg': Register.TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
|
||||
0x301f: {'reg': Register.PV1_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x3020: {'reg': Register.PV1_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
|
||||
0x3022: {'reg': Register.PV2_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x3023: {'reg': Register.PV2_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
|
||||
0x3025: {'reg': Register.PV3_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x3026: {'reg': Register.PV3_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
|
||||
0x3028: {'reg': Register.PV4_DAILY_GENERATION, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501
|
||||
0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
|
||||
}
|
||||
|
||||
def __init__(self, snd_handler: Callable[[bytes, int, str], None],
|
||||
timeout: int = 1):
|
||||
if not len(self.__crc_tab):
|
||||
self.__build_crc_tab(CRC_POLY)
|
||||
self.que = asyncio.Queue(100)
|
||||
self.snd_handler = snd_handler
|
||||
'''Send handler to transmit a MODBUS RTU request'''
|
||||
self.rsp_handler = None
|
||||
'''Response handler to forward the response'''
|
||||
self.timeout = timeout
|
||||
'''MODBUS response timeout in seconds'''
|
||||
self.max_retries = 1
|
||||
'''Max retransmit for MODBUS requests'''
|
||||
self.retry_cnt = 0
|
||||
self.last_req = b''
|
||||
self.counter = {}
|
||||
'''Dictenary with statistic counter'''
|
||||
self.counter['timeouts'] = 0
|
||||
self.counter['retries'] = {}
|
||||
for i in range(0, self.max_retries+1):
|
||||
self.counter['retries'][f'{i}'] = 0
|
||||
self.last_log_lvl = logging.DEBUG
|
||||
self.last_addr = 0
|
||||
self.last_fcode = 0
|
||||
self.last_len = 0
|
||||
self.last_reg = 0
|
||||
self.err = 0
|
||||
self.loop = asyncio.get_event_loop()
|
||||
self.req_pend = False
|
||||
self.tim = None
|
||||
self.node_id = ''
|
||||
|
||||
def close(self):
|
||||
"""free the queue and erase the callback handlers"""
|
||||
logging.debug('Modbus close:')
|
||||
self.__stop_timer()
|
||||
self.rsp_handler = None
|
||||
self.snd_handler = None
|
||||
while not self.que.empty():
|
||||
self.que.get_nowait()
|
||||
|
||||
def __del__(self):
|
||||
"""log statistics on the deleting of a MODBUS instance"""
|
||||
logging.debug(f'Modbus __del__:\n {self.counter}')
|
||||
|
||||
def build_msg(self, addr: int, func: int, reg: int, val: int,
|
||||
log_lvl=logging.DEBUG) -> None:
|
||||
"""Build MODBUS RTU request frame and add it to the tx queue
|
||||
|
||||
Keyword arguments:
|
||||
addr: RTU server address (inverter)
|
||||
func: MODBUS function code
|
||||
reg: 16-bit register number
|
||||
val: 16 bit value
|
||||
"""
|
||||
msg = struct.pack('>BBHH', addr, func, reg, val)
|
||||
msg += struct.pack('<H', self.__calc_crc(msg))
|
||||
self.que.put_nowait({'req': msg,
|
||||
'rsp_hdl': None,
|
||||
'log_lvl': log_lvl})
|
||||
if self.que.qsize() == 1:
|
||||
self.__send_next_from_que()
|
||||
|
||||
def recv_req(self, buf: bytearray,
|
||||
rsp_handler: Callable[[None], None] = None) -> bool:
|
||||
"""Add the received Modbus RTU request to the tx queue
|
||||
|
||||
Keyword arguments:
|
||||
buf: Modbus RTU pdu incl ADDR byte and trailing CRC
|
||||
rsp_handler: Callback, if the received pdu is valid
|
||||
|
||||
Returns:
|
||||
True: PDU was added to the queue
|
||||
False: PDU was ignored, due to an error
|
||||
"""
|
||||
# logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}')
|
||||
if not self.__check_crc(buf):
|
||||
self.err = 1
|
||||
logger.error('Modbus recv: CRC error')
|
||||
return False
|
||||
self.que.put_nowait({'req': buf,
|
||||
'rsp_hdl': rsp_handler,
|
||||
'log_lvl': logging.INFO})
|
||||
if self.que.qsize() == 1:
|
||||
self.__send_next_from_que()
|
||||
|
||||
return True
|
||||
|
||||
def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \
|
||||
Generator[tuple[str, bool, int | float | str], None, None]:
|
||||
"""Generator which check and parse a received MODBUS response.
|
||||
|
||||
Keyword arguments:
|
||||
info_db: database for info lockups
|
||||
buf: received Modbus RTU response frame
|
||||
node_id: string for logging which identifies the slave
|
||||
|
||||
Returns on error and set Self.err to:
|
||||
1: CRC error
|
||||
2: Wrong server address
|
||||
3: Unexpected function code
|
||||
4: Unexpected data length
|
||||
5: No MODBUS request pending
|
||||
"""
|
||||
# logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}')
|
||||
self.node_id = node_id
|
||||
|
||||
if not self.req_pend:
|
||||
self.err = 5
|
||||
return
|
||||
if not self.__check_crc(buf):
|
||||
logger.error(f'[{node_id}] Modbus resp: CRC error')
|
||||
self.err = 1
|
||||
return
|
||||
if buf[0] != self.last_addr:
|
||||
logger.info(f'[{node_id}] Modbus resp: Wrong addr {buf[0]}')
|
||||
self.err = 2
|
||||
return
|
||||
fcode = buf[1]
|
||||
if fcode != self.last_fcode:
|
||||
logger.info(f'[{node_id}] Modbus: Wrong fcode {fcode}'
|
||||
f' != {self.last_fcode}')
|
||||
self.err = 3
|
||||
return
|
||||
if self.last_addr == self.INV_ADDR and \
|
||||
(fcode == 3 or fcode == 4):
|
||||
elmlen = buf[2] >> 1
|
||||
if elmlen != self.last_len:
|
||||
logger.info(f'[{node_id}] Modbus: len error {elmlen}'
|
||||
f' != {self.last_len}')
|
||||
self.err = 4
|
||||
return
|
||||
first_reg = self.last_reg # save last_reg before sending next pdu
|
||||
self.__stop_timer() # stop timer and send next pdu
|
||||
|
||||
for i in range(0, elmlen):
|
||||
addr = first_reg+i
|
||||
if addr in self.map:
|
||||
row = self.map[addr]
|
||||
info_id = row['reg']
|
||||
fmt = row['fmt']
|
||||
val = struct.unpack_from(fmt, buf, 3+2*i)
|
||||
result = val[0]
|
||||
|
||||
if 'eval' in row:
|
||||
result = eval(row['eval'])
|
||||
if 'ratio' in row:
|
||||
result = round(result * row['ratio'], 2)
|
||||
|
||||
keys, level, unit, must_incr = info_db._key_obj(info_id)
|
||||
|
||||
if keys:
|
||||
name, update = info_db.update_db(keys, must_incr,
|
||||
result)
|
||||
yield keys[0], update, result
|
||||
if update:
|
||||
info_db.tracer.log(level,
|
||||
f'[{node_id}] MODBUS: {name}'
|
||||
f' : {result}{unit}')
|
||||
else:
|
||||
self.__stop_timer()
|
||||
|
||||
self.counter['retries'][f'{self.retry_cnt}'] += 1
|
||||
if self.rsp_handler:
|
||||
self.rsp_handler()
|
||||
self.__send_next_from_que()
|
||||
|
||||
'''
|
||||
MODBUS response timer
|
||||
'''
|
||||
def __start_timer(self) -> None:
|
||||
'''Start response timer and set `req_pend` to True'''
|
||||
self.req_pend = True
|
||||
self.tim = self.loop.call_later(self.timeout, self.__timeout_cb)
|
||||
# logging.debug(f'Modbus start timer {self}')
|
||||
|
||||
def __stop_timer(self) -> None:
|
||||
'''Stop response timer and set `req_pend` to False'''
|
||||
self.req_pend = False
|
||||
# logging.debug(f'Modbus stop timer {self}')
|
||||
if self.tim:
|
||||
self.tim.cancel()
|
||||
self.tim = None
|
||||
|
||||
def __timeout_cb(self) -> None:
|
||||
'''Rsponse timeout handler retransmit pdu or send next pdu'''
|
||||
self.req_pend = False
|
||||
|
||||
if self.retry_cnt < self.max_retries:
|
||||
logger.debug(f'Modbus retrans {self}')
|
||||
self.retry_cnt += 1
|
||||
self.__start_timer()
|
||||
self.snd_handler(self.last_req, self.last_log_lvl, state='Retrans')
|
||||
else:
|
||||
logger.info(f'[{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.__send_next_from_que()
|
||||
|
||||
def __send_next_from_que(self) -> None:
|
||||
'''Get next MODBUS pdu from queue and transmit it'''
|
||||
if self.req_pend:
|
||||
return
|
||||
try:
|
||||
item = self.que.get_nowait()
|
||||
req = item['req']
|
||||
self.last_req = req
|
||||
self.rsp_handler = item['rsp_hdl']
|
||||
self.last_log_lvl = item['log_lvl']
|
||||
self.last_addr = req[0]
|
||||
self.last_fcode = req[1]
|
||||
|
||||
res = struct.unpack_from('>HH', req, 2)
|
||||
self.last_reg = res[0]
|
||||
self.last_len = res[1]
|
||||
self.retry_cnt = 0
|
||||
self.__start_timer()
|
||||
self.snd_handler(self.last_req, self.last_log_lvl, state='Command')
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
'''
|
||||
Helper function for CRC-16 handling
|
||||
'''
|
||||
def __check_crc(self, msg: bytearray) -> bool:
|
||||
'''Check CRC-16 and returns True if valid'''
|
||||
return 0 == self.__calc_crc(msg)
|
||||
|
||||
def __calc_crc(self, buffer: bytearray) -> int:
|
||||
'''Build CRC-16 for buffer and returns it'''
|
||||
crc = CRC_INIT
|
||||
|
||||
for cur in buffer:
|
||||
crc = (crc >> 8) ^ self.__crc_tab[(crc ^ cur) & 0xFF]
|
||||
return crc
|
||||
|
||||
def __build_crc_tab(self, poly: int) -> None:
|
||||
'''Build CRC-16 helper table, must be called exactly one time'''
|
||||
for index in range(256):
|
||||
data = index << 1
|
||||
crc = 0
|
||||
for _ in range(8, 0, -1):
|
||||
data >>= 1
|
||||
if (data ^ crc) & 1:
|
||||
crc = (crc >> 1) ^ poly
|
||||
else:
|
||||
crc >>= 1
|
||||
self.__crc_tab.append(crc)
|
||||
76
app/src/modbus_tcp.py
Normal file
76
app/src/modbus_tcp.py
Normal 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)
|
||||
128
app/src/mqtt.py
128
app/src/mqtt.py
@@ -1,22 +1,15 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import aiomqtt
|
||||
import traceback
|
||||
from modbus import Modbus
|
||||
from messages import Message
|
||||
from config import Config
|
||||
from singleton import Singleton
|
||||
|
||||
logger_mqtt = logging.getLogger('mqtt')
|
||||
|
||||
|
||||
class Singleton(type):
|
||||
_instances = {}
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
logger_mqtt.debug('singleton: __call__')
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super(Singleton,
|
||||
cls).__call__(*args, **kwargs)
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
class Mqtt(metaclass=Singleton):
|
||||
__client = None
|
||||
__cb_MqttIsUp = None
|
||||
@@ -45,7 +38,8 @@ class Mqtt(metaclass=Singleton):
|
||||
self.task.cancel()
|
||||
try:
|
||||
await self.task
|
||||
except Exception as e:
|
||||
|
||||
except (asyncio.CancelledError, Exception) as e:
|
||||
logging.debug(f"Mqtt.close: exception: {e} ...")
|
||||
|
||||
async def publish(self, topic: str, payload: str | bytes | bytearray
|
||||
@@ -65,6 +59,13 @@ class Mqtt(metaclass=Singleton):
|
||||
password=mqtt['passwd'])
|
||||
|
||||
interval = 5 # Seconds
|
||||
ha_status_topic = f"{ha['auto_conf_prefix']}/status"
|
||||
mb_rated_topic = "tsun/+/rated_load" # fixme
|
||||
mb_out_coeff_topic = "tsun/+/out_coeff" # fixme
|
||||
mb_reads_topic = "tsun/+/modbus_read_regs" # fixme
|
||||
mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme
|
||||
mb_at_cmd_topic = "tsun/+/at_cmd" # fixme
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self.__client:
|
||||
@@ -74,16 +75,50 @@ class Mqtt(metaclass=Singleton):
|
||||
await self.__cb_MqttIsUp()
|
||||
|
||||
# async with self.__client.messages() as messages:
|
||||
await self.__client.subscribe(
|
||||
f"{ha['auto_conf_prefix']}"
|
||||
"/status")
|
||||
await self.__client.subscribe(ha_status_topic)
|
||||
await self.__client.subscribe(mb_rated_topic)
|
||||
await self.__client.subscribe(mb_out_coeff_topic)
|
||||
await self.__client.subscribe(mb_reads_topic)
|
||||
await self.__client.subscribe(mb_inputs_topic)
|
||||
await self.__client.subscribe(mb_at_cmd_topic)
|
||||
|
||||
async for message in self.__client.messages:
|
||||
status = message.payload.decode("UTF-8")
|
||||
logger_mqtt.info('Home-Assistant Status:'
|
||||
f' {status}')
|
||||
if status == 'online':
|
||||
self.ha_restarts += 1
|
||||
await self.__cb_MqttIsUp()
|
||||
if message.topic.matches(ha_status_topic):
|
||||
status = message.payload.decode("UTF-8")
|
||||
logger_mqtt.info('Home-Assistant Status:'
|
||||
f' {status}')
|
||||
if status == 'online':
|
||||
self.ha_restarts += 1
|
||||
await self.__cb_MqttIsUp()
|
||||
|
||||
if message.topic.matches(mb_rated_topic):
|
||||
await self.modbus_cmd(message,
|
||||
Modbus.WRITE_SINGLE_REG,
|
||||
1, 0x2008)
|
||||
|
||||
if message.topic.matches(mb_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):
|
||||
await self.modbus_cmd(message,
|
||||
Modbus.READ_REGS, 2)
|
||||
|
||||
if message.topic.matches(mb_inputs_topic):
|
||||
await self.modbus_cmd(message,
|
||||
Modbus.READ_INPUTS, 2)
|
||||
|
||||
if message.topic.matches(mb_at_cmd_topic):
|
||||
await self.at_cmd(message)
|
||||
|
||||
except aiomqtt.MqttError:
|
||||
if Config.is_default('mqtt'):
|
||||
@@ -101,3 +136,54 @@ class Mqtt(metaclass=Singleton):
|
||||
logger_mqtt.debug("MQTT task cancelled")
|
||||
self.__client = None
|
||||
return
|
||||
except Exception:
|
||||
# self.inc_counter('SW_Exception') # fixme
|
||||
logger_mqtt.error(
|
||||
f"Exception:\n"
|
||||
f"{traceback.format_exc()}")
|
||||
|
||||
def each_inverter(self, message, func_name: str):
|
||||
topic = str(message.topic)
|
||||
node_id = topic.split('/')[1] + '/'
|
||||
found = False
|
||||
for m in Message:
|
||||
if m.server_side and (m.node_id == node_id):
|
||||
found = True
|
||||
logger_mqtt.debug(f'Found: {node_id}')
|
||||
fnc = getattr(m, func_name, None)
|
||||
if callable(fnc):
|
||||
yield fnc
|
||||
else:
|
||||
logger_mqtt.warning(f'Cmd not supported by: {node_id}')
|
||||
|
||||
if not found:
|
||||
logger_mqtt.warning(f'Node_id: {node_id} not found')
|
||||
|
||||
async def modbus_cmd(self, message, func, params=0, addr=0, val=0):
|
||||
topic = str(message.topic)
|
||||
node_id = topic.split('/')[1] + '/'
|
||||
# refactor into a loop over a table
|
||||
payload = message.payload.decode("UTF-8")
|
||||
logger_mqtt.info(f'MODBUS via MQTT: {topic} = {payload}')
|
||||
for m in Message:
|
||||
if m.server_side and (m.node_id == node_id):
|
||||
logger_mqtt.debug(f'Found: {node_id}')
|
||||
fnc = getattr(m, "send_modbus_cmd", None)
|
||||
res = payload.split(',')
|
||||
if params > 0 and params != len(res):
|
||||
logger_mqtt.error(f'Parameter expected: {params}, '
|
||||
f'got: {len(res)}')
|
||||
return
|
||||
|
||||
if callable(fnc):
|
||||
if params == 1:
|
||||
val = int(payload)
|
||||
elif params == 2:
|
||||
addr = int(res[0], base=16)
|
||||
val = int(res[1]) # lenght
|
||||
await fnc(func, addr, val, logging.INFO)
|
||||
|
||||
async def at_cmd(self, message):
|
||||
payload = message.payload.decode("UTF-8")
|
||||
for fnc in self.each_inverter(message, "send_at_cmd"):
|
||||
await fnc(payload)
|
||||
|
||||
35
app/src/my_timer.py
Normal file
35
app/src/my_timer.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from itertools import count
|
||||
|
||||
|
||||
class Timer:
|
||||
def __init__(self, cb, id_str: str = ''):
|
||||
self.__timeout_cb = cb
|
||||
self.loop = asyncio.get_event_loop()
|
||||
self.tim = None
|
||||
self.id_str = id_str
|
||||
self.exp_count = count(0)
|
||||
|
||||
def start(self, timeout: float) -> None:
|
||||
'''Start timer with timeout seconds'''
|
||||
if self.tim:
|
||||
self.tim.cancel()
|
||||
self.tim = self.loop.call_later(timeout, self.__timeout)
|
||||
logging.debug(f'[{self.id_str}]Start timer')
|
||||
|
||||
def stop(self) -> None:
|
||||
'''Stop timer'''
|
||||
logging.debug(f'[{self.id_str}]Stop timer')
|
||||
if self.tim:
|
||||
self.tim.cancel()
|
||||
self.tim = None
|
||||
|
||||
def __timeout(self) -> None:
|
||||
'''timer expired handler'''
|
||||
logging.debug(f'[{self.id_str}]Timer expired')
|
||||
self.__timeout_cb(next(self.exp_count))
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop()
|
||||
self.__timeout_cb = None
|
||||
@@ -9,15 +9,15 @@ logger_mqtt = logging.getLogger('mqtt')
|
||||
|
||||
class Schedule:
|
||||
mqtt = None
|
||||
count = 0
|
||||
|
||||
@classmethod
|
||||
def start(cls) -> None:
|
||||
'''Start the scheduler and schedule the tasks (cron jobs)'''
|
||||
logging.info("Scheduler init")
|
||||
logging.debug("Scheduler init")
|
||||
cls.mqtt = Mqtt(None)
|
||||
|
||||
crontab('0 0 * * *', func=cls.atmidnight, start=True)
|
||||
# crontab('*/5 * * * *', func=cls.atmidnight, start=True)
|
||||
|
||||
@classmethod
|
||||
async def atmidnight(cls) -> None:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import signal
|
||||
import functools
|
||||
import os
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from aiohttp import web
|
||||
from logging import config # noqa F401
|
||||
from messages import Message
|
||||
from inverter import Inverter
|
||||
@@ -10,39 +11,124 @@ from gen3.inverter_g3 import InverterG3
|
||||
from gen3plus.inverter_g3p import InverterG3P
|
||||
from scheduler import Schedule
|
||||
from config import Config
|
||||
from modbus_tcp import ModbusTcp
|
||||
|
||||
routes = web.RouteTableDef()
|
||||
proxy_is_up = False
|
||||
|
||||
|
||||
async def handle_client(reader, writer):
|
||||
@routes.get('/')
|
||||
async def hello(request):
|
||||
return web.Response(text="Hello, world")
|
||||
|
||||
|
||||
@routes.get('/-/ready')
|
||||
async def ready(request):
|
||||
if proxy_is_up:
|
||||
status = 200
|
||||
text = 'Is ready'
|
||||
else:
|
||||
status = 503
|
||||
text = 'Not ready'
|
||||
return web.Response(status=status, text=text)
|
||||
|
||||
|
||||
@routes.get('/-/healthy')
|
||||
async def healthy(request):
|
||||
|
||||
if proxy_is_up:
|
||||
# logging.info('web reqeust healthy()')
|
||||
for stream in Message:
|
||||
try:
|
||||
res = stream.healthy()
|
||||
if not res:
|
||||
return web.Response(status=503, text="I have a problem")
|
||||
except Exception as err:
|
||||
logging.info(f'Exception:{err}')
|
||||
|
||||
return web.Response(status=200, text="I'm fine")
|
||||
|
||||
|
||||
async def webserver(addr, port):
|
||||
'''coro running our webserver'''
|
||||
app = web.Application()
|
||||
app.add_routes(routes)
|
||||
runner = web.AppRunner(app)
|
||||
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, addr, port)
|
||||
await site.start()
|
||||
logging.info(f'HTTP server listen on port: {port}')
|
||||
|
||||
try:
|
||||
# Normal interaction with aiohttp
|
||||
while True:
|
||||
await asyncio.sleep(3600) # sleep forever
|
||||
except asyncio.CancelledError:
|
||||
logging.info('HTTP server cancelled')
|
||||
await runner.cleanup()
|
||||
logging.debug('HTTP cleanup done')
|
||||
|
||||
|
||||
async def handle_client(reader: StreamReader, writer: StreamWriter):
|
||||
'''Handles a new incoming connection and starts an async loop'''
|
||||
|
||||
addr = writer.get_extra_info('peername')
|
||||
await InverterG3(reader, writer, addr).server_loop(addr)
|
||||
|
||||
|
||||
async def handle_client_v2(reader, writer):
|
||||
async def handle_client_v2(reader: StreamReader, writer: StreamWriter):
|
||||
'''Handles a new incoming connection and starts an async loop'''
|
||||
|
||||
addr = writer.get_extra_info('peername')
|
||||
await InverterG3P(reader, writer, addr).server_loop(addr)
|
||||
|
||||
|
||||
def handle_SIGTERM(loop):
|
||||
async def handle_shutdown(web_task):
|
||||
'''Close all TCP connections and stop the event loop'''
|
||||
|
||||
logging.info('Shutdown due to SIGTERM')
|
||||
global proxy_is_up
|
||||
proxy_is_up = False
|
||||
|
||||
#
|
||||
# first, close all open TCP connections
|
||||
# first, disc all open TCP connections gracefully
|
||||
#
|
||||
for stream in Message:
|
||||
stream.shutdown_started = True
|
||||
try:
|
||||
await asyncio.wait_for(stream.disc(), 2)
|
||||
except Exception:
|
||||
pass
|
||||
logging.info('Proxy disconnecting done')
|
||||
|
||||
#
|
||||
# second, close all open TCP connections
|
||||
#
|
||||
for stream in Message:
|
||||
stream.close()
|
||||
|
||||
#
|
||||
# at last, we stop the loop
|
||||
#
|
||||
loop.stop()
|
||||
await asyncio.sleep(0.1) # give time for closing
|
||||
logging.info('Proxy closing done')
|
||||
|
||||
logging.info('Shutdown complete')
|
||||
#
|
||||
# third, cancel the web server
|
||||
#
|
||||
web_task.cancel()
|
||||
await web_task
|
||||
|
||||
#
|
||||
# now cancel all remaining (pending) tasks
|
||||
#
|
||||
pending = asyncio.all_tasks()
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
#
|
||||
# at last, start a coro for stopping the loop
|
||||
#
|
||||
logging.debug("Stop event loop")
|
||||
loop.stop()
|
||||
|
||||
|
||||
def get_log_level() -> int:
|
||||
@@ -74,16 +160,29 @@ if __name__ == "__main__":
|
||||
logging.getLogger('msg').setLevel(log_level)
|
||||
logging.getLogger('conn').setLevel(log_level)
|
||||
logging.getLogger('data').setLevel(log_level)
|
||||
logging.getLogger('tracer').setLevel(log_level)
|
||||
logging.getLogger('asyncio').setLevel(log_level)
|
||||
# logging.getLogger('mqtt').setLevel(log_level)
|
||||
|
||||
# read config file
|
||||
Config.read()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# read config file
|
||||
ConfigErr = Config.class_init()
|
||||
if ConfigErr is not None:
|
||||
logging.info(f'ConfigErr: {ConfigErr}')
|
||||
Inverter.class_init()
|
||||
Schedule.start()
|
||||
mb_tcp = ModbusTcp(loop)
|
||||
|
||||
#
|
||||
# Create tasks for our listening servers. These must be tasks! If we call
|
||||
# start_server directly out of our main task, the eventloop will be blocked
|
||||
# and we can't receive and handle the UNIX signals!
|
||||
#
|
||||
loop.create_task(asyncio.start_server(handle_client, '0.0.0.0', 5005))
|
||||
loop.create_task(asyncio.start_server(handle_client_v2, '0.0.0.0', 10000))
|
||||
web_task = loop.create_task(webserver('0.0.0.0', 8127))
|
||||
|
||||
#
|
||||
# Register some UNIX Signal handler for a gracefully server shutdown
|
||||
@@ -91,22 +190,19 @@ if __name__ == "__main__":
|
||||
#
|
||||
for signame in ('SIGINT', 'SIGTERM'):
|
||||
loop.add_signal_handler(getattr(signal, signame),
|
||||
functools.partial(handle_SIGTERM, loop))
|
||||
|
||||
#
|
||||
# Create taska for our listening servera. These must be tasks! If we call
|
||||
# start_server directly out of our main task, the eventloop will be blocked
|
||||
# and we can't receive and handle the UNIX signals!
|
||||
#
|
||||
loop.create_task(asyncio.start_server(handle_client, '0.0.0.0', 5005))
|
||||
loop.create_task(asyncio.start_server(handle_client_v2, '0.0.0.0', 10000))
|
||||
lambda loop=loop: asyncio.create_task(
|
||||
handle_shutdown(web_task)))
|
||||
|
||||
loop.set_debug(log_level == logging.DEBUG)
|
||||
try:
|
||||
if ConfigErr is None:
|
||||
proxy_is_up = True
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
logging.info("Event loop is stopped")
|
||||
Inverter.class_close(loop)
|
||||
logging.info('Close event loop')
|
||||
logging.debug('Close event loop')
|
||||
loop.close()
|
||||
logging.info(f'Finally, exit Server "{serv_name}"')
|
||||
|
||||
9
app/src/singleton.py
Normal file
9
app/src/singleton.py
Normal file
@@ -0,0 +1,9 @@
|
||||
class Singleton(type):
|
||||
_instances = {}
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
# logger_mqtt.debug('singleton: __call__')
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super(Singleton,
|
||||
cls).__call__(*args, **kwargs)
|
||||
return cls._instances[cls]
|
||||
139
app/tests/test_config.py
Normal file
139
app/tests/test_config.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# test_with_pytest.py
|
||||
import tomllib
|
||||
from schema import SchemaMissingKeyError
|
||||
from app.src.config import Config
|
||||
|
||||
class TstConfig(Config):
|
||||
|
||||
@classmethod
|
||||
def set(cls, cnf):
|
||||
cls.config = cnf
|
||||
|
||||
@classmethod
|
||||
def _read_config_file(cls) -> dict:
|
||||
return cls.config
|
||||
|
||||
|
||||
def test_empty_config():
|
||||
cnf = {}
|
||||
try:
|
||||
Config.conf_schema.validate(cnf)
|
||||
assert False
|
||||
except SchemaMissingKeyError:
|
||||
pass
|
||||
|
||||
def test_default_config():
|
||||
with open("app/config/default_config.toml", "rb") as f:
|
||||
cnf = tomllib.load(f)
|
||||
|
||||
try:
|
||||
validated = Config.conf_schema.validate(cnf)
|
||||
except Exception:
|
||||
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': '', 'modbus_polling': False, 'monitor_sn': 0, 'suggested_area': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
||||
|
||||
def test_full_config():
|
||||
cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005},
|
||||
'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []},
|
||||
'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}},
|
||||
'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000},
|
||||
'mqtt': {'host': 'mqtt', 'port': 1883, 'user': '', 'passwd': ''},
|
||||
'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
|
||||
'inverters': {'allow_all': True,
|
||||
'R170000000000001': {'modbus_polling': True, 'node_id': '', 'suggested_area': '', 'pv1': {'type': 'type1', 'manufacturer': 'man1'}, 'pv2': {'type': 'type2', 'manufacturer': 'man2'}, 'pv3': {'type': 'type3', 'manufacturer': 'man3'}},
|
||||
'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': ''}}}
|
||||
try:
|
||||
validated = Config.conf_schema.validate(cnf)
|
||||
except Exception:
|
||||
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': '', '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():
|
||||
cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005},
|
||||
'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+']},
|
||||
'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE']}}},
|
||||
'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000},
|
||||
'mqtt': {'host': 'mqtt', 'port': 1883, 'user': '', 'passwd': ''},
|
||||
'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
|
||||
'inverters': {'allow_all': True,
|
||||
'R170000000000001': {}}
|
||||
}
|
||||
|
||||
try:
|
||||
validated = Config.conf_schema.validate(cnf)
|
||||
except Exception:
|
||||
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': '', 'modbus_polling': True, 'monitor_sn': 0, 'suggested_area': ''}}}
|
||||
|
||||
def test_read_empty():
|
||||
cnf = {}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('app/config/')
|
||||
assert err == None
|
||||
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': '', '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')
|
||||
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
|
||||
assert True == TstConfig.is_default('solarman')
|
||||
|
||||
def test_no_file():
|
||||
cnf = {}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('')
|
||||
assert err == "Config.read: [Errno 2] No such file or directory: 'default_config.toml'"
|
||||
cnf = TstConfig.get()
|
||||
assert cnf == {}
|
||||
defcnf = TstConfig.def_config.get('solarman')
|
||||
assert defcnf == None
|
||||
|
||||
def test_read_cnf1():
|
||||
cnf = {'solarman' : {'enabled': False}}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('app/config/')
|
||||
assert err == None
|
||||
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': '', 'modbus_polling': False, 'monitor_sn': 0, 'node_id': ''}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'suggested_area': '', 'node_id': ''}}}
|
||||
cnf = TstConfig.get('solarman')
|
||||
assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}
|
||||
defcnf = TstConfig.def_config.get('solarman')
|
||||
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
|
||||
assert False == TstConfig.is_default('solarman')
|
||||
|
||||
def test_read_cnf2():
|
||||
cnf = {'solarman' : {'enabled': 'FALSE'}}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('app/config/')
|
||||
assert err == None
|
||||
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': '', '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')
|
||||
|
||||
def test_read_cnf3():
|
||||
cnf = {'solarman' : {'port': 'FALSE'}}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('app/config/')
|
||||
assert err == 'Config.read: Key \'solarman\' error:\nKey \'port\' error:\nint(\'FALSE\') raised ValueError("invalid literal for int() with base 10: \'FALSE\'")'
|
||||
cnf = TstConfig.get()
|
||||
assert cnf == {'solarman': {'port': 'FALSE'}}
|
||||
|
||||
def test_read_cnf4():
|
||||
cnf = {'solarman' : {'port': 5000}}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('app/config/')
|
||||
assert err == None
|
||||
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': '', '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')
|
||||
|
||||
def test_read_cnf5():
|
||||
cnf = {'solarman' : {'port': 1023}}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('app/config/')
|
||||
assert err != None
|
||||
|
||||
def test_read_cnf6():
|
||||
cnf = {'solarman' : {'port': 65536}}
|
||||
TstConfig.set(cnf)
|
||||
err = TstConfig.read('app/config/')
|
||||
assert err != None
|
||||
@@ -1,6 +1,6 @@
|
||||
# test_with_pytest.py
|
||||
import pytest
|
||||
import json
|
||||
import json, math
|
||||
import logging
|
||||
from app.src.infos import Register, ClrAtMidnight
|
||||
from app.src.infos import Infos
|
||||
@@ -17,13 +17,13 @@ def test_statistic_counter():
|
||||
assert val == None or val == 0
|
||||
|
||||
i.static_init() # initialize counter
|
||||
assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0}})
|
||||
assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}})
|
||||
|
||||
val = i.dev_value(Register.INVERTER_CNT) # valid and initiliazed addr
|
||||
assert val == 0
|
||||
|
||||
i.inc_counter('Inverter_Cnt')
|
||||
assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0}})
|
||||
assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}})
|
||||
val = i.dev_value(Register.INVERTER_CNT)
|
||||
assert val == 1
|
||||
|
||||
@@ -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'):
|
||||
pass
|
||||
pass # sideeffect is calling generator i.ha_proxy_confs()
|
||||
|
||||
val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter
|
||||
assert val == 0
|
||||
@@ -123,6 +123,30 @@ def test_table_definition():
|
||||
val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter
|
||||
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():
|
||||
i = Infos()
|
||||
i.static_init() # initialize counter
|
||||
@@ -198,24 +222,24 @@ def test_get_value():
|
||||
|
||||
i.set_db_def_value(Register.PV2_VOLTAGE, 30.3)
|
||||
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():
|
||||
i = Infos()
|
||||
assert None == i.get_db_value(Register.PV1_VOLTAGE, None)
|
||||
|
||||
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 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
|
||||
|
||||
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 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
|
||||
|
||||
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 29 == i.get_db_value(Register.PV1_VOLTAGE, None)
|
||||
|
||||
|
||||
@@ -140,6 +140,82 @@ def InvDataSeq2(): # Data indication from the controller
|
||||
msg += b'\x53\x00\x00'
|
||||
return msg
|
||||
|
||||
@pytest.fixture
|
||||
def InvDataNew(): # Data indication from DSP V5.0.17
|
||||
msg = b'\x00\x00\x00\xa3\x00\x00\x00\x00\x53\x00\x00'
|
||||
msg += b'\x00\x00\x00\x80\x53\x00\x00\x00\x00\x01\x04\x53\x00\x00\x00\x00'
|
||||
msg += b'\x01\x90\x41\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00'
|
||||
msg += b'\x00\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00\x00\x00'
|
||||
msg += b'\x00\x01\x91\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01'
|
||||
msg += b'\x95\x53\x00\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\x99\x53'
|
||||
msg += b'\x00\x00\x00\x00\x01\x80\x53\x00\x00\x00\x00\x01\x90\x41\x00\x00'
|
||||
msg += b'\x01\x94\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x96'
|
||||
msg += b'\x53\x00\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\xa0\x53\x00'
|
||||
msg += b'\x00\x00\x00\x01\xf0\x41\x00\x00\x01\xf1\x53\x00\x00\x00\x00\x01'
|
||||
msg += b'\xf4\x53\x00\x00\x00\x00\x01\xf5\x53\x00\x00\x00\x00\x01\xf8\x53'
|
||||
msg += b'\x00\x00\x00\x00\x01\xf9\x53\x00\x00\x00\x00\x00\x00\x53\x00\x00'
|
||||
msg += b'\x00\x00\x00\x01\x53\x00\x00\x00\x00\x00\x00\x53\x00\x00\x00\x00'
|
||||
msg += b'\x00\x01\x53\x00\x00\x00\x00\x00\x04\x53\x00\x00\x00\x00\x00\x58'
|
||||
msg += b'\x41\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00'
|
||||
msg += b'\x00\x02\x02\x53\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02'
|
||||
msg += b'\x04\x53\x00\x00\x00\x00\x02\x58\x41\x00\x00\x02\x59\x53\x00\x00'
|
||||
msg += b'\x00\x00\x02\x40\x53\x00\x00\x00\x00\x02\x41\x53\x00\x00\x00\x00'
|
||||
msg += b'\x02\x40\x53\x00\x00\x00\x00\x02\x41\x53\x00\x00\x00\x00\x02\x44'
|
||||
msg += b'\x53\x00\x00\x00\x00\x02\x45\x53\x00\x00\x00\x00\x02\x60\x53\x00'
|
||||
msg += b'\x00\x00\x00\x02\x61\x53\x00\x00\x00\x00\x02\x60\x53\x00\x00\x00'
|
||||
msg += b'\x00\x02\x20\x41\x00\x00\x02\x24\x53\x00\x00\x00\x00\x02\x24\x53'
|
||||
msg += b'\x00\x00\x00\x00\x02\x26\x53\x00\x00\x00\x00\x02\x40\x53\x00\x00'
|
||||
msg += b'\x00\x00\x02\x40\x53\x00\x00\x00\x00\x02\x80\x41\x00\x00\x02\x81'
|
||||
msg += b'\x53\x00\x00\x00\x00\x02\x84\x53\x00\x00\x00\x00\x02\x85\x53\x00'
|
||||
msg += b'\x00\x00\x00\x02\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00'
|
||||
msg += b'\x00\x02\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00\x00\x02'
|
||||
msg += b'\xc0\x53\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00\x00\x02\xc4\x53'
|
||||
msg += b'\x00\x00\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02\x80\x53\x00\x00'
|
||||
msg += b'\x00\x00\x02\xc8\x42\x00\x00\x00\x00\x48\x42\x00\x00\x00\x00\x80'
|
||||
msg += b'\x42\x00\x00\x00\x00\x04\x53\x00\x00\x00\x00\x01\x20\x53\x00\x00'
|
||||
msg += b'\x00\x00\x01\x84\x53\x00\x10\x00\x00\x02\x40\x46\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x04\x04\x46\x02\x00\x46\x02\x00\x00\x04\x00\x46\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x05\x04\x42\x00\x00\x00\x05\x50\x42\x00\x00\x00'
|
||||
msg += b'\x00\x14\x42\x00\x00\x00\x00\x00\x46\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\xa4\x46\x00\x00\x00\x00\x00\x00\x01\x00\x46\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x01\x44\x46\x00\x00\x00\x00\x00\x00\x02\x00\x46\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x08\x04\x46\x00\x00\x00\x00\x00\x00\x08\x90\x46\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x08\x54\x46\x00\x00\x00\x00\x00\x00\x09\x20'
|
||||
msg += b'\x46\x00\x00\x00\x00\x00\x00\x08\x04\x46\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x08\x00\x46\x00\x00\x00\x00\x00\x00\x08\x84\x46\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x08\x40\x46\x00\x00\x00\x00\x00\x00\x09\x04\x46\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x0a\x10\x46\x00\x00\x00\x00\x00\x00\x0c\x14\x46'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x0c\x80\x46\x00\x00\x00\x00\x00\x00\x0c'
|
||||
msg += b'\x24\x42\x00\x00\x00\x0d\x00\x42\x00\x00\x00\x00\x04\x42\x00\x00'
|
||||
msg += b'\x00\x00\x00\x42\x00\x00\x00\x00\x44\x42\x00\x00\x00\x00\x10\x42'
|
||||
msg += b'\x00\x00\x00\x01\x14\x53\x00\x00\x00\x00\x01\xa0\x53\x00\x00\x00'
|
||||
msg += b'\x00\x10\x04\x53\x55\xaa\x00\x00\x10\x40\x53\x00\x00\x00\x00\x10'
|
||||
msg += b'\x04\x53\x00\x00\x00\x00\x11\x00\x53\x00\x00\x00\x00\x11\x84\x53'
|
||||
msg += b'\x00\x00\x00\x00\x10\x50\x53\xff\xff\x00\x00\x10\x14\x53\x03\x20'
|
||||
msg += b'\x00\x00\x10\x00\x53\x00\x00\x00\x00\x11\x24\x53\x00\x00\x00\x00'
|
||||
msg += b'\x03\x00\x53\x00\x00\x00\x00\x03\x64\x53\x00\x00\x00\x00\x04\x50'
|
||||
msg += b'\x53\x00\x00\x00\x00\x00\x34\x53\x00\x00\x00\x00\x00\x00\x42\x02'
|
||||
msg += b'\x00\x00\x01\x04\x42\x00\x00\x00\x21\x00\x42\x00\x00\x00\x21\x44'
|
||||
msg += b'\x42\x00\x00\x00\x22\x10\x53\x00\x00\x00\x00\x28\x14\x42\x01\x00'
|
||||
msg += b'\x00\x28\xa0\x46\x42\x48\x00\x00\x00\x00\x29\x04\x42\x00\x00\x00'
|
||||
msg += b'\x29\x40\x42\x00\x00\x00\x28\x04\x46\x42\x10\x00\x00\x00\x00\x28'
|
||||
msg += b'\x00\x42\x00\x00\x00\x28\x84\x42\x00\x00\x00\x28\x50\x42\x00\x00'
|
||||
msg += b'\x00\x29\x14\x42\x00\x00\x00\x2a\x00\x42\x00\x00\x00\x2c\x24\x46'
|
||||
msg += b'\x42\x10\x00\x00\x00\x00\x2c\x80\x42\x00\x00\x00\x2c\x44\x53\x00'
|
||||
msg += b'\x02\x00\x00\x2d\x00\x42\x00\x00\x00\x20\x04\x46\x42\x4d\x00\x00'
|
||||
msg += b'\x00\x00\x20\x10\x42\x00\x00\x00\x20\x54\x42\x00\x00\x00\x20\x20'
|
||||
msg += b'\x42\x00\x00\x00\x21\x04\x53\x00\x01\x00\x00\x22\x00\x42\x00\x00'
|
||||
msg += b'\x00\x30\x04\x42\x00\x00\x00\x30\x40\x53\x00\x00\x00\x00\x30\x04'
|
||||
msg += b'\x53\x00\x00\x00\x00\x31\x10\x42\x00\x00\x00\x31\x94\x53\x00\x04'
|
||||
msg += b'\x00\x00\x30\x00\x53\x00\x00\x00\x00\x30\x24\x53\x00\x00\x00\x00'
|
||||
msg += b'\x30\x00\x53\x00\x00\x00\x00\x31\x04\x53\x00\x00\x00\x00\x31\x80'
|
||||
msg += b'\x53\x00\x00\x00\x00\x32\x44\x53\x00\x00\x00\x00\x30\x00\x53\x00'
|
||||
msg += b'\x00\x00\x00\x30\x80\x53\x00\x00\x00\x00\x30\x00\x53\x00\x00\x00'
|
||||
msg += b'\x00\x30\x80\x53\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03\x00'
|
||||
msg += b'\x00\x00\x00\x00'
|
||||
return msg
|
||||
|
||||
@pytest.fixture
|
||||
def InvDataSeq2_Zero(): # Data indication from the controller
|
||||
msg = b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00'
|
||||
@@ -391,6 +467,25 @@ def test_must_incr_total2(InvDataSeq2, InvDataSeq2_Zero):
|
||||
assert json.dumps(i.db['total']) == json.dumps({'Daily_Generation': 1.7, 'Total_Generation': 17.36})
|
||||
assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 64.5, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 45.7, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}})
|
||||
|
||||
def test_new_data_types(InvDataNew):
|
||||
i = InfosG3()
|
||||
tests = 0
|
||||
for key, update in i.parse (InvDataNew):
|
||||
if key == 'events':
|
||||
tests +=1
|
||||
elif key == 'inverter':
|
||||
assert update == True
|
||||
tests +=1
|
||||
elif key == 'input':
|
||||
assert update == False
|
||||
tests +=1
|
||||
else:
|
||||
assert False
|
||||
|
||||
assert tests==15
|
||||
assert json.dumps(i.db['inverter']) == json.dumps({"Manufacturer": 0})
|
||||
assert json.dumps(i.db['input']) == json.dumps({"pv1": {}})
|
||||
assert json.dumps(i.db['events']) == json.dumps({"401_": 0, "404_": 0, "405_": 0, "408_": 0, "409_No_Utility": 0, "406_": 0, "416_": 0})
|
||||
|
||||
def test_invalid_data_type(InvalidDataSeq):
|
||||
i = InfosG3()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
# test_with_pytest.py
|
||||
import pytest, json
|
||||
import pytest, json, math
|
||||
from app.src.infos import Register
|
||||
from app.src.gen3plus.infos_g3p import InfosG3P
|
||||
from app.src.gen3plus.infos_g3p import RegisterMap
|
||||
|
||||
@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'\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'
|
||||
@@ -24,7 +24,7 @@ def DeviceData(): # 0x4110 ftype: 0x02
|
||||
return msg
|
||||
|
||||
@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'\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'
|
||||
@@ -56,34 +56,34 @@ def InverterData(): # 0x4210 ftype: 0x01
|
||||
|
||||
|
||||
def test_default_db():
|
||||
i = InfosG3P()
|
||||
i = InfosG3P(client_mode=False)
|
||||
|
||||
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"},
|
||||
})
|
||||
|
||||
def test_parse_4110(DeviceData: bytes):
|
||||
i = InfosG3P()
|
||||
def test_parse_4110(device_data: bytes):
|
||||
i = InfosG3P(client_mode=False)
|
||||
i.db.clear()
|
||||
for key, update in i.parse (DeviceData, 0x41, 2):
|
||||
pass
|
||||
for key, update in i.parse (device_data, 0x41, 2):
|
||||
pass # side effect is calling generator i.parse()
|
||||
|
||||
assert json.dumps(i.db) == json.dumps({
|
||||
'controller': {"Data_Up_Interval": 300, "Collect_Interval": 60, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Adress": "192.168.80.49"},
|
||||
'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": "192.168.80.49"},
|
||||
'collector': {"Chip_Model": "LSW5BLE_17_02B0_1.05", "Collector_Fw_Version": "V1.1.00.0B"},
|
||||
})
|
||||
|
||||
def test_parse_4210(InverterData: bytes):
|
||||
i = InfosG3P()
|
||||
def test_parse_4210(inverter_data: bytes):
|
||||
i = InfosG3P(client_mode=False)
|
||||
i.db.clear()
|
||||
|
||||
for key, update in i.parse (InverterData, 0x42, 1):
|
||||
pass
|
||||
for key, update in i.parse (inverter_data, 0x42, 1):
|
||||
pass # side effect is calling generator i.parse()
|
||||
|
||||
assert json.dumps(i.db) == json.dumps({
|
||||
"controller": {"Power_On_Time": 2051},
|
||||
"inverter": {"Serial_Number": "Y17E00000000000E", "Version": "v4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000, "No_Inputs": 4},
|
||||
"inverter": {"Serial_Number": "Y17E00000000000E", "Version": "V4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000},
|
||||
"env": {"Inverter_Status": 1, "Inverter_Temp": 14},
|
||||
"grid": {"Voltage": 224.8, "Current": 0.73, "Frequency": 50.05, "Output_Power": 165.8},
|
||||
"input": {"pv1": {"Voltage": 35.3, "Current": 1.68, "Power": 59.6, "Daily_Generation": 0.04, "Total_Generation": 30.76},
|
||||
@@ -94,7 +94,7 @@ def test_parse_4210(InverterData: bytes):
|
||||
})
|
||||
|
||||
def test_build_ha_conf1():
|
||||
i = InfosG3P()
|
||||
i = InfosG3P(client_mode=False)
|
||||
i.static_init() # initialize counter
|
||||
|
||||
tests = 0
|
||||
@@ -116,8 +116,19 @@ def test_build_ha_conf1():
|
||||
tests +=1
|
||||
|
||||
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':
|
||||
assert comp == 'sensor'
|
||||
@@ -126,7 +137,7 @@ def test_build_ha_conf1():
|
||||
elif id == 'inv_count_456':
|
||||
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'):
|
||||
@@ -138,8 +149,11 @@ def test_build_ha_conf1():
|
||||
elif id == 'power_pv1_123':
|
||||
assert False
|
||||
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':
|
||||
assert False
|
||||
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"}})
|
||||
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
|
||||
RegisterMap.map[0x420100d8]['eval'] = '(result-32)/1.8'
|
||||
# map PV1_VOLTAGE to invalid register
|
||||
RegisterMap.map[0x420100e0]['reg'] = Register.TEST_REG2
|
||||
# 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'
|
||||
|
||||
i = InfosG3P()
|
||||
i = InfosG3P(client_mode=False)
|
||||
# i.db.clear()
|
||||
|
||||
for key, update in i.parse (InverterData, 0x42, 1):
|
||||
pass
|
||||
assert 12.2222 == round (i.get_db_value(Register.INVERTER_TEMP, 0),4)
|
||||
|
||||
for key, update in i.parse (inverter_data, 0x42, 1):
|
||||
pass # side effect is calling generator i.parse()
|
||||
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
|
||||
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):
|
||||
pass
|
||||
for key, update in i.parse (inverter_data, 0x42, 1):
|
||||
pass # side effect is calling generator i.parse()
|
||||
assert 54 == i.get_db_value(Register.INVERTER_TEMP, 0)
|
||||
|
||||
395
app/tests/test_modbus.py
Normal file
395
app/tests/test_modbus.py
Normal file
@@ -0,0 +1,395 @@
|
||||
# test_with_pytest.py
|
||||
import pytest
|
||||
import asyncio
|
||||
from app.src.modbus import Modbus
|
||||
from app.src.infos import Infos, Register
|
||||
|
||||
pytest_plugins = ('pytest_asyncio',)
|
||||
|
||||
class ModbusTestHelper(Modbus):
|
||||
def __init__(self):
|
||||
super().__init__(self.send_cb)
|
||||
self.db = Infos()
|
||||
self.pdu = None
|
||||
self.send_calls = 0
|
||||
self.recv_responses = 0
|
||||
def send_cb(self, pdu: bytearray, log_lvl: int, state: str):
|
||||
self.pdu = pdu
|
||||
self.send_calls += 1
|
||||
def resp_handler(self):
|
||||
self.recv_responses += 1
|
||||
|
||||
def test_modbus_crc():
|
||||
'''Check CRC-16 calculation'''
|
||||
mb = Modbus(None)
|
||||
assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04')
|
||||
assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b')
|
||||
assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b')
|
||||
|
||||
assert 0xc803 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00')
|
||||
assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8')
|
||||
assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8')
|
||||
|
||||
assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46')
|
||||
msg = b'\x01\x03\x28\x51'
|
||||
msg += b'\x0e\x08\xd3\x00\x29\x13\x87\x00\x3e\x00\x00\x01\x2c\x03\xb4\x00'
|
||||
msg += b'\x08\x00\x00\x00\x00\x01\x59\x01\x21\x03\xe6\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\xe6\xef'
|
||||
assert 0 == mb._Modbus__calc_crc(msg)
|
||||
|
||||
def test_build_modbus_pdu():
|
||||
'''Check building and sending a MODBUS RTU'''
|
||||
mb = ModbusTestHelper()
|
||||
mb.build_msg(1,6,0x2000,0x12)
|
||||
assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07'
|
||||
assert mb._Modbus__check_crc(mb.pdu)
|
||||
assert mb.last_addr == 1
|
||||
assert mb.last_fcode == 6
|
||||
assert mb.last_reg == 0x2000
|
||||
assert mb.last_len == 18
|
||||
assert mb.err == 0
|
||||
|
||||
def test_recv_req():
|
||||
'''Receive a valid request, which must transmitted'''
|
||||
mb = ModbusTestHelper()
|
||||
assert mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07')
|
||||
assert mb.last_fcode == 6
|
||||
assert mb.last_reg == 0x2000
|
||||
assert mb.last_len == 0x12
|
||||
assert mb.err == 0
|
||||
|
||||
def test_recv_req_crc_err():
|
||||
'''Receive a request with invalid CRC, which must be dropped'''
|
||||
mb = ModbusTestHelper()
|
||||
assert not mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08')
|
||||
assert mb.pdu == None
|
||||
assert mb.last_fcode == 0
|
||||
assert mb.last_reg == 0
|
||||
assert mb.last_len == 0
|
||||
assert mb.err == 1
|
||||
|
||||
def test_recv_resp_crc_err():
|
||||
'''Receive a response with invalid CRC, which must be dropped'''
|
||||
mb = ModbusTestHelper()
|
||||
# simulate a transmitted request
|
||||
mb.req_pend = True
|
||||
mb.last_addr = 1
|
||||
mb.last_fcode = 3
|
||||
mb.last_reg = 0x300e
|
||||
mb.last_len = 2
|
||||
# check matching response, but with CRC error
|
||||
call = 0
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'):
|
||||
call += 1
|
||||
assert mb.err == 1
|
||||
assert 0 == call
|
||||
assert mb.req_pend == True
|
||||
# cleanup queue
|
||||
mb._Modbus__stop_timer()
|
||||
assert not mb.req_pend
|
||||
|
||||
def test_recv_resp_invalid_addr():
|
||||
'''Receive a response with wrong server addr, which must be dropped'''
|
||||
mb = ModbusTestHelper()
|
||||
mb.req_pend = True
|
||||
# simulate a transmitted request
|
||||
mb.last_addr = 1
|
||||
mb.last_fcode = 3
|
||||
mb.last_reg = 0x300e
|
||||
mb.last_len = 2
|
||||
|
||||
# check not matching response, with wrong server addr
|
||||
call = 0
|
||||
for key, update in mb.recv_resp(mb.db, b'\x02\x03\x04\x01\x2c\x00\x46\x88\xf4', 'test'):
|
||||
call += 1
|
||||
assert mb.err == 2
|
||||
assert 0 == call
|
||||
assert mb.req_pend == True
|
||||
assert mb.que.qsize() == 0
|
||||
|
||||
# cleanup queue
|
||||
mb._Modbus__stop_timer()
|
||||
assert not mb.req_pend
|
||||
|
||||
def test_recv_recv_fcode():
|
||||
'''Receive a response with wrong function code, which must be dropped'''
|
||||
mb = ModbusTestHelper()
|
||||
mb.build_msg(1,4,0x300e,2)
|
||||
assert mb.que.qsize() == 0
|
||||
assert mb.req_pend
|
||||
|
||||
# check not matching response, with wrong function code
|
||||
call = 0
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
|
||||
call += 1
|
||||
|
||||
assert mb.err == 3
|
||||
assert 0 == call
|
||||
assert mb.req_pend == True
|
||||
assert mb.que.qsize() == 0
|
||||
|
||||
# cleanup queue
|
||||
mb._Modbus__stop_timer()
|
||||
assert not mb.req_pend
|
||||
|
||||
def test_recv_resp_len():
|
||||
'''Receive a response with wrong data length, which must be dropped'''
|
||||
mb = ModbusTestHelper()
|
||||
mb.build_msg(1,3,0x300e,3)
|
||||
assert mb.que.qsize() == 0
|
||||
assert mb.req_pend
|
||||
assert mb.last_len == 3
|
||||
|
||||
# check not matching response, with wrong data length
|
||||
call = 0
|
||||
for key, update, _ in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
|
||||
call += 1
|
||||
|
||||
assert mb.err == 4
|
||||
assert 0 == call
|
||||
assert mb.req_pend == True
|
||||
assert mb.que.qsize() == 0
|
||||
|
||||
# cleanup queue
|
||||
mb._Modbus__stop_timer()
|
||||
assert not mb.req_pend
|
||||
|
||||
def test_recv_unexpect_resp():
|
||||
'''Receive a response when we havb't sent a request'''
|
||||
mb = ModbusTestHelper()
|
||||
assert not mb.req_pend
|
||||
|
||||
# check unexpected response, which must be dropped
|
||||
call = 0
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
|
||||
call += 1
|
||||
|
||||
assert mb.err == 5
|
||||
assert 0 == call
|
||||
assert mb.req_pend == False
|
||||
assert mb.que.qsize() == 0
|
||||
|
||||
def test_parse_resp():
|
||||
'''Receive matching response and parse the values'''
|
||||
mb = ModbusTestHelper()
|
||||
mb.build_msg(1,3,0x3007,6)
|
||||
assert mb.que.qsize() == 0
|
||||
assert mb.req_pend
|
||||
|
||||
call = 0
|
||||
exp_result = ['V0.0.2C', 4.4, 0.7, 0.7, 30]
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'):
|
||||
if key == 'grid':
|
||||
assert update == True
|
||||
elif key == 'inverter':
|
||||
assert update == True
|
||||
elif key == 'env':
|
||||
assert update == True
|
||||
else:
|
||||
assert False
|
||||
assert exp_result[call] == val
|
||||
call += 1
|
||||
assert 0 == mb.err
|
||||
assert 5 == call
|
||||
assert mb.que.qsize() == 0
|
||||
assert not mb.req_pend
|
||||
|
||||
def test_queue():
|
||||
mb = ModbusTestHelper()
|
||||
mb.build_msg(1,3,0x3022,4)
|
||||
assert mb.que.qsize() == 0
|
||||
assert mb.req_pend
|
||||
|
||||
assert mb.send_calls == 1
|
||||
assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03'
|
||||
mb.pdu = None
|
||||
assert mb.send_calls == 1
|
||||
assert mb.pdu == None
|
||||
|
||||
assert mb.que.qsize() == 0
|
||||
|
||||
# cleanup queue
|
||||
mb._Modbus__stop_timer()
|
||||
assert not mb.req_pend
|
||||
|
||||
def test_queue2():
|
||||
'''Check queue handling for build_msg() calls'''
|
||||
mb = ModbusTestHelper()
|
||||
mb.build_msg(1,3,0x3007,6)
|
||||
mb.build_msg(1,6,0x2008,4)
|
||||
assert mb.que.qsize() == 1
|
||||
assert mb.req_pend
|
||||
mb.build_msg(1,3,0x3007,6)
|
||||
assert mb.que.qsize() == 2
|
||||
assert mb.req_pend
|
||||
|
||||
assert mb.send_calls == 1
|
||||
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
|
||||
call = 0
|
||||
exp_result = ['V0.0.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'):
|
||||
if key == 'grid':
|
||||
assert update == True
|
||||
elif key == 'inverter':
|
||||
assert update == True
|
||||
elif key == 'env':
|
||||
assert update == True
|
||||
else:
|
||||
assert False
|
||||
assert exp_result[call] == val
|
||||
call += 1
|
||||
assert 0 == mb.err
|
||||
assert 5 == call
|
||||
|
||||
assert mb.que.qsize() == 1
|
||||
assert mb.send_calls == 2
|
||||
assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
|
||||
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
|
||||
pass # call generator mb.recv_resp()
|
||||
|
||||
assert mb.que.qsize() == 0
|
||||
assert mb.send_calls == 3
|
||||
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
|
||||
call = 0
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'):
|
||||
call += 1
|
||||
assert 0 == mb.err
|
||||
assert 5 == call
|
||||
|
||||
assert mb.que.qsize() == 0
|
||||
assert not mb.req_pend
|
||||
|
||||
def test_queue3():
|
||||
'''Check queue handling for recv_req() calls'''
|
||||
mb = ModbusTestHelper()
|
||||
assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t', mb.resp_handler)
|
||||
assert mb.recv_req(b'\x01\x06\x20\x08\x00\x04\x02\x0b', mb.resp_handler)
|
||||
assert mb.que.qsize() == 1
|
||||
assert mb.req_pend
|
||||
assert mb.recv_req(b'\x01\x03\x30\x07\x00\x06{\t')
|
||||
assert mb.que.qsize() == 2
|
||||
assert mb.req_pend
|
||||
|
||||
assert mb.send_calls == 1
|
||||
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
|
||||
assert mb.recv_responses == 0
|
||||
|
||||
call = 0
|
||||
exp_result = ['V0.0.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'):
|
||||
if key == 'grid':
|
||||
assert update == True
|
||||
elif key == 'inverter':
|
||||
assert update == True
|
||||
elif key == 'env':
|
||||
assert update == True
|
||||
else:
|
||||
assert False
|
||||
assert exp_result[call] == val
|
||||
call += 1
|
||||
assert 0 == mb.err
|
||||
assert 5 == call
|
||||
assert mb.recv_responses == 1
|
||||
|
||||
assert mb.que.qsize() == 1
|
||||
assert mb.send_calls == 2
|
||||
assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
|
||||
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
|
||||
pass # no code in loop is OK; calling the generator is the purpose
|
||||
assert 0 == mb.err
|
||||
assert mb.recv_responses == 2
|
||||
|
||||
assert mb.que.qsize() == 0
|
||||
assert mb.send_calls == 3
|
||||
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
|
||||
call = 0
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x0c\x01\x2c\x00\x2c\x00\x2c\x00\x46\x00\x46\x00\x46\x32\xc8', 'test'):
|
||||
call += 1
|
||||
assert 0 == mb.err
|
||||
assert mb.recv_responses == 2
|
||||
assert 5 == call
|
||||
|
||||
|
||||
assert mb.que.qsize() == 0
|
||||
assert not mb.req_pend
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout():
|
||||
'''Test MODBUS response timeout and RTU retransmitting'''
|
||||
assert asyncio.get_running_loop()
|
||||
mb = ModbusTestHelper()
|
||||
mb.max_retries = 2
|
||||
mb.timeout = 0.1 # 100ms timeout for fast testing, expect a time resolution of at least 10ms
|
||||
assert asyncio.get_running_loop() == mb.loop
|
||||
mb.build_msg(1,3,0x3007,6)
|
||||
mb.build_msg(1,6,0x2008,4)
|
||||
|
||||
assert mb.que.qsize() == 1
|
||||
assert mb.req_pend
|
||||
assert mb.retry_cnt == 0
|
||||
assert mb.send_calls == 1
|
||||
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
|
||||
|
||||
mb.pdu = None
|
||||
await asyncio.sleep(0.11) # wait for first timeout and retransmittion
|
||||
assert mb.que.qsize() == 1
|
||||
assert mb.req_pend
|
||||
assert mb.retry_cnt == 1
|
||||
assert mb.send_calls == 2
|
||||
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
|
||||
|
||||
mb.pdu = None
|
||||
await asyncio.sleep(0.11) # wait for second timeout and retransmittion
|
||||
assert mb.que.qsize() == 1
|
||||
assert mb.req_pend
|
||||
assert mb.retry_cnt == 2
|
||||
assert mb.send_calls == 3
|
||||
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
|
||||
|
||||
mb.pdu = None
|
||||
await asyncio.sleep(0.11) # wait for third timeout and next pdu
|
||||
assert mb.que.qsize() == 0
|
||||
assert mb.req_pend
|
||||
assert mb.retry_cnt == 0
|
||||
assert mb.send_calls == 4
|
||||
assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
|
||||
|
||||
mb.max_retries = 0 # next pdu without retranmsission
|
||||
await asyncio.sleep(0.11) # wait for fourth timout
|
||||
assert mb.que.qsize() == 0
|
||||
assert not mb.req_pend
|
||||
assert mb.retry_cnt == 0
|
||||
assert mb.send_calls == 4
|
||||
|
||||
def test_recv_unknown_data():
|
||||
'''Receive a response with an unknwon register'''
|
||||
mb = ModbusTestHelper()
|
||||
assert 0x9000 not in mb.map
|
||||
mb.map[0x9000] = {'reg': Register.TEST_REG1, 'fmt': '!H', 'ratio': 1}
|
||||
|
||||
mb.build_msg(1,3,0x9000,2)
|
||||
|
||||
# check matching response, but with CRC error
|
||||
call = 0
|
||||
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf4', 'test'):
|
||||
call += 1
|
||||
assert mb.err == 0
|
||||
assert 0 == call
|
||||
assert not mb.req_pend
|
||||
|
||||
del mb.map[0x9000]
|
||||
|
||||
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
@@ -1,6 +1,3 @@
|
||||
|
||||
version: '3.0'
|
||||
|
||||
services:
|
||||
####### H O M E - A S S I S T A N T #####
|
||||
home-assistant:
|
||||
@@ -34,7 +31,7 @@ services:
|
||||
ports:
|
||||
- 8123:8123
|
||||
volumes:
|
||||
- ${PROJECT_DIR}./homeassistant/config:/config
|
||||
- ${PROJECT_DIR:-./}homeassistant/config:/config
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: curl --fail http://0.0.0.0:8123/auth/providers || exit 1
|
||||
@@ -56,18 +53,18 @@ services:
|
||||
expose:
|
||||
- 1883
|
||||
volumes:
|
||||
- ${PROJECT_DIR}./mosquitto/config:/mosquitto/config
|
||||
- ${PROJECT_DIR}./mosquitto/data:/mosquitto/data
|
||||
- ${PROJECT_DIR:-./}mosquitto/config:/mosquitto/config
|
||||
- ${PROJECT_DIR:-./}mosquitto/data:/mosquitto/data
|
||||
networks:
|
||||
outside:
|
||||
ipv4_address: 172.28.1.5 # static IP required to receive mDNS traffic
|
||||
|
||||
- outside
|
||||
|
||||
|
||||
|
||||
####### T S U N - P R O X Y ######
|
||||
tsun-proxy:
|
||||
container_name: tsun-proxy
|
||||
image: ghcr.io/s-allius/tsun-gen3-proxy:latest
|
||||
# image: ghcr.io/s-allius/tsun-gen3-proxy:rc
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mqtt
|
||||
@@ -77,13 +74,18 @@ services:
|
||||
- GID=${GID:-1000}
|
||||
dns:
|
||||
- ${DNS1:-8.8.8.8}
|
||||
- $(DNS2:-4.4.4.4}
|
||||
- ${DNS2:-4.4.4.4}
|
||||
ports:
|
||||
- 5005:5005
|
||||
- 8127:8127
|
||||
- 10000:10000
|
||||
volumes:
|
||||
- ${PROJECT_DIR}./tsun-proxy/log:/home/tsun-proxy/log
|
||||
- ${PROJECT_DIR}./tsun-proxy/config:/home/tsun-proxy/config
|
||||
- ${PROJECT_DIR:-./}tsun-proxy/log:/home/tsun-proxy/log
|
||||
- ${PROJECT_DIR:-./}tsun-proxy/config:/home/tsun-proxy/config
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:8127/-/healthy || exit 1
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
networks:
|
||||
- outside
|
||||
|
||||
@@ -93,11 +95,4 @@ services:
|
||||
networks:
|
||||
outside:
|
||||
name: home-assistant
|
||||
external: true
|
||||
ipam:
|
||||
driver: default
|
||||
config:
|
||||
- subnet: 172.28.1.0/26
|
||||
ip_range: 172.28.1.32/27
|
||||
gateway: 172.28.1.62
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# test_with_pytest.py and scapy
|
||||
#
|
||||
import pytest, socket, time
|
||||
#from scapy.all import *
|
||||
#from scapy.layers.inet import IP, TCP, TCP_client
|
||||
|
||||
def get_sn() -> bytes:
|
||||
return b'R170000000000001'
|
||||
@@ -120,9 +118,7 @@ def MsgOtaUpdateReq(): # Over the air update request from talent cloud
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ClientConnection():
|
||||
#host = '172.16.30.7'
|
||||
host = 'logger.talent-monitoring.com'
|
||||
#host = '127.0.0.1'
|
||||
port = 5005
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((host, port))
|
||||
@@ -132,9 +128,7 @@ def ClientConnection():
|
||||
s.close()
|
||||
|
||||
def tempClientConnection():
|
||||
#host = '172.16.30.7'
|
||||
host = 'logger.talent-monitoring.com'
|
||||
#host = '127.0.0.1'
|
||||
port = 5005
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((host, port))
|
||||
@@ -148,7 +142,6 @@ def test_open_close():
|
||||
pass
|
||||
except:
|
||||
assert False
|
||||
assert True
|
||||
|
||||
def test_send_contact_info1(ClientConnection, MsgContactInfo, MsgContactResp):
|
||||
s = ClientConnection
|
||||
@@ -166,7 +159,7 @@ def test_send_contact_info2(ClientConnection, MsgContactInfo2, MsgContactInfo, M
|
||||
s.sendall(MsgContactInfo2)
|
||||
data = s.recv(1024)
|
||||
except TimeoutError:
|
||||
assert True
|
||||
pass
|
||||
else:
|
||||
assert False
|
||||
|
||||
@@ -198,7 +191,7 @@ def test_send_contact_resp(ClientConnection, MsgContactResp):
|
||||
s.sendall(MsgContactResp)
|
||||
data = s.recv(1024)
|
||||
except TimeoutError:
|
||||
assert True
|
||||
pass
|
||||
else:
|
||||
assert data == b''
|
||||
|
||||
@@ -224,7 +217,7 @@ def test_send_inv_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgI
|
||||
data = s.recv(1024)
|
||||
except TimeoutError:
|
||||
pass
|
||||
# time.sleep(32.5)
|
||||
# time.sleep(32.5)
|
||||
# assert data == MsgTimeStampResp
|
||||
try:
|
||||
s.sendall(MsgInvData)
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
import pytest, socket, time, os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
#from scapy.all import *
|
||||
#from scapy.layers.inet import IP, TCP, TCP_client
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SOLARMAN_SNR = os.getenv('SOLARMAN_SNR', '00000080')
|
||||
@@ -19,6 +16,9 @@ def get_inv_no() -> bytes:
|
||||
def get_invalid_sn():
|
||||
return b'R170000000000002'
|
||||
|
||||
def correct_checksum(buf):
|
||||
checksum = sum(buf[1:]) & 0xff
|
||||
return checksum.to_bytes(length=1)
|
||||
|
||||
@pytest.fixture
|
||||
def MsgContactInfo(): # Contact Info message
|
||||
@@ -61,10 +61,11 @@ def MsgDataInd():
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x01\x12\x02\x12\x12\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x40\x10\x08\xd8\x00\x09\x13\x84\x00\x35\x00\x00\x02\x58\x00\xd8'
|
||||
msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18'
|
||||
msg += b'\x01\x3f\x00\x17\x00\x4d\x01\x44\x00\x14\x00\x43\x01\x45\x00\x18'
|
||||
msg += b'\x00\x52\x00\x12\x00\x01\x00\x00\x00\x7c\x00\x00\x24\xed\x00\x2c'
|
||||
msg += b'\x00\x00\x0b\x10\x00\x26\x00\x00\x0a\x0f\x00\x30\x00\x00\x0b\x76'
|
||||
msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x00\x55\xaa\x00\x01\x00\x00'
|
||||
|
||||
msg += b'\x00\x00\x00\x00\x06\x16\x00\x00\x00\x01\x55\xaa\x00\x01\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\xff\xff\x07\xd0\x00\x03\x04\x00\x04\x00\x04\x00'
|
||||
msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00'
|
||||
msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41'
|
||||
@@ -73,7 +74,9 @@ def MsgDataInd():
|
||||
msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00'
|
||||
msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x24\x15'
|
||||
msg += b'\x00\x00\x00\x00'
|
||||
msg += correct_checksum(msg)
|
||||
msg += b'\x15'
|
||||
return msg
|
||||
|
||||
@pytest.fixture
|
||||
@@ -83,14 +86,29 @@ def MsgDataResp(): # Contact Response message
|
||||
return msg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def MsgInvalidInfo(): # Contact Info message wrong start byte
|
||||
msg = b'\x47\xd4\x00\x10\x41\x00\x01' +get_sn() +b'\x02\xba\xd2\x00\x00'
|
||||
msg += b'\x19\x00\x00\x00\x00\x00\x00\x00\x05\x3c\x78\x01\x64\x01\x4c\x53'
|
||||
msg += b'\x57\x35\x42\x4c\x45\x5f\x31\x37\x5f\x30\x32\x42\x30\x5f\x31\x2e'
|
||||
msg += b'\x30\x35\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x40\x2a\x8f\x4f\x51\x54\x31\x39\x32\x2e'
|
||||
msg += b'\x31\x36\x38\x2e\x38\x30\x2e\x34\x39\x00\x00\x00\x0f\x00\x01\xb0'
|
||||
msg += b'\x02\x0f\x00\xff\x56\x31\x2e\x31\x2e\x30\x30\x2e\x30\x42\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xfe\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x41\x6c\x6c\x69\x75\x73\x2d\x48\x6f'
|
||||
msg += b'\x6d\x65\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x3c'
|
||||
msg += b'\x15'
|
||||
return msg
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ClientConnection():
|
||||
#host = '172.16.30.7'
|
||||
host = 'logger.talent-monitoring.com'
|
||||
#host = 'iot.talent-monitoring.com'
|
||||
#host = '127.0.0.1'
|
||||
port = 10000
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((host, port))
|
||||
@@ -107,10 +125,7 @@ def checkResponse(data, Msg):
|
||||
|
||||
|
||||
def tempClientConnection():
|
||||
#host = '172.16.30.7'
|
||||
host = 'logger.talent-monitoring.com'
|
||||
#host = 'iot.talent-monitoring.com'
|
||||
#host = '127.0.0.1'
|
||||
port = 10000
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((host, port))
|
||||
@@ -121,11 +136,10 @@ def tempClientConnection():
|
||||
|
||||
def test_open_close():
|
||||
try:
|
||||
for s in tempClientConnection():
|
||||
pass
|
||||
for _ in tempClientConnection():
|
||||
pass # test generator tempClientConnection()
|
||||
except:
|
||||
assert False
|
||||
assert True
|
||||
|
||||
def test_conn_msg(ClientConnection,MsgContactInfo, MsgContactResp):
|
||||
s = ClientConnection
|
||||
@@ -147,4 +161,24 @@ def test_data_ind(ClientConnection,MsgDataInd, MsgDataResp):
|
||||
except TimeoutError:
|
||||
pass
|
||||
# time.sleep(2.5)
|
||||
checkResponse(data, MsgDataResp)
|
||||
checkResponse(data, MsgDataResp)
|
||||
|
||||
def test_inavlid_msg(ClientConnection,MsgInvalidInfo,MsgContactInfo, MsgContactResp):
|
||||
s = ClientConnection
|
||||
try:
|
||||
s.sendall(MsgInvalidInfo)
|
||||
# time.sleep(2.5)
|
||||
data = s.recv(1024)
|
||||
except TimeoutError:
|
||||
pass
|
||||
# time.sleep(2.5)
|
||||
try:
|
||||
s.sendall(MsgContactInfo)
|
||||
# time.sleep(2.5)
|
||||
data = s.recv(1024)
|
||||
except TimeoutError:
|
||||
pass
|
||||
# time.sleep(2.5)
|
||||
checkResponse(data, MsgContactResp)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user