Compare commits

..

48 Commits

Author SHA1 Message Date
Stefan Allius
3f4997a0fd Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy 2024-12-22 23:55:11 +01:00
Stefan Allius
2220517b55 Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy into dev-0.12 2024-12-22 22:44:29 +01:00
Stefan Allius
55c403a754 Dev 0.12 (#258)
* add ha_addons repository to cscode workspace

* Issue220 ha addon dokumentation update (#232)

* initial DOCS.md for Addon

* links to Mosquitto and Adguard

* replaced _ by . for PV-Strings

* mentioned add-on installation method in README.md

* fix most of the markdown linter warnings

* add missing alt texts

* added nice add repository to my Home Assistant badges

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
Co-authored-by: Stefan Allius <stefan.allius@t-online.de>

* S allius/issue216 (#235)


* improve docker run

- establish multistage Dockerfile
- build a python wheel for all needed packages
- remove unneeded tools like apk for runtime

* pin versions, fix hadolint warnings

* merge from dev-0.12

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* Issue220 ha addon dokumentation update (#245)

* revised config disclaimer

* add newline at end of file to fix linter warning

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* 238 ha addon repository check (#244)

* move Makefile and bake file into parent folder

* build config.yaml from template

* use Makefile instead of build shell script

* ignore temporary or created files

* add rules for building the add-on repository

* add rel version of add-on

* add  jinja2-cli

* ignore inverter replays which a older than 1 day (#246)

* S allius/issue7 (#248)

* report alarm and fault bitfield to ha

* define the alarm and fault names

* configure log path and max number of daily log files (#243)

* configure log path and max number of daily log files

* don't use a subfolder for configs

* use make instead of a build script

* mount /homeassistant/tsun-proxy

* Add venv to base image

* give write access to mounted folder

* intial checkin, ignore SC1091

* set advanced and stage value in config.yaml

* fix typo

* added watchdog and removed Port 8127 from mapping

* fixed typo and use new add-on repro

- change the install button to install from
 https://github.com/s-allius/ha-addons

* add addon-rel target

* disable watchdog due to exceptions in the ha supervisor

* update changelog

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* Update README.md (#251)

install `https://github.com/s-allius/ha-addons` as repro for our add-on

* add german language file (#253)

* fix return type get_extra_info in FakeWriter

* move global startup code into main methdod

* pin version of base image

* avoid forwarding to a private (lokal) IP addr (#256)

* avoid forwarding to a private (lokal) IP addr

* test DNS resolver issues

* increase test coverage

* update changelog

* fix client_mode configuration block (#252)

* fix client_mode block

* add client mode

* fix tests with client_mode values

* log client_mode configuration

* add forward flag for client_mode

* improve startup logging

* added client_mode example

* adjusted translation files

* AT commands added

* typo

* missing "PLUS"

* link to config details

* improve log msg for config problems

* improve log msg on config errors

* improve log msg for config problems

* copy CHANGELOG.md into add-on repro

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* rename "ConfigErr" to match naming convention

* disable test coverage for __main__

* update changelog version 0.12

---------

Co-authored-by: metzi <147942647+mime24@users.noreply.github.com>
Co-authored-by: Michael Metz <michael.metz@siemens.com>
2024-12-22 22:35:12 +01:00
Stefan Allius
3bf245300d Dev 0.12 (#257)
* add ha_addons repository to cscode workspace

* Issue220 ha addon dokumentation update (#232)

* initial DOCS.md for Addon

* links to Mosquitto and Adguard

* replaced _ by . for PV-Strings

* mentioned add-on installation method in README.md

* fix most of the markdown linter warnings

* add missing alt texts

* added nice add repository to my Home Assistant badges

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
Co-authored-by: Stefan Allius <stefan.allius@t-online.de>

* S allius/issue216 (#235)


* improve docker run

- establish multistage Dockerfile
- build a python wheel for all needed packages
- remove unneeded tools like apk for runtime

* pin versions, fix hadolint warnings

* merge from dev-0.12

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* Issue220 ha addon dokumentation update (#245)

* revised config disclaimer

* add newline at end of file to fix linter warning

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* 238 ha addon repository check (#244)

* move Makefile and bake file into parent folder

* build config.yaml from template

* use Makefile instead of build shell script

* ignore temporary or created files

* add rules for building the add-on repository

* add rel version of add-on

* add  jinja2-cli

* ignore inverter replays which a older than 1 day (#246)

* S allius/issue7 (#248)

* report alarm and fault bitfield to ha

* define the alarm and fault names

* configure log path and max number of daily log files (#243)

* configure log path and max number of daily log files

* don't use a subfolder for configs

* use make instead of a build script

* mount /homeassistant/tsun-proxy

* Add venv to base image

* give write access to mounted folder

* intial checkin, ignore SC1091

* set advanced and stage value in config.yaml

* fix typo

* added watchdog and removed Port 8127 from mapping

* fixed typo and use new add-on repro

- change the install button to install from
 https://github.com/s-allius/ha-addons

* add addon-rel target

* disable watchdog due to exceptions in the ha supervisor

* update changelog

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* Update README.md (#251)

install `https://github.com/s-allius/ha-addons` as repro for our add-on

* add german language file (#253)

* fix return type get_extra_info in FakeWriter

* move global startup code into main methdod

* pin version of base image

* avoid forwarding to a private (lokal) IP addr (#256)

* avoid forwarding to a private (lokal) IP addr

* test DNS resolver issues

* increase test coverage

* update changelog

* fix client_mode configuration block (#252)

* fix client_mode block

* add client mode

* fix tests with client_mode values

* log client_mode configuration

* add forward flag for client_mode

* improve startup logging

* added client_mode example

* adjusted translation files

* AT commands added

* typo

* missing "PLUS"

* link to config details

* improve log msg for config problems

* improve log msg on config errors

* improve log msg for config problems

* copy CHANGELOG.md into add-on repro

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>

* rename "ConfigErr" to match naming convention

* disable test coverage for __main__

* update changelog version 0.12

---------

Co-authored-by: metzi <147942647+mime24@users.noreply.github.com>
Co-authored-by: Michael Metz <michael.metz@siemens.com>
2024-12-22 22:25:50 +01:00
Stefan Allius
b28792a654 update changelog version 0.12 2024-12-22 21:51:10 +01:00
Stefan Allius
d4ecbfe2b3 disable test coverage for __main__ 2024-12-22 20:39:14 +01:00
Stefan Allius
84dfd17d7b Merge branch 'dev-0.12' of https://github.com/s-allius/tsun-gen3-proxy into dev-0.12 2024-12-22 20:33:46 +01:00
Stefan Allius
7067977d78 rename "ConfigErr" to match naming convention 2024-12-22 20:33:13 +01:00
Stefan Allius
f227faa16e fix client_mode configuration block (#252)
* fix client_mode block

* add client mode

* fix tests with client_mode values

* log client_mode configuration

* add forward flag for client_mode

* improve startup logging

* added client_mode example

* adjusted translation files

* AT commands added

* typo

* missing "PLUS"

* link to config details

* improve log msg for config problems

* improve log msg on config errors

* improve log msg for config problems

* copy CHANGELOG.md into add-on repro

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
2024-12-22 20:16:46 +01:00
Stefan Allius
9572fbf609 avoid forwarding to a private (lokal) IP addr (#256)
* avoid forwarding to a private (lokal) IP addr

* test DNS resolver issues

* increase test coverage

* update changelog
2024-12-22 19:44:36 +01:00
Stefan Allius
3fddbe9456 pin version of base image 2024-12-22 02:55:49 +01:00
Stefan Allius
34c09edb07 move global startup code into main methdod 2024-12-22 02:54:43 +01:00
Stefan Allius
ef3ed5d287 fix return type get_extra_info in FakeWriter 2024-12-22 02:51:21 +01:00
Stefan Allius
7878c54ecc add german language file (#253) 2024-12-18 20:31:42 +01:00
Stefan Allius
097e0d2104 Update README.md (#251)
install `https://github.com/s-allius/ha-addons` as repro for our add-on
2024-12-17 22:52:13 +01:00
Stefan Allius
181a22c369 configure log path and max number of daily log files (#243)
* configure log path and max number of daily log files

* don't use a subfolder for configs

* use make instead of a build script

* mount /homeassistant/tsun-proxy

* Add venv to base image

* give write access to mounted folder

* intial checkin, ignore SC1091

* set advanced and stage value in config.yaml

* fix typo

* added watchdog and removed Port 8127 from mapping

* fixed typo and use new add-on repro

- change the install button to install from
 https://github.com/s-allius/ha-addons

* add addon-rel target

* disable watchdog due to exceptions in the ha supervisor

* update changelog

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
2024-12-17 22:09:51 +01:00
Stefan Allius
89ab2eea3f S allius/issue7 (#248)
* report alarm and fault bitfield to ha

* define the alarm and fault names
2024-12-17 20:47:32 +01:00
Stefan Allius
264e09651f ignore inverter replays which a older than 1 day (#246) 2024-12-16 21:09:32 +01:00
Stefan Allius
3bb360d359 238 ha addon repository check (#244)
* move Makefile and bake file into parent folder

* build config.yaml from template

* use Makefile instead of build shell script

* ignore temporary or created files

* add rules for building the add-on repository

* add rel version of add-on

* add  jinja2-cli
2024-12-15 17:16:26 +01:00
Stefan Allius
8731d1bae3 Issue220 ha addon dokumentation update (#245)
* revised config disclaimer

* add newline at end of file to fix linter warning

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
2024-12-15 16:52:13 +01:00
Stefan Allius
4f0918ef83 S allius/issue216 (#235)
* improve docker run

- establish multistage Dockerfile
- build a python wheel for all needed packages
- remove unneeded tools like apk for runtime

* pin versions, fix hadolint warnings

* merge from dev-0.12

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
2024-12-13 23:54:17 +01:00
metzi
5501288b51 Issue220 ha addon dokumentation update (#232)
* initial DOCS.md for Addon

* links to Mosquitto and Adguard

* replaced _ by . for PV-Strings

* mentioned add-on installation method in README.md

* fix most of the markdown linter warnings

* add missing alt texts

* added nice add repository to my Home Assistant badges

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
Co-authored-by: Stefan Allius <stefan.allius@t-online.de>
2024-12-13 23:35:40 +01:00
Stefan Allius
851122515e add ha_addons repository to cscode workspace 2024-12-11 00:25:56 +01:00
Stefan Allius
badc065b7a Merge pull request #242 from s-allius/ha-repro
move ha repro file into root dir
2024-12-10 19:09:33 +01:00
Stefan Allius
aea6cc9763 move file into root dir 2024-12-10 19:06:29 +01:00
Stefan Allius
92d1e648ae Merge pull request #241 from s-allius/renovate/python-3.x
Update python Docker tag
2024-12-09 21:58:41 +01:00
renovate[bot]
879b6608b3 Update python Docker tag 2024-12-09 20:56:38 +00:00
Stefan Allius
b69e7e2242 Merge pull request #240 from s-allius/renovate/aiohttp-3.x
Update dependency aiohttp to v3.11.10
2024-12-09 21:54:28 +01:00
renovate[bot]
0913fde126 Update dependency aiohttp to v3.11.10 2024-12-09 20:50:01 +00:00
Stefan Allius
bedbe08eeb Merge pull request #237 from s-allius/dev-0.12
Dev 0.12
2024-12-08 18:59:50 +01:00
Stefan Allius
3c81d446dd update changelog 2024-12-08 18:57:40 +01:00
Stefan Allius
b335881500 S allius/issue217 2 (#230)
* add some reader classes to get the configuration

* adapt unittests

* get config from json or toml file

* loop over all config readers to get the configuration

* rename config test files

* use relative paths for coverage test in vscode

* do not throw an error for missing config files

* remove obsolete tests

* use dotted key notation for pv sub dictonary

* log config reading progress

* remove create_config_toml.py

* remove obsolete tests for the ha_addon

* disable mosquitto tests if the server is down

* ignore main method for test coverage

* increase test coverage

* pytest-cov: use relative_files only on github, so coverage will work with vscode locally

* remove unneeded imports

* add missing test cases

* disable branch coverage, cause its not reachable
2024-12-08 13:25:04 +01:00
Stefan Allius
ac7b02bde9 init act_config, def_config even without init() call 2024-12-03 22:49:38 +01:00
Stefan Allius
47a89c269f fix some flake8 warnings 2024-12-03 22:48:52 +01:00
Stefan Allius
be3b4d6df0 S allius/issue206 (#213)
* update changelog

* add addon-dev target

* initial version

* use prebuild docker image

* initial version for multi arch images

* fix missing label latest

* create log and config folder first.

* clean up and translate to english

* set labels with docker bake

* add addon-debug and addon-dev targets

* pass version number to proxy at runtime

* add two more callbacks

* get addon version from app

* deploy rc addon container to ghcr

* move ha_addon test into subdir

* fix crash on container restart

- mkdir -p returns no error even if the director
  exists

* prepation for unit testing

- move script into a method

* added further config to schema

* typo fixed

* added monitor_sn + PV-strings 3-6 to create toml

* added options.json for testing

* prepare pytest and coverage for addons

* fix missing values in resulting config.toml
- define mqtt default values
- convert filter configuration

* first running unittest for addons

* add ha_addons

* increase test coverage

* test empty options.json file for HA AddOn

* fix pytest call in terminal

* improve test coverage

* remove uneeded options.json

* move config.py into subdir cnf

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
2024-12-03 22:22:50 +01:00
Stefan Allius
a5b2b4b7c2 S allius/issue217 (#229)
* move config.py into a sub directory cnf

* adapt unit test

* split config class

- use depency injection to get config

* increase test coverage
2024-12-03 22:02:23 +01:00
Stefan Allius
668c631018 S allius/issue222 (#223)
* github action: use ubuntu 24.04 and sonar-scanner-action 4
2024-12-02 23:41:58 +01:00
Stefan Allius
07c989a305 increase mqtt timeout to 10s 2024-12-02 23:11:30 +01:00
Stefan Allius
28cf875533 migrate paho.mqtt CallbackAPIVersion to VERSION2 (#225) 2024-12-02 22:49:56 +01:00
Stefan Allius
9bae905c08 Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy into dev-0.12 2024-11-29 21:38:23 +01:00
metzi
45b57109a8 Add on (#212)
* added service to transfer Add-on config from options.json to config.toml

* added feature to get MQTT config from Homeassistant

current version is MVP. can run as Home Assistant Add-On, config.toml is automatically created from option parameters in the add-on configuration tab.

* fix pylance and flake8 warnings

* prepare building a ha addon

- move build script into root dir
- cp source files in addon build-tree

* ignore proxy source files in addon build tree

* move proxy source files in own directory

* remove duplicates source files from repro

* check for a valis SONAR_TOKEN

* rename add_on path

* prepare for unittests and coverage measurement

* move file cause of the changes pathname

* move the proxy dir to /home/proxy

* build addon with make now

* remove duplicated requirements.txt file from repo

* undo changes

---------

Co-authored-by: Michael Metz <michael.metz@siemens.com>
Co-authored-by: Stefan Allius <stefan.allius@t-online.de>
2024-11-29 21:02:19 +01:00
Stefan Allius
2c69044bf8 initial test version 2024-11-24 22:26:55 +01:00
Stefan Allius
3bada76516 S allius/pytest (#211)
* - fix pytest setup that can be startet from the rootdir
  - support python venv environment
  - add pytest.ini
  - move common settings from .vscode/settings.json into pytest.ini
  - add missing requirements
  - fix import paths for pytests

* - support python venv environment

* initial version

* - add missing requirements python-dotenv

* fix import paths for pytests

* fix pytest warnings

* initial version

* report 5 slowest test durations

* add more vscode settings for python
2024-11-24 22:07:43 +01:00
Stefan Allius
84231c034c specify more offset of the 0x4110 message 2024-11-23 16:31:44 +01:00
Stefan Allius
d4fd396dcf Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy into dev-0.12 2024-11-20 22:09:53 +01:00
dependabot[bot]
976eaed9ea Bump aiohttp from 3.10.5 to 3.10.11 in /app (#209)
* Bump aiohttp from 3.10.5 to 3.10.11 in /app

Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.10.5 to 3.10.11.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.10.5...v3.10.11)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bump sonarcloud-github-action to v3.1.0

* prepare version 0.11.1

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stefan Allius
2024-11-20 21:08:47 +01:00
Stefan Allius
211a958080 add PROD_COMPL_TYPE to trace 2024-11-20 20:08:20 +01:00
Stefan Allius
6da5d2cef6 define __slots__ for class ByteFifo (#202)
* define __slots__ for class ByteFifo

* disable set-timezone action

* set set-timezone to UTC

* try MathRobin/timezone-action@v1.1

* set TZ to "Europe/Berlin"

* define __slots__
2024-10-15 22:16:22 +02:00
81 changed files with 2592 additions and 671 deletions

3
.cover_ghaction_rc Normal file
View File

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

View File

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

9
.env_example Normal file
View File

@@ -0,0 +1,9 @@
# example file for the .env file. The .env set private values
# which are needed for builing containers
# registry for debug an dev container
PRIVAT_CONTAINER_REGISTRY=docker.io/<user>/
# registry for official container (preview, rc, rel)
PUBLIC_CONTAINER_REGISTRY=ghcr.io/<user>/
PUBLIC_CR_KEY=

View File

@@ -26,11 +26,12 @@ permissions:
env:
TZ: "Europe/Berlin"
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -53,13 +54,13 @@ jobs:
flake8 --exit-zero --ignore=C901,E121,E123,E126,E133,E226,E241,E242,E704,W503,W504,W505 --format=pylint --output-file=output_flake.txt --exclude=*.pyc app/src/
- name: Test with pytest
run: |
python -m pytest app --cov=app/src --cov-report=xml
python -m pytest app --cov=app/src --cov-config=.cover_ghaction_rc --cov-report=xml
coverage report
- name: Analyze with SonarCloud
uses: SonarSource/sonarcloud-github-action@v3.1.0
if: ${{ env.SONAR_TOKEN != 0 }}
uses: SonarSource/sonarqube-scan-action@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
projectBaseDir: .
args:

4
.gitignore vendored
View File

@@ -1,11 +1,15 @@
__pycache__
.pytest_cache
.venv/**
bin/**
mosquitto/**
homeassistant/**
ha_addons/ha_addon/rootfs/home/proxy/*
ha_addons/ha_addon/rootfs/requirements.txt
tsun_proxy/**
Doku/**
.DS_Store
.coverage
.env
.venv
coverage.xml

2
.hadolint.yaml Normal file
View File

@@ -0,0 +1,2 @@
ignored:
- SC1091

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13.1

20
.vscode/settings.json vendored
View File

@@ -1,16 +1,20 @@
{
"python.analysis.extraPaths": [
"app/src",
"app/tests",
".venv/lib",
],
"python.testing.pytestArgs": [
"-vv",
"app",
"-vvv",
"--cov=app/src",
"--cov-report=xml",
"--cov-report=html",
"app",
"system_tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"flake8.args": [
"--extend-exclude=app/tests/*.py system_tests/*.py"
"--extend-exclude=app/tests/*.py,system_tests/*.py"
],
"sonarlint.connectedMode.project": {
"connectionId": "s-allius",
@@ -18,5 +22,11 @@
},
"files.exclude": {
"**/*.pyi": true
}
},
"python.analysis.typeEvaluation.deprecateTypingAliases": true,
"python.autoComplete.extraPaths": [
".venv/lib"
],
"coverage-gutters.coverageBaseDir": "tsun",
"makefile.configureOnOpen": false
}

View File

@@ -7,9 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased]
## [0.12.0] - 2024-12-22
- add hadolint configuration
- detect usage of a local DNS resolver [#37](https://github.com/s-allius/tsun-gen3-proxy/issues/37)
- path for logs is now configurable by cli args
- configure the number of keeped logfiles by cli args
- add DOCS.md and CHANGELOG.md for add-ons
- pin library version und update them with renovate
- build config.yaml for add-ons by a jinja2 template
- use gnu make to build proxy and add-on
- make the configuration more flexible, add command line args to control this
- fix the python path so we don't need special import paths for unit tests anymore
- add emulator mode [#205](https://github.com/s-allius/tsun-gen3-proxy/issues/205)
- ignore inverter replays which a older than 1 day [#246](https://github.com/s-allius/tsun-gen3-proxy/issues/246)
- support test coverage in vscode
- upgrade SonarQube action to version 4
- update github action to Ubuntu 24-04
- add initial support for home assistant add-ons from @mime24
- github action: use ubuntu 24.04 and sonar-scanner-action 4 [#222](https://github.com/s-allius/tsun-gen3-proxy/issues/222)
- migrate paho.mqtt CallbackAPIVersion to VERSION2 [#224](https://github.com/s-allius/tsun-gen3-proxy/issues/224)
- add PROD_COMPL_TYPE to trace
- add SolarmanV5 messages builder
- report inverter alarms and faults per MQTT [#7](https://github.com/s-allius/tsun-gen3-proxy/issues/7)
## [0.11.1] - 2024-11-20
- fix pytest setup that can be startet from the rootdir
- support python venv environment
- add pytest.ini
- move common settings from .vscode/settings.json into pytest.ini
- add missing requirements
- fix import paths for pytests
- Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.10.5 to 3.10.11.
## [0.11.0] - 2024-10-13
- fix healthcheck on infrastructure with IPv6 support [#196](https://github.com/s-allius/tsun-gen3-proxy/issues/196)

14
Makefile Normal file
View File

@@ -0,0 +1,14 @@
.PHONY: build clean addon-dev addon-debug addon-rc addon-rel debug dev preview rc rel
debug dev preview rc rel:
$(MAKE) -C app $@
clean build:
$(MAKE) -C ha_addons $@
addon-dev addon-debug addon-rc addon-rel:
$(MAKE) -C ha_addons $(patsubst addon-%,%,$@)
check-docker-compose:
docker-compose config -q

View File

@@ -9,13 +9,13 @@
<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.3.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>
<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>
<br>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=alert_status"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=alert_status"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=bugs"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=bugs"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=code_smells"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=code_smells"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=alert_status"><img alt="The quality gate status" src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=alert_status"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=bugs"><img alt="No of bugs" src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=bugs"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=code_smells"><img alt="No of code-smells" src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=code_smells"></a>
<br>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=coverage"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=coverage"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=coverage"><img alt="Test coverage in percent" src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=coverage"></a>
</p>
# Overview
@@ -28,6 +28,9 @@ Through this, the inverter then establishes a connection to the proxy and the pr
By means of `docker` a simple installation and operation is possible. By using `docker-composer`, a complete stack of proxy, `MQTT-brocker` and `home-assistant` can be started easily.
Alternatively you can run the TSUN-Proxy as a Home Assistant Add-on. The installation of this add-on is pretty straightforward and not different in comparison to installing any other custom Home Assistant add-on.
Follow the Instructions mentioned in the add-on subdirectory `ha_addons`.
<br>
This project is not related to the company TSUN. It is a private initiative that aims to connect TSUN inverters with an MQTT broker. There is no support and no warranty from TSUN.
<br><br>
@@ -65,11 +68,20 @@ Here are some screenshots of how the inverter is displayed in the Home Assistant
## Requirements
### for Docker Installation
- A running Docker engine to host the container
- Ability to loop the proxy into the connection between the inverter and the TSUN cloud
### for Home Assistant Add-on Installation
- Running Home Assistant on Home Assistant OS or Supervised. Container and Core installations doesn't support add-ons.
- Ability to loop the proxy into the connection between the inverter and the TSUN cloud
# Getting Started
## for Docker Installation
To run the proxy, you first need to create the image. You can do this quite simply as follows:
```sh
@@ -95,8 +107,22 @@ With this information we can customize the `docker run`` statement:
docker run --dns '8.8.8.8' --env 'UID=1050' -p '5005:5005' -p '10000:10000' -v ./config:/home/tsun-proxy/config -v ./log:/home/tsun-proxy/log tsun-proxy
```
## for Home Assistant Add-on Installation
1. Add the repository URL to the Home Assistant add-on store
[![Add repository on my Home Assistant][repository-badge]][repository-url]
2. Reload the add-on store page
3. Click the "Install" button to install the add-on.
# Configuration
```txt
❗The following description applies to the Docker installation. When installing the Home Assistant add-on, the
configuration is carried out via the Home Assistant UI. Some of the options described below are not required for
this. Additionally, creating a config.toml file is not necessary. However, for a general understanding of the
configuration and functionality, it is helpful to read the following description.
```
The configuration consists of several parts. First, the container and the proxy itself must be configured, and then the connection of the inverter to the proxy must be set up, which is done differently depending on the inverter generation
For GEN3PLUS inverters, this can be done easily via the web interface of the inverter. The GEN3 inverters do not have a web interface, so the proxy is integrated via a modified DNS resolution.
@@ -275,7 +301,7 @@ 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}
#client_mode = {host = '192.168.0.1', port = 8899, forward = true}
pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
@@ -320,7 +346,6 @@ In this case, you MUST NOT change the port or the host address, as this may caus
require a complete reset. Use the configuration in client mode instead.
```
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)
@@ -408,3 +433,6 @@ We're very happy to receive contributions to this project! You can get started b
## Changelog
The changelog lives in [CHANGELOG.md](https://github.com/s-allius/tsun-gen3-proxy/blob/main/CHANGELOG.md). It follows the principles of [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
[repository-badge]: https://img.shields.io/badge/Add%20repository%20to%20my-Home%20Assistant-41BDF5?logo=home-assistant&style=for-the-badge
[repository-url]: https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fs-allius%2Fha-addons

1
app/.version Normal file
View File

@@ -0,0 +1 @@
0.12.0

View File

@@ -4,14 +4,13 @@ ARG GID=1000
#
# first stage for our base image
FROM python:3.12-alpine AS base
USER root
FROM python:3.13-alpine AS base
COPY --chmod=0700 ./hardening_base.sh .
COPY --chmod=0700 ./hardening_base.sh /
RUN apk upgrade --no-cache && \
apk add --no-cache su-exec && \
./hardening_base.sh && \
rm ./hardening_base.sh
apk add --no-cache su-exec=0.2-r3 && \
/hardening_base.sh && \
rm /hardening_base.sh
#
# second stage for building wheels packages
@@ -19,8 +18,8 @@ FROM base AS builder
# copy the dependencies file to the root dir and install requirements
COPY ./requirements.txt /root/
RUN apk add --no-cache build-base && \
python -m pip install --no-cache-dir -U pip wheel && \
RUN apk add --no-cache build-base=0.5-r3 && \
python -m pip install --no-cache-dir pip==24.3.1 wheel==0.45.1 && \
python -OO -m pip wheel --no-cache-dir --wheel-dir=/root/wheels -r /root/requirements.txt
@@ -50,9 +49,9 @@ VOLUME ["/home/$SERVICE_NAME/log", "/home/$SERVICE_NAME/config"]
# and unistall python packages and alpine package manger to reduce attack surface
COPY --from=builder /root/wheels /root/wheels
COPY --chmod=0700 ./hardening_final.sh .
RUN python -m pip install --no-cache --no-index /root/wheels/* && \
RUN python -m pip install --no-cache-dir --no-cache --no-index /root/wheels/* && \
rm -rf /root/wheels && \
python -m pip uninstall --yes setuptools wheel pip && \
python -m pip uninstall --yes wheel pip && \
apk --purge del apk-tools && \
./hardening_final.sh && \
rm ./hardening_final.sh

67
app/Makefile Normal file
View File

@@ -0,0 +1,67 @@
#!make
include ../.env
SHELL = /bin/sh
IMAGE = tsun-gen3-proxy
# Folders
SRC=.
SRC_PROXY=$(SRC)/src
CNF_PROXY=$(SRC)/config
DST=rootfs
DST_PROXY=$(DST)/home/proxy
# collect source files
SRC_FILES := $(wildcard $(SRC_PROXY)/*.py)\
$(wildcard $(SRC_PROXY)/*.ini)\
$(wildcard $(SRC_PROXY)/cnf/*.py)\
$(wildcard $(SRC_PROXY)/gen3/*.py)\
$(wildcard $(SRC_PROXY)/gen3plus/*.py)
CNF_FILES := $(wildcard $(CNF_PROXY)/*.toml)
# determine destination files
TARGET_FILES = $(SRC_FILES:$(SRC_PROXY)/%=$(DST_PROXY)/%)
CONFIG_FILES = $(CNF_FILES:$(CNF_PROXY)/%=$(DST_PROXY)/%)
export BUILD_DATE := ${shell date -Iminutes}
VERSION := $(shell cat $(SRC)/.version)
export MAJOR := $(shell echo $(VERSION) | cut -f1 -d.)
PUBLIC_URL := $(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f1 -d/)
PUBLIC_USER :=$(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f2 -d/)
dev debug:
@echo version: $(VERSION) build-date: $(BUILD_DATE) image: $(PRIVAT_CONTAINER_REGISTRY)$(IMAGE)
export VERSION=$(VERSION)-$@ && \
export IMAGE=$(PRIVAT_CONTAINER_REGISTRY)$(IMAGE) && \
docker buildx bake -f docker-bake.hcl $@
preview rc rel:
@echo version: $(VERSION) build-date: $(BUILD_DATE) image: $(PUBLIC_CONTAINER_REGISTRY)$(IMAGE)
@echo login at $(PUBLIC_URL) as $(PUBLIC_USER)
@DO_LOGIN="$(shell echo $(PUBLIC_CR_KEY) | docker login $(PUBLIC_URL) -u $(PUBLIC_USER) --password-stdin)"
export VERSION=$(VERSION)-$@ && \
export IMAGE=$(PUBLIC_CONTAINER_REGISTRY)$(IMAGE) && \
docker buildx bake -f docker-bake.hcl $@
.PHONY: debug dev preview rc rel
$(CONFIG_FILES): $(DST_PROXY)/% : $(CNF_PROXY)/%
@echo Copy $< to $@
@mkdir -p $(@D)
@cp $< $@
$(TARGET_FILES): $(DST_PROXY)/% : $(SRC_PROXY)/%
@echo Copy $< to $@
@mkdir -p $(@D)
@cp $< $@
$(DST)/requirements.txt : $(SRC)/requirements.txt
@echo Copy $< to $@
@cp $< $@

View File

@@ -1,51 +0,0 @@
#!/bin/bash
# Usage: ./build.sh [dev|rc|rel]
# dev: development build
# rc: release candidate build
# rel: release build and push to ghcr.io
# Note: for release build, you need to set GHCR_TOKEN
# export GHCR_TOKEN=<YOUR_GITHUB_TOKEN> in your .zprofile
# see also: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry
set -e
BUILD_DATE=$(date -Iminutes)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
VERSION=$(git describe --tags --abbrev=0)
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 == debug ]] || [[ $1 == dev ]] ;then
IMAGE=docker.io/sallius/${IMAGE}
VERSION=${VERSION}+$1
elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then
IMAGE=ghcr.io/s-allius/${IMAGE}
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
else
echo argument missing!
echo try: $0 '[debug|dev|preview|rc|rel]'
exit 1
fi
export IMAGE
export VERSION
export BUILD_DATE
export BRANCH
export MAJOR
echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE
docker buildx bake -f app/docker-bake.hcl $1
echo -e "${BLUE} => checking docker-compose.yaml file${NC}"
docker-compose config -q
echo
echo -e "${GREEN}${BUILD_DATE} => Version: ${VERSION}${NC} finished"
echo

View File

@@ -149,7 +149,7 @@ 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}
#client_mode = {host = '192.168.0.1', port = 8899, forward = true}
pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr

View File

@@ -18,7 +18,7 @@ variable "DESCRIPTION" {
}
target "_common" {
context = "app"
context = "."
dockerfile = "Dockerfile"
args = {
VERSION = "${VERSION}"

View File

@@ -2,5 +2,7 @@
pytest
pytest-asyncio
pytest-cov
python-dotenv
mock
coverage
coverage
jinja2-cli

View File

@@ -1,4 +1,4 @@
aiomqtt==2.3.0
schema==0.7.7
aiocron==1.8
aiohttp==3.10.5
aiohttp==3.11.10

View File

@@ -6,16 +6,10 @@ from asyncio import StreamReader, StreamWriter
from typing import Self
from itertools import count
if __name__ == "app.src.async_stream":
from app.src.proxy import Proxy
from app.src.byte_fifo import ByteFifo
from app.src.async_ifc import AsyncIfc
from app.src.infos import Infos
else: # pragma: no cover
from proxy import Proxy
from byte_fifo import ByteFifo
from async_ifc import AsyncIfc
from infos import Infos
from proxy import Proxy
from byte_fifo import ByteFifo
from async_ifc import AsyncIfc
from infos import Infos
import gc

View File

@@ -1,8 +1,4 @@
if __name__ == "app.src.byte_fifo":
from app.src.messages import hex_dump_str, hex_dump_memory
else: # pragma: no cover
from messages import hex_dump_str, hex_dump_memory
from messages import hex_dump_str, hex_dump_memory
class ByteFifo:

View File

@@ -1,19 +1,48 @@
'''Config module handles the proxy configuration in the config.toml file'''
'''Config module handles the proxy configuration'''
import shutil
import tomllib
import logging
from abc import ABC, abstractmethod
from schema import Schema, And, Or, Use, Optional
class ConfigIfc(ABC):
'''Abstract basis class for config readers'''
def __init__(self):
Config.add(self)
@abstractmethod
def get_config(self) -> dict: # pragma: no cover
'''get the unverified config from the reader'''
pass
@abstractmethod
def descr(self) -> str: # pragma: no cover
'''return a descriction of the source, e.g. the file name'''
pass
def _extend_key(self, conf, key, val):
'''split a dotted dict key into a hierarchical dict tree '''
lst = key.split('.')
d = conf
for i, idx in enumerate(lst, 1): # pragma: no branch
if i == len(lst):
d[idx] = val
break
if idx not in d:
d[idx] = {}
d = d[idx]
class Config():
'''Static class Config is reads and sanitize the config.
'''Static class Config build and sanitize the internal config dictenary.
Read config.toml file and sanitize it with read().
Get named parts of the config with get()'''
Using config readers, a partial configuration is added to config.
Config readers are a derivation of the abstract ConfigIfc reader.
When a config reader is instantiated, theits `get_config` method is
called automatically and afterwards the config will be merged.
'''
act_config = {}
def_config = {}
conf_schema = Schema({
'tsun': {
'enabled': Use(bool),
@@ -28,8 +57,10 @@ class Config():
'mqtt': {
'host': Use(str),
'port': And(Use(int), lambda n: 1024 <= n <= 65535),
'user': And(Use(str), Use(lambda s: s if len(s) > 0 else None)),
'passwd': And(Use(str), Use(lambda s: s if len(s) > 0 else None))
'user': Or(None, And(Use(str),
Use(lambda s: s if len(s) > 0 else None))),
'passwd': Or(None, And(Use(str),
Use(lambda s: s if len(s) > 0 else None)))
},
'ha': {
'auto_conf_prefix': Use(str),
@@ -93,7 +124,13 @@ class Config():
)
@classmethod
def class_init(cls) -> None | str: # pragma: no cover
def init(cls, def_reader: ConfigIfc) -> None | str:
'''Initialise the Proxy-Config
Copy the internal default config file into the config directory
and initialise the Config with the default configuration '''
cls.err = None
cls.def_config = {}
try:
# make the default config transparaent by copying it
# in the config.example file
@@ -103,66 +140,59 @@ class Config():
"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 = {}
# read example config file as default configuration
try:
with open("config/config.toml", "rb") as f:
usr_config = tomllib.load(f)
def_config = def_reader.get_config()
cls.def_config = cls.conf_schema.validate(def_config)
logging.info(f'Read from {def_reader.descr()} => ok')
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
cls.err = f'Config.read: {error}'
logging.error(
f"Can't read from {def_reader.descr()} => error\n {error}")
cls.act_config = cls.def_config.copy()
@classmethod
def read(cls, path='') -> None | str:
'''Read config file, merge it with the default config
def add(cls, reader: ConfigIfc):
'''Merge the config from the Config Reader into the config
Checks if a default config exists. If no default configuration exists,
the Config.init method has not yet been called.This is normal for the very
first Config Reader which creates the default config and must be ignored
here. The default config reader is handled in the Config.init method'''
if hasattr(cls, 'def_config'):
cls.__parse(reader)
@classmethod
def get_error(cls) -> None | str:
'''return the last error as a string or None if there is no error'''
return cls.err
@classmethod
def __parse(cls, reader) -> None | str:
'''Read config from the reader, merge it with the default config
and sanitize the result'''
err = None
config = {}
logger = logging.getLogger('data')
res = 'ok'
try:
# read example config file as default configuration
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()
rd_config = reader.get_config()
config = cls.act_config.copy()
for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters',
'gen3plus']:
if key in usr_config:
config[key] |= usr_config[key]
try:
cls.act_config = cls.conf_schema.validate(config)
except Exception as error:
err = f'Config.read: {error}'
logging.error(err)
# logging.debug(f'Readed config: "{cls.act_config}" ')
if key in rd_config:
config[key] = config[key] | rd_config[key]
cls.act_config = cls.conf_schema.validate(config)
except FileNotFoundError:
res = 'n/a'
except Exception as error:
err = f'Config.read: {error}'
logger.error(err)
cls.act_config = {}
cls.err = f'error: {error}'
logging.error(
f"Can't read from {reader.descr()} => error\n {error}")
return cls.err
return err
logging.info(f'Read from {reader.descr()} => {res}')
return cls.err
@classmethod
def get(cls, member: str = None):

View File

@@ -0,0 +1,25 @@
'''Config Reader module which handles config values from the environment'''
import os
from cnf.config import ConfigIfc
class ConfigReadEnv(ConfigIfc):
'''Reader for environment values of the configuration'''
def get_config(self) -> dict:
conf = {}
data = [
('mqtt.host', 'MQTT_HOST'),
('mqtt.port', 'MQTT_PORT'),
('mqtt.user', 'MQTT_USER'),
('mqtt.passwd', 'MQTT_PASSWORD'),
]
for key, env_var in data:
val = os.getenv(env_var)
if val:
self._extend_key(conf, key, val)
return conf
def descr(self):
return "environment"

View File

@@ -0,0 +1,46 @@
'''Config Reader module which handles *.json config files'''
import json
from cnf.config import ConfigIfc
class ConfigReadJson(ConfigIfc):
'''Reader for json config files'''
def __init__(self, cnf_file='/data/options.json'):
'''Read a json file and add the settings to the config'''
if not isinstance(cnf_file, str):
return
self.cnf_file = cnf_file
super().__init__()
def convert_inv(self, conf, inv):
if 'serial' in inv:
snr = inv['serial']
del inv['serial']
conf[snr] = {}
for key, val in inv.items():
self._extend_key(conf[snr], key, val)
def convert_inv_arr(self, conf, key, val: list):
if key not in conf:
conf[key] = {}
for elm in val:
self.convert_inv(conf[key], elm)
def convert_to_obj(self, data):
conf = {}
for key, val in data.items():
if key == 'inverters' and isinstance(val, list):
self.convert_inv_arr(conf, key, val)
else:
self._extend_key(conf, key, val)
return conf
def get_config(self) -> dict:
with open(self.cnf_file) as f:
data = json.load(f)
return self.convert_to_obj(data)
def descr(self):
return self.cnf_file

View File

@@ -0,0 +1,21 @@
'''Config Reader module which handles *.toml config files'''
import tomllib
from cnf.config import ConfigIfc
class ConfigReadToml(ConfigIfc):
'''Reader for toml config files'''
def __init__(self, cnf_file):
'''Read a toml file and add the settings to the config'''
if not isinstance(cnf_file, str):
return
self.cnf_file = cnf_file
super().__init__()
def get_config(self) -> dict:
with open(self.cnf_file, "rb") as f:
return tomllib.load(f)
def descr(self):
return self.cnf_file

View File

@@ -3,10 +3,7 @@ import struct
import logging
from typing import Generator
if __name__ == "app.src.gen3.infos_g3":
from app.src.infos import Infos, Register
else: # pragma: no cover
from infos import Infos, Register
from infos import Infos, Register
class RegisterMap:
@@ -84,6 +81,7 @@ class RegisterMap:
0x000012c0: {'reg': Register.RATED_LEVEL},
0x00001324: {'reg': Register.INPUT_COEFFICIENT, 'ratio': 100/1024},
0x00001388: {'reg': Register.GRID_VOLT_CAL_COEF},
0x00002710: {'reg': Register.PROD_COMPL_TYPE},
0x00003200: {'reg': Register.OUTPUT_COEFFICIENT, 'ratio': 100/1024},
}

View File

@@ -1,10 +1,7 @@
from asyncio import StreamReader, StreamWriter
if __name__ == "app.src.gen3.inverter_g3":
from app.src.inverter_base import InverterBase
from app.src.gen3.talent import Talent
else: # pragma: no cover
from inverter_base import InverterBase
from gen3.talent import Talent
from inverter_base import InverterBase
from gen3.talent import Talent
class InverterG3(InverterBase):

View File

@@ -4,20 +4,12 @@ from zoneinfo import ZoneInfo
from datetime import datetime
from tzlocal import get_localzone
if __name__ == "app.src.gen3.talent":
from app.src.async_ifc import AsyncIfc
from app.src.messages import Message, State
from app.src.modbus import Modbus
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 async_ifc import AsyncIfc
from messages import Message, State
from modbus import Modbus
from config import Config
from gen3.infos_g3 import InfosG3
from infos import Register
from async_ifc import AsyncIfc
from messages import Message, State
from modbus import Modbus
from cnf.config import Config
from gen3.infos_g3 import InfosG3
from infos import Register
logger = logging.getLogger('msg')
@@ -186,11 +178,9 @@ class Talent(Message):
if 2 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, 0x2000,
96, logging.DEBUG)
self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG)
else:
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, 0x3000,
48, logging.DEBUG)
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
def _init_new_client_conn(self) -> bool:
contact_name = self.contact_name
@@ -459,7 +449,7 @@ class Talent(Message):
self.__build_header(0x99)
self.ifc.tx_add(b'\x01')
self.__finish_send_msg()
self.__process_data()
self.__process_data(False)
elif self.ctrl.is_resp():
return # ignore received response
@@ -474,7 +464,7 @@ class Talent(Message):
self.__build_header(0x99)
self.ifc.tx_add(b'\x01')
self.__finish_send_msg()
self.__process_data()
self.__process_data(True)
self.state = State.up # allow MODBUS cmds
if (self.modbus_polling):
self.mb_timer.start(self.mb_first_timeout)
@@ -489,8 +479,14 @@ class Talent(Message):
self.forward()
def __process_data(self):
def __process_data(self, ignore_replay: bool):
msg_hdr_len, ts = self.parse_msg_header()
if ignore_replay:
age = self._utc() - self._utcfromts(ts)
age = age/(3600*24)
logger.debug(f"Age: {age} days")
if age > 1:
return
for key, update in self.db.parse(self.ifc.rx_peek(), self.header_len
+ msg_hdr_len, self.node_id):

View File

@@ -1,10 +1,7 @@
from typing import Generator
if __name__ == "app.src.gen3plus.infos_g3p":
from app.src.infos import Infos, Register, ProxyMode, Fmt
else: # pragma: no cover
from infos import Infos, Register, ProxyMode, Fmt
from infos import Infos, Register, ProxyMode, Fmt
class RegisterMap:
@@ -29,9 +26,11 @@ class RegisterMap:
0x4102005c: {'reg': None, 'fmt': '<B', 'const': 15}, # noqa: E501
0x4102005e: {'reg': None, 'fmt': '<B', 'const': 1}, # noqa: E501 No Of Sensors (ListLen)
0x4102005f: {'reg': Register.SENSOR_LIST, 'fmt': '<H', 'func': Fmt.hex4}, # noqa: E501
0x41020061: {'reg': None, 'fmt': '<BBB', 'const': (15, 0, 255)}, # noqa: E501
0x41020061: {'reg': None, 'fmt': '<HB', 'const': (15, 255)}, # noqa: E501
0x41020064: {'reg': Register.COLLECTOR_FW_VERSION, 'fmt': '!40s'}, # noqa: E501
0x4102008c: {'reg': None, 'fmt': '<BB', 'const': (254, 254)}, # noqa: E501
0x4102008c: {'reg': None, 'fmt': '<BB', 'const': (254, 254)}, # noqa: E501
0x4102008e: {'reg': None, 'fmt': '<B'}, # noqa: E501 Encryption Certificate File Status
0x4102008f: {'reg': None, 'fmt': '!40s'}, # noqa: E501
0x410200b7: {'reg': Register.SSID, 'fmt': '!40s'}, # noqa: E501
0x4201000c: {'reg': Register.SENSOR_LIST, 'fmt': '<H', 'func': Fmt.hex4}, # noqa: E501
@@ -91,7 +90,8 @@ class RegisterMap:
0x4201012c: {'reg': Register.GRID_VOLT_CAL_COEF, 'fmt': '!H'},
0x4201012e: {'reg': None, 'fmt': '!H', 'const': 1024}, # noqa: E501
0x42010130: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (1024, 1, 0xffff, 1)}, # noqa: E501
0x42010138: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (6, 0x68, 0x68, 0x500)}, # noqa: E501
0x42010138: {'reg': Register.PROD_COMPL_TYPE, 'fmt': '!H'},
0x4201013a: {'reg': None, 'fmt': FMT_3_16BIT_VAL, 'const': (0x68, 0x68, 0x500)}, # noqa: E501
0x42010140: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0x9cd, 0x7b6, 0x139c, 0x1324)}, # noqa: E501
0x42010148: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (1, 0x7ae, 0x40f, 0x41)}, # noqa: E501
0x42010150: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (0xf, 0xa64, 0xa64, 0x6)}, # noqa: E501

View File

@@ -1,13 +1,8 @@
from asyncio import StreamReader, StreamWriter
if __name__ == "app.src.gen3plus.inverter_g3p":
from app.src.inverter_base import InverterBase
from app.src.gen3plus.solarman_v5 import SolarmanV5
from app.src.gen3plus.solarman_emu import SolarmanEmu
else: # pragma: no cover
from inverter_base import InverterBase
from gen3plus.solarman_v5 import SolarmanV5
from gen3plus.solarman_emu import SolarmanEmu
from inverter_base import InverterBase
from gen3plus.solarman_v5 import SolarmanV5
from gen3plus.solarman_emu import SolarmanEmu
class InverterG3P(InverterBase):

View File

@@ -1,16 +1,10 @@
import logging
import struct
if __name__ == "app.src.gen3plus.solarman_emu":
from app.src.async_ifc import AsyncIfc
from app.src.gen3plus.solarman_v5 import SolarmanBase
from app.src.my_timer import Timer
from app.src.infos import Register
else: # pragma: no cover
from async_ifc import AsyncIfc
from gen3plus.solarman_v5 import SolarmanBase
from my_timer import Timer
from infos import Register
from async_ifc import AsyncIfc
from gen3plus.solarman_v5 import SolarmanBase
from my_timer import Timer
from infos import Register
logger = logging.getLogger('msg')

View File

@@ -4,20 +4,12 @@ import time
import asyncio
from datetime import datetime
if __name__ == "app.src.gen3plus.solarman_v5":
from app.src.async_ifc import AsyncIfc
from app.src.messages import hex_dump_memory, Message, State
from app.src.modbus import Modbus
from app.src.config import Config
from app.src.gen3plus.infos_g3p import InfosG3P
from app.src.infos import Register, Fmt
else: # pragma: no cover
from async_ifc import AsyncIfc
from messages import hex_dump_memory, Message, State
from config import Config
from modbus import Modbus
from gen3plus.infos_g3p import InfosG3P
from infos import Register, Fmt
from async_ifc import AsyncIfc
from messages import hex_dump_memory, Message, State
from cnf.config import Config
from modbus import Modbus
from gen3plus.infos_g3p import InfosG3P
from infos import Register, Fmt
logger = logging.getLogger('msg')
@@ -253,8 +245,7 @@ class SolarmanBase(Message):
class SolarmanV5(SolarmanBase):
AT_CMD = 1
MB_RTU_CMD = 2
'''regular Modbus polling time in server mode'''
MB_CLIENT_DATA_UP = 10
MB_CLIENT_DATA_UP = 30
'''Data up time in client mode'''
HDR_FMT = '<BLLL'
'''format string for packing of the header'''
@@ -329,11 +320,7 @@ class SolarmanV5(SolarmanBase):
if 'at_acl' in g3p_cnf: # pragma: no cover
self.at_acl = g3p_cnf['at_acl']
self.sensor_list = 0x0000
self.mb_start_reg = 0x0001 # 0x7001
self.mb_incr_reg = 0x100 # 4
self.mb_inv_no = 144 # 3
self.mb_scan_len = 4
self.sensor_list = 0
'''
Our puplic methods
@@ -369,23 +356,7 @@ class SolarmanV5(SolarmanBase):
self.new_data['controller'] = True
self.state = State.up
# self.__build_header(0x1710)
# self.ifc.write += struct.pack('<B', 0)
# self.__finish_send_msg()
# hex_dump_memory(logging.INFO, f'Send StartCmd:{self.addr}:',
# self.ifc.write, len(self.ifc.write))
# self.writer.write(self.ifc.write)
# self.ifc.write = bytearray(0) # self.ifc.write[sent:]
if self.sensor_list != 0x02b0:
self._send_modbus_cmd(self.mb_inv_no, Modbus.READ_REGS,
self.mb_start_reg, self.mb_scan_len,
logging.INFO)
else:
self.mb_inv_no = Modbus.INV_ADDR
self._send_modbus_cmd(self.mb_inv_no, Modbus.READ_REGS, 0x3000,
48, logging.DEBUG)
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
self.mb_timer.start(self.mb_timeout)
def new_state_up(self):
@@ -488,29 +459,12 @@ class SolarmanV5(SolarmanBase):
def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.mb_timeout)
if self.sensor_list != 0x02b0:
self.mb_start_reg += self.mb_incr_reg
if self.mb_start_reg > 0xffff:
self.mb_start_reg = self.mb_start_reg & 0xffff
self.mb_inv_no += 1
logging.info(f"Next Round: inv:{self.mb_inv_no}"
f" reg:{self.mb_start_reg:04x}")
if (self.mb_start_reg & 0xfffc) % 0x80 == 0:
logging.info(f"Scan info: inv:{self.mb_inv_no}"
f" reg:{self.mb_start_reg:04x}")
self._send_modbus_cmd(self.mb_inv_no, Modbus.READ_REGS,
self.mb_start_reg, self.mb_scan_len,
logging.INFO)
else:
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, 0x3000,
48, logging.DEBUG)
if 1 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS,
0x5000, 8, logging.DEBUG)
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS,
0x2000, 96, logging.DEBUG)
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 \
@@ -723,21 +677,6 @@ class SolarmanV5(SolarmanBase):
if valid == 1 and modbus_msg_len > 4:
# logger.info(f'first byte modbus:{data[14]}')
inv_update = self.__parse_modbus_rsp(data)
self.modbus_elms = 0
if (self.sensor_list != 0x02b0 and data[15] != 0):
logging.info('Valid MODBUS data '
f'(inv:{self.mb_inv_no} '
f'reg: 0x{self.mb.last_reg:04x}):')
hex_dump_memory(logging.INFO, 'Valid MODBUS data '
f'(reg: 0x{self.mb.last_reg:04x}):',
data[14:], modbus_msg_len)
for key, update, _ in self.mb.recv_resp(self.db, data[14:]):
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()

View File

@@ -30,6 +30,7 @@ class Register(Enum):
INPUT_COEFFICIENT = 28
GRID_VOLT_CAL_COEF = 29
OUTPUT_COEFFICIENT = 30
PROD_COMPL_TYPE = 31
INVERTER_CNT = 50
UNKNOWN_SNR = 51
UNKNOWN_MSG = 52
@@ -298,37 +299,53 @@ class Infos:
{% set result = 'noAlarm'%}
{%else%}
{% set result = '' %}
{% if val_int | bitwise_and(1)%}{% set result = result + 'Bit1, '%}
{% if val_int | bitwise_and(1)%}
{% set result = result + 'HBridgeFault, '%}
{% endif %}
{% if val_int | bitwise_and(2)%}{% set result = result + 'Bit2, '%}
{% if val_int | bitwise_and(2)%}
{% set result = result + 'DriVoltageFault, '%}
{% endif %}
{% if val_int | bitwise_and(3)%}{% set result = result + 'Bit3, '%}
{% if val_int | bitwise_and(3)%}
{% set result = result + 'GFDI-Fault, '%}
{% endif %}
{% if val_int | bitwise_and(4)%}{% set result = result + 'Bit4, '%}
{% if val_int | bitwise_and(4)%}
{% set result = result + 'OverTemp, '%}
{% endif %}
{% if val_int | bitwise_and(5)%}{% set result = result + 'Bit5, '%}
{% if val_int | bitwise_and(5)%}
{% set result = result + 'CommLose, '%}
{% endif %}
{% if val_int | bitwise_and(6)%}{% set result = result + 'Bit6, '%}
{% if val_int | bitwise_and(6)%}
{% set result = result + 'Bit6, '%}
{% endif %}
{% if val_int | bitwise_and(7)%}{% set result = result + 'Bit7, '%}
{% if val_int | bitwise_and(7)%}
{% set result = result + 'Bit7, '%}
{% endif %}
{% if val_int | bitwise_and(8)%}{% set result = result + 'Bit8, '%}
{% if val_int | bitwise_and(8)%}
{% set result = result + 'EEPROM-Fault, '%}
{% endif %}
{% if val_int | bitwise_and(9)%}{% set result = result + 'noUtility, '%}
{% if val_int | bitwise_and(9)%}
{% set result = result + 'NoUtility, '%}
{% endif %}
{% if val_int | bitwise_and(10)%}{% set result = result + 'Bit10, '%}
{% if val_int | bitwise_and(10)%}
{% set result = result + 'VG_Offset, '%}
{% endif %}
{% if val_int | bitwise_and(11)%}{% set result = result + 'Bit11, '%}
{% if val_int | bitwise_and(11)%}
{% set result = result + 'Relais_Open, '%}
{% endif %}
{% if val_int | bitwise_and(12)%}{% set result = result + 'Bit12, '%}
{% if val_int | bitwise_and(12)%}
{% set result = result + 'Relais_Short, '%}
{% endif %}
{% if val_int | bitwise_and(13)%}{% set result = result + 'Bit13, '%}
{% if val_int | bitwise_and(13)%}
{% set result = result + 'GridVoltOverRating, '%}
{% endif %}
{% if val_int | bitwise_and(14)%}{% set result = result + 'Bit14, '%}
{% if val_int | bitwise_and(14)%}
{% set result = result + 'GridVoltUnderRating, '%}
{% endif %}
{% if val_int | bitwise_and(15)%}{% set result = result + 'Bit15, '%}
{% if val_int | bitwise_and(15)%}
{% set result = result + 'GridFreqOverRating, '%}
{% endif %}
{% if val_int | bitwise_and(16)%}{% set result = result + 'Bit16, '%}
{% if val_int | bitwise_and(16)%}
{% set result = result + 'GridFreqUnderRating, '%}
{% endif %}
{% endif %}
{{ result }}
@@ -344,15 +361,20 @@ class Infos:
{% set result = 'noFault'%}
{%else%}
{% set result = '' %}
{% if val_int | bitwise_and(1)%}{% set result = result + 'Bit1, '%}
{% if val_int | bitwise_and(1)%}
{% set result = result + 'PVOV-Fault (PV OverVolt), '%}
{% endif %}
{% if val_int | bitwise_and(2)%}{% set result = result + 'Bit2, '%}
{% if val_int | bitwise_and(2)%}
{% set result = result + 'PVLV-Fault (PV LowVolt), '%}
{% endif %}
{% if val_int | bitwise_and(3)%}{% set result = result + 'Bit3, '%}
{% if val_int | bitwise_and(3)%}
{% set result = result + 'PV OI-Fault (PV OverCurrent), '%}
{% endif %}
{% if val_int | bitwise_and(4)%}{% set result = result + 'Bit4, '%}
{% if val_int | bitwise_and(4)%}
{% set result = result + 'PV OFV-Fault, '%}
{% endif %}
{% if val_int | bitwise_and(5)%}{% set result = result + 'Bit5, '%}
{% if val_int | bitwise_and(5)%}
{% set result = result + 'DC ShortCircuitFault, '%}
{% endif %}
{% if val_int | bitwise_and(6)%}{% set result = result + 'Bit6, '%}
{% endif %}
@@ -511,6 +533,7 @@ class Infos:
Register.OUTPUT_SHUTDOWN: {'name': ['other', 'Output_Shutdown'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.RATED_LEVEL: {'name': ['other', 'Rated_Level'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.GRID_VOLT_CAL_COEF: {'name': ['other', 'Grid_Volt_Cal_Coef'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.PROD_COMPL_TYPE: {'name': ['other', 'Prod_Compliance_Type'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
Register.INV_UNKNOWN_1: {'name': ['inv_unknown', 'Unknown_1'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
}

View File

@@ -6,23 +6,15 @@ import json
import gc
from aiomqtt import MqttCodeError
from asyncio import StreamReader, StreamWriter
from ipaddress import ip_address
if __name__ == "app.src.inverter_base":
from app.src.inverter_ifc import InverterIfc
from app.src.proxy import Proxy
from app.src.async_stream import StreamPtr
from app.src.async_stream import AsyncStreamClient
from app.src.async_stream import AsyncStreamServer
from app.src.config import Config
from app.src.infos import Infos
else: # pragma: no cover
from inverter_ifc import InverterIfc
from proxy import Proxy
from async_stream import StreamPtr
from async_stream import AsyncStreamClient
from async_stream import AsyncStreamServer
from config import Config
from infos import Infos
from inverter_ifc import InverterIfc
from proxy import Proxy
from async_stream import StreamPtr
from async_stream import AsyncStreamClient
from async_stream import AsyncStreamServer
from cnf.config import Config
from infos import Infos
logger_mqtt = logging.getLogger('mqtt')
@@ -110,6 +102,20 @@ class InverterBase(InverterIfc, Proxy):
logging.info(f'[{stream.node_id}] Connect to {addr}')
connect = asyncio.open_connection(host, port)
reader, writer = await connect
r_addr = writer.get_extra_info('peername')
if r_addr is not None:
(ip, _) = r_addr
if ip_address(ip).is_private:
logging.error(
f"""resolve {host} to {ip}, which is a private IP!
\u001B[31m Check your DNS settings and use a public DNS resolver!
To prevent a possible loop, forwarding to local IP addresses is
not supported and is deactivated for subsequent connections
\u001B[0m
""")
Config.act_config[self.config_id]['enabled'] = False
ifc = AsyncStreamClient(
reader, writer, self.local, self.__del_remote)

View File

@@ -2,10 +2,7 @@ from abc import abstractmethod
import logging
from asyncio import StreamReader, StreamWriter
if __name__ == "app.src.inverter_ifc":
from app.src.iter_registry import AbstractIterMeta
else: # pragma: no cover
from iter_registry import AbstractIterMeta
from iter_registry import AbstractIterMeta
logger_mqtt = logging.getLogger('mqtt')

View File

@@ -58,13 +58,13 @@ formatter=console_formatter
class=handlers.TimedRotatingFileHandler
level=INFO
formatter=file_formatter
args=('log/proxy.log', when:='midnight')
args=(handlers.log_path + 'proxy.log', when:='midnight', backupCount:=handlers.log_backups)
[handler_file_handler_name2]
class=handlers.TimedRotatingFileHandler
level=NOTSET
formatter=file_formatter
args=('log/trace.log', when:='midnight')
args=(handlers.log_path + 'trace.log', when:='midnight', backupCount:=handlers.log_backups)
[formatter_console_formatter]
format=%(asctime)s %(levelname)5s | %(name)4s | %(message)s'

View File

@@ -3,19 +3,11 @@ import weakref
from typing import Callable
from enum import Enum
if __name__ == "app.src.messages":
from app.src.async_ifc import AsyncIfc
from app.src.protocol_ifc import ProtocolIfc
from app.src.infos import Infos, Register
from app.src.modbus import Modbus
from app.src.my_timer import Timer
else: # pragma: no cover
from async_ifc import AsyncIfc
from protocol_ifc import ProtocolIfc
from infos import Infos, Register
from modbus import Modbus
from my_timer import Timer
from async_ifc import AsyncIfc
from protocol_ifc import ProtocolIfc
from infos import Infos, Register
from modbus import Modbus
from my_timer import Timer
logger = logging.getLogger('msg')
@@ -95,7 +87,7 @@ class Message(ProtocolIfc):
'''maximum default time without a received msg in sec'''
MB_START_TIMEOUT = 40
'''start delay for Modbus polling in server mode'''
MB_REGULAR_TIMEOUT = 20
MB_REGULAR_TIMEOUT = 60
'''regular Modbus polling time in server mode'''
def __init__(self, node_id, ifc: "AsyncIfc", server_side: bool,
@@ -168,15 +160,15 @@ class Message(ProtocolIfc):
to = self.MAX_DEF_IDLE_TIME
return to
def _send_modbus_cmd(self, mb_no, func, addr, val, log_lvl) -> None:
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(mb_no, func, addr, val, log_lvl)
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(Modbus.INV_ADDR, func, addr, val, log_lvl)
self._send_modbus_cmd(func, addr, val, log_lvl)
'''
Our puplic methods

View File

@@ -16,10 +16,7 @@ import logging
import asyncio
from typing import Generator, Callable
if __name__ == "app.src.modbus":
from app.src.infos import Register, Fmt
else: # pragma: no cover
from infos import Register, Fmt
from infos import Register, Fmt
logger = logging.getLogger('data')
@@ -44,11 +41,11 @@ class Modbus():
0x2001: {'reg': Register.DSP_STATUS, 'fmt': '!H'}, # noqa: E501
0x2003: {'reg': Register.WORK_MODE, 'fmt': '!H'},
0x2006: {'reg': Register.OUTPUT_SHUTDOWN, 'fmt': '!H'},
0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
0x2008: {'reg': Register.RATED_LEVEL, 'fmt': '!H'},
0x2009: {'reg': Register.INPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501
0x200a: {'reg': Register.GRID_VOLT_CAL_COEF, 'fmt': '!H'},
0x2010: {'reg': Register.PROD_COMPL_TYPE, 'fmt': '!H'},
0x202c: {'reg': Register.OUTPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501
0x3000: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501
@@ -103,8 +100,8 @@ class Modbus():
'''Response handler to forward the response'''
self.timeout = timeout
'''MODBUS response timeout in seconds'''
self.max_retries = 0
'''Max retransmit for MODBU requests'''
self.max_retries = 1
'''Max retransmit for MODBUS requests'''
self.retry_cnt = 0
self.last_req = b''
self.counter = {}

View File

@@ -2,14 +2,9 @@ import logging
import traceback
import asyncio
if __name__ == "app.src.modbus_tcp":
from app.src.config import Config
from app.src.gen3plus.inverter_g3p import InverterG3P
from app.src.infos import Infos
else: # pragma: no cover
from config import Config
from gen3plus.inverter_g3p import InverterG3P
from infos import Infos
from cnf.config import Config
from gen3plus.inverter_g3p import InverterG3P
from infos import Infos
logger = logging.getLogger('conn')
@@ -54,7 +49,7 @@ class ModbusTcp():
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
logger.info(f"'client_mode' for snr: {inv['monitor_sn']} host: {client['host']}:{client['port']}, forward: {client['forward']}") # noqa: E501
loop.create_task(self.modbus_loop(client['host'],
client['port'],
inv['monitor_sn'],

View File

@@ -2,16 +2,11 @@ import asyncio
import logging
import aiomqtt
import traceback
if __name__ == "app.src.mqtt":
from app.src.modbus import Modbus
from app.src.messages import Message
from app.src.config import Config
from app.src.singleton import Singleton
else: # pragma: no cover
from modbus import Modbus
from messages import Message
from config import Config
from singleton import Singleton
from modbus import Modbus
from messages import Message
from cnf.config import Config
from singleton import Singleton
logger_mqtt = logging.getLogger('mqtt')

View File

@@ -1,11 +1,7 @@
from abc import abstractmethod
if __name__ == "app.src.protocol_ifc":
from app.src.iter_registry import AbstractIterMeta
from app.src.async_ifc import AsyncIfc
else: # pragma: no cover
from iter_registry import AbstractIterMeta
from async_ifc import AsyncIfc
from async_ifc import AsyncIfc
from iter_registry import AbstractIterMeta
class ProtocolIfc(metaclass=AbstractIterMeta):

View File

@@ -2,14 +2,9 @@ import asyncio
import logging
import json
if __name__ == "app.src.proxy":
from app.src.config import Config
from app.src.mqtt import Mqtt
from app.src.infos import Infos
else: # pragma: no cover
from config import Config
from mqtt import Mqtt
from infos import Infos
from cnf.config import Config
from mqtt import Mqtt
from infos import Infos
logger_mqtt = logging.getLogger('mqtt')

View File

@@ -1,7 +1,9 @@
import logging
import asyncio
import logging.handlers
import signal
import os
import argparse
from asyncio import StreamReader, StreamWriter
from aiohttp import web
from logging import config # noqa F401
@@ -10,7 +12,10 @@ from inverter_ifc import InverterIfc
from gen3.inverter_g3 import InverterG3
from gen3plus.inverter_g3p import InverterG3P
from scheduler import Schedule
from config import Config
from cnf.config import Config
from cnf.config_read_env import ConfigReadEnv
from cnf.config_read_toml import ConfigReadToml
from cnf.config_read_json import ConfigReadJson
from modbus_tcp import ModbusTcp
routes = web.RouteTableDef()
@@ -77,7 +82,7 @@ async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class):
await inv.local.ifc.server_loop()
async def handle_shutdown(web_task):
async def handle_shutdown(loop, web_task):
'''Close all TCP connections and stop the event loop'''
logging.info('Shutdown due to SIGTERM')
@@ -116,6 +121,8 @@ def get_log_level() -> int:
'''checks if LOG_LVL is set in the environment and returns the
corresponding logging.LOG_LEVEL'''
log_level = os.getenv('LOG_LVL', 'INFO')
logging.info(f"LOG_LVL : {log_level}")
if log_level == 'DEBUG':
log_level = logging.DEBUG
elif log_level == 'WARN':
@@ -125,18 +132,46 @@ def get_log_level() -> int:
return log_level
if __name__ == "__main__":
def main(): # pragma: no cover
parser = argparse.ArgumentParser()
parser.add_argument('-c', '--config_path', type=str,
default='./config/',
help='set path for the configuration files')
parser.add_argument('-j', '--json_config', type=str,
help='read user config from json-file')
parser.add_argument('-t', '--toml_config', type=str,
help='read user config from toml-file')
parser.add_argument('-l', '--log_path', type=str,
default='./log/',
help='set path for the logging files')
parser.add_argument('-b', '--log_backups', type=int,
default=0,
help='set max number of daily log-files')
args = parser.parse_args()
#
# Setup our daily, rotating logger
#
serv_name = os.getenv('SERVICE_NAME', 'proxy')
version = os.getenv('VERSION', 'unknown')
setattr(logging.handlers, "log_path", args.log_path)
setattr(logging.handlers, "log_backups", args.log_backups)
logging.config.fileConfig('logging.ini')
logging.info(f'Server "{serv_name} - {version}" will be started')
logging.info(f'current dir: {os.getcwd()}')
logging.info(f"config_path: {args.config_path}")
logging.info(f"json_config: {args.json_config}")
logging.info(f"toml_config: {args.toml_config}")
logging.info(f"log_path: {args.log_path}")
if args.log_backups == 0:
logging.info("log_backups: unlimited")
else:
logging.info(f"log_backups: {args.log_backups} days")
log_level = get_log_level()
logging.info('******')
# set lowest-severity for 'root', 'msg', 'conn' and 'data' logger
log_level = get_log_level()
logging.getLogger().setLevel(log_level)
logging.getLogger('msg').setLevel(log_level)
logging.getLogger('conn').setLevel(log_level)
@@ -149,9 +184,20 @@ if __name__ == "__main__":
asyncio.set_event_loop(loop)
# read config file
ConfigErr = Config.class_init()
if ConfigErr is not None:
logging.info(f'ConfigErr: {ConfigErr}')
Config.init(ConfigReadToml("default_config.toml"))
ConfigReadEnv()
ConfigReadJson(args.config_path + "config.json")
ConfigReadToml(args.config_path + "config.toml")
ConfigReadJson(args.json_config)
ConfigReadToml(args.toml_config)
config_err = Config.get_error()
if config_err is not None:
logging.info(f'config_err: {config_err}')
return
logging.info('******')
Proxy.class_init()
Schedule.start()
ModbusTcp(loop)
@@ -162,6 +208,7 @@ if __name__ == "__main__":
# and we can't receive and handle the UNIX signals!
#
for inv_class, port in [(InverterG3, 5005), (InverterG3P, 10000)]:
logging.info(f'listen on port: {port} for inverters')
loop.create_task(asyncio.start_server(lambda r, w, i=inv_class:
handle_client(r, w, i),
'0.0.0.0', port))
@@ -174,12 +221,12 @@ if __name__ == "__main__":
for signame in ('SIGINT', 'SIGTERM'):
loop.add_signal_handler(getattr(signal, signame),
lambda loop=loop: asyncio.create_task(
handle_shutdown(web_task)))
handle_shutdown(loop, web_task)))
loop.set_debug(log_level == logging.DEBUG)
try:
if ConfigErr is None:
proxy_is_up = True
global proxy_is_up
proxy_is_up = True
loop.run_forever()
except KeyboardInterrupt:
pass
@@ -189,3 +236,7 @@ if __name__ == "__main__":
logging.debug('Close event loop')
loop.close()
logging.info(f'Finally, exit Server "{serv_name}"')
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -4,12 +4,13 @@ import asyncio
import gc
import time
from app.src.infos import Infos
from app.src.inverter_base import InverterBase
from app.src.async_stream import AsyncStreamServer, AsyncStreamClient, StreamPtr
from app.src.messages import Message
from app.tests.test_modbus_tcp import FakeReader, FakeWriter
from app.tests.test_inverter_base import config_conn, patch_open_connection
from infos import Infos
from inverter_base import InverterBase
from async_stream import AsyncStreamServer, AsyncStreamClient, StreamPtr
from messages import Message
from test_modbus_tcp import FakeReader, FakeWriter
from test_inverter_base import config_conn, patch_open_connection
pytest_plugins = ('pytest_asyncio',)
@@ -541,7 +542,7 @@ async def test_forward_resp():
remote = StreamPtr(None)
cnt = 0
async def _close_cb():
def _close_cb():
nonlocal cnt, remote, ifc
cnt += 1
@@ -550,7 +551,7 @@ async def test_forward_resp():
create_remote(remote, TestType.FWD_NO_EXCPT)
ifc.fwd_add(b'test-forward_msg')
await ifc.client_loop('')
assert cnt == 0
assert cnt == 1
del ifc
@pytest.mark.asyncio
@@ -559,7 +560,7 @@ async def test_forward_resp2():
remote = StreamPtr(None)
cnt = 0
async def _close_cb():
def _close_cb():
nonlocal cnt, remote, ifc
cnt += 1
@@ -568,5 +569,5 @@ async def test_forward_resp2():
create_remote(remote, TestType.FWD_NO_EXCPT)
ifc.fwd_add(b'test-forward_msg')
await ifc.client_loop('')
assert cnt == 0
assert cnt == 1
del ifc

View File

@@ -1,6 +1,6 @@
# test_with_pytest.py
from app.src.byte_fifo import ByteFifo
from byte_fifo import ByteFifo
def test_fifo():
read = ByteFifo()

View File

@@ -1,16 +1,56 @@
# test_with_pytest.py
import tomllib
import pytest
import json
from mock import patch
from schema import SchemaMissingKeyError
from app.src.config import Config
from cnf.config import Config, ConfigIfc
from cnf.config_read_toml import ConfigReadToml
class TstConfig(Config):
class FakeBuffer:
rd = str()
test_buffer = FakeBuffer
class FakeFile():
def __init__(self):
self.buf = test_buffer
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
pass
class FakeOptionsFile(FakeFile):
def __init__(self, OpenTextMode):
super().__init__()
self.bin_mode = 'b' in OpenTextMode
def read(self):
if self.bin_mode:
return bytearray(self.buf.rd.encode('utf-8')).copy()
else:
return self.buf.rd.copy()
def patch_open():
def new_open(file: str, OpenTextMode="rb"):
if file == "_no__file__no_":
raise FileNotFoundError
return FakeOptionsFile(OpenTextMode)
with patch('builtins.open', new_open) as conn:
yield conn
class TstConfig(ConfigIfc):
@classmethod
def set(cls, cnf):
def __init__(cls, cnf):
cls.act_config = cnf
@classmethod
def _read_config_file(cls) -> dict:
def add_config(cls) -> dict:
return cls.act_config
@@ -22,82 +62,9 @@ def test_empty_config():
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': False,
'R170000000000001': {
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'modbus_polling': False,
'monitor_sn': 0,
'suggested_area': '',
'sensor_list': 688},
'Y170000000000001': {
'modbus_polling': True,
'monitor_sn': 2000000000,
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'pv3': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'pv4': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'suggested_area': '',
'sensor_list': 688}}}
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': '', 'sensor_list': 0, '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': '', 'sensor_list': 0x1511, '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': '', 'sensor_list': 0}, 'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': '', 'suggested_area': '', 'sensor_list': 5393}}}
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': '', 'sensor_list': 688}}}
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'},
@pytest.fixture
def ConfigDefault():
return {'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': False,
'R170000000000001': {
@@ -128,27 +95,150 @@ def test_read_empty():
}
}
}
@pytest.fixture
def ConfigComplete():
return {
'gen3plus': {
'at_acl': {
'mqtt': {'allow': ['AT+'], 'block': ['AT+SUPDATE']},
'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'],
'block': ['AT+SUPDATE']}
}
},
'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': False,
'R170000000000001': {'node_id': 'PV-Garage/',
'modbus_polling': False,
'monitor_sn': 0,
'pv1': {'manufacturer': 'man1',
'type': 'type1'},
'pv2': {'manufacturer': 'man2',
'type': 'type2'},
'suggested_area': 'Garage',
'sensor_list': 688},
'Y170000000000001': {'modbus_polling': True,
'monitor_sn': 2000000000,
'node_id': 'PV-Garage2/',
'pv1': {'manufacturer': 'man1',
'type': 'type1'},
'pv2': {'manufacturer': 'man2',
'type': 'type2'},
'pv3': {'manufacturer': 'man3',
'type': 'type3'},
'pv4': {'manufacturer': 'man4',
'type': 'type4'},
'suggested_area': 'Garage2',
'sensor_list': 688}
}
}
def test_default_config():
Config.init(ConfigReadToml("app/config/default_config.toml"))
validated = Config.def_config
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': False,
'R170000000000001': {
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'modbus_polling': False,
'monitor_sn': 0,
'suggested_area': '',
'sensor_list': 688},
'Y170000000000001': {
'modbus_polling': True,
'monitor_sn': 2000000000,
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'pv3': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'pv4': {'manufacturer': 'Risen',
'type': 'RSM40-8-410M'},
'suggested_area': '',
'sensor_list': 688}}}
def test_full_config(ConfigComplete):
cnf = {'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005},
'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': ['AT+SUPDATE']},
'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': ['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': False,
'R170000000000001': {'modbus_polling': False, 'node_id': 'PV-Garage/', 'sensor_list': 0x02B0, 'suggested_area': 'Garage', 'pv1': {'type': 'type1', 'manufacturer': 'man1'}, 'pv2': {'type': 'type2', 'manufacturer': 'man2'}},
'Y170000000000001': {'modbus_polling': True, 'monitor_sn': 2000000000, 'node_id': 'PV-Garage2/', 'sensor_list': 0x02B0, 'suggested_area': 'Garage2', 'pv1': {'type': 'type1', 'manufacturer': 'man1'}, 'pv2': {'type': 'type2', 'manufacturer': 'man2'}, 'pv3': {'type': 'type3', 'manufacturer': 'man3'}, 'pv4': {'type': 'type4', 'manufacturer': 'man4'}}}}
try:
validated = Config.conf_schema.validate(cnf)
except Exception:
assert False
assert validated == ConfigComplete
def test_read_empty(ConfigDefault):
test_buffer.rd = ""
defcnf = TstConfig.def_config.get('solarman')
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
assert err == None
cnf = Config.get()
assert cnf == ConfigDefault
defcnf = Config.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
assert True == TstConfig.is_default('solarman')
assert True == Config.is_default('solarman')
def test_no_file():
cnf = {}
TstConfig.set(cnf)
err = TstConfig.read('')
Config.init(ConfigReadToml("default_config.toml"))
err = Config.get_error()
assert err == "Config.read: [Errno 2] No such file or directory: 'default_config.toml'"
cnf = TstConfig.get()
cnf = Config.get()
assert cnf == {}
defcnf = TstConfig.def_config.get('solarman')
defcnf = Config.def_config.get('solarman')
assert defcnf == None
def test_read_cnf1():
cnf = {'solarman' : {'enabled': False}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
def test_no_file2():
Config.init(ConfigReadToml("app/config/default_config.toml"))
assert Config.err == None
ConfigReadToml("_no__file__no_")
err = Config.get_error()
assert err == None
cnf = TstConfig.get()
def test_invalid_filename():
Config.init(ConfigReadToml("app/config/default_config.toml"))
assert Config.err == None
ConfigReadToml(None)
err = Config.get_error()
assert err == None
def test_read_cnf1():
test_buffer.rd = "solarman.enabled = false"
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
assert err == None
cnf = Config.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': False,
@@ -180,18 +270,22 @@ def test_read_cnf1():
}
}
}
cnf = TstConfig.get('solarman')
cnf = Config.get('solarman')
assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}
defcnf = TstConfig.def_config.get('solarman')
defcnf = Config.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
assert False == TstConfig.is_default('solarman')
assert False == Config.is_default('solarman')
def test_read_cnf2():
cnf = {'solarman' : {'enabled': 'FALSE'}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
test_buffer.rd = "solarman.enabled = 'FALSE'"
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
assert err == None
cnf = TstConfig.get()
cnf = Config.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': False,
@@ -223,22 +317,30 @@ def test_read_cnf2():
}
}
}
assert True == TstConfig.is_default('solarman')
assert True == Config.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_cnf3(ConfigDefault):
test_buffer.rd = "solarman.port = 'FALSE'"
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
assert err == 'error: Key \'solarman\' error:\nKey \'port\' error:\nint(\'FALSE\') raised ValueError("invalid literal for int() with base 10: \'FALSE\'")'
cnf = Config.get()
assert cnf == ConfigDefault
def test_read_cnf4():
cnf = {'solarman' : {'port': 5000}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
test_buffer.rd = "solarman.port = 5000"
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
assert err == None
cnf = TstConfig.get()
cnf = Config.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': False,
@@ -270,16 +372,22 @@ def test_read_cnf4():
}
}
}
assert False == TstConfig.is_default('solarman')
assert False == Config.is_default('solarman')
def test_read_cnf5():
cnf = {'solarman' : {'port': 1023}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
test_buffer.rd = "solarman.port = 1023"
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
assert err != None
def test_read_cnf6():
cnf = {'solarman' : {'port': 65536}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
test_buffer.rd = "solarman.port = 65536"
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
assert err != None

View File

@@ -0,0 +1,53 @@
# test_with_pytest.py
import pytest
import os
from mock import patch
from cnf.config import Config
from cnf.config_read_toml import ConfigReadToml
from cnf.config_read_env import ConfigReadEnv
def patch_getenv():
def new_getenv(key: str, defval=None):
"""Get an environment variable, return None if it doesn't exist.
The optional second argument can specify an alternate default. key,
default and the result are str."""
if key == 'MQTT_PASSWORD':
return 'passwd'
elif key == 'MQTT_PORT':
return 1234
elif key == 'MQTT_HOST':
return ""
return defval
with patch.object(os, 'getenv', new_getenv) as conn:
yield conn
def test_extend_key():
cnf_rd = ConfigReadEnv()
conf = {}
cnf_rd._extend_key(conf, "mqtt.user", "testuser")
assert conf == {
'mqtt': {
'user': 'testuser',
},
}
conf = {}
cnf_rd._extend_key(conf, "mqtt", "testuser")
assert conf == {
'mqtt': 'testuser',
}
conf = {}
cnf_rd._extend_key(conf, "", "testuser")
assert conf == {'': 'testuser'}
def test_read_env_config():
Config.init(ConfigReadToml("app/config/default_config.toml"))
assert Config.get('mqtt') == {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}
for _ in patch_getenv():
ConfigReadEnv()
assert Config.get_error() == None
assert Config.get('mqtt') == {'host': 'mqtt', 'port': 1234, 'user': None, 'passwd': 'passwd'}

View File

@@ -0,0 +1,411 @@
# test_with_pytest.py
import pytest
from mock import patch
from cnf.config import Config
from cnf.config_read_json import ConfigReadJson
from cnf.config_read_toml import ConfigReadToml
from test_config import ConfigDefault, ConfigComplete
class CnfIfc(ConfigReadJson):
def __init__(self):
pass
class FakeBuffer:
rd = str()
wr = str()
test_buffer = FakeBuffer
class FakeFile():
def __init__(self):
self.buf = test_buffer
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
pass
class FakeOptionsFile(FakeFile):
def __init__(self, OpenTextMode):
super().__init__()
self.bin_mode = 'b' in OpenTextMode
def read(self):
print(f"Fake.read: bmode:{self.bin_mode}")
if self.bin_mode:
return bytearray(self.buf.rd.encode('utf-8')).copy()
else:
print(f"Fake.read: str:{self.buf.rd}")
return self.buf.rd
def patch_open():
def new_open(file: str, OpenTextMode="r"):
if file == "_no__file__no_":
raise FileNotFoundError
return FakeOptionsFile(OpenTextMode)
with patch('builtins.open', new_open) as conn:
yield conn
@pytest.fixture
def ConfigTomlEmpty():
return {
'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'},
'solarman': {
'enabled': True,
'host': 'iot.talent-monitoring.com',
'port': 10000,
},
'tsun': {
'enabled': True,
'host': 'logger.talent-monitoring.com',
'port': 5005,
},
'inverters': {
'allow_all': False
},
'gen3plus': {'at_acl': {'tsun': {'allow': [], 'block': []},
'mqtt': {'allow': [], 'block': []}}},
}
def test_no_config(ConfigDefault):
test_buffer.rd = "" # empty buffer, no json
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadJson()
err = Config.get_error()
assert err == 'error: Expecting value: line 1 column 1 (char 0)'
cnf = Config.get()
assert cnf == ConfigDefault
def test_no_file(ConfigDefault):
test_buffer.rd = "" # empty buffer, no json
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadJson("_no__file__no_")
err = Config.get_error()
assert err == None
cnf = Config.get()
assert cnf == ConfigDefault
def test_invalid_filename(ConfigDefault):
test_buffer.rd = "" # empty buffer, no json
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadJson(None)
err = Config.get_error()
assert err == None
cnf = Config.get()
assert cnf == ConfigDefault
def test_cnv1():
"""test dotted key converting"""
tst = {
"gen3plus.at_acl.mqtt.block": [
"AT+SUPDATE",
"AT+"
]
}
cnf = ConfigReadJson()
obj = cnf.convert_to_obj(tst)
assert obj == {
'gen3plus': {
'at_acl': {
'mqtt': {
'block': [
'AT+SUPDATE',
"AT+"
],
},
},
},
}
def test_cnv2():
"""test a valid list with serials in inverters"""
tst = {
"inverters": [
{
"serial": "R170000000000001",
},
{
"serial": "Y170000000000001",
}
],
}
cnf = ConfigReadJson()
obj = cnf.convert_to_obj(tst)
assert obj == {
'inverters': {
'R170000000000001': {},
'Y170000000000001': {}
},
}
def test_cnv3():
"""test the combination of a list and a scalar in inverters"""
tst = {
"inverters": [
{
"serial": "R170000000000001",
},
{
"serial": "Y170000000000001",
}
],
"inverters.allow_all": False,
}
cnf = ConfigReadJson()
obj = cnf.convert_to_obj(tst)
assert obj == {
'inverters': {
'R170000000000001': {},
'Y170000000000001': {},
'allow_all': False,
},
}
def test_cnv4():
tst = {
"inverters": [
{
"serial": "R170000000000001",
"node_id": "PV-Garage/",
"suggested_area": "Garage",
"modbus_polling": False,
"pv1.manufacturer": "man1",
"pv1.type": "type1",
"pv2.manufacturer": "man2",
"pv2.type": "type2",
"sensor_list": 688
},
{
"serial": "Y170000000000001",
"monitor_sn": 2000000000,
"node_id": "PV-Garage2/",
"suggested_area": "Garage2",
"modbus_polling": True,
"client_mode.host": "InverterIP",
"client_mode.port": 1234,
"client_mode.forward": True,
"pv1.manufacturer": "man1",
"pv1.type": "type1",
"pv2.manufacturer": "man2",
"pv2.type": "type2",
"pv3.manufacturer": "man3",
"pv3.type": "type3",
"pv4.manufacturer": "man4",
"pv4.type": "type4",
"sensor_list": 688
}
],
"tsun.enabled": True,
"solarman.enabled": True,
"inverters.allow_all": False,
"gen3plus.at_acl.tsun.allow": [
"AT+Z",
"AT+UPURL",
"AT+SUPDATE"
],
"gen3plus.at_acl.tsun.block": [
"AT+SUPDATE"
],
"gen3plus.at_acl.mqtt.allow": [
"AT+"
],
"gen3plus.at_acl.mqtt.block": [
"AT+SUPDATE"
]
}
cnf = ConfigReadJson()
obj = cnf.convert_to_obj(tst)
assert obj == {
'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': ['AT+SUPDATE']},
'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'],
'block': ['AT+SUPDATE']}}},
'inverters': {'R170000000000001': {'modbus_polling': False,
'node_id': 'PV-Garage/',
'pv1': {
'manufacturer': 'man1',
'type': 'type1'},
'pv2': {
'manufacturer': 'man2',
'type': 'type2'},
'sensor_list': 688,
'suggested_area': 'Garage'},
'Y170000000000001': {'client_mode': {
'host': 'InverterIP',
'port': 1234,
'forward': True},
'modbus_polling': True,
'monitor_sn': 2000000000,
'node_id': 'PV-Garage2/',
'pv1': {
'manufacturer': 'man1',
'type': 'type1'},
'pv2': {
'manufacturer': 'man2',
'type': 'type2'},
'pv3': {
'manufacturer': 'man3',
'type': 'type3'},
'pv4': {
'manufacturer': 'man4',
'type': 'type4'},
'sensor_list': 688,
'suggested_area': 'Garage2'},
'allow_all': False},
'solarman': {'enabled': True},
'tsun': {'enabled': True}
}
def test_cnv5():
"""test a invalid list with missing serials"""
tst = {
"inverters": [
{
"node_id": "PV-Garage1/",
},
{
"serial": "Y170000000000001",
"node_id": "PV-Garage2/",
}
],
}
cnf = ConfigReadJson()
obj = cnf.convert_to_obj(tst)
assert obj == {
'inverters': {
'Y170000000000001': {'node_id': 'PV-Garage2/'}
},
}
def test_cnv6():
"""test overwritting a value in inverters"""
tst = {
"inverters": [{
"serial": "Y170000000000001",
"node_id": "PV-Garage2/",
}],
}
tst2 = {
"inverters": [{
"serial": "Y170000000000001",
"node_id": "PV-Garden/",
}],
}
cnf = ConfigReadJson()
conf = {}
for key, val in tst.items():
cnf.convert_inv_arr(conf, key, val)
assert conf == {
'inverters': {
'Y170000000000001': {'node_id': 'PV-Garage2/'}
},
}
for key, val in tst2.items():
cnf.convert_inv_arr(conf, key, val)
assert conf == {
'inverters': {
'Y170000000000001': {'node_id': 'PV-Garden/'}
},
}
def test_empty_config(ConfigDefault):
test_buffer.rd = "{}" # empty json
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadJson()
err = Config.get_error()
assert err == None
cnf = Config.get()
assert cnf == ConfigDefault
def test_full_config(ConfigComplete):
test_buffer.rd = """
{
"inverters": [
{
"serial": "R170000000000001",
"node_id": "PV-Garage/",
"suggested_area": "Garage",
"modbus_polling": false,
"pv1.manufacturer": "man1",
"pv1.type": "type1",
"pv2.manufacturer": "man2",
"pv2.type": "type2",
"sensor_list": 688
},
{
"serial": "Y170000000000001",
"monitor_sn": 2000000000,
"node_id": "PV-Garage2/",
"suggested_area": "Garage2",
"modbus_polling": true,
"pv1.manufacturer": "man1",
"pv1.type": "type1",
"pv2.manufacturer": "man2",
"pv2.type": "type2",
"pv3.manufacturer": "man3",
"pv3.type": "type3",
"pv4.manufacturer": "man4",
"pv4.type": "type4",
"sensor_list": 688
}
],
"tsun.enabled": true,
"solarman.enabled": true,
"inverters.allow_all": false,
"gen3plus.at_acl.tsun.allow": [
"AT+Z",
"AT+UPURL",
"AT+SUPDATE"
],
"gen3plus.at_acl.tsun.block": [
"AT+SUPDATE"
],
"gen3plus.at_acl.mqtt.allow": [
"AT+"
],
"gen3plus.at_acl.mqtt.block": [
"AT+SUPDATE"
]
}
"""
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadJson()
err = Config.get_error()
assert err == None
cnf = Config.get()
assert cnf == ConfigComplete

View File

@@ -2,8 +2,8 @@
import pytest
import json, math
import logging
from app.src.infos import Register, ClrAtMidnight
from app.src.infos import Infos, Fmt
from infos import Register, ClrAtMidnight
from infos import Infos, Fmt
def test_statistic_counter():
i = Infos()

View File

@@ -1,7 +1,7 @@
# test_with_pytest.py
import pytest, json, math
from app.src.infos import Register
from app.src.gen3.infos_g3 import InfosG3, RegisterMap
from infos import Register
from gen3.infos_g3 import InfosG3, RegisterMap
@pytest.fixture
def contr_data_seq(): # Get Time Request message

View File

@@ -1,9 +1,9 @@
# test_with_pytest.py
import pytest, json, math, random
from app.src.infos import Register
from app.src.gen3plus.infos_g3p import InfosG3P
from app.src.gen3plus.infos_g3p import RegisterMap
from infos import Register
from gen3plus.infos_g3p import InfosG3P
from gen3plus.infos_g3p import RegisterMap
@pytest.fixture(scope="session")
def str_test_ip():
@@ -120,7 +120,7 @@ def test_parse_4210(inverter_data: bytes):
"pv4": {"Voltage": 1.7, "Current": 0.01, "Power": 0.0, "Total_Generation": 15.58}},
"total": {"Daily_Generation": 0.11, "Total_Generation": 101.36},
"inv_unknown": {"Unknown_1": 512},
"other": {"Output_Shutdown": 65535, "Rated_Level": 3, "Grid_Volt_Cal_Coef": 1024}
"other": {"Output_Shutdown": 65535, "Rated_Level": 3, "Grid_Volt_Cal_Coef": 1024, "Prod_Compliance_Type": 6}
})
def test_build_4210(inverter_data: bytes):

View File

@@ -5,14 +5,14 @@ import gc
from mock import patch
from enum import Enum
from app.src.infos import Infos
from app.src.config import Config
from app.src.gen3.talent import Talent
from app.src.inverter_base import InverterBase
from app.src.singleton import Singleton
from app.src.async_stream import AsyncStream, AsyncStreamClient
from infos import Infos
from cnf.config import Config
from gen3.talent import Talent
from inverter_base import InverterBase
from singleton import Singleton
from async_stream import AsyncStream, AsyncStreamClient
from app.tests.test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
from test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
pytest_plugins = ('pytest_asyncio',)
@@ -54,11 +54,12 @@ class FakeReader():
class FakeWriter():
peer = ('47.1.2.3', 10000)
def write(self, buf: bytes):
return
def get_extra_info(self, sel: str):
if sel == 'peername':
return 'remote.intern'
return self.peer
elif sel == 'sockname':
return 'sock:1234'
assert False
@@ -69,13 +70,13 @@ class FakeWriter():
async def wait_closed(self):
return
class TestType(Enum):
class MockType(Enum):
RD_TEST_0_BYTES = 1
RD_TEST_TIMEOUT = 2
RD_TEST_EXCEPT = 3
test = TestType.RD_TEST_0_BYTES
test = MockType.RD_TEST_0_BYTES
@pytest.fixture
def patch_open_connection():
@@ -85,9 +86,9 @@ def patch_open_connection():
def new_open(host: str, port: int):
global test
if test == TestType.RD_TEST_TIMEOUT:
if test == MockType.RD_TEST_TIMEOUT:
raise ConnectionRefusedError
elif test == TestType.RD_TEST_EXCEPT:
elif test == MockType.RD_TEST_EXCEPT:
raise ValueError("Value cannot be negative") # Compliant
return new_conn(None)
@@ -241,6 +242,118 @@ async def test_remote_conn(config_conn, patch_open_connection):
cnt += 1
assert cnt == 0
@pytest.mark.asyncio
async def test_remote_conn_to_private(config_conn, patch_open_connection):
'''check DNS resolving of the TSUN FQDN to a local address'''
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
InverterBase._registry.clear()
reader = FakeReader()
writer = FakeWriter()
FakeWriter.peer = ("192.168.0.1", 10000)
with InverterBase(reader, writer, 'tsun', Talent) as inverter:
assert inverter.local.stream
assert inverter.local.ifc
await inverter.create_remote()
await asyncio.sleep(0)
assert not Config.act_config['tsun']['enabled']
assert inverter.remote.stream
assert inverter.remote.ifc
assert inverter.local.ifc.healthy()
# outside context manager the unhealth AsyncStream is released
FakeWriter.peer = ("47.1.2.3", 10000)
cnt = 0
for inv in InverterBase:
assert inv.healthy() # inverter is healthy again (without the unhealty AsyncStream)
cnt += 1
del inv
assert cnt == 1
del inverter
cnt = 0
for inv in InverterBase:
print(f'InverterBase refs:{gc.get_referrers(inv)}')
cnt += 1
assert cnt == 0
@pytest.mark.asyncio
async def test_remote_conn_to_loopback(config_conn, patch_open_connection):
'''check DNS resolving of the TSUN FQDN to the loopback address'''
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
InverterBase._registry.clear()
reader = FakeReader()
writer = FakeWriter()
FakeWriter.peer = ("127.0.0.1", 10000)
with InverterBase(reader, writer, 'tsun', Talent) as inverter:
assert inverter.local.stream
assert inverter.local.ifc
await inverter.create_remote()
await asyncio.sleep(0)
assert not Config.act_config['tsun']['enabled']
assert inverter.remote.stream
assert inverter.remote.ifc
assert inverter.local.ifc.healthy()
# outside context manager the unhealth AsyncStream is released
FakeWriter.peer = ("47.1.2.3", 10000)
cnt = 0
for inv in InverterBase:
assert inv.healthy() # inverter is healthy again (without the unhealty AsyncStream)
cnt += 1
del inv
assert cnt == 1
del inverter
cnt = 0
for inv in InverterBase:
print(f'InverterBase refs:{gc.get_referrers(inv)}')
cnt += 1
assert cnt == 0
@pytest.mark.asyncio
async def test_remote_conn_to_None(config_conn, patch_open_connection):
'''check if get_extra_info() return None in case of an error'''
_ = config_conn
_ = patch_open_connection
assert asyncio.get_running_loop()
InverterBase._registry.clear()
reader = FakeReader()
writer = FakeWriter()
FakeWriter.peer = None
with InverterBase(reader, writer, 'tsun', Talent) as inverter:
assert inverter.local.stream
assert inverter.local.ifc
await inverter.create_remote()
await asyncio.sleep(0)
assert Config.act_config['tsun']['enabled']
assert inverter.remote.stream
assert inverter.remote.ifc
assert inverter.local.ifc.healthy()
# outside context manager the unhealth AsyncStream is released
FakeWriter.peer = ("47.1.2.3", 10000)
cnt = 0
for inv in InverterBase:
assert inv.healthy() # inverter is healthy again (without the unhealty AsyncStream)
cnt += 1
del inv
assert cnt == 1
del inverter
cnt = 0
for inv in InverterBase:
print(f'InverterBase refs:{gc.get_referrers(inv)}')
cnt += 1
assert cnt == 0
@pytest.mark.asyncio
async def test_unhealthy_remote(config_conn, patch_open_connection, patch_unhealthy_remote):
_ = config_conn

View File

@@ -5,15 +5,15 @@ import sys,gc
from mock import patch
from enum import Enum
from app.src.infos import Infos
from app.src.config import Config
from app.src.proxy import Proxy
from app.src.inverter_base import InverterBase
from app.src.singleton import Singleton
from app.src.gen3.inverter_g3 import InverterG3
from app.src.async_stream import AsyncStream
from infos import Infos
from cnf.config import Config
from proxy import Proxy
from inverter_base import InverterBase
from singleton import Singleton
from gen3.inverter_g3 import InverterG3
from async_stream import AsyncStream
from app.tests.test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
from test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
pytest_plugins = ('pytest_asyncio',)
@@ -59,7 +59,7 @@ class FakeWriter():
return
def get_extra_info(self, sel: str):
if sel == 'peername':
return 'remote.intern'
return ('47.1.2.3', 10000)
elif sel == 'sockname':
return 'sock:1234'
assert False
@@ -70,13 +70,13 @@ class FakeWriter():
async def wait_closed(self):
return
class TestType(Enum):
class MockType(Enum):
RD_TEST_0_BYTES = 1
RD_TEST_TIMEOUT = 2
RD_TEST_EXCEPT = 3
test = TestType.RD_TEST_0_BYTES
test = MockType.RD_TEST_0_BYTES
@pytest.fixture
def patch_open_connection():
@@ -86,9 +86,9 @@ def patch_open_connection():
def new_open(host: str, port: int):
global test
if test == TestType.RD_TEST_TIMEOUT:
if test == MockType.RD_TEST_TIMEOUT:
raise ConnectionRefusedError
elif test == TestType.RD_TEST_EXCEPT:
elif test == MockType.RD_TEST_EXCEPT:
raise ValueError("Value cannot be negative") # Compliant
return new_conn(None)
@@ -144,14 +144,14 @@ async def test_remote_except(config_conn, patch_open_connection):
assert asyncio.get_running_loop()
global test
test = TestType.RD_TEST_TIMEOUT
test = MockType.RD_TEST_TIMEOUT
with InverterG3(FakeReader(), FakeWriter()) as inverter:
await inverter.create_remote()
await asyncio.sleep(0)
assert inverter.remote.stream==None
test = TestType.RD_TEST_EXCEPT
test = MockType.RD_TEST_EXCEPT
await inverter.create_remote()
await asyncio.sleep(0)
assert inverter.remote.stream==None

View File

@@ -4,14 +4,14 @@ import asyncio
from mock import patch
from enum import Enum
from app.src.infos import Infos
from app.src.config import Config
from app.src.proxy import Proxy
from app.src.inverter_base import InverterBase
from app.src.singleton import Singleton
from app.src.gen3plus.inverter_g3p import InverterG3P
from infos import Infos
from cnf.config import Config
from proxy import Proxy
from inverter_base import InverterBase
from singleton import Singleton
from gen3plus.inverter_g3p import InverterG3P
from app.tests.test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
from test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
pytest_plugins = ('pytest_asyncio',)
@@ -58,7 +58,7 @@ class FakeWriter():
return
def get_extra_info(self, sel: str):
if sel == 'peername':
return 'remote.intern'
return ('47.1.2.3', 10000)
elif sel == 'sockname':
return 'sock:1234'
assert False
@@ -69,13 +69,13 @@ class FakeWriter():
async def wait_closed(self):
return
class TestType(Enum):
class MockType(Enum):
RD_TEST_0_BYTES = 1
RD_TEST_TIMEOUT = 2
RD_TEST_EXCEPT = 3
test = TestType.RD_TEST_0_BYTES
test = MockType.RD_TEST_0_BYTES
@pytest.fixture
def patch_open_connection():
@@ -85,16 +85,17 @@ def patch_open_connection():
def new_open(host: str, port: int):
global test
if test == TestType.RD_TEST_TIMEOUT:
if test == MockType.RD_TEST_TIMEOUT:
raise ConnectionRefusedError
elif test == TestType.RD_TEST_EXCEPT:
elif test == MockType.RD_TEST_EXCEPT:
raise ValueError("Value cannot be negative") # Compliant
return new_conn(None)
with patch.object(asyncio, 'open_connection', new_open) as conn:
yield conn
def test_method_calls():
def test_method_calls(config_conn):
_ = config_conn
reader = FakeReader()
writer = FakeWriter()
InverterBase._registry.clear()
@@ -121,14 +122,14 @@ async def test_remote_except(config_conn, patch_open_connection):
assert asyncio.get_running_loop()
global test
test = TestType.RD_TEST_TIMEOUT
test = MockType.RD_TEST_TIMEOUT
with InverterG3P(FakeReader(), FakeWriter(), client_mode=False) as inverter:
await inverter.create_remote()
await asyncio.sleep(0)
assert inverter.remote.stream==None
test = TestType.RD_TEST_EXCEPT
test = MockType.RD_TEST_EXCEPT
await inverter.create_remote()
await asyncio.sleep(0)
assert inverter.remote.stream==None

View File

@@ -1,8 +1,8 @@
# test_with_pytest.py
import pytest
import asyncio
from app.src.modbus import Modbus
from app.src.infos import Infos, Register
from modbus import Modbus
from infos import Infos, Register
pytest_plugins = ('pytest_asyncio',)

View File

@@ -5,14 +5,14 @@ from aiomqtt import MqttCodeError
from mock import patch
from enum import Enum
from app.src.singleton import Singleton
from app.src.config import Config
from app.src.infos import Infos
from app.src.mqtt import Mqtt
from app.src.inverter_base import InverterBase
from app.src.messages import Message, State
from app.src.proxy import Proxy
from app.src.modbus_tcp import ModbusConn, ModbusTcp
from singleton import Singleton
from cnf.config import Config
from infos import Infos
from mqtt import Mqtt
from inverter_base import InverterBase
from messages import Message, State
from proxy import Proxy
from modbus_tcp import ModbusConn, ModbusTcp
pytest_plugins = ('pytest_asyncio',)

View File

@@ -5,13 +5,15 @@ import aiomqtt
import logging
from mock import patch, Mock
from app.src.async_stream import AsyncIfcImpl
from app.src.singleton import Singleton
from app.src.mqtt import Mqtt
from app.src.modbus import Modbus
from app.src.gen3plus.solarman_v5 import SolarmanV5
from app.src.config import Config
from async_stream import AsyncIfcImpl
from singleton import Singleton
from mqtt import Mqtt
from modbus import Modbus
from gen3plus.solarman_v5 import SolarmanV5
from cnf.config import Config
NO_MOSQUITTO_TEST = False
'''disable all tests with connections to test.mosquitto.org'''
pytest_plugins = ('pytest_asyncio',)
@@ -69,23 +71,79 @@ def spy_modbus_cmd_client():
def test_native_client(test_hostname, test_port):
"""Sanity check: Make sure the paho-mqtt client can connect to the test
MQTT server.
MQTT server. Otherwise the test set NO_MOSQUITTO_TEST to True and disable
all test cases which depends on the test.mosquitto.org server
"""
global NO_MOSQUITTO_TEST
if NO_MOSQUITTO_TEST:
pytest.skip('skipping, since Mosquitto is not reliable at the moment')
import paho.mqtt.client as mqtt
import threading
c = mqtt.Client()
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
c.loop_start()
try:
# Just make sure the client connects successfully
on_connect = threading.Event()
c.on_connect = Mock(side_effect=lambda *_: on_connect.set())
c.connect_async(test_hostname, test_port)
assert on_connect.wait(5)
if not on_connect.wait(3):
NO_MOSQUITTO_TEST = True # skip all mosquitto tests
pytest.skip('skipping, since Mosquitto is not reliable at the moment')
finally:
c.loop_stop()
@pytest.mark.asyncio
async def test_mqtt_connection(config_mqtt_conn):
global NO_MOSQUITTO_TEST
if NO_MOSQUITTO_TEST:
pytest.skip('skipping, since Mosquitto is not reliable at the moment')
_ = config_mqtt_conn
assert asyncio.get_running_loop()
on_connect = asyncio.Event()
async def cb():
on_connect.set()
try:
m = Mqtt(cb)
assert m.task
assert await asyncio.wait_for(on_connect.wait(), 5)
# await asyncio.sleep(1)
assert 0 == m.ha_restarts
await m.publish('homeassistant/status', 'online')
except TimeoutError:
assert False
finally:
await m.close()
await m.publish('homeassistant/status', 'online')
@pytest.mark.asyncio
async def test_ha_reconnect(config_mqtt_conn):
global NO_MOSQUITTO_TEST
if NO_MOSQUITTO_TEST:
pytest.skip('skipping, since Mosquitto is not reliable at the moment')
_ = config_mqtt_conn
on_connect = asyncio.Event()
async def cb():
on_connect.set()
try:
m = Mqtt(cb)
msg = aiomqtt.Message(topic= 'homeassistant/status', payload= b'offline', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
assert not on_connect.is_set()
msg = aiomqtt.Message(topic= 'homeassistant/status', payload= b'online', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
assert on_connect.is_set()
finally:
await m.close()
@pytest.mark.asyncio
async def test_mqtt_no_config(config_no_conn):
_ = config_no_conn
@@ -110,29 +168,6 @@ async def test_mqtt_no_config(config_no_conn):
finally:
await m.close()
@pytest.mark.asyncio
async def test_mqtt_connection(config_mqtt_conn):
_ = config_mqtt_conn
assert asyncio.get_running_loop()
on_connect = asyncio.Event()
async def cb():
on_connect.set()
try:
m = Mqtt(cb)
assert m.task
assert await asyncio.wait_for(on_connect.wait(), 5)
# await asyncio.sleep(1)
assert 0 == m.ha_restarts
await m.publish('homeassistant/status', 'online')
except TimeoutError:
assert False
finally:
await m.close()
await m.publish('homeassistant/status', 'online')
@pytest.mark.asyncio
async def test_msg_dispatch(config_mqtt_conn, spy_modbus_cmd):
_ = config_mqtt_conn
@@ -209,26 +244,6 @@ async def test_msg_ignore_client_conn(config_mqtt_conn, spy_modbus_cmd_client):
finally:
await m.close()
@pytest.mark.asyncio
async def test_ha_reconnect(config_mqtt_conn):
_ = config_mqtt_conn
on_connect = asyncio.Event()
async def cb():
on_connect.set()
try:
m = Mqtt(cb)
msg = aiomqtt.Message(topic= 'homeassistant/status', payload= b'offline', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
assert not on_connect.is_set()
msg = aiomqtt.Message(topic= 'homeassistant/status', payload= b'online', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
assert on_connect.is_set()
finally:
await m.close()
@pytest.mark.asyncio
async def test_ignore_unknown_func(config_mqtt_conn):
'''don't dispatch for unknwon function names'''

View File

@@ -5,11 +5,11 @@ import aiomqtt
import logging
from mock import patch, Mock
from app.src.singleton import Singleton
from app.src.proxy import Proxy
from app.src.mqtt import Mqtt
from app.src.gen3plus.solarman_v5 import SolarmanV5
from app.src.config import Config
from singleton import Singleton
from proxy import Proxy
from mqtt import Mqtt
from gen3plus.solarman_v5 import SolarmanV5
from cnf.config import Config
pytest_plugins = ('pytest_asyncio',)

24
app/tests/test_server.py Normal file
View File

@@ -0,0 +1,24 @@
# test_with_pytest.py
import pytest
import logging
import os
from mock import patch
from server import get_log_level
def test_get_log_level():
with patch.dict(os.environ, {'LOG_LVL': ''}):
log_lvl = get_log_level()
assert log_lvl == logging.INFO
with patch.dict(os.environ, {'LOG_LVL': 'DEBUG'}):
log_lvl = get_log_level()
assert log_lvl == logging.DEBUG
with patch.dict(os.environ, {'LOG_LVL': 'WARN'}):
log_lvl = get_log_level()
assert log_lvl == logging.WARNING
with patch.dict(os.environ, {'LOG_LVL': 'UNKNOWN'}):
log_lvl = get_log_level()
assert log_lvl == logging.INFO

View File

@@ -1,16 +1,16 @@
# test_with_pytest.py
import pytest
from app.src.singleton import Singleton
from singleton import Singleton
class Test(metaclass=Singleton):
class Example(metaclass=Singleton):
def __init__(self):
pass # is a dummy test class
def test_singleton_metaclass():
Singleton._instances.clear()
a = Test()
a = Example()
assert 1 == len(Singleton._instances)
b = Test()
b = Example()
assert 1 == len(Singleton._instances)
assert a is b
del a

View File

@@ -5,12 +5,12 @@ import asyncio
import logging
import random
from math import isclose
from app.src.async_stream import AsyncIfcImpl, StreamPtr
from app.src.gen3plus.solarman_v5 import SolarmanV5, SolarmanBase
from app.src.config import Config
from app.src.infos import Infos, Register
from app.src.modbus import Modbus
from app.src.messages import State, Message
from async_stream import AsyncIfcImpl, StreamPtr
from gen3plus.solarman_v5 import SolarmanV5, SolarmanBase
from cnf.config import Config
from infos import Infos, Register
from modbus import Modbus
from messages import State, Message
pytest_plugins = ('pytest_asyncio',)
@@ -1730,7 +1730,6 @@ async def test_modbus_polling(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp
assert asyncio.get_running_loop() == m.mb_timer.loop
m.db.stat['proxy']['Unknown_Ctrl'] = 0
assert m.mb_timer.tim == None
m.read() # read complete msg, and dispatch msg
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 1
@@ -1768,10 +1767,6 @@ async def test_start_client_mode(config_tsun_inv1, str_test_ip):
_ = config_tsun_inv1
assert asyncio.get_running_loop()
m = MemoryStream(b'')
m.mb_start_reg = 0x3000
m.mb_incr_reg = 0x00 # 4
m.mb_scan_len = 48
assert m.state == State.init
assert m.no_forwarding == False
assert m.mb_timer.tim == None

View File

@@ -1,11 +1,13 @@
import pytest
import asyncio
from app.src.async_stream import AsyncIfcImpl, StreamPtr
from app.src.gen3plus.solarman_v5 import SolarmanV5, SolarmanBase
from app.src.gen3plus.solarman_emu import SolarmanEmu
from app.src.infos import Infos, Register
from app.tests.test_solarman import FakeIfc, MemoryStream, get_sn_int, get_sn, correct_checksum, config_tsun_inv1, msg_modbus_rsp
from app.tests.test_infos_g3p import str_test_ip, bytes_test_ip
from async_stream import AsyncIfcImpl, StreamPtr
from gen3plus.solarman_v5 import SolarmanV5, SolarmanBase
from gen3plus.solarman_emu import SolarmanEmu
from infos import Infos, Register
from test_solarman import FakeIfc, MemoryStream, get_sn_int, get_sn, correct_checksum, config_tsun_inv1, msg_modbus_rsp
from test_infos_g3p import str_test_ip, bytes_test_ip
timestamp = 0x3224c8bc
@@ -170,6 +172,7 @@ async def test_snd_inv_data(config_tsun_inv1, inverter_ind_msg, inverter_rsp_msg
inv.db.set_db_def_value(Register.GRID_VOLTAGE, 224.8)
inv.db.set_db_def_value(Register.GRID_CURRENT, 0.73)
inv.db.set_db_def_value(Register.GRID_FREQUENCY, 50.05)
inv.db.set_db_def_value(Register.PROD_COMPL_TYPE, 6)
assert asyncio.get_running_loop() == inv.mb_timer.loop
await inv.send_start_cmd(get_sn_int(), str_test_ip, False, inv.mb_first_timeout)
inv.db.set_db_def_value(Register.DATA_UP_INTERVAL, 17) # set test value

View File

@@ -1,12 +1,12 @@
# test_with_pytest.py
import pytest, logging, asyncio
from math import isclose
from app.src.async_stream import AsyncIfcImpl, StreamPtr
from app.src.gen3.talent import Talent, Control
from app.src.config import Config
from app.src.infos import Infos, Register
from app.src.modbus import Modbus
from app.src.messages import State
from async_stream import AsyncIfcImpl, StreamPtr
from gen3.talent import Talent, Control
from cnf.config import Config
from infos import Infos, Register
from modbus import Modbus
from messages import State
pytest_plugins = ('pytest_asyncio',)
@@ -328,6 +328,90 @@ def msg_inverter_ind_new(): # Data indication from DSP V5.0.17
msg += b'\x00\x00\x00\x00'
return msg
@pytest.fixture
def msg_inverter_ind_new2(): # Data indication from DSP V5.0.17
msg = b'\x00\x00\x04\xf4\x10R170000000000001\x91\x04\x01\x90\x00\x01\x10R170000000000001'
msg += b'\x01\x00\x00\x01'
msg += b'\x86\x98\x55\xe7\x48\x00\x00\x00\xa3\x00\x00\x01\x93\x53\x00\x00'
msg += b'\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x95\x53\x00\x00\x00\x00'
msg += b'\x01\x96\x53\x00\x00\x00\x00\x01\x97\x53\x00\x00\x00\x00\x01\x98'
msg += b'\x53\x00\x00\x00\x00\x01\x99\x53\x00\x00\x00\x00\x01\x9a\x53\x00'
msg += b'\x00\x00\x00\x01\x9b\x53\x00\x00\x00\x00\x01\x9c\x53\x00\x00\x00'
msg += b'\x00\x01\x9d\x53\x00\x00\x00\x00\x01\x9e\x53\x00\x00\x00\x00\x01'
msg += b'\x9f\x53\x00\x00\x00\x00\x01\xa0\x53\x00\x00\x00\x00\x01\xf4\x49'
msg += b'\x00\x00\x00\x00\x00\x00\x01\xf5\x53\x00\x00\x00\x00\x01\xf6\x53'
msg += b'\x00\x00\x00\x00\x01\xf7\x53\x00\x00\x00\x00\x01\xf8\x53\x00\x00'
msg += b'\x00\x00\x01\xf9\x53\x00\x00\x00\x00\x01\xfa\x53\x00\x00\x00\x00'
msg += b'\x01\xfb\x53\x00\x00\x00\x00\x01\xfc\x53\x00\x00\x00\x00\x01\xfd'
msg += b'\x53\x00\x00\x00\x00\x01\xfe\x53\x00\x00\x00\x00\x01\xff\x53\x00'
msg += b'\x00\x00\x00\x02\x00\x53\x00\x00\x00\x00\x02\x01\x53\x00\x00\x00'
msg += b'\x00\x02\x02\x53\x00\x00\x00\x00\x02\x03\x53\x00\x00\x00\x00\x02'
msg += b'\x04\x53\x00\x00\x00\x00\x02\x58\x49\x00\x00\x00\x00\x00\x00\x02'
msg += b'\x59\x53\x00\x00\x00\x00\x02\x5a\x53\x00\x00\x00\x00\x02\x5b\x53'
msg += b'\x00\x00\x00\x00\x02\x5c\x53\x00\x00\x00\x00\x02\x5d\x53\x00\x00'
msg += b'\x00\x00\x02\x5e\x53\x00\x00\x00\x00\x02\x5f\x53\x00\x00\x00\x00'
msg += b'\x02\x60\x53\x00\x00\x00\x00\x02\x61\x53\x00\x00\x00\x00\x02\x62'
msg += b'\x53\x00\x00\x00\x00\x02\x63\x53\x00\x00\x00\x00\x02\x64\x53\x00'
msg += b'\x00\x00\x00\x02\x65\x53\x00\x00\x00\x00\x02\x66\x53\x00\x00\x00'
msg += b'\x00\x02\x67\x53\x00\x00\x00\x00\x02\x68\x53\x00\x00\x00\x00\x02'
msg += b'\xbc\x49\x00\x00\x00\x00\x00\x00\x02\xbd\x53\x00\x00\x00\x00\x02'
msg += b'\xbe\x53\x00\x00\x00\x00\x02\xbf\x53\x00\x00\x00\x00\x02\xc0\x53'
msg += b'\x00\x00\x00\x00\x02\xc1\x53\x00\x00\x00\x00\x02\xc2\x53\x00\x00'
msg += b'\x00\x00\x02\xc3\x53\x00\x00\x00\x00\x02\xc4\x53\x00\x00\x00\x00'
msg += b'\x02\xc5\x53\x00\x00\x00\x00\x02\xc6\x53\x00\x00\x00\x00\x02\xc7'
msg += b'\x53\x00\x00\x00\x00\x02\xc8\x53\x00\x00\x00\x00\x02\xc9\x53\x00'
msg += b'\x00\x00\x00\x02\xca\x53\x00\x00\x00\x00\x02\xcb\x53\x00\x00\x00'
msg += b'\x00\x02\xcc\x53\x00\x00\x00\x00\x03\x20\x53\x00\x00\x00\x00\x03'
msg += b'\x84\x53\x50\x11\x00\x00\x03\xe8\x46\x43\x65\xcc\xcd\x00\x00\x04'
msg += b'\x4c\x46\x40\x0c\xcc\xcd\x00\x00\x04\xb0\x46\x42\x47\xd7\x0a\x00'
msg += b'\x00\x05\x14\x53\x00\x35\x00\x00\x05\x78\x53\x00\x00\x00\x00\x05'
msg += b'\xdc\x53\x03\x20\x00\x00\x06\x40\x46\x43\xfd\x4c\xcd\x00\x00\x06'
msg += b'\xa4\x46\x42\x18\x00\x00\x00\x00\x07\x08\x46\x40\xde\x14\x7b\x00'
msg += b'\x00\x07\x6c\x46\x43\x84\x33\x33\x00\x00\x07\xd0\x46\x42\x1a\x00'
msg += b'\x00\x00\x00\x08\x34\x46\x40\xda\x8f\x5c\x00\x00\x08\x98\x46\x43'
msg += b'\x83\xb3\x33\x00\x00\x08\xfc\x46\x00\x00\x00\x00\x00\x00\x09\x60'
msg += b'\x46\x00\x00\x00\x00\x00\x00\x09\xc4\x46\x00\x00\x00\x00\x00\x00'
msg += b'\x0a\x28\x46\x00\x00\x00\x00\x00\x00\x0a\x8c\x46\x00\x00\x00\x00'
msg += b'\x00\x00\x0a\xf0\x46\x00\x00\x00\x00\x00\x00\x0b\x54\x46\x40\x9c'
msg += b'\xcc\xcd\x00\x00\x0b\xb8\x46\x43\xea\xb5\xc3\x00\x00\x0c\x1c\x46'
msg += b'\x40\x1e\xb8\x52\x00\x00\x0c\x80\x46\x43\x6d\x2b\x85\x00\x00\x0c'
msg += b'\xe4\x46\x40\x1a\xe1\x48\x00\x00\x0d\x48\x46\x43\x68\x40\x00\x00'
msg += b'\x00\x0d\xac\x46\x00\x00\x00\x00\x00\x00\x0e\x10\x46\x00\x00\x00'
msg += b'\x00\x00\x00\x0e\x74\x46\x00\x00\x00\x00\x00\x00\x0e\xd8\x46\x00'
msg += b'\x00\x00\x00\x00\x00\x0f\x3c\x53\x00\x00\x00\x00\x0f\xa0\x53\x00'
msg += b'\x00\x00\x00\x10\x04\x53\x55\xaa\x00\x00\x10\x68\x53\x00\x01\x00'
msg += b'\x00\x10\xcc\x53\x00\x00\x00\x00\x11\x30\x53\x00\x00\x00\x00\x11'
msg += b'\x94\x53\x00\x00\x00\x00\x11\xf8\x53\xff\xff\x00\x00\x12\x5c\x53'
msg += b'\xff\xff\x00\x00\x12\xc0\x53\x00\x00\x00\x00\x13\x24\x53\xff\xff'
msg += b'\x00\x00\x13\x88\x53\xff\xff\x00\x00\x13\xec\x53\xff\xff\x00\x00'
msg += b'\x14\x50\x53\xff\xff\x00\x00\x14\xb4\x53\xff\xff\x00\x00\x15\x18'
msg += b'\x53\xff\xff\x00\x00\x15\x7c\x53\x00\x00\x00\x00\x27\x10\x53\x00'
msg += b'\x02\x00\x00\x27\x74\x53\x00\x3c\x00\x00\x27\xd8\x53\x00\x68\x00'
msg += b'\x00\x28\x3c\x53\x05\x00\x00\x00\x28\xa0\x46\x43\x79\x00\x00\x00'
msg += b'\x00\x29\x04\x46\x43\x48\x00\x00\x00\x00\x29\x68\x46\x42\x48\x33'
msg += b'\x33\x00\x00\x29\xcc\x46\x42\x3e\x3d\x71\x00\x00\x2a\x30\x53\x00'
msg += b'\x01\x00\x00\x2a\x94\x46\x43\x37\x00\x00\x00\x00\x2a\xf8\x46\x42'
msg += b'\xce\x00\x00\x00\x00\x2b\x5c\x53\x00\x96\x00\x00\x2b\xc0\x53\x00'
msg += b'\x10\x00\x00\x2c\x24\x46\x43\x90\x00\x00\x00\x00\x2c\x88\x46\x43'
msg += b'\x95\x00\x00\x00\x00\x2c\xec\x53\x00\x06\x00\x00\x2d\x50\x53\x00'
msg += b'\x06\x00\x00\x2d\xb4\x46\x43\x7d\x00\x00\x00\x00\x2e\x18\x46\x42'
msg += b'\x3d\xeb\x85\x00\x00\x2e\x7c\x46\x42\x3d\xeb\x85\x00\x00\x2e\xe0'
msg += b'\x53\x00\x03\x00\x00\x2f\x44\x53\x00\x03\x00\x00\x2f\xa8\x46\x42'
msg += b'\x4d\xeb\x85\x00\x00\x30\x0c\x46\x42\x4d\xeb\x85\x00\x00\x30\x70'
msg += b'\x53\x00\x03\x00\x00\x30\xd4\x53\x00\x03\x00\x00\x31\x38\x46\x42'
msg += b'\x08\x00\x00\x00\x00\x31\x9c\x53\x00\x05\x00\x00\x32\x00\x53\x04'
msg += b'\x00\x00\x00\x32\x64\x53\x00\x01\x00\x00\x32\xc8\x53\x13\x9c\x00'
msg += b'\x00\x33\x2c\x53\x0f\xa0\x00\x00\x33\x90\x53\x00\x4f\x00\x00\x33'
msg += b'\xf4\x53\x00\x66\x00\x00\x34\x58\x53\x03\xe8\x00\x00\x34\xbc\x53'
msg += b'\x04\x00\x00\x00\x35\x20\x53\x00\x00\x00\x00\x35\x84\x53\x00\x00'
msg += b'\x00\x00\x35\xe8\x53\x00\x00\x00\x00\x36\x4c\x53\x00\x00\x00\x01'
msg += b'\x38\x80\x53\x00\x02\x00\x01\x38\x81\x53\x00\x01\x00\x01\x38\x82'
msg += b'\x53\x00\x01\x00\x01\x38\x83\x53\x00\x00\x00\x00\x00\x0a\x08\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x14\x04\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x1e\x07\x00\x00\x00\x00\x00'
return msg
@pytest.fixture
def msg_inverter_ind_0w(): # Data indication with 0.5W grid output
msg = b'\x00\x00\x05\x02\x10R170000000000001\x91\x04\x01\x90\x00\x01\x10R170000000000001'
@@ -2151,3 +2235,34 @@ def test_timeout(config_tsun_inv1):
m.modbus_polling = False
assert Talent.MAX_DEF_IDLE_TIME == m._timeout()
m.close()
def test_msg_inv_replay(config_tsun_inv1, msg_inverter_ind_0w, msg_inverter_ind_new2):
'''replay must be ignores, since HA only supports realtime values'''
_ = config_tsun_inv1
m = MemoryStream(msg_inverter_ind_0w, (0,)) # realtime msg with 0.5W Output Power
m.append_msg(msg_inverter_ind_new2) # replay msg with 506.6W Output Power
m.db.db['grid'] = {'Output_Power': 100}
m.db.stat['proxy']['Unknown_Ctrl'] = 0
m.db.stat['proxy']['Invalid_Data_Type'] = 0
m.read() # read complete msg, and dispatch msg
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
assert m.db.stat['proxy']['Invalid_Data_Type'] == 0
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 2
assert m.msg_recvd[0]['ctrl']==145
assert m.msg_recvd[0]['msg_id']==4
assert m.msg_recvd[0]['header_len']==23
assert m.msg_recvd[0]['data_len']==1263
assert m.msg_recvd[1]['ctrl']==145
assert m.msg_recvd[1]['msg_id']==4
assert m.msg_recvd[1]['header_len']==23
assert m.msg_recvd[1]['data_len']==1249
assert m.id_str == b"R170000000000001"
assert m.unique_id == 'R170000000000001'
assert m.db.get_db_value(Register.INVERTER_STATUS) == 1
assert isclose(m.db.db['grid']['Output_Power'], 0.5) # must be 0.5W not 100W nor 506.6W
m.close()
assert m.db.get_db_value(Register.INVERTER_STATUS) == 0

2
ha_addons/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.data.json
config.yaml

136
ha_addons/Makefile Normal file
View File

@@ -0,0 +1,136 @@
#!make
include ../.env
.PHONY: debug dev build clean rootfs repro rc rel
SHELL = /bin/sh
JINJA = jinja2
IMAGE = tsun-gen3-addon
# Folders
SRC=../app
SRC_PROXY=$(SRC)/src
CNF_PROXY=$(SRC)/config
ADDON_PATH = ha_addon
DST=$(ADDON_PATH)/rootfs
DST_PROXY=$(DST)/home/proxy
INST_BASE=../../ha-addons/ha-addons
TEMPL=templates
# collect source files
SRC_FILES := $(wildcard $(SRC_PROXY)/*.py)\
$(wildcard $(SRC_PROXY)/*.ini)\
$(wildcard $(SRC_PROXY)/cnf/*.py)\
$(wildcard $(SRC_PROXY)/gen3/*.py)\
$(wildcard $(SRC_PROXY)/gen3plus/*.py)
CNF_FILES := $(wildcard $(CNF_PROXY)/*.toml)
# determine destination files
TARGET_FILES = $(SRC_FILES:$(SRC_PROXY)/%=$(DST_PROXY)/%)
CONFIG_FILES = $(CNF_FILES:$(CNF_PROXY)/%=$(DST_PROXY)/%)
export BUILD_DATE := ${shell date -Iminutes}
VERSION := $(shell cat $(SRC)/.version)
export MAJOR := $(shell echo $(VERSION) | cut -f1 -d.)
PUBLIC_URL := $(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f1 -d/)
PUBLIC_USER :=$(shell echo $(PUBLIC_CONTAINER_REGISTRY) | cut -f2 -d/)
dev debug: build
@echo version: $(VERSION) build-date: $(BUILD_DATE) image: $(PRIVAT_CONTAINER_REGISTRY)$(IMAGE)
export VERSION=$(VERSION)-$@ && \
export IMAGE=$(PRIVAT_CONTAINER_REGISTRY)$(IMAGE) && \
docker buildx bake -f docker-bake.hcl $@
rc rel: build
@echo version: $(VERSION) build-date: $(BUILD_DATE) image: $(PUBLIC_CONTAINER_REGISTRY)$(IMAGE)
@echo login at $(PUBLIC_URL) as $(PUBLIC_USER)
@DO_LOGIN="$(shell echo $(PUBLIC_CR_KEY) | docker login $(PUBLIC_URL) -u $(PUBLIC_USER) --password-stdin)"
export VERSION=$(VERSION)-$@ && \
export IMAGE=$(PUBLIC_CONTAINER_REGISTRY)$(IMAGE) && \
docker buildx bake -f docker-bake.hcl $@
build: rootfs $(ADDON_PATH)/config.yaml repro
clean:
rm -r -f $(DST_PROXY)
rm -f $(DST)/requirements.txt
rm -f $(ADDON_PATH)/config.yaml
rm -f $(TEMPL)/.data.json
#
# Build rootfs and config.yaml as local add-on
# The rootfs is needed to build the add-on Dockercontainers
#
rootfs: $(TARGET_FILES) $(CONFIG_FILES) $(DST)/requirements.txt
STAGE=dev
debug : STAGE=debug
rc : STAGE=rc
rel : STAGE=rel
$(CONFIG_FILES): $(DST_PROXY)/% : $(CNF_PROXY)/%
@echo Copy $< to $@
@mkdir -p $(@D)
@cp $< $@
$(TARGET_FILES): $(DST_PROXY)/% : $(SRC_PROXY)/%
@echo Copy $< to $@
@mkdir -p $(@D)
@cp $< $@
$(DST)/requirements.txt : $(SRC)/requirements.txt
@echo Copy $< to $@
@cp $< $@
$(ADDON_PATH)/%.yaml: $(TEMPL)/%.jinja $(TEMPL)/.data.json
$(JINJA) --strict -D AppVersion=$(VERSION) --format=json $^ -o $@
$(TEMPL)/.data.json: FORCE
rsync --checksum $(TEMPL)/$(STAGE)_data.json $@
FORCE : ;
#
# Build repository for Home Assistant Add-On
#
INST=$(INST_BASE)/ha_addon_dev
repro_files = DOCS.md icon.png logo.png translations/de.yaml translations/en.yaml
repro_root = CHANGELOG.md
repro_templates = config.yaml
repro_subdirs = translations
repro_vers = debug dev rel
repro_all_files := $(foreach dir,$(repro_vers), $(foreach file,$(repro_files),$(INST_BASE)/ha_addon_$(dir)/$(file)))
repro_root_files := $(foreach dir,$(repro_vers), $(foreach file,$(repro_root),$(INST_BASE)/ha_addon_$(dir)/$(file)))
repro_all_templates := $(foreach dir,$(repro_vers), $(foreach file,$(repro_templates),$(INST_BASE)/ha_addon_$(dir)/$(file)))
repro_all_subdirs := $(foreach dir,$(repro_vers), $(foreach file,$(repro_subdirs),$(INST_BASE)/ha_addon_$(dir)/$(file)))
repro: $(repro_all_subdirs) $(repro_all_templates) $(repro_all_files) $(repro_root_files)
$(repro_all_subdirs) :
mkdir -p $@
$(repro_all_templates) : $(INST_BASE)/ha_addon_%/config.yaml: $(TEMPL)/config.jinja $(TEMPL)/%_data.json $(SRC)/.version
$(JINJA) --strict -D AppVersion=$(VERSION)-$* $< $(filter %.json,$^) -o $@
$(repro_root_files) : %/CHANGELOG.md : ../CHANGELOG.md
cp $< $@
$(filter $(INST_BASE)/ha_addon_debug/%,$(repro_all_files)) : $(INST_BASE)/ha_addon_debug/% : ha_addon/%
cp $< $@
$(filter $(INST_BASE)/ha_addon_dev/%,$(repro_all_files)) : $(INST_BASE)/ha_addon_dev/% : ha_addon/%
cp $< $@
$(filter $(INST_BASE)/ha_addon_rel/%,$(repro_all_files)) : $(INST_BASE)/ha_addon_rel/% : ha_addon/%
cp $< $@

99
ha_addons/docker-bake.hcl Normal file
View File

@@ -0,0 +1,99 @@
variable "IMAGE" {
default = "tsun-gen3-addon"
}
variable "VERSION" {
default = "0.0.0"
}
variable "MAJOR" {
default = "0"
}
variable "BUILD_DATE" {
default = "dev"
}
variable "BRANCH" {
default = ""
}
variable "DESCRIPTION" {
default = "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."
}
target "_common" {
context = "ha_addon"
dockerfile = "Dockerfile"
args = {
VERSION = "${VERSION}"
environment = "production"
}
attest = [
"type =provenance,mode=max",
"type =sbom,generator=docker/scout-sbom-indexer:latest"
]
annotations = [
"index:io.hass.version=${VERSION}",
"index:io.hass.type=addon",
"index:io.hass.arch=armhf|aarch64|i386|amd64",
"index:org.opencontainers.image.title=TSUN-Proxy",
"index:org.opencontainers.image.authors=Stefan Allius",
"index:org.opencontainers.image.created=${BUILD_DATE}",
"index:org.opencontainers.image.version=${VERSION}",
"index:org.opencontainers.image.revision=${BRANCH}",
"index:org.opencontainers.image.description=${DESCRIPTION}",
"index:org.opencontainers.image.licenses=BSD-3-Clause",
"index:org.opencontainers.image.source=https://github.com/s-allius/tsun-gen3-proxy/ha_addons/ha_addon"
]
labels = {
"io.hass.version" = "${VERSION}"
"io.hass.type" = "addon"
"io.hass.arch" = "armhf|aarch64|i386|amd64"
"org.opencontainers.image.title" = "TSUN-Proxy"
"org.opencontainers.image.authors" = "Stefan Allius"
"org.opencontainers.image.created" = "${BUILD_DATE}"
"org.opencontainers.image.version" = "${VERSION}"
"org.opencontainers.image.revision" = "${BRANCH}"
"org.opencontainers.image.description" = "${DESCRIPTION}"
"org.opencontainers.image.licenses" = "BSD-3-Clause"
"org.opencontainers.image.source" = "https://github.com/s-allius/tsun-gen3-proxy/ha_addonsha_addon"
}
output = [
"type=image,push=true"
]
no-cache = false
platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7"]
}
target "_debug" {
args = {
LOG_LVL = "DEBUG"
environment = "dev"
}
}
target "_prod" {
args = {
}
}
target "debug" {
inherits = ["_common", "_debug"]
tags = ["${IMAGE}:debug"]
}
target "dev" {
inherits = ["_common"]
tags = ["${IMAGE}:dev"]
}
target "preview" {
inherits = ["_common", "_prod"]
tags = ["${IMAGE}:preview", "${IMAGE}:${VERSION}"]
}
target "rc" {
inherits = ["_common", "_prod"]
tags = ["${IMAGE}:rc", "${IMAGE}:${VERSION}"]
}
target "rel" {
inherits = ["_common", "_prod"]
tags = ["${IMAGE}:latest", "${IMAGE}:${MAJOR}", "${IMAGE}:${VERSION}"]
no-cache = true
}

162
ha_addons/ha_addon/DOCS.md Normal file
View File

@@ -0,0 +1,162 @@
# Home Assistant Add-on: TSUN Proxy
[TSUN Proxy][tsunproxy] enables a reliable connection between TSUN third generation
inverters and an MQTT broker. With the proxy, you can easily retrieve real-time values
such as power, current and daily energy and integrate the inverter into Home Assistant.
This works even without an internet connection.
The optional connection to the TSUN Cloud can be disabled!
## Pre-requisites
1. This Add-on requires an MQTT broker to work.
For a typical installation, we recommend the [Mosquitto add-on][Mosquitto] running on your Home Assistant.
2. You need to loop the proxy into the connection between the inverter and the TSUN Cloud,
you must adapt the DNS record within the network that your inverter uses. You need a mapping
from logger.talent-monitoring.com and/or iot.talent-monitoring.com to the IP address of your
Home Assistant.
This can be done, for example, by adding a local DNS record to [AdGuard Home Add-on][AdGuard]
(navigate to `filters` on the AdGuard panel and add an entry under `custom filtering rules`).
## Installation
The installation of this add-on is pretty straightforward and not different in
comparison to installing any other Home Assistant add-on.
1. Add the repository URL to the Home Assistant add-on store
[![Add repository on my Home Assistant][repository-badge]][repository-url]
2. Reload the add-on store page
3. Click the "Install" button to install the add-on.
4. Add your inverter configuration to the add-on configuration
5. Start the "TSUN-Proxy" add-on
6. Check the logs of the "TSUN-Proxy" add-on to see if everything went well.
_Please note, the add-on is pre-configured to connect with
Home Assistants default MQTT Broker. There is no need to configure any MQTT parameters
if you're running an MOSQUITTO add-on. Home Assistant communication and TSUN Cloud URL
and Ports are also pre-configured._
This automatic handling of the TSUN Cloud and MQTT Broker conflicts with the
[TSUN Proxy official documentation][tsunproxy]. The official documentation
will state `mqtt.host`, `mqtt.port`, `mqtt.user`, `mqtt.passwd` `solarman.host`,
`solarman.port` `tsun.host`, `tsun.port` and Home Assistant options are required.
For the add-on, however, this isn't needed.
## Configuration
**Note**: _Remember to restart the add-on when the configuration is changed._
Example add-on configuration after installation:
```yaml
inverters:
- serial: R17E760702080400
node_id: PV-Garage
suggested_area: Garage
modbus_polling: false
pv1.manufacturer: Shinefar
pv1.type: SF-M18/144550
pv2.manufacturer: Shinefar
pv2.type: SF-M18/144550
```
**Note**: _This is just an example, you need to replace the values with your own!_
Example add-on configuration for GEN3PLUS inverters:
```yaml
inverters:
- serial: Y17000000000000
monitor_sn: '2000000000'
node_id: PV-Garage
suggested_area: Garage
modbus_polling: true
client_mode.host: 192.168.x.x
client_mode.port: 8899
client_mode.forward: true
pv1.manufacturer: Shinefar
pv1.type: SF-M18/144550
pv2.manufacturer: Shinefar
pv2.type: SF-M18/144550
pv3.manufacturer: Shinefar
pv3.type: SF-M18/144550
pv4.manufacturer: Shinefar
pv4.type: SF-M18/144550
```
**Note**: _This is just an example, you need to replace the values with your own!_
more information about the configuration can be found in the [configuration details page][configdetails].
## MQTT settings
By default, this add-on requires no `mqtt` config from the user. **This is not an error!**
However, you are free to set them if you want to override, however, in
general usage, that should not be needed and is not recommended for this add-on.
## Changelog & Releases
This repository keeps a change log using [GitHub's releases][releases]
functionality.
Releases are based on [Semantic Versioning][semver], and use the format
of `MAJOR.MINOR.PATCH`. In a nutshell, the version will be incremented
based on the following:
- `MAJOR`: Incompatible or major changes.
- `MINOR`: Backwards-compatible new features and enhancements.
- `PATCH`: Backwards-compatible bugfixes and package updates.
## Support
Got questions?
You have several options to get them answered:
- The Discussions section on [GitHub][discussions].
- The [Home Assistant Discord chat server][discord-ha] for general Home
Assistant discussions and questions.
You could also [open an issue here][issue] GitHub.
## Authors & contributors
The original setup of this repository is by [Stefan Allius][author].
We're very happy to receive contributions to this project! You can get started by reading [CONTRIBUTING.md][contribute].
## License
This project is licensed under the [BSD 3-clause License][bsd].
Note the aiomqtt library used is based on the paho-mqtt library, which has a dual license.
One of the licenses is the so-called [Eclipse Distribution License v1.0.][eclipse]
It is almost word-for-word identical to the BSD 3-clause License. The only differences are:
- One use of "COPYRIGHT OWNER" (EDL) instead of "COPYRIGHT HOLDER" (BSD)
- One use of "Eclipse Foundation, Inc." (EDL) instead of "copyright holder" (BSD)
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
[tsunproxy]: https://github.com/s-allius/tsun-gen3-proxy
[discussions]: https://github.com/s-allius/tsun-gen3-proxy/discussions
[author]: https://github.com/s-allius
[discord-ha]: https://discord.gg/c5DvZ4e
[issue]: https://github.com/s-allius/tsun-gen3-proxy/issues
[releases]: https://github.com/s-allius/tsun-gen3-proxy/releases
[contribute]: https://github.com/s-allius/tsun-gen3-proxy/blob/main/CONTRIBUTING.md
[semver]: http://semver.org/spec/v2.0.0.htm
[bsd]: https://opensource.org/licenses/BSD-3-Clause
[eclipse]: https://www.eclipse.org/org/documents/edl-v10.php
[Mosquitto]: https://github.com/home-assistant/addons/blob/master/mosquitto/DOCS.md
[AdGuard]: https://github.com/hassio-addons/addon-adguard-home
[repository-badge]: https://img.shields.io/badge/Add%20repository%20to%20my-Home%20Assistant-41BDF5?logo=home-assistant&style=for-the-badge
[repository-url]: https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fs-allius%2Fha-addons
[configdetails]: https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-details

90
ha_addons/ha_addon/Dockerfile Executable file
View File

@@ -0,0 +1,90 @@
############################################################################
#
# TSUN Proxy
# Homeassistant Add-on
#
# based on https://github.com/s-allius/tsun-gen3-proxy/tree/main
#
############################################################################
######################
# 1 Build Base Image #
######################
ARG BUILD_FROM="ghcr.io/hassio-addons/base:17.0.1"
# hadolint ignore=DL3006
FROM $BUILD_FROM AS base
# Installiere Python, pip und virtuelle Umgebungstools
RUN apk add --no-cache python3=3.12.8-r1 py3-pip=24.3.1-r0 && \
python -m venv /opt/venv && \
. /opt/venv/bin/activate
ENV PATH="/opt/venv/bin:$PATH"
#######################
# 2 Build wheel #
#######################
FROM base AS builder
COPY rootfs/requirements.txt /root/
RUN apk add --no-cache build-base=0.5-r3 && \
python -m pip install --no-cache-dir wheel==0.45.1 && \
python -OO -m pip wheel --no-cache-dir --wheel-dir=/root/wheels -r /root/requirements.txt
#######################
# 3 Build runtime #
#######################
FROM base AS runtime
ARG SERVICE_NAME
ARG VERSION
ENV SERVICE_NAME=${SERVICE_NAME}
#######################
# 4 Install libraries #
#######################
# install the requirements from the wheels packages from the builder stage
# and unistall python packages and alpine package manger to reduce attack surface
COPY --from=builder /root/wheels /root/wheels
RUN python -m pip install --no-cache-dir --no-cache --no-index /root/wheels/* && \
rm -rf /root/wheels && \
python -m pip uninstall --yes wheel pip && \
apk --purge del apk-tools
#######################
# 5 copy data #
#######################
COPY rootfs/ /
#######################
# 6 run app #
#######################
# make run.sh executable
RUN chmod a+x /run.sh && \
echo ${VERSION} > /proxy-version.txt
# command to run on container start
CMD [ "/run.sh" ]
#######################

BIN
ha_addons/ha_addon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
ha_addons/ha_addon/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -0,0 +1,33 @@
#!/usr/bin/with-contenv bashio
echo "Add-on environment started"
echo "check for Home Assistant MQTT"
MQTT_HOST=$(bashio::services mqtt "host")
MQTT_PORT=$(bashio::services mqtt "port")
MQTT_USER=$(bashio::services mqtt "username")
MQTT_PASSWORD=$(bashio::services mqtt "password")
# if a MQTT was/not found, drop a note
if [ -z "$MQTT_HOST" ]; then
echo "MQTT not found"
else
echo "MQTT found"
export MQTT_HOST
export MQTT_PORT
export MQTT_USER
export MQTT_PASSWORD
fi
# Create folder for log und config files
mkdir -p /homeassistant/tsun-proxy/logs
cd /home/proxy || exit
export VERSION=$(cat /proxy-version.txt)
echo "Start Proxyserver..."
python3 server.py --json_config=/data/options.json --log_path=/homeassistant/tsun-proxy/logs/ --config_path=/homeassistant/tsun-proxy/ --log_backups=2

View File

@@ -0,0 +1,95 @@
---
configuration:
inverters:
name: Wechselrichter
description: >+
Für jeden Wechselrichter muss die Seriennummer des Wechselrichters einer MQTT
Definition zugeordnet werden. Dazu wird der entsprechende Konfigurationsblock mit der
16-stellige Seriennummer gestartet, so dass alle nachfolgenden Parameter diesem
Wechselrichter zugeordnet sind.
Weitere wechselrichterspezifische Parameter (z.B. Polling Mode) können im
Konfigurationsblock gesetzt werden.
Die Seriennummer der GEN3 Wechselrichter beginnen mit `R17` und die der GEN3PLUS
Wechselrichter mir `Y17`oder `47`!
Siehe Beispielkonfiguration im Dokumentations-Tab
tsun.enabled:
name: Verbindung zur TSUN Cloud - nur für GEN3-Wechselrichter
description: >+
Schaltet die Verbindung zur TSUN Cloud ein/aus.
Diese Verbindung ist erforderlich, wenn Sie Daten an die TSUN Cloud senden möchten,
z.B. um die TSUN-Apps zu nutzen oder Firmware-Updates zu erhalten.
ein => normaler Proxy-Betrieb.
aus => Der Wechselrichter wird vom Internet isoliert.
solarman.enabled:
name: Verbindung zur Solarman Cloud - nur für GEN3PLUS Wechselrichter
description: >+
Schaltet die Verbindung zur Solarman Cloud ein/aus.
Diese Verbindung ist erforderlich, wenn Sie Daten an die Solarman Cloud senden möchten,
z.B. um die Solarman Apps zu nutzen oder Firmware-Updates zu erhalten.
ein => normaler Proxy-Betrieb.
aus => Der Wechselrichter wird vom Internet isoliert.
inverters.allow_all:
name: Erlaube Verbindungen von sämtlichen Wechselrichtern
description: >-
Der Proxy akzeptiert normalerweise nur Verbindungen von konfigurierten Wechselrichtern.
Schalten Sie dies für Testzwecke und unbekannte Seriennummern ein.
mqtt.host:
name: MQTT Broker Host
description: >-
Hostname oder IP-Adresse des MQTT-Brokers. Wenn nicht gesetzt, versucht das Addon, eine Verbindung zum Home Assistant MQTT-Broker herzustellen.
mqtt.port:
name: MQTT Broker Port
description: >-
Port des MQTT-Brokers. Wenn nicht gesetzt, versucht das Addon, eine Verbindung zum Home Assistant MQTT-Broker herzustellen.
mqtt.user:
name: MQTT Broker Benutzer
description: >-
Benutzer für den MQTT-Broker. Wenn nicht gesetzt, versucht das Addon, eine Verbindung zum Home Assistant MQTT-Broker herzustellen.
mqtt.passwd:
name: MQTT Broker Passwort
description: >-
Passwort für den MQTT-Broker. Wenn nicht gesetzt, versucht das Addon, eine Verbindung zum Home Assistant MQTT-Broker herzustellen.
ha.auto_conf_prefix:
name: MQTT-Präfix für das Abonnieren von Home Assistant-Statusaktualisierungen
ha.discovery_prefix:
name: MQTT-Präfix für das discovery topic
ha.entity_prefix:
name: MQTT-Themenpräfix für die Veröffentlichung von Wechselrichterwerten
ha.proxy_node_id:
name: MQTT-Knoten-ID für die proxy_node_id
ha.proxy_unique_id:
name: MQTT-eindeutige ID zur Identifizierung einer Proxy-Instanz
tsun.host:
name: TSUN Cloud Host
description: >-
Hostname oder IP-Adresse der TSUN-Cloud. Wenn nicht gesetzt, versucht das Addon, eine Verbindung zur Cloud logger.talent-monitoring.com herzustellen.
solarman.host:
name: Solarman Cloud Host
description: >-
Hostname oder IP-Adresse der Solarman-Cloud. Wenn nicht gesetzt, versucht das Addon, eine Verbindung zur Cloud iot.talent-monitoring.com herzustellen.
gen3plus.at_acl.tsun.allow:
name: TSUN GEN3PLUS ACL allow
description: >-
Liste erlaubter AT-Befehle für TSUN GEN3PLUS
gen3plus.at_acl.tsun.block:
name: TSUN GEN3 ACL block
description: >-
Liste blockierter AT-Befehle für TSUN GEN3PLUS
gen3plus.at_acl.mqtt.allow:
name: MQTT GEN3PLUS ACL allow
description: >-
Liste erlaubter MQTT-Befehle für GEN3PLUS
gen3plus.at_acl.mqtt.block:
name: MQTT GEN3PLUS ACL block
description: >-
Liste blockierter MQTT-Befehle für GEN3PLUS
network:
5005/tcp: listening Port für TSUN GEN3 Wechselrichter
10000/tcp: listening Port für TSUN GEN3PLUS Wechselrichter

View File

@@ -0,0 +1,95 @@
---
configuration:
inverters:
name: Inverters
description: >+
For each GEN3 inverter, the serial number of the inverter must be mapped to an MQTT
definition. To do this, the corresponding configuration block is started with
16-digit serial number so that all subsequent parameters are assigned
to this inverter. Further inverter-specific parameters (e.g. polling mode) can be set
in the configuration block
The serial numbers of all GEN3 inverters start with `R17` and that of the GEN3PLUS
inverters with Y17 or 47!
For reference see example configuration in Documentation Tab
tsun.enabled:
name: Connection to TSUN Cloud - for GEN3 inverter only
description: >+
switch on/off connection to the TSUN cloud.
This connection is only required if you want send data to the TSUN cloud
eg. to use the TSUN APPs or receive firmware updates.
on => normal proxy operation.
off => The Inverter become isolated from Internet.
solarman.enabled:
name: Connection to Solarman Cloud - for GEN3PLUS inverter only
description: >+
switch on/off connection to the Solarman cloud.
This connection is only required if you want send data to the Solarman cloud
eg. to use the Solarman APPs or receive firmware updates.
on => normal proxy operation.
off => The Inverter become isolated from Internet
inverters.allow_all:
name: Allow all connections from all inverters
description: >-
The proxy only usually accepts connections from configured inverters.
Switch on for test purposes and unknown serial numbers.
mqtt.host:
name: MQTT Broker Host
description: >-
Hostname or IP address of the MQTT broker. if not set, the addon will try to connect to the Home Assistant MQTT broker
mqtt.port:
name: MQTT Broker Port
description: >-
Port of the MQTT broker. if not set, the addon will try to connect to the Home Assistant MQTT broker
mqtt.user:
name: MQTT Broker User
description: >-
User for the MQTT broker. if not set, the addon will try to connect to the Home Assistant MQTT broker
mqtt.passwd:
name: MQTT Broker Password
description: >-
Password for the MQTT broker. if not set, the addon will try to connect to the Home Assistant MQTT broker
ha.auto_conf_prefix:
name: MQTT prefix for subscribing for homeassistant status updates
ha.discovery_prefix:
name: MQTT prefix for discovery topic
ha.entity_prefix:
name: MQTT topic prefix for publishing inverter values
ha.proxy_node_id:
name: MQTT node id, for the proxy_node_id
ha.proxy_unique_id:
name: MQTT unique id, to identify a proxy instance
tsun.host:
name: TSUN Cloud Host
description: >-
Hostname or IP address of the TSUN cloud. if not set, the addon will try to connect to the cloud
on logger.talent-monitoring.com
solarman.host:
name: Solarman Cloud Host
description: >-
Hostname or IP address of the Solarman cloud. if not set, the addon will try to connect to the cloud
on iot.talent-monitoring.com
gen3plus.at_acl.tsun.allow:
name: TSUN GEN3PLUS ACL allow
description: >-
List of allowed TSUN GEN3PLUS AT commands
gen3plus.at_acl.tsun.block:
name: TSUN GEN3 ACL block
description: >-
List of blocked TSUN GEN3PLUS AT commands
gen3plus.at_acl.mqtt.allow:
name: MQTT GEN3PLUS ACL allow
description: >-
List of allowed MQTT GEN3PLUS commands
gen3plus.at_acl.mqtt.block:
name: MQTT GEN3PLUS ACL block
description: >-
List of blocked MQTT GEN3PLUS commands
network:
5005/tcp: listening Port for TSUN GEN3 Devices
10000/tcp: listening Port for TSUN GEN3PLUS Devices

View File

@@ -0,0 +1,3 @@
name: TSUN-Proxy
url: https://github.com/s-allius/tsun-gen3-proxy/ha_addons
maintainer: Stefan Allius

111
ha_addons/templates/config.jinja Executable file
View File

@@ -0,0 +1,111 @@
name: {{name}}
description: {{description}}
version: {% if version is defined and version|length %} {{version}} {% else %} {{AppVersion}} {% endif %}
image: docker.io/sallius/tsun-gen3-addon
url: https://github.com/s-allius/tsun-gen3-proxy
slug: {{slug}}
advanced: {{advanced}}
stage: {{stage}}
init: false
arch:
- aarch64
- amd64
- armhf
- armv7
startup: services
homeassistant_api: true
map:
- type: addon_config
path: /homeassistant/tsun-proxy
read_only: False
services:
- mqtt:want
ports:
5005/tcp: 5005
10000/tcp: 10000
# FIXME: we disabled the watchdog due to exceptions in the ha supervisor. See: https://github.com/s-allius/tsun-gen3-proxy/issues/249
# watchdog: "http://[HOST]:[PORT:8127]/-/healthy"
# Definition of parameters in the configuration tab of the addon
# parameters are available within the container as /data/options.json
# and should become picked up by the proxy - current workaround as a transfer script
# TODO: check again for multi hierarchie parameters
# TODO: implement direct reading of the configuration file
schema:
inverters:
- serial: str
monitor_sn: int?
node_id: str
suggested_area: str
modbus_polling: bool
client_mode.host: str?
client_mode.port: int?
client_mode.forward: bool?
#strings: # leider funktioniert es nicht die folgenden 3 parameter im schema aufzulisten. möglicherweise wird die verschachtelung nicht unterstützt.
# - string: str
# type: str
# manufacturer: str
# daher diese variante
pv1.manufacturer: str?
pv1.type: str?
pv2.manufacturer: str?
pv2.type: str?
pv3.manufacturer: str?
pv3.type: str?
pv4.manufacturer: str?
pv4.type: str?
pv5.manufacturer: str?
pv5.type: str?
pv6.manufacturer: str?
pv6.type: str?
tsun.enabled: bool
solarman.enabled: bool
inverters.allow_all: bool
# optionale parameter
# TODO besser strukturieren und vervollständigen
mqtt.host: str?
mqtt.port: int?
mqtt.user: str?
mqtt.passwd: password?
ha.auto_conf_prefix: str? # suggeriert optionale konfigurationsoption -> es darf jedoch kein default unter "options" angegeben werden
ha.discovery_prefix: str? # dito
ha.entity_prefix: str? #dito
ha.proxy_node_id: str? #dito
ha.proxy_unique_id: str? #dito
tsun.host: str?
solarman.host: str?
gen3plus.at_acl.tsun.allow:
- str
gen3plus.at_acl.tsun.block:
- str?
gen3plus.at_acl.mqtt.allow:
- str
gen3plus.at_acl.mqtt.block:
- str?
# set default options for mandatory parameters
# for optional parameters do not define any default value in the options dictionary.
# If any default value is given, the option becomes a required value.
options:
inverters:
- serial: R17E760702080400
node_id: PV-Garage
suggested_area: Garage
modbus_polling: false
# strings:
# - string: PV1
# type: SF-M18/144550
# manufacturer: Shinefar
# - string: PV2
# type: SF-M18/144550
# manufacturer: Shinefar
pv1.manufacturer: Shinefar
pv1.type: SF-M18/144550
pv2.manufacturer: Shinefar
pv2.type: SF-M18/144550
tsun.enabled: true # set default
solarman.enabled: true # set default
inverters.allow_all: false # set default
gen3plus.at_acl.tsun.allow: ["AT+Z", "AT+UPURL", "AT+SUPDATE"]
gen3plus.at_acl.mqtt.allow: ["AT+"]

View File

@@ -0,0 +1,9 @@
{
"name": "TSUN-Proxy (Debug)",
"description": "MQTT Proxy for TSUN Photovoltaic Inverters with Debug Logging",
"version": "debug",
"slug": "tsun-proxy-debug",
"advanced": true,
"stage": "experimental"
}

View File

@@ -0,0 +1,9 @@
{
"name": "TSUN-Proxy (Dev)",
"description": "MQTT Proxy for TSUN Photovoltaic Inverters",
"version": "dev",
"slug": "tsun-proxy-dev",
"advanced": false,
"stage": "experimental"
}

View File

@@ -0,0 +1,8 @@
{
"name": "TSUN-Proxy",
"description": "MQTT Proxy for TSUN Photovoltaic Inverters",
"slug": "tsun-proxy",
"advanced": false,
"stage": "stable"
}

20
proxy.c4 Normal file
View File

@@ -0,0 +1,20 @@
model {
extend home.logger.proxy {
component webserver 'http server'
component inverter 'inverter'
component local 'local connection'
component remote 'remote connection'
component r-ifc 'async-ifc'
component l-ifc 'async-ifc'
component prot 'Protocol' 'SolarmanV5 or Talent'
component config 'config' 'reads the file confg.toml'
component mqtt
inverter -> local
inverter -> remote
remote -> r-ifc
remote -> prot
local -> l-ifc
local -> prot
prot -> mqtt
}
}

8
pytest.ini Normal file
View File

@@ -0,0 +1,8 @@
# pytest.ini or .pytest.ini
[pytest]
minversion = 8.0
addopts = -ra -q --durations=5
pythonpath = app/src app/tests ha_addons/ha_addon/rootfs
testpaths = app/tests ha_addons/ha_addon/tests
asyncio_default_fixture_loop_scope = function
asyncio_mode = strict

View File

@@ -5,6 +5,10 @@
},
{
"path": "../wiki"
},
{
"name": "ha-addons",
"path": "../ha-addons/ha-addons"
}
],
"settings": {}