Compare commits

..

74 Commits

Author SHA1 Message Date
Stefan Allius
1f4d3740d6 Merge branch 'dev-0.12' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-11-14 19:38:18 +01:00
Stefan Allius
abdc323dc6 Merge branch 'dev-0.12' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-11-02 15:40:32 +01:00
Stefan Allius
35efa65410 apdate start_reg 2024-10-23 19:10:28 +02:00
Stefan Allius
399d0b58ff fix testcase 2024-10-22 00:08:05 +02:00
Stefan Allius
2b4bd2922c don't scan sensor list type 0x02b0 2024-10-22 00:07:36 +02:00
Stefan Allius
3eba999a2b fix _send_modbus_cmd calls 2024-10-22 00:06:31 +02:00
Stefan Allius
ad8a902ac6 configure scan increment 2024-10-20 16:52:30 +02:00
Stefan Allius
fda4008036 update start reg 2024-10-18 23:47:44 +02:00
Stefan Allius
e7e693c0b6 Merge branch 'dev-0.12' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-10-17 23:33:05 +02:00
Stefan Allius
98665b3aac log valid modbus responses for all inverter 2024-10-16 23:48:54 +02:00
Stefan Allius
6b488e5269 Merge branch 'dev-0.12' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-10-16 23:33:06 +02:00
Stefan Allius
010ae2b2c7 Merge branch 'dev-0.12' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-10-15 22:18:30 +02:00
Stefan Allius
24840e67c0 adapt start_reg 2024-10-13 22:38:21 +02:00
Stefan Allius
2dd89d3174 git push 2024-10-13 19:45:11 +02:00
Stefan Allius
c84b610229 also scan GEN3(PLUS) inverters 2024-10-13 09:33:23 +02:00
Stefan Allius
5dc890dde0 Merge branch 'refactoring-async-stream' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-10-11 20:30:09 +02:00
Stefan Allius
0c10cc32dc update changelog 2024-10-11 20:29:20 +02:00
Stefan Allius
71f581eb0b add more unittests 2024-10-10 22:41:13 +02:00
Stefan Allius
a4acddd769 remove obsolete tx_get method 2024-10-10 00:30:10 +02:00
Stefan Allius
724f6f3b22 simplify heartbeat handler 2024-10-10 00:00:32 +02:00
Stefan Allius
18c3020282 increase tes coverage 2024-10-09 23:59:56 +02:00
Stefan Allius
e127716317 increase test coverage 2024-10-08 23:48:49 +02:00
Stefan Allius
5790b657d0 update start-REG 2024-10-07 23:20:21 +02:00
Stefan Allius
5d4ff9051d Merge branch 'refactoring-async-stream' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-10-06 21:28:12 +02:00
Stefan Allius
595b68ba03 reduce cognitive complexity 2024-10-06 21:20:53 +02:00
Stefan Allius
d7628689f0 icrease test coverage 2024-10-06 21:15:46 +02:00
Stefan Allius
e074a39f5a add unit tests for AsyncStream class 2024-10-06 17:43:40 +02:00
Stefan Allius
9852f44dfa use ProtocolIfc class 2024-10-05 21:11:42 +02:00
Stefan Allius
c7d0a91371 add more testcases 2024-10-05 01:35:04 +02:00
Stefan Allius
5b68610f78 move class InverterIfc into a separate file 2024-10-05 01:34:03 +02:00
Stefan Allius
0b79a37fe7 fix typo 2024-10-05 01:33:05 +02:00
Stefan Allius
00e9a4534d rename class Inverter into Proxy 2024-10-04 23:37:05 +02:00
Stefan Allius
3a94afb48d fix sonar qube warnings 2024-10-04 01:50:24 +02:00
Stefan Allius
949f3c9608 initial commit 2024-10-04 01:41:25 +02:00
Stefan Allius
cd2f41a713 add abstract inverter interface class 2024-10-04 01:35:44 +02:00
Stefan Allius
84034127e3 remove test_inverter_base.py 2024-10-03 15:11:22 +02:00
Stefan Allius
22d59ed659 move more code into InverterBase class 2024-10-03 15:08:07 +02:00
Stefan Allius
cfe2c9cb9d remove connection classes 2024-10-02 23:40:42 +02:00
Stefan Allius
39aba31bbd refactor close handling 2024-10-01 19:50:42 +02:00
Stefan Allius
a1441fb4fd add close callback 2024-09-30 19:17:06 +02:00
Stefan Allius
f2ade43410 fixes
- fixes null pointer accesses
- initalize AsyncStreamClient with proper
  StreamPtr instance
2024-09-30 19:14:50 +02:00
Stefan Allius
8f695518bd update class diagramm 2024-09-30 19:13:29 +02:00
Stefan Allius
b3068a256c don't overwrite self.remote in constructor 2024-09-30 19:12:49 +02:00
Stefan Allius
2336955bb8 fix client loop closing 2024-09-29 21:11:53 +02:00
Stefan Allius
8a745b2d10 Merge branch 'refactoring-async-stream' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-09-29 20:52:08 +02:00
Stefan Allius
518375e8a8 fix server connections 2024-09-29 20:49:08 +02:00
Stefan Allius
b57ded1a73 Merge branch 'refactoring-async-stream' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-09-29 20:27:28 +02:00
Stefan Allius
41aeac4168 resolution of connection classes
- remove ConnectionG3Client
- remove ConnectionG3Server
- remove ConnectionG3PClient
- remove ConnectionG3PServer
2024-09-29 20:08:04 +02:00
Stefan Allius
5a0ef30ceb move StremPtr instances into Inverter class 2024-09-29 15:31:14 +02:00
Stefan Allius
4c15495670 update start register 2024-09-29 14:30:33 +02:00
Stefan Allius
ca610b15ff Merge branch 'refactoring-async-stream' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-09-29 10:23:04 +02:00
Stefan Allius
0c824b4a2a reduce code duplication 2024-09-29 10:15:38 +02:00
Stefan Allius
95f817a92b Merge branch 'refactoring-async-stream' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-09-28 23:31:02 +02:00
Stefan Allius
2ef7a7e201 remove duplicated imports 2024-09-28 23:30:23 +02:00
Stefan Allius
2be0ef67af refactor server creation 2024-09-28 22:43:29 +02:00
Stefan Allius
43700b3da8 Merge branch 'refactoring-async-stream' of https://github.com/s-allius/tsun-gen3-proxy into titan-scan 2024-09-27 19:28:39 +02:00
Stefan Allius
7b6810cb46 update class diagramm 2024-09-27 19:25:40 +02:00
Stefan Allius
aa7c1832ef split ConnectionG3(P) in server and client class 2024-09-27 00:47:44 +02:00
Stefan Allius
73526b7dc6 split AsyncStream in two classes 2024-09-26 23:04:11 +02:00
Stefan Allius
b6761e7517 FIX update_header_cb handling 2024-09-26 00:14:51 +02:00
Stefan Allius
209956865b add two more callbacks 2024-09-26 00:11:11 +02:00
Stefan Allius
bc54944077 add two more callbacks 2024-09-25 22:41:01 +02:00
Stefan Allius
37c977c2fe avoid mqtt handling for invalid serial numbers 2024-09-25 00:11:39 +02:00
Stefan Allius
d5c369e5fe refactoring 2024-09-24 21:12:51 +02:00
Stefan Allius
89c2c11ed9 refactoring 2024-09-24 21:10:58 +02:00
Stefan Allius
eea725b8da remove _forward_buffer 2024-09-22 15:00:53 +02:00
Stefan Allius
0b437cf3bc - refactoring
- remove _forward_buffer
- make async_write private
2024-09-22 14:59:18 +02:00
Stefan Allius
ae4bcee41f experimental modbus scan 2024-09-22 10:56:48 +02:00
Stefan Allius
edc0f7a6af Merge branch 'dev-0.11' of https://github.com/s-allius/tsun-gen3-proxy into refactoring-async-stream 2024-09-22 10:51:41 +02:00
Stefan Allius
9ffcfbc9ce declare more methods as classmethods 2024-09-22 10:42:48 +02:00
Stefan Allius
b7c63b5cf8 use AsyncIfc class with FIFO 2024-09-22 10:40:30 +02:00
Stefan Allius
af81aef07c add object factory 2024-09-22 10:29:38 +02:00
Stefan Allius
f216c2434e introduce ifc with FIFOs 2024-09-22 10:28:36 +02:00
Stefan Allius
39540678fb GEN3: Invalid Contact Info Msg
Fixes #191
2024-09-18 22:07:26 +02:00
81 changed files with 705 additions and 2626 deletions

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
# 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,12 +26,11 @@ permissions:
env:
TZ: "Europe/Berlin"
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
jobs:
build:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -54,13 +53,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-config=.cover_ghaction_rc --cov-report=xml
python -m pytest app --cov=app/src --cov-report=xml
coverage report
- name: Analyze with SonarCloud
if: ${{ env.SONAR_TOKEN != 0 }}
uses: SonarSource/sonarqube-scan-action@v4
uses: SonarSource/sonarcloud-github-action@v3.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
projectBaseDir: .
args:

4
.gitignore vendored
View File

@@ -1,15 +1,11 @@
__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

View File

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

View File

@@ -1 +0,0 @@
3.13.1

20
.vscode/settings.json vendored
View File

@@ -1,20 +1,16 @@
{
"python.analysis.extraPaths": [
"app/src",
"app/tests",
".venv/lib",
],
"python.testing.pytestArgs": [
"-vvv",
"-vv",
"app",
"--cov=app/src",
"--cov-report=xml",
"app",
"--cov-report=html",
"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",
@@ -22,11 +18,5 @@
},
"files.exclude": {
"**/*.pyi": true
},
"python.analysis.typeEvaluation.deprecateTypingAliases": true,
"python.autoComplete.extraPaths": [
".venv/lib"
],
"coverage-gutters.coverageBaseDir": "tsun",
"makefile.configureOnOpen": false
}
}

View File

@@ -7,40 +7,9 @@ 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)

View File

@@ -1,14 +0,0 @@
.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 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>
<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>
<br>
<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>
<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>
</p>
# Overview
@@ -28,9 +28,6 @@ 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>
@@ -68,20 +65,11 @@ 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
@@ -107,22 +95,8 @@ 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.
@@ -301,7 +275,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, forward = true}
#client_mode = {host = '192.168.0.1', port = 8899}
pv1 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
pv2 = {type = 'RSM40-8-410M', manufacturer = 'Risen'} # Optional, PV module descr
@@ -346,6 +320,7 @@ 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)
@@ -433,6 +408,3 @@ 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

View File

@@ -1 +0,0 @@
0.12.0

View File

@@ -4,13 +4,14 @@ ARG GID=1000
#
# first stage for our base image
FROM python:3.13-alpine AS base
FROM python:3.12-alpine AS base
USER root
COPY --chmod=0700 ./hardening_base.sh /
COPY --chmod=0700 ./hardening_base.sh .
RUN apk upgrade --no-cache && \
apk add --no-cache su-exec=0.2-r3 && \
/hardening_base.sh && \
rm /hardening_base.sh
apk add --no-cache su-exec && \
./hardening_base.sh && \
rm ./hardening_base.sh
#
# second stage for building wheels packages
@@ -18,8 +19,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=0.5-r3 && \
python -m pip install --no-cache-dir pip==24.3.1 wheel==0.45.1 && \
RUN apk add --no-cache build-base && \
python -m pip install --no-cache-dir -U pip wheel && \
python -OO -m pip wheel --no-cache-dir --wheel-dir=/root/wheels -r /root/requirements.txt
@@ -49,9 +50,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-dir --no-cache --no-index /root/wheels/* && \
RUN python -m pip install --no-cache --no-index /root/wheels/* && \
rm -rf /root/wheels && \
python -m pip uninstall --yes wheel pip && \
python -m pip uninstall --yes setuptools wheel pip && \
apk --purge del apk-tools && \
./hardening_final.sh && \
rm ./hardening_final.sh

View File

@@ -1,67 +0,0 @@
#!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 $< $@

51
app/build.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,16 @@ from asyncio import StreamReader, StreamWriter
from typing import Self
from itertools import count
from proxy import Proxy
from byte_fifo import ByteFifo
from async_ifc import AsyncIfc
from infos import Infos
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
import gc

View File

@@ -1,4 +1,8 @@
from messages import hex_dump_str, hex_dump_memory
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
class ByteFifo:

View File

@@ -1,25 +0,0 @@
'''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

@@ -1,46 +0,0 @@
'''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

@@ -1,21 +0,0 @@
'''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

@@ -1,48 +1,19 @@
'''Config module handles the proxy configuration'''
'''Config module handles the proxy configuration in the config.toml file'''
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 build and sanitize the internal config dictenary.
'''Static class Config is reads and sanitize the config.
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.
'''
Read config.toml file and sanitize it with read().
Get named parts of the config with get()'''
act_config = {}
def_config = {}
conf_schema = Schema({
'tsun': {
'enabled': Use(bool),
@@ -57,10 +28,8 @@ class Config():
'mqtt': {
'host': Use(str),
'port': And(Use(int), lambda n: 1024 <= n <= 65535),
'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)))
'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))
},
'ha': {
'auto_conf_prefix': Use(str),
@@ -124,13 +93,7 @@ class Config():
)
@classmethod
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 = {}
def class_init(cls) -> None | str: # pragma: no cover
try:
# make the default config transparaent by copying it
# in the config.example file
@@ -140,59 +103,66 @@ and initialise the Config with the default configuration '''
"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:
def_config = def_reader.get_config()
cls.def_config = cls.conf_schema.validate(def_config)
logging.info(f'Read from {def_reader.descr()} => ok')
with open("config/config.toml", "rb") as f:
usr_config = tomllib.load(f)
except Exception as error:
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()
err = f'Config.read: {error}'
logging.error(err)
logging.info(
'\n To create the missing config.toml file, '
'you can rename the template config.example.toml\n'
' and customize it for your scenario.\n')
return usr_config
@classmethod
def 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
def read(cls, path='') -> None | str:
'''Read config file, merge it with the default config
and sanitize the result'''
res = 'ok'
err = None
config = {}
logger = logging.getLogger('data')
try:
rd_config = reader.get_config()
config = cls.act_config.copy()
# 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()
for key in ['tsun', 'solarman', 'mqtt', 'ha', 'inverters',
'gen3plus']:
if key in rd_config:
config[key] = config[key] | rd_config[key]
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}" ')
cls.act_config = cls.conf_schema.validate(config)
except FileNotFoundError:
res = 'n/a'
except Exception as error:
cls.err = f'error: {error}'
logging.error(
f"Can't read from {reader.descr()} => error\n {error}")
return cls.err
err = f'Config.read: {error}'
logger.error(err)
cls.act_config = {}
logging.info(f'Read from {reader.descr()} => {res}')
return cls.err
return err
@classmethod
def get(cls, member: str = None):

View File

@@ -3,7 +3,10 @@ import struct
import logging
from typing import Generator
from infos import Infos, Register
if __name__ == "app.src.gen3.infos_g3":
from app.src.infos import Infos, Register
else: # pragma: no cover
from infos import Infos, Register
class RegisterMap:
@@ -81,7 +84,6 @@ 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,7 +1,10 @@
from asyncio import StreamReader, StreamWriter
from inverter_base import InverterBase
from gen3.talent import Talent
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
class InverterG3(InverterBase):

View File

@@ -4,12 +4,20 @@ from zoneinfo import ZoneInfo
from datetime import datetime
from tzlocal import get_localzone
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
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
logger = logging.getLogger('msg')
@@ -178,9 +186,11 @@ class Talent(Message):
if 2 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG)
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, 0x2000,
96, logging.DEBUG)
else:
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, 0x3000,
48, logging.DEBUG)
def _init_new_client_conn(self) -> bool:
contact_name = self.contact_name
@@ -449,7 +459,7 @@ class Talent(Message):
self.__build_header(0x99)
self.ifc.tx_add(b'\x01')
self.__finish_send_msg()
self.__process_data(False)
self.__process_data()
elif self.ctrl.is_resp():
return # ignore received response
@@ -464,7 +474,7 @@ class Talent(Message):
self.__build_header(0x99)
self.ifc.tx_add(b'\x01')
self.__finish_send_msg()
self.__process_data(True)
self.__process_data()
self.state = State.up # allow MODBUS cmds
if (self.modbus_polling):
self.mb_timer.start(self.mb_first_timeout)
@@ -479,14 +489,8 @@ class Talent(Message):
self.forward()
def __process_data(self, ignore_replay: bool):
def __process_data(self):
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,7 +1,10 @@
from typing import Generator
from infos import Infos, Register, ProxyMode, Fmt
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
class RegisterMap:
@@ -26,11 +29,9 @@ 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': '<HB', 'const': (15, 255)}, # noqa: E501
0x41020061: {'reg': None, 'fmt': '<BBB', 'const': (15, 0, 255)}, # noqa: E501
0x41020064: {'reg': Register.COLLECTOR_FW_VERSION, 'fmt': '!40s'}, # 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
0x4102008c: {'reg': None, 'fmt': '<BB', 'const': (254, 254)}, # noqa: E501
0x410200b7: {'reg': Register.SSID, 'fmt': '!40s'}, # noqa: E501
0x4201000c: {'reg': Register.SENSOR_LIST, 'fmt': '<H', 'func': Fmt.hex4}, # noqa: E501
@@ -90,8 +91,7 @@ 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': Register.PROD_COMPL_TYPE, 'fmt': '!H'},
0x4201013a: {'reg': None, 'fmt': FMT_3_16BIT_VAL, 'const': (0x68, 0x68, 0x500)}, # noqa: E501
0x42010138: {'reg': None, 'fmt': FMT_4_16BIT_VAL, 'const': (6, 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,8 +1,13 @@
from asyncio import StreamReader, StreamWriter
from inverter_base import InverterBase
from gen3plus.solarman_v5 import SolarmanV5
from gen3plus.solarman_emu import SolarmanEmu
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
class InverterG3P(InverterBase):

View File

@@ -1,10 +1,16 @@
import logging
import struct
from async_ifc import AsyncIfc
from gen3plus.solarman_v5 import SolarmanBase
from my_timer import Timer
from infos import Register
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
logger = logging.getLogger('msg')

View File

@@ -4,12 +4,20 @@ import time
import asyncio
from datetime import datetime
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
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
logger = logging.getLogger('msg')
@@ -245,7 +253,8 @@ class SolarmanBase(Message):
class SolarmanV5(SolarmanBase):
AT_CMD = 1
MB_RTU_CMD = 2
MB_CLIENT_DATA_UP = 30
'''regular Modbus polling time in server mode'''
MB_CLIENT_DATA_UP = 10
'''Data up time in client mode'''
HDR_FMT = '<BLLL'
'''format string for packing of the header'''
@@ -320,7 +329,11 @@ class SolarmanV5(SolarmanBase):
if 'at_acl' in g3p_cnf: # pragma: no cover
self.at_acl = g3p_cnf['at_acl']
self.sensor_list = 0
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
'''
Our puplic methods
@@ -356,7 +369,23 @@ class SolarmanV5(SolarmanBase):
self.new_data['controller'] = True
self.state = State.up
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
# 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.mb_timer.start(self.mb_timeout)
def new_state_up(self):
@@ -459,12 +488,29 @@ 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)
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)
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)
def at_cmd_forbidden(self, cmd: str, connection: str) -> bool:
return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \
@@ -677,6 +723,21 @@ 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,7 +30,6 @@ 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
@@ -299,53 +298,37 @@ class Infos:
{% set result = 'noAlarm'%}
{%else%}
{% set result = '' %}
{% if val_int | bitwise_and(1)%}
{% set result = result + 'HBridgeFault, '%}
{% if val_int | bitwise_and(1)%}{% set result = result + 'Bit1, '%}
{% endif %}
{% if val_int | bitwise_and(2)%}
{% set result = result + 'DriVoltageFault, '%}
{% if val_int | bitwise_and(2)%}{% set result = result + 'Bit2, '%}
{% endif %}
{% if val_int | bitwise_and(3)%}
{% set result = result + 'GFDI-Fault, '%}
{% if val_int | bitwise_and(3)%}{% set result = result + 'Bit3, '%}
{% endif %}
{% if val_int | bitwise_and(4)%}
{% set result = result + 'OverTemp, '%}
{% if val_int | bitwise_and(4)%}{% set result = result + 'Bit4, '%}
{% endif %}
{% if val_int | bitwise_and(5)%}
{% set result = result + 'CommLose, '%}
{% if val_int | bitwise_and(5)%}{% set result = result + 'Bit5, '%}
{% 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 + 'EEPROM-Fault, '%}
{% if val_int | bitwise_and(8)%}{% set result = result + 'Bit8, '%}
{% 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 + 'VG_Offset, '%}
{% if val_int | bitwise_and(10)%}{% set result = result + 'Bit10, '%}
{% endif %}
{% if val_int | bitwise_and(11)%}
{% set result = result + 'Relais_Open, '%}
{% if val_int | bitwise_and(11)%}{% set result = result + 'Bit11, '%}
{% endif %}
{% if val_int | bitwise_and(12)%}
{% set result = result + 'Relais_Short, '%}
{% if val_int | bitwise_and(12)%}{% set result = result + 'Bit12, '%}
{% endif %}
{% if val_int | bitwise_and(13)%}
{% set result = result + 'GridVoltOverRating, '%}
{% if val_int | bitwise_and(13)%}{% set result = result + 'Bit13, '%}
{% endif %}
{% if val_int | bitwise_and(14)%}
{% set result = result + 'GridVoltUnderRating, '%}
{% if val_int | bitwise_and(14)%}{% set result = result + 'Bit14, '%}
{% endif %}
{% if val_int | bitwise_and(15)%}
{% set result = result + 'GridFreqOverRating, '%}
{% if val_int | bitwise_and(15)%}{% set result = result + 'Bit15, '%}
{% endif %}
{% if val_int | bitwise_and(16)%}
{% set result = result + 'GridFreqUnderRating, '%}
{% if val_int | bitwise_and(16)%}{% set result = result + 'Bit16, '%}
{% endif %}
{% endif %}
{{ result }}
@@ -361,20 +344,15 @@ class Infos:
{% set result = 'noFault'%}
{%else%}
{% set result = '' %}
{% if val_int | bitwise_and(1)%}
{% set result = result + 'PVOV-Fault (PV OverVolt), '%}
{% if val_int | bitwise_and(1)%}{% set result = result + 'Bit1, '%}
{% endif %}
{% if val_int | bitwise_and(2)%}
{% set result = result + 'PVLV-Fault (PV LowVolt), '%}
{% if val_int | bitwise_and(2)%}{% set result = result + 'Bit2, '%}
{% endif %}
{% if val_int | bitwise_and(3)%}
{% set result = result + 'PV OI-Fault (PV OverCurrent), '%}
{% if val_int | bitwise_and(3)%}{% set result = result + 'Bit3, '%}
{% endif %}
{% if val_int | bitwise_and(4)%}
{% set result = result + 'PV OFV-Fault, '%}
{% if val_int | bitwise_and(4)%}{% set result = result + 'Bit4, '%}
{% endif %}
{% if val_int | bitwise_and(5)%}
{% set result = result + 'DC ShortCircuitFault, '%}
{% if val_int | bitwise_and(5)%}{% set result = result + 'Bit5, '%}
{% endif %}
{% if val_int | bitwise_and(6)%}{% set result = result + 'Bit6, '%}
{% endif %}
@@ -533,7 +511,6 @@ 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,15 +6,23 @@ import json
import gc
from aiomqtt import MqttCodeError
from asyncio import StreamReader, StreamWriter
from ipaddress import ip_address
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
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
logger_mqtt = logging.getLogger('mqtt')
@@ -102,20 +110,6 @@ 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,7 +2,10 @@ from abc import abstractmethod
import logging
from asyncio import StreamReader, StreamWriter
from iter_registry import AbstractIterMeta
if __name__ == "app.src.inverter_ifc":
from app.src.iter_registry import AbstractIterMeta
else: # pragma: no cover
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=(handlers.log_path + 'proxy.log', when:='midnight', backupCount:=handlers.log_backups)
args=('log/proxy.log', when:='midnight')
[handler_file_handler_name2]
class=handlers.TimedRotatingFileHandler
level=NOTSET
formatter=file_formatter
args=(handlers.log_path + 'trace.log', when:='midnight', backupCount:=handlers.log_backups)
args=('log/trace.log', when:='midnight')
[formatter_console_formatter]
format=%(asctime)s %(levelname)5s | %(name)4s | %(message)s'

View File

@@ -3,11 +3,19 @@ import weakref
from typing import Callable
from enum import Enum
from async_ifc import AsyncIfc
from protocol_ifc import ProtocolIfc
from infos import Infos, Register
from modbus import Modbus
from my_timer import Timer
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
logger = logging.getLogger('msg')
@@ -87,7 +95,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 = 60
MB_REGULAR_TIMEOUT = 20
'''regular Modbus polling time in server mode'''
def __init__(self, node_id, ifc: "AsyncIfc", server_side: bool,
@@ -160,15 +168,15 @@ class Message(ProtocolIfc):
to = self.MAX_DEF_IDLE_TIME
return to
def _send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
def _send_modbus_cmd(self, mb_no, func, addr, val, log_lvl) -> None:
if self.state != State.up:
logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,'
' as the state is not UP')
return
self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl)
self.mb.build_msg(mb_no, func, addr, val, log_lvl)
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
self._send_modbus_cmd(func, addr, val, log_lvl)
self._send_modbus_cmd(Modbus.INV_ADDR, func, addr, val, log_lvl)
'''
Our puplic methods

View File

@@ -16,7 +16,10 @@ import logging
import asyncio
from typing import Generator, Callable
from infos import Register, Fmt
if __name__ == "app.src.modbus":
from app.src.infos import Register, Fmt
else: # pragma: no cover
from infos import Register, Fmt
logger = logging.getLogger('data')
@@ -41,11 +44,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
@@ -100,8 +103,8 @@ class Modbus():
'''Response handler to forward the response'''
self.timeout = timeout
'''MODBUS response timeout in seconds'''
self.max_retries = 1
'''Max retransmit for MODBUS requests'''
self.max_retries = 0
'''Max retransmit for MODBU requests'''
self.retry_cnt = 0
self.last_req = b''
self.counter = {}

View File

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

View File

@@ -2,11 +2,16 @@ import asyncio
import logging
import aiomqtt
import traceback
from modbus import Modbus
from messages import Message
from cnf.config import Config
from singleton import Singleton
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
logger_mqtt = logging.getLogger('mqtt')

View File

@@ -1,7 +1,11 @@
from abc import abstractmethod
from async_ifc import AsyncIfc
from iter_registry import AbstractIterMeta
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
class ProtocolIfc(metaclass=AbstractIterMeta):

View File

@@ -2,9 +2,14 @@ import asyncio
import logging
import json
from cnf.config import Config
from mqtt import Mqtt
from infos import Infos
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
logger_mqtt = logging.getLogger('mqtt')

View File

@@ -1,9 +1,7 @@
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
@@ -12,10 +10,7 @@ from inverter_ifc import InverterIfc
from gen3.inverter_g3 import InverterG3
from gen3plus.inverter_g3p import InverterG3P
from scheduler import Schedule
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 config import Config
from modbus_tcp import ModbusTcp
routes = web.RouteTableDef()
@@ -82,7 +77,7 @@ async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class):
await inv.local.ifc.server_loop()
async def handle_shutdown(loop, web_task):
async def handle_shutdown(web_task):
'''Close all TCP connections and stop the event loop'''
logging.info('Shutdown due to SIGTERM')
@@ -121,8 +116,6 @@ 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':
@@ -132,46 +125,18 @@ def get_log_level() -> int:
return log_level
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()
if __name__ == "__main__":
#
# 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)
@@ -184,20 +149,9 @@ def main(): # pragma: no cover
asyncio.set_event_loop(loop)
# read config file
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('******')
ConfigErr = Config.class_init()
if ConfigErr is not None:
logging.info(f'ConfigErr: {ConfigErr}')
Proxy.class_init()
Schedule.start()
ModbusTcp(loop)
@@ -208,7 +162,6 @@ def main(): # pragma: no cover
# 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))
@@ -221,12 +174,12 @@ def main(): # pragma: no cover
for signame in ('SIGINT', 'SIGTERM'):
loop.add_signal_handler(getattr(signal, signame),
lambda loop=loop: asyncio.create_task(
handle_shutdown(loop, web_task)))
handle_shutdown(web_task)))
loop.set_debug(log_level == logging.DEBUG)
try:
global proxy_is_up
proxy_is_up = True
if ConfigErr is None:
proxy_is_up = True
loop.run_forever()
except KeyboardInterrupt:
pass
@@ -236,7 +189,3 @@ def main(): # pragma: no cover
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,13 +4,12 @@ import asyncio
import gc
import time
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
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
pytest_plugins = ('pytest_asyncio',)
@@ -542,7 +541,7 @@ async def test_forward_resp():
remote = StreamPtr(None)
cnt = 0
def _close_cb():
async def _close_cb():
nonlocal cnt, remote, ifc
cnt += 1
@@ -551,7 +550,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 == 1
assert cnt == 0
del ifc
@pytest.mark.asyncio
@@ -560,7 +559,7 @@ async def test_forward_resp2():
remote = StreamPtr(None)
cnt = 0
def _close_cb():
async def _close_cb():
nonlocal cnt, remote, ifc
cnt += 1
@@ -569,5 +568,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 == 1
assert cnt == 0
del ifc

View File

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

View File

@@ -1,56 +1,16 @@
# test_with_pytest.py
import pytest
import json
from mock import patch
import tomllib
from schema import SchemaMissingKeyError
from cnf.config import Config, ConfigIfc
from cnf.config_read_toml import ConfigReadToml
from app.src.config import 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):
class TstConfig(Config):
@classmethod
def __init__(cls, cnf):
def set(cls, cnf):
cls.act_config = cnf
@classmethod
def add_config(cls) -> dict:
def _read_config_file(cls) -> dict:
return cls.act_config
@@ -62,90 +22,14 @@ def test_empty_config():
except SchemaMissingKeyError:
pass
@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': {
'suggested_area': '',
'modbus_polling': False,
'monitor_sn': 0,
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'sensor_list': 688
},
'Y170000000000001': {
'modbus_polling': True,
'monitor_sn': 2000000000,
'suggested_area': '',
'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'},
'sensor_list': 688
}
}
}
@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
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,
@@ -174,118 +58,45 @@ def test_default_config():
'suggested_area': '',
'sensor_list': 688}}}
def test_full_config(ConfigComplete):
def test_full_config():
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']}}},
'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': 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'}}}}
'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 == ConfigComplete
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_read_empty(ConfigDefault):
test_buffer.rd = ""
Config.init(ConfigReadToml("app/config/default_config.toml"))
for _ in patch_open():
ConfigReadToml("config/config.toml")
err = Config.get_error()
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 = Config.get()
assert cnf == ConfigDefault
defcnf = Config.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
assert True == Config.is_default('solarman')
def test_no_file():
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 = Config.get()
assert cnf == {}
defcnf = Config.def_config.get('solarman')
assert defcnf == None
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
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,
'R170000000000001': {
'suggested_area': '',
'modbus_polling': False,
'monitor_sn': 0,
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'sensor_list': 688
},
'Y170000000000001': {
'modbus_polling': True,
'monitor_sn': 2000000000,
'suggested_area': '',
'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'},
'sensor_list': 688
}
}
}
cnf = Config.get('solarman')
assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}
defcnf = Config.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
assert False == Config.is_default('solarman')
def test_read_cnf2():
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()
cnf = TstConfig.get()
assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
'inverters': {
'allow_all': False,
@@ -317,30 +128,117 @@ def test_read_cnf2():
}
}
}
assert True == Config.is_default('solarman')
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()
defcnf = TstConfig.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
assert True == TstConfig.is_default('solarman')
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_no_file():
cnf = {}
TstConfig.set(cnf)
err = TstConfig.read('')
assert err == "Config.read: [Errno 2] No such file or directory: 'default_config.toml'"
cnf = TstConfig.get()
assert cnf == {}
defcnf = TstConfig.def_config.get('solarman')
assert defcnf == None
def test_read_cnf1():
cnf = {'solarman' : {'enabled': False}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
assert err == None
cnf = TstConfig.get()
assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
'inverters': {
'allow_all': False,
'R170000000000001': {
'suggested_area': '',
'modbus_polling': False,
'monitor_sn': 0,
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'sensor_list': 688
},
'Y170000000000001': {
'modbus_polling': True,
'monitor_sn': 2000000000,
'suggested_area': '',
'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'},
'sensor_list': 688
}
}
}
cnf = TstConfig.get('solarman')
assert cnf == {'enabled': False, 'host': 'iot.talent-monitoring.com', 'port': 10000}
defcnf = TstConfig.def_config.get('solarman')
assert defcnf == {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}
assert False == TstConfig.is_default('solarman')
def test_read_cnf2():
cnf = {'solarman' : {'enabled': 'FALSE'}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
assert err == None
cnf = TstConfig.get()
assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 10000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
'inverters': {
'allow_all': False,
'R170000000000001': {
'suggested_area': '',
'modbus_polling': False,
'monitor_sn': 0,
'node_id': '',
'pv1': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'pv2': {'manufacturer': 'Risen',
'type': 'RSM40-8-395M'},
'sensor_list': 688
},
'Y170000000000001': {
'modbus_polling': True,
'monitor_sn': 2000000000,
'suggested_area': '',
'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'},
'sensor_list': 688
}
}
}
assert True == TstConfig.is_default('solarman')
def test_read_cnf3():
cnf = {'solarman' : {'port': 'FALSE'}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
assert err == 'Config.read: Key \'solarman\' error:\nKey \'port\' error:\nint(\'FALSE\') raised ValueError("invalid literal for int() with base 10: \'FALSE\'")'
cnf = TstConfig.get()
assert cnf == {'solarman': {'port': 'FALSE'}}
def test_read_cnf4():
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()
cnf = {'solarman' : {'port': 5000}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
assert err == None
cnf = Config.get()
cnf = TstConfig.get()
assert cnf == {'gen3plus': {'at_acl': {'mqtt': {'allow': ['AT+'], 'block': []}, 'tsun': {'allow': ['AT+Z', 'AT+UPURL', 'AT+SUPDATE'], 'block': []}}}, 'tsun': {'enabled': True, 'host': 'logger.talent-monitoring.com', 'port': 5005}, 'solarman': {'enabled': True, 'host': 'iot.talent-monitoring.com', 'port': 5000}, 'mqtt': {'host': 'mqtt', 'port': 1883, 'user': None, 'passwd': None}, 'ha': {'auto_conf_prefix': 'homeassistant', 'discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun', 'proxy_node_id': 'proxy', 'proxy_unique_id': 'P170000000000001'},
'inverters': {
'allow_all': False,
@@ -372,22 +270,16 @@ def test_read_cnf4():
}
}
}
assert False == Config.is_default('solarman')
assert False == TstConfig.is_default('solarman')
def test_read_cnf5():
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()
cnf = {'solarman' : {'port': 1023}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
assert err != None
def test_read_cnf6():
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()
cnf = {'solarman' : {'port': 65536}}
TstConfig.set(cnf)
err = TstConfig.read('app/config/')
assert err != None

View File

@@ -1,53 +0,0 @@
# 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

@@ -1,411 +0,0 @@
# 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 infos import Register, ClrAtMidnight
from infos import Infos, Fmt
from app.src.infos import Register, ClrAtMidnight
from app.src.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 infos import Register
from gen3.infos_g3 import InfosG3, RegisterMap
from app.src.infos import Register
from app.src.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 infos import Register
from gen3plus.infos_g3p import InfosG3P
from gen3plus.infos_g3p import RegisterMap
from app.src.infos import Register
from app.src.gen3plus.infos_g3p import InfosG3P
from app.src.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, "Prod_Compliance_Type": 6}
"other": {"Output_Shutdown": 65535, "Rated_Level": 3, "Grid_Volt_Cal_Coef": 1024}
})
def test_build_4210(inverter_data: bytes):

View File

@@ -5,14 +5,14 @@ import gc
from mock import patch
from enum import Enum
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.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 test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
from app.tests.test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
pytest_plugins = ('pytest_asyncio',)
@@ -54,12 +54,11 @@ 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 self.peer
return 'remote.intern'
elif sel == 'sockname':
return 'sock:1234'
assert False
@@ -70,13 +69,13 @@ class FakeWriter():
async def wait_closed(self):
return
class MockType(Enum):
class TestType(Enum):
RD_TEST_0_BYTES = 1
RD_TEST_TIMEOUT = 2
RD_TEST_EXCEPT = 3
test = MockType.RD_TEST_0_BYTES
test = TestType.RD_TEST_0_BYTES
@pytest.fixture
def patch_open_connection():
@@ -86,9 +85,9 @@ def patch_open_connection():
def new_open(host: str, port: int):
global test
if test == MockType.RD_TEST_TIMEOUT:
if test == TestType.RD_TEST_TIMEOUT:
raise ConnectionRefusedError
elif test == MockType.RD_TEST_EXCEPT:
elif test == TestType.RD_TEST_EXCEPT:
raise ValueError("Value cannot be negative") # Compliant
return new_conn(None)
@@ -242,118 +241,6 @@ 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 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.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 test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
from app.tests.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 ('47.1.2.3', 10000)
return 'remote.intern'
elif sel == 'sockname':
return 'sock:1234'
assert False
@@ -70,13 +70,13 @@ class FakeWriter():
async def wait_closed(self):
return
class MockType(Enum):
class TestType(Enum):
RD_TEST_0_BYTES = 1
RD_TEST_TIMEOUT = 2
RD_TEST_EXCEPT = 3
test = MockType.RD_TEST_0_BYTES
test = TestType.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 == MockType.RD_TEST_TIMEOUT:
if test == TestType.RD_TEST_TIMEOUT:
raise ConnectionRefusedError
elif test == MockType.RD_TEST_EXCEPT:
elif test == TestType.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 = MockType.RD_TEST_TIMEOUT
test = TestType.RD_TEST_TIMEOUT
with InverterG3(FakeReader(), FakeWriter()) as inverter:
await inverter.create_remote()
await asyncio.sleep(0)
assert inverter.remote.stream==None
test = MockType.RD_TEST_EXCEPT
test = TestType.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 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.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 test_modbus_tcp import patch_mqtt_err, patch_mqtt_except, test_port, test_hostname
from app.tests.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 ('47.1.2.3', 10000)
return 'remote.intern'
elif sel == 'sockname':
return 'sock:1234'
assert False
@@ -69,13 +69,13 @@ class FakeWriter():
async def wait_closed(self):
return
class MockType(Enum):
class TestType(Enum):
RD_TEST_0_BYTES = 1
RD_TEST_TIMEOUT = 2
RD_TEST_EXCEPT = 3
test = MockType.RD_TEST_0_BYTES
test = TestType.RD_TEST_0_BYTES
@pytest.fixture
def patch_open_connection():
@@ -85,17 +85,16 @@ def patch_open_connection():
def new_open(host: str, port: int):
global test
if test == MockType.RD_TEST_TIMEOUT:
if test == TestType.RD_TEST_TIMEOUT:
raise ConnectionRefusedError
elif test == MockType.RD_TEST_EXCEPT:
elif test == TestType.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(config_conn):
_ = config_conn
def test_method_calls():
reader = FakeReader()
writer = FakeWriter()
InverterBase._registry.clear()
@@ -122,14 +121,14 @@ async def test_remote_except(config_conn, patch_open_connection):
assert asyncio.get_running_loop()
global test
test = MockType.RD_TEST_TIMEOUT
test = TestType.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 = MockType.RD_TEST_EXCEPT
test = TestType.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 modbus import Modbus
from infos import Infos, Register
from app.src.modbus import Modbus
from app.src.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 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
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
pytest_plugins = ('pytest_asyncio',)

View File

@@ -5,15 +5,13 @@ import aiomqtt
import logging
from mock import patch, Mock
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
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
NO_MOSQUITTO_TEST = False
'''disable all tests with connections to test.mosquitto.org'''
pytest_plugins = ('pytest_asyncio',)
@@ -71,79 +69,23 @@ 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. Otherwise the test set NO_MOSQUITTO_TEST to True and disable
all test cases which depends on the test.mosquitto.org server
MQTT 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(mqtt.CallbackAPIVersion.VERSION2)
c = mqtt.Client()
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)
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')
assert on_connect.wait(5)
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
@@ -168,6 +110,29 @@ 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
@@ -244,6 +209,26 @@ 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 singleton import Singleton
from proxy import Proxy
from mqtt import Mqtt
from gen3plus.solarman_v5 import SolarmanV5
from cnf.config import Config
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
pytest_plugins = ('pytest_asyncio',)

View File

@@ -1,24 +0,0 @@
# 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 singleton import Singleton
from app.src.singleton import Singleton
class Example(metaclass=Singleton):
class Test(metaclass=Singleton):
def __init__(self):
pass # is a dummy test class
def test_singleton_metaclass():
Singleton._instances.clear()
a = Example()
a = Test()
assert 1 == len(Singleton._instances)
b = Example()
b = Test()
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 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
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
pytest_plugins = ('pytest_asyncio',)
@@ -1730,6 +1730,7 @@ 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
@@ -1767,6 +1768,10 @@ 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,13 +1,11 @@
import pytest
import asyncio
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
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
timestamp = 0x3224c8bc
@@ -172,7 +170,6 @@ 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 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
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
pytest_plugins = ('pytest_asyncio',)
@@ -328,90 +328,6 @@ 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'
@@ -2235,34 +2151,3 @@ 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

View File

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

View File

@@ -1,136 +0,0 @@
#!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 $< $@

View File

@@ -1,99 +0,0 @@
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
}

View File

@@ -1,162 +0,0 @@
# 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

View File

@@ -1,90 +0,0 @@
############################################################################
#
# 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" ]
#######################

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -1,33 +0,0 @@
#!/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

@@ -1,95 +0,0 @@
---
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

@@ -1,95 +0,0 @@
---
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

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

View File

@@ -1,111 +0,0 @@
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

@@ -1,9 +0,0 @@
{
"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

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

View File

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

View File

@@ -1,20 +0,0 @@
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
}
}

View File

@@ -1,8 +0,0 @@
# 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,10 +5,6 @@
},
{
"path": "../wiki"
},
{
"name": "ha-addons",
"path": "../ha-addons/ha-addons"
}
],
"settings": {}