Compare commits

..

15 Commits

Author SHA1 Message Date
Stefan Allius
47f7580184 MQTT timestamps and protocol improvements (#140)
* add TS_INPUT, TS_GRID and TS_TOTAL

* prepare MQTT timestamps

- add _set_mqtt_timestamp method
- fix hexdump printing

* push dev and debug images to docker.io

* add unix epoche timestamp for MQTT pakets

* set timezone for unit tests

* set name für setting timezone step

* trigger new action

* GEN3 and GEN3PLUS: handle multiple message

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

* GEN3: optimize timeout handling

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

* update changelog

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

* add client_mode arg to constructors

- add PollingInvervall

* hide some topics in client mode

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

* add client_mode value

* update class diagram

* fix modbus close handler

- fix empty call and cleanup que
- add unit test

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

* change HA icon for inverter status

* increase test coverage

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

* update proxy counter

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

* don't set Register.NO_INPUTS per MODBUS

* fix unit tests

* register OUTPUT_COEFFICIENT at HA

* update changelog

* - Home Assistant: improve inverter status value texts

* - GEN3: add inverter status

* on closing send outstanding MQTT data to the broker

* force MQTT publish on every conn open and close

* reset inverter state on close

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

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

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

* move sequence diagramm to wiki

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

* read more modbus regs in polling mode

* extend connection timeouts if polling mode is disabled

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

* add more register definitions

* add start commando for client side connections

* add first support for port 8899

* fix shutdown

* add client_mode configuration

* read client_mode config to setup inverter connections

* add client_mode connections over port 8899

* add preview build

* add documentation for client_mode

* catch os error and log thme with DEBUG level

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

* add more register definitions

* add start commando for client side connections

* add first support for port 8899

* fix shutdown

* add client_mode configuration

* read client_mode config to setup inverter connections

* add client_mode connections over port 8899

* add preview build
2024-07-08 19:08:58 +02:00
47 changed files with 1409 additions and 2364 deletions

View File

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

View File

@@ -18,11 +18,10 @@ on:
- '**.dockerfile' # Do no build on *.dockerfile changes - '**.dockerfile' # Do no build on *.dockerfile changes
- '**.sh' # Do no build on *.sh changes - '**.sh' # Do no build on *.sh changes
pull_request: pull_request:
branches: [ "main", "dev-*" ] branches: [ "main" ]
permissions: permissions:
contents: read contents: read
pull-requests: read # allows SonarCloud to decorate PRs with analysis results
jobs: jobs:
build: build:
@@ -36,18 +35,7 @@ jobs:
timezoneLinux: "Europe/Berlin" timezoneLinux: "Europe/Berlin"
timezoneMacos: "Europe/Berlin" timezoneMacos: "Europe/Berlin"
timezoneWindows: "Europe/Berlin" timezoneWindows: "Europe/Berlin"
# - name: Start Mosquitto
# uses: namoshek/mosquitto-github-action@v1
# with:
# version: '1.6'
# ports: '1883:1883 8883:8883'
# certificates: ${{ github.workspace }}/.ci/tls-certificates
# config: ${{ github.workspace }}/.ci/mosquitto.conf
# password-file: ${{ github.workspace}}/.ci/mosquitto.passwd
# container-name: 'mqtt'
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up Python 3.12 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -55,28 +43,14 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
if [ -f requirements-test.txt ]; then pip install -r requirements-test.txt; fi pip install flake8 pytest pytest-asyncio
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8 - name: Lint with flake8
run: | run: |
# stop the build if there are Python syntax errors or undefined names # stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
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/ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest - name: Test with pytest
run: | run: |
python -m pytest app --cov=app/src --cov-report=xml python -m pytest app
coverage report
- name: Analyze with SonarCloud
uses: SonarSource/sonarcloud-github-action@v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
projectBaseDir: .
args:
-Dsonar.projectKey=s-allius_tsun-gen3-proxy
-Dsonar.python.coverage.reportPaths=coverage.xml
-Dsonar.python.flake8.reportPaths=output_flake.txt
# -Dsonar.docker.hadolint.reportPaths=

View File

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

View File

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

View File

@@ -7,19 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- GEN3: don't crash on overwritten msg in the receive buffer
- Reading the version string from the image updates it even if the image is re-pulled without re-deployment
## [0.10.1] - 2024-08-10
- fix displaying the version string at startup and in HA [#153](https://github.com/s-allius/tsun-gen3-proxy/issues/153)
## [0.10.0] - 2024-08-09
- bump aiohttp to version 3.10.2
- add SonarQube and code coverage support
- don't send MODBUS request when state is note up; adapt timeouts [#141](https://github.com/s-allius/tsun-gen3-proxy/issues/141)
- build multi arch images with sboms [#144](https://github.com/s-allius/tsun-gen3-proxy/issues/144)
- add timestamp to MQTT topics [#138](https://github.com/s-allius/tsun-gen3-proxy/issues/138) - add timestamp to MQTT topics [#138](https://github.com/s-allius/tsun-gen3-proxy/issues/138)
- improve the message handling, to avoid hangs - improve the message handling, to avoid hangs
- GEN3: allow long timeouts until we received first inverter data (not only device data) - GEN3: allow long timeouts until we received first inverter data (not only device data)

View File

@@ -10,12 +10,6 @@
<a href="https://sbtinstruments.github.io/aiomqtt/introduction.html"><img alt="Supported aiomqtt versions" src="https://img.shields.io/badge/aiomqtt-2.2.0-lightblue.svg"></a> <a href="https://sbtinstruments.github.io/aiomqtt/introduction.html"><img alt="Supported aiomqtt versions" src="https://img.shields.io/badge/aiomqtt-2.2.0-lightblue.svg"></a>
<a href="https://libraries.io/pypi/aiocron"><img alt="Supported aiocron versions" src="https://img.shields.io/badge/aiocron-1.8-lightblue.svg"></a> <a href="https://libraries.io/pypi/aiocron"><img alt="Supported aiocron versions" src="https://img.shields.io/badge/aiocron-1.8-lightblue.svg"></a>
<a href="https://toml.io/en/v1.0.0"><img alt="Supported toml versions" src="https://img.shields.io/badge/toml-1.0.0-lightblue.svg"></a> <a href="https://toml.io/en/v1.0.0"><img alt="Supported toml versions" src="https://img.shields.io/badge/toml-1.0.0-lightblue.svg"></a>
<br>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=alert_status"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=alert_status"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=bugs"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=bugs"></a>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=code_smells"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=code_smells"></a>
<br>
<a href="https://sonarcloud.io/component_measures?id=s-allius_tsun-gen3-proxy&metric=coverage"><img src="https://sonarcloud.io/api/project_badges/measure?project=s-allius_tsun-gen3-proxy&metric=coverage"></a>
</p> </p>
# Overview # Overview
@@ -158,14 +152,12 @@ inverters.allow_all = false # True: allow inverters, even if we have no invert
[inverters."R17xxxxxxxxxxxx1"] [inverters."R17xxxxxxxxxxxx1"]
node_id = 'inv1' # Optional, MQTT replacement for inverters serial number node_id = 'inv1' # Optional, MQTT replacement for inverters serial number
suggested_area = 'roof' # Optional, suggested installation area for home-assistant suggested_area = 'roof' # Optional, suggested installation area for home-assistant
modbus_polling = false # Disable optional MODBUS polling for GEN3 inverter
pv1 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr pv1 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr
pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr pv2 = {type = 'RSM40-8-395M', manufacturer = 'Risen'} # Optional, PV module descr
[inverters."R17xxxxxxxxxxxx2"] [inverters."R17xxxxxxxxxxxx2"]
node_id = 'inv2' # Optional, MQTT replacement for inverters serial number node_id = 'inv2' # Optional, MQTT replacement for inverters serial number
suggested_area = 'balcony' # Optional, suggested installation area for home-assistant suggested_area = 'balcony' # Optional, suggested installation area for home-assistant
modbus_polling = false # Disable optional MODBUS polling for GEN3 inverter
pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr pv1 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module descr
@@ -173,7 +165,6 @@ pv2 = {type = 'RSM40-8-405M', manufacturer = 'Risen'} # Optional, PV module de
monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter monitor_sn = 2000000000 # The "Monitoring SN:" can be found on a sticker enclosed with the inverter
node_id = 'inv_3' # MQTT replacement for inverters serial number node_id = 'inv_3' # MQTT replacement for inverters serial number
suggested_area = 'garage' # suggested installation place for home-assistant suggested_area = 'garage' # suggested installation place for home-assistant
modbus_polling = false # Enable optional MODBUS polling for GEN3PLUS inverter
# if your inverter supports SSL connections you must use the client_mode. Pls, uncomment # 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 # the next line and configure the fixed IP of your inverter
#client_mode = {host = '192.168.0.1', port = 8899} #client_mode = {host = '192.168.0.1', port = 8899}
@@ -200,12 +191,7 @@ The standard web interface of the inverter can be accessed at `http://<ip-adress
For our purpose, the hidden URL `http://<ip-adress>/config_hide.html` should be called. There you can see and modify the parameters for accessing the cloud. Here we enter the IP address of our proxy and the IP port `10000` for the `Server A Setting` and for `Optional Server Setting`. The second entry is used as a backup in the event of connection problems. For our purpose, the hidden URL `http://<ip-adress>/config_hide.html` should be called. There you can see and modify the parameters for accessing the cloud. Here we enter the IP address of our proxy and the IP port `10000` for the `Server A Setting` and for `Optional Server Setting`. The second entry is used as a backup in the event of connection problems.
```txt ❗If the IP port is set to 10443 in the inverter configuration, you probably have a firmware with SSL support. In this case, you must use the client-mode configuration.
❗If the IP port is set to 10443 in the inverter configuration, you probably have a firmware with SSL support.
In this case, you MUST NOT change the port or the host address, as this may cause the inverter to hang and
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. If access to the web interface does not work, it can also be redirected via DNS redirection, as is necessary for the GEN3 inverters.

View File

@@ -34,6 +34,7 @@ ARG GID
ARG LOG_LVL ARG LOG_LVL
ARG environment ARG environment
ENV VERSION=$VERSION
ENV SERVICE_NAME=$SERVICE_NAME ENV SERVICE_NAME=$SERVICE_NAME
ENV UID=$UID ENV UID=$UID
ENV GID=$GID ENV GID=$GID
@@ -62,10 +63,17 @@ RUN python -m pip install --no-cache --no-index /root/wheels/* && \
COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh COPY --chmod=0700 entrypoint.sh /root/entrypoint.sh
COPY config . COPY config .
COPY src . COPY src .
RUN echo ${VERSION} > /proxy-version.txt \ RUN date > /build-date.txt
&& date > /build-date.txt
EXPOSE 5005 8127 10000 EXPOSE 5005 8127 10000
# command to run on container start # command to run on container start
ENTRYPOINT ["/root/entrypoint.sh"] ENTRYPOINT ["/root/entrypoint.sh"]
CMD [ "python3", "./server.py" ] CMD [ "python3", "./server.py" ]
LABEL org.opencontainers.image.title="TSUN Gen3 Proxy"
LABEL org.opencontainers.image.authors="Stefan Allius"
LABEL org.opencontainers.image.source=https://github.com/s-allius/tsun-gen3-proxy
LABEL org.opencontainers.image.description='This proxy enables a reliable connection between TSUN third generation inverters (eg. TSOL MS600, MS800, MS2000) and an MQTT broker to integrate the inverter into typical home automations.'
LABEL org.opencontainers.image.licenses="BSD-3-Clause"
LABEL org.opencontainers.image.vendor="Stefan Allius"

View File

@@ -18,34 +18,58 @@ arr=(${VERSION//./ })
MAJOR=${arr[0]} MAJOR=${arr[0]}
IMAGE=tsun-gen3-proxy IMAGE=tsun-gen3-proxy
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
if [[ $1 == debug ]] || [[ $1 == dev ]] ;then if [[ $1 == debug ]] || [[ $1 == dev ]] ;then
IMAGE=docker.io/sallius/${IMAGE} IMAGE=docker.io/sallius/${IMAGE}
VERSION=${VERSION}+$1 VERSION=${VERSION}-$1
elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then elif [[ $1 == rc ]] || [[ $1 == rel ]] || [[ $1 == preview ]] ;then
IMAGE=ghcr.io/s-allius/${IMAGE} IMAGE=ghcr.io/s-allius/${IMAGE}
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
else else
echo argument missing! echo argument missing!
echo try: $0 '[debug|dev|preview|rc|rel]' echo try: $0 '[debug|dev|preview|rc|rel]'
exit 1 exit 1
fi fi
export IMAGE
export VERSION
export BUILD_DATE
export BRANCH
export MAJOR
echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE echo version: $VERSION build-date: $BUILD_DATE image: $IMAGE
docker buildx bake -f app/docker-bake.hcl $1 if [[ $1 == debug ]];then
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=dev --build-arg "LOG_LVL=DEBUG" --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:debug app
echo " => pushing ${IMAGE}:debug"
docker push -q ${IMAGE}:debug
echo -e "${BLUE} => checking docker-compose.yaml file${NC}" elif [[ $1 == dev ]];then
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:dev app
echo " => pushing ${IMAGE}:dev"
docker push -q ${IMAGE}:dev
elif [[ $1 == preview ]];then
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:preview -t ${IMAGE}:${VERSION} app
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
echo " => pushing ${IMAGE}:preview"
docker push -q ${IMAGE}:preview
echo " => pushing ${IMAGE}:${VERSION}"
docker push -q ${IMAGE}:${VERSION}
elif [[ $1 == rc ]];then
docker build --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:rc -t ${IMAGE}:${VERSION} app
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
echo " => pushing ${IMAGE}:rc"
docker push -q ${IMAGE}:rc
echo " => pushing ${IMAGE}:${VERSION}"
docker push -q ${IMAGE}:${VERSION}
elif [[ $1 == rel ]];then
docker build --no-cache --build-arg "VERSION=${VERSION}" --build-arg environment=production --label "org.opencontainers.image.created=${BUILD_DATE}" --label "org.opencontainers.image.version=${VERSION}" --label "org.opencontainers.image.revision=${BRANCH}" -t ${IMAGE}:latest -t ${IMAGE}:${MAJOR} -t ${IMAGE}:${VERSION} app
echo 'login to ghcr.io'
echo $GHCR_TOKEN | docker login ghcr.io -u s-allius --password-stdin
echo " => pushing ${IMAGE}:latest"
docker push -q ${IMAGE}:latest
echo " => pushing ${IMAGE}:${MAJOR}"
docker push -q ${IMAGE}:${MAJOR}
echo " => pushing ${IMAGE}:${VERSION}"
docker push -q ${IMAGE}:${VERSION}
fi
echo ' => checking docker-compose.yaml file'
docker-compose config -q docker-compose config -q
echo echo 'done'
echo -e "${GREEN}${BUILD_DATE} => Version: ${VERSION}${NC} finished"
echo

View File

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

View File

@@ -1,93 +0,0 @@
variable "IMAGE" {
default = "tsun-gen3-proxy"
}
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 = "app"
dockerfile = "Dockerfile"
args = {
VERSION = "${VERSION}"
environment = "production"
}
attest = [
"type =provenance,mode=max",
"type =sbom,generator=docker/scout-sbom-indexer:latest"
]
annotations = [
"index:org.opencontainers.image.title=TSUN Gen3 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"
]
labels = {
"org.opencontainers.image.title" = "TSUN Gen3 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"
}
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}:dev", "${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

@@ -2,8 +2,6 @@
set -e set -e
user="$(id -u)" user="$(id -u)"
export VERSION=$(cat /proxy-version.txt)
echo "######################################################" echo "######################################################"
echo "# prepare: '$SERVICE_NAME' Version:$VERSION" echo "# prepare: '$SERVICE_NAME' Version:$VERSION"
echo "# for running with UserID:$UID, GroupID:$GID" echo "# for running with UserID:$UID, GroupID:$GID"

View File

@@ -188,7 +188,7 @@
<polygon fill="none" stroke="#000000" points="410.5,-330 410.5,-362 560.5,-362 560.5,-330 410.5,-330"/> <polygon fill="none" stroke="#000000" points="410.5,-330 410.5,-362 560.5,-362 560.5,-330 410.5,-330"/>
<text text-anchor="start" x="453.5455" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3</text> <text text-anchor="start" x="453.5455" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3</text>
<polygon fill="none" stroke="#000000" points="410.5,-298 410.5,-330 560.5,-330 560.5,-298 410.5,-298"/> <polygon fill="none" stroke="#000000" points="410.5,-298 410.5,-330 560.5,-330 560.5,-298 410.5,-298"/>
<text text-anchor="start" x="420.487" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remote_stream:ConnectionG3</text> <text text-anchor="start" x="420.487" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remoteStream:ConnectionG3</text>
<polygon fill="none" stroke="#000000" points="410.5,-254 410.5,-298 560.5,-298 560.5,-254 410.5,-254"/> <polygon fill="none" stroke="#000000" points="410.5,-254 410.5,-298 560.5,-298 560.5,-254 410.5,-254"/>
<text text-anchor="start" x="466.054" y="-279" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">healthy()</text> <text text-anchor="start" x="466.054" y="-279" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">healthy()</text>
<text text-anchor="start" x="470.5025" y="-267" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="470.5025" y="-267" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>
@@ -213,7 +213,7 @@
<polygon fill="none" stroke="#000000" points="125.5,-330 125.5,-362 281.5,-362 281.5,-330 125.5,-330"/> <polygon fill="none" stroke="#000000" points="125.5,-330 125.5,-362 281.5,-362 281.5,-330 125.5,-330"/>
<text text-anchor="start" x="168.211" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3P</text> <text text-anchor="start" x="168.211" y="-343" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">ConnectionG3P</text>
<polygon fill="none" stroke="#000000" points="125.5,-298 125.5,-330 281.5,-330 281.5,-298 125.5,-298"/> <polygon fill="none" stroke="#000000" points="125.5,-298 125.5,-330 281.5,-330 281.5,-298 125.5,-298"/>
<text text-anchor="start" x="135.1525" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remote_stream:ConnectionG3P</text> <text text-anchor="start" x="135.1525" y="-311" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">remoteStream:ConnectionG3P</text>
<polygon fill="none" stroke="#000000" points="125.5,-254 125.5,-298 281.5,-298 281.5,-254 125.5,-254"/> <polygon fill="none" stroke="#000000" points="125.5,-254 125.5,-298 281.5,-298 281.5,-254 125.5,-254"/>
<text text-anchor="start" x="184.054" y="-279" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">healthy()</text> <text text-anchor="start" x="184.054" y="-279" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">healthy()</text>
<text text-anchor="start" x="188.5025" y="-267" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text> <text text-anchor="start" x="188.5025" y="-267" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#000000">close()</text>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -8,9 +8,9 @@
[IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list;state|_read():void<abstract>;close():void;inc_counter():void;dec_counter():void] [IterRegistry||__iter__]^[Message|server_side:bool;header_valid:bool;header_len:unsigned;data_len:unsigned;unique_id;node_id;sug_area;_recv_buffer:bytearray;_send_buffer:bytearray;_forward_buffer:bytearray;db:Infos;new_data:list;state|_read():void<abstract>;close():void;inc_counter():void;dec_counter():void]
[Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()] [Message]^[Talent|await_conn_resp_cnt;id_str;contact_name;contact_mail;db:InfosG3;mb:Modbus;switch|msg_contact_info();msg_ota_update();msg_get_time();msg_collector_data();msg_inverter_data();msg_unknown();;close()]
[Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()] [Message]^[SolarmanV5|control;serial;snr;db:InfosG3P;mb:Modbus;switch|msg_unknown();;close()]
[Talent]^[ConnectionG3|remote_stream:ConnectionG3|healthy();close()] [Talent]^[ConnectionG3|remoteStream:ConnectionG3|healthy();close()]
[Talent]has-1>[Modbus] [Talent]has-1>[Modbus]
[SolarmanV5]^[ConnectionG3P|remote_stream:ConnectionG3P|healthy();close()] [SolarmanV5]^[ConnectionG3P|remoteStream:ConnectionG3P|healthy();close()]
[SolarmanV5]has-1>[Modbus] [SolarmanV5]has-1>[Modbus]
[AsyncStream|reader;writer;addr;r_addr;l_addr|<async>server_loop();<async>client_loop();<async>loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3] [AsyncStream|reader;writer;addr;r_addr;l_addr|<async>server_loop();<async>client_loop();<async>loop;disc();close();;__async_read();async_write();__async_forward()]^[ConnectionG3]
[AsyncStream]^[ConnectionG3P] [AsyncStream]^[ConnectionG3P]

View File

@@ -1,6 +0,0 @@
flake8
pytest
pytest-asyncio
pytest-cov
mock
coverage

View File

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

View File

@@ -3,15 +3,10 @@ import logging
import traceback import traceback
import time import time
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
from messages import hex_dump_memory, State
from typing import Self from typing import Self
from itertools import count from itertools import count
if __name__ == "app.src.async_stream":
from app.src.messages import hex_dump_memory, State
else: # pragma: no cover
from messages import hex_dump_memory, State
import gc import gc
logger = logging.getLogger('conn') logger = logging.getLogger('conn')
@@ -22,10 +17,10 @@ class AsyncStream():
'''maximum processing time for a received msg in sec''' '''maximum processing time for a received msg in sec'''
MAX_START_TIME = 400 MAX_START_TIME = 400
'''maximum time without a received msg in sec''' '''maximum time without a received msg in sec'''
MAX_INV_IDLE_TIME = 120 MAX_INV_IDLE_TIME = 90
'''maximum time without a received msg from the inverter in sec''' '''maximum time without a received msg from the inverter in sec'''
MAX_DEF_IDLE_TIME = 360 MAX_CLOUD_IDLE_TIME = 360
'''maximum default time without a received msg in sec''' '''maximum time without a received msg from cloud side in sec'''
def __init__(self, reader: StreamReader, writer: StreamWriter, def __init__(self, reader: StreamReader, writer: StreamWriter,
addr) -> None: addr) -> None:
@@ -42,11 +37,11 @@ class AsyncStream():
def __timeout(self) -> int: def __timeout(self) -> int:
if self.state == State.init or self.state == State.received: if self.state == State.init or self.state == State.received:
to = self.MAX_START_TIME to = self.MAX_START_TIME
elif self.state == State.up and \
self.server_side and self.modbus_polling:
to = self.MAX_INV_IDLE_TIME
else: else:
to = self.MAX_DEF_IDLE_TIME if self.server_side and self.modbus_polling:
to = self.MAX_INV_IDLE_TIME
else:
to = self.MAX_CLOUD_IDLE_TIME
return to return to
async def publish_outstanding_mqtt(self): async def publish_outstanding_mqtt(self):
@@ -72,18 +67,18 @@ class AsyncStream():
# if the server connection closes, we also have to disconnect # if the server connection closes, we also have to disconnect
# the connection to te TSUN cloud # the connection to te TSUN cloud
if self.remote_stream: if self.remoteStream:
logger.info(f'[{self.node_id}:{self.conn_no}] disc client ' logger.info(f'[{self.node_id}:{self.conn_no}] disc client '
f'connection: [{self.remote_stream.node_id}:' f'connection: [{self.remoteStream.node_id}:'
f'{self.remote_stream.conn_no}]') f'{self.remoteStream.conn_no}]')
await self.remote_stream.disc() await self.remoteStream.disc()
async def client_loop(self, _: str) -> None: async def client_loop(self, addr: str) -> None:
'''Loop for receiving messages from the TSUN cloud (client-side)''' '''Loop for receiving messages from the TSUN cloud (client-side)'''
client_stream = await self.remote_stream.loop() clientStream = await self.remoteStream.loop()
logger.info(f'[{client_stream.node_id}:{client_stream.conn_no}] ' logger.info(f'[{clientStream.node_id}:{clientStream.conn_no}] '
'Client loop stopped for' 'Client loop stopped for'
f' l{client_stream.l_addr}') f' l{clientStream.l_addr}')
# if the client connection closes, we don't touch the server # if the client connection closes, we don't touch the server
# connection. Instead we erase the client connection stream, # connection. Instead we erase the client connection stream,
@@ -91,13 +86,13 @@ class AsyncStream():
# establish a new connection to the TSUN cloud # establish a new connection to the TSUN cloud
# erase backlink to inverter # erase backlink to inverter
client_stream.remote_stream = None clientStream.remoteStream = None
if self.remote_stream == client_stream: if self.remoteStream == clientStream:
# logging.debug(f'Client l{client_stream.l_addr} refs:' # logging.debug(f'Client l{clientStream.l_addr} refs:'
# f' {gc.get_referrers(client_stream)}') # f' {gc.get_referrers(clientStream)}')
# than erase client connection # than erase client connection
self.remote_stream = None self.remoteStream = None
async def loop(self) -> Self: async def loop(self) -> Self:
"""Async loop handler for precessing all received messages""" """Async loop handler for precessing all received messages"""
@@ -147,7 +142,6 @@ class AsyncStream():
logger.error( logger.error(
f"Exception for {self.addr}:\n" f"Exception for {self.addr}:\n"
f"{traceback.format_exc()}") f"{traceback.format_exc()}")
await asyncio.sleep(0) # be cooperative to other task
async def async_write(self, headline: str = 'Transmit to ') -> None: async def async_write(self, headline: str = 'Transmit to ') -> None:
"""Async write handler to transmit the send_buffer""" """Async write handler to transmit the send_buffer"""
@@ -209,35 +203,35 @@ class AsyncStream():
if not self._forward_buffer: if not self._forward_buffer:
return return
try: try:
if not self.remote_stream: if not self.remoteStream:
await self.async_create_remote() await self.async_create_remote()
if self.remote_stream: if self.remoteStream:
if self.remote_stream._init_new_client_conn(): if self.remoteStream._init_new_client_conn():
await self.remote_stream.async_write() await self.remoteStream.async_write()
if self.remote_stream: if self.remoteStream:
self.remote_stream._update_header(self._forward_buffer) self.remoteStream._update_header(self._forward_buffer)
hex_dump_memory(logging.INFO, hex_dump_memory(logging.INFO,
f'Forward to {self.remote_stream.addr}:', f'Forward to {self.remoteStream.addr}:',
self._forward_buffer, self._forward_buffer,
len(self._forward_buffer)) len(self._forward_buffer))
self.remote_stream.writer.write(self._forward_buffer) self.remoteStream.writer.write(self._forward_buffer)
await self.remote_stream.writer.drain() await self.remoteStream.writer.drain()
self._forward_buffer = bytearray(0) self._forward_buffer = bytearray(0)
except OSError as error: except OSError as error:
if self.remote_stream: if self.remoteStream:
rmt = self.remote_stream rmt = self.remoteStream
self.remote_stream = None self.remoteStream = None
logger.error(f'[{rmt.node_id}:{rmt.conn_no}] Fwd: {error} for ' logger.error(f'[{rmt.node_id}:{rmt.conn_no}] Fwd: {error} for '
f'l{rmt.l_addr} | r{rmt.r_addr}') f'l{rmt.l_addr} | r{rmt.r_addr}')
await rmt.disc() await rmt.disc()
rmt.close() rmt.close()
except RuntimeError as error: except RuntimeError as error:
if self.remote_stream: if self.remoteStream:
rmt = self.remote_stream rmt = self.remoteStream
self.remote_stream = None self.remoteStream = None
logger.info(f'[{rmt.node_id}:{rmt.conn_no}] ' logger.info(f'[{rmt.node_id}:{rmt.conn_no}] '
f'Fwd: {error} for {rmt.l_addr}') f'Fwd: {error} for {rmt.l_addr}')
await rmt.disc() await rmt.disc()

View File

@@ -12,84 +12,81 @@ class Config():
Read config.toml file and sanitize it with read(). Read config.toml file and sanitize it with read().
Get named parts of the config with get()''' Get named parts of the config with get()'''
act_config = {} config = {}
def_config = {} def_config = {}
conf_schema = Schema({ conf_schema = Schema({
'tsun': { 'tsun': {
'enabled': Use(bool), 'enabled': Use(bool),
'host': Use(str), 'host': Use(str),
'port': And(Use(int), lambda n: 1024 <= n <= 65535) 'port': And(Use(int), lambda n: 1024 <= n <= 65535)
}, },
'solarman': { 'solarman': {
'enabled': Use(bool), 'enabled': Use(bool),
'host': Use(str), 'host': Use(str),
'port': And(Use(int), lambda n: 1024 <= n <= 65535) 'port': And(Use(int), lambda n: 1024 <= n <= 65535)
}, },
'mqtt': { 'mqtt': {
'host': Use(str), 'host': Use(str),
'port': And(Use(int), lambda n: 1024 <= n <= 65535), 'port': And(Use(int), lambda n: 1024 <= n <= 65535),
'user': 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)) 'passwd': And(Use(str), Use(lambda s: s if len(s) > 0 else None))
}, },
'ha': { 'ha': {
'auto_conf_prefix': Use(str), 'auto_conf_prefix': Use(str),
'discovery_prefix': Use(str), 'discovery_prefix': Use(str),
'entity_prefix': Use(str), 'entity_prefix': Use(str),
'proxy_node_id': Use(str), 'proxy_node_id': Use(str),
'proxy_unique_id': Use(str) 'proxy_unique_id': Use(str)
}, },
'gen3plus': { 'gen3plus': {
'at_acl': { 'at_acl': {
Or('mqtt', 'tsun'): { Or('mqtt', 'tsun'): {
'allow': [str], 'allow': [str],
Optional('block', default=[]): [str] Optional('block', default=[]): [str]
}
} }
} },
},
'inverters': { 'inverters': {
'allow_all': Use(bool), And(Use(str), lambda s: len(s) == 16): { 'allow_all': Use(bool), And(Use(str), lambda s: len(s) == 16): {
Optional('monitor_sn', default=0): Use(int), Optional('monitor_sn', default=0): Use(int),
Optional('node_id', default=""): And(Use(str), Optional('node_id', default=""): And(Use(str),
Use(lambda s: s + '/' Use(lambda s: s + '/'
if len(s) > 0 if len(s) > 0 and
and s[-1] != '/' s[-1] != '/' else s)),
else s)),
Optional('client_mode'): { Optional('client_mode'): {
'host': Use(str), 'host': Use(str),
Optional('port', default=8899): Optional('port', default=8899):
And(Use(int), lambda n: 1024 <= n <= 65535) And(Use(int), lambda n: 1024 <= n <= 65535)
}, },
Optional('modbus_polling', default=True): Use(bool), Optional('modbus_polling', default=True): Use(bool),
Optional('suggested_area', default=""): Use(str), Optional('suggested_area', default=""): Use(str),
Optional('sensor_list', default=0x2b0): Use(int),
Optional('pv1'): { Optional('pv1'): {
Optional('type'): Use(str), Optional('type'): Use(str),
Optional('manufacturer'): Use(str), Optional('manufacturer'): Use(str),
}, },
Optional('pv2'): { Optional('pv2'): {
Optional('type'): Use(str), Optional('type'): Use(str),
Optional('manufacturer'): Use(str), Optional('manufacturer'): Use(str),
}, },
Optional('pv3'): { Optional('pv3'): {
Optional('type'): Use(str), Optional('type'): Use(str),
Optional('manufacturer'): Use(str), Optional('manufacturer'): Use(str),
}, },
Optional('pv4'): { Optional('pv4'): {
Optional('type'): Use(str), Optional('type'): Use(str),
Optional('manufacturer'): Use(str), Optional('manufacturer'): Use(str),
}, },
Optional('pv5'): { Optional('pv5'): {
Optional('type'): Use(str), Optional('type'): Use(str),
Optional('manufacturer'): Use(str), Optional('manufacturer'): Use(str),
}, },
Optional('pv6'): { Optional('pv6'): {
Optional('type'): Use(str), Optional('type'): Use(str),
Optional('manufacturer'): Use(str), Optional('manufacturer'): Use(str),
} }
} }}
} }, ignore_extra_keys=True
}, ignore_extra_keys=True )
)
@classmethod @classmethod
def class_init(cls) -> None | str: # pragma: no cover def class_init(cls) -> None | str: # pragma: no cover
@@ -149,17 +146,17 @@ class Config():
config[key] |= usr_config[key] config[key] |= usr_config[key]
try: try:
cls.act_config = cls.conf_schema.validate(config) cls.config = cls.conf_schema.validate(config)
except Exception as error: except Exception as error:
err = f'Config.read: {error}' err = f'Config.read: {error}'
logging.error(err) logging.error(err)
# logging.debug(f'Readed config: "{cls.act_config}" ') # logging.debug(f'Readed config: "{cls.config}" ')
except Exception as error: except Exception as error:
err = f'Config.read: {error}' err = f'Config.read: {error}'
logger.error(err) logger.error(err)
cls.act_config = {} cls.config = {}
return err return err
@@ -169,12 +166,12 @@ class Config():
None it returns the complete config dict''' None it returns the complete config dict'''
if member: if member:
return cls.act_config.get(member, {}) return cls.config.get(member, {})
else: else:
return cls.act_config return cls.config
@classmethod @classmethod
def is_default(cls, member: str) -> bool: def is_default(cls, member: str) -> bool:
'''Check if the member is the default value''' '''Check if the member is the default value'''
return cls.act_config.get(member) == cls.def_config.get(member) return cls.config.get(member) == cls.def_config.get(member)

View File

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

View File

@@ -139,6 +139,7 @@ class InfosG3(Infos):
i = elms # abort the loop i = elms # abort the loop
elif data_type == 0x41: # 'A' -> Nop ?? elif data_type == 0x41: # 'A' -> Nop ??
# result = struct.unpack_from('!l', buf, ind)[0]
ind += 0 ind += 0
i += 1 i += 1
continue continue
@@ -170,17 +171,17 @@ class InfosG3(Infos):
" not supported") " not supported")
return return
yield from self.__store_result(addr, result, info_id, node_id) keys, level, unit, must_incr = self._key_obj(info_id)
i += 1
def __store_result(self, addr, result, info_id, node_id): if keys:
keys, level, unit, must_incr = self._key_obj(info_id) name, update = self.update_db(keys, must_incr, result)
if keys: yield keys[0], update
name, update = self.update_db(keys, must_incr, result) else:
yield keys[0], update update = False
else: name = str(f'info-id.0x{addr:x}')
update = False
name = str(f'info-id.0x{addr:x}') if update:
if update: self.tracer.log(level, f'[{node_id}] GEN3: {name} :'
self.tracer.log(level, f'[{node_id}] GEN3: {name} :' f' {result}{unit}')
f' {result}{unit}')
i += 1

View File

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

View File

@@ -1,6 +1,7 @@
import struct import struct
import logging import logging
from zoneinfo import ZoneInfo import time
import pytz
from datetime import datetime from datetime import datetime
from tzlocal import get_localzone from tzlocal import get_localzone
@@ -42,10 +43,9 @@ class Control:
class Talent(Message): class Talent(Message):
MB_START_TIMEOUT = 40 MB_START_TIMEOUT = 40
MB_REGULAR_TIMEOUT = 60 MB_REGULAR_TIMEOUT = 60
TXT_UNKNOWN_CTRL = 'Unknown Ctrl'
def __init__(self, server_side: bool, id_str=b''): def __init__(self, server_side: bool, id_str=b''):
super().__init__(server_side, self.send_modbus_cb, mb_timeout=15) super().__init__(server_side, self.send_modbus_cb, mb_timeout=11)
self.await_conn_resp_cnt = 0 self.await_conn_resp_cnt = 0
self.id_str = id_str self.id_str = id_str
self.contact_name = b'' self.contact_name = b''
@@ -76,7 +76,7 @@ class Talent(Message):
self.node_id = 'G3' # will be overwritten in __set_serial_no self.node_id = 'G3' # will be overwritten in __set_serial_no
self.mb_timer = Timer(self.mb_timout_cb, self.node_id) self.mb_timer = Timer(self.mb_timout_cb, self.node_id)
self.mb_timeout = self.MB_REGULAR_TIMEOUT self.mb_timeout = self.MB_REGULAR_TIMEOUT
self.mb_first_timeout = self.MB_START_TIMEOUT self.mb_start_timeout = self.MB_START_TIMEOUT
self.modbus_polling = False self.modbus_polling = False
''' '''
@@ -169,6 +169,7 @@ class Talent(Message):
fnc = self.switch.get(self.msg_id, self.msg_unknown) fnc = self.switch.get(self.msg_id, self.msg_unknown)
logger.info(self.__flow_str(self.server_side, 'forwrd') + logger.info(self.__flow_str(self.server_side, 'forwrd') +
f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}') f' Ctl: {int(self.ctrl):#02x} Msg: {fnc.__name__!r}')
return
def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str): def send_modbus_cb(self, modbus_pdu: bytearray, log_lvl: int, state: str):
if self.state != State.up: if self.state != State.up:
@@ -177,7 +178,7 @@ class Talent(Message):
return return
self.__build_header(0x70, 0x77) self.__build_header(0x70, 0x77)
self._send_buffer += b'\x00\x01\xa3\x28' # magic ? self._send_buffer += b'\x00\x01\xa3\x28' # fixme
self._send_buffer += struct.pack('!B', len(modbus_pdu)) self._send_buffer += struct.pack('!B', len(modbus_pdu))
self._send_buffer += modbus_pdu self._send_buffer += modbus_pdu
self.__finish_send_msg() self.__finish_send_msg()
@@ -241,13 +242,17 @@ class Talent(Message):
def _timestamp(self): # pragma: no cover def _timestamp(self): # pragma: no cover
'''returns timestamp fo the inverter as localtime '''returns timestamp fo the inverter as localtime
since 1.1.1970 in msec''' since 1.1.1970 in msec'''
# convert localtime in epoche if False:
ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds() # utc as epoche
ts = time.time()
else:
# convert localtime in epoche
ts = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
return round(ts*1000) return round(ts*1000)
def _utcfromts(self, ts: float): def _utcfromts(self, ts: float):
'''converts inverter timestamp into unix time (epoche)''' '''converts inverter timestamp into unix time (epoche)'''
dt = datetime.fromtimestamp(ts/1000, tz=ZoneInfo("UTC")). \ dt = datetime.fromtimestamp(ts/1000, pytz.UTC). \
replace(tzinfo=get_localzone()) replace(tzinfo=get_localzone())
return dt.timestamp() return dt.timestamp()
@@ -292,15 +297,8 @@ class Talent(Message):
if (buf_len < 5): # enough bytes to read len and id_len? if (buf_len < 5): # enough bytes to read len and id_len?
return return
result = struct.unpack_from('!lB', buf, 0) result = struct.unpack_from('!lB', buf, 0)
msg_len = result[0] # len of complete message len = result[0] # len of complete message
id_len = result[1] # len of variable id string id_len = result[1] # len of variable id string
if id_len > 17:
logger.warning(f'len of ID string must == 16 but is {id_len}')
self.inc_counter('Invalid_Msg_Format')
# erase broken recv buffer
self._recv_buffer = bytearray()
return
hdr_len = 5+id_len+2 hdr_len = 5+id_len+2
@@ -313,9 +311,10 @@ class Talent(Message):
self.id_str = result[0] self.id_str = result[0]
self.ctrl = Control(result[1]) self.ctrl = Control(result[1])
self.msg_id = result[2] self.msg_id = result[2]
self.data_len = msg_len-id_len-3 self.data_len = len-id_len-3
self.header_len = hdr_len self.header_len = hdr_len
self.header_valid = True self.header_valid = True
return
def __build_header(self, ctrl, msg_id=None) -> None: def __build_header(self, ctrl, msg_id=None) -> None:
if not msg_id: if not msg_id:
@@ -361,8 +360,9 @@ class Talent(Message):
self.await_conn_resp_cnt -= 1 self.await_conn_resp_cnt -= 1
else: else:
self.forward() self.forward()
return
else: else:
logger.warning(self.TXT_UNKNOWN_CTRL) logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward() self.forward()
@@ -388,6 +388,10 @@ class Talent(Message):
if self.data_len == 0: if self.data_len == 0:
if self.state == State.up: if self.state == State.up:
self.state = State.pend # block MODBUS cmds self.state = State.pend # block MODBUS cmds
if (self.modbus_polling):
self.mb_timer.start(self.mb_start_timeout)
self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout)
ts = self._timestamp() ts = self._timestamp()
logger.debug(f'time: {ts:08x}') logger.debug(f'time: {ts:08x}')
@@ -405,7 +409,7 @@ class Talent(Message):
f' offset: {self.ts_offset}') f' offset: {self.ts_offset}')
return # ignore received response return # ignore received response
else: else:
logger.warning(self.TXT_UNKNOWN_CTRL) logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward() self.forward()
@@ -439,7 +443,7 @@ class Talent(Message):
elif self.ctrl.is_resp(): elif self.ctrl.is_resp():
return # ignore received response return # ignore received response
else: else:
logger.warning(self.TXT_UNKNOWN_CTRL) logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward() self.forward()
@@ -451,15 +455,11 @@ class Talent(Message):
self.__finish_send_msg() self.__finish_send_msg()
self.__process_data() self.__process_data()
self.state = State.up # allow MODBUS cmds self.state = State.up # allow MODBUS cmds
if (self.modbus_polling):
self.mb_timer.start(self.mb_first_timeout)
self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout)
elif self.ctrl.is_resp(): elif self.ctrl.is_resp():
return # ignore received response return # ignore received response
else: else:
logger.warning(self.TXT_UNKNOWN_CTRL) logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward() self.forward()
@@ -477,9 +477,9 @@ class Talent(Message):
if self.ctrl.is_req(): if self.ctrl.is_req():
self.inc_counter('OTA_Start_Msg') self.inc_counter('OTA_Start_Msg')
elif self.ctrl.is_ind(): elif self.ctrl.is_ind():
pass # Ok, nothing to do pass
else: else:
logger.warning(self.TXT_UNKNOWN_CTRL) logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward() self.forward()
@@ -490,24 +490,27 @@ class Talent(Message):
result = struct.unpack_from('!lBB', self._recv_buffer, result = struct.unpack_from('!lBB', self._recv_buffer,
self.header_len) self.header_len)
modbus_len = result[1] modbus_len = result[1]
# logger.debug(f'Ref: {result[0]}')
# logger.debug(f'Modbus MsgLen: {modbus_len} Func:{result[2]}')
return msg_hdr_len, modbus_len return msg_hdr_len, modbus_len
def get_modbus_log_lvl(self) -> int: def get_modbus_log_lvl(self) -> int:
if self.ctrl.is_req(): if self.ctrl.is_req():
return logging.INFO return logging.INFO
elif self.ctrl.is_ind() and self.server_side: elif self.ctrl.is_ind():
return self.mb.last_log_lvl if self.server_side:
return self.mb.last_log_lvl
return logging.WARNING return logging.WARNING
def msg_modbus(self): def msg_modbus(self):
hdr_len, _ = self.parse_modbus_header() hdr_len, modbus_len = self.parse_modbus_header()
data = self._recv_buffer[self.header_len: data = self._recv_buffer[self.header_len:
self.header_len+self.data_len] self.header_len+self.data_len]
if self.ctrl.is_req(): if self.ctrl.is_req():
if self.remote_stream.mb.recv_req(data[hdr_len:], if self.remoteStream.mb.recv_req(data[hdr_len:],
self.remote_stream. self.remoteStream.
msg_forward): msg_forward):
self.inc_counter('Modbus_Command') self.inc_counter('Modbus_Command')
else: else:
self.inc_counter('Invalid_Msg_Format') self.inc_counter('Invalid_Msg_Format')
@@ -527,7 +530,7 @@ class Talent(Message):
self.new_data[key] = True self.new_data[key] = True
self.modbus_elms += 1 # count for unit tests self.modbus_elms += 1 # count for unit tests
else: else:
logger.warning(self.TXT_UNKNOWN_CTRL) logger.warning('Unknown Ctrl')
self.inc_counter('Unknown_Ctrl') self.inc_counter('Unknown_Ctrl')
self.forward() self.forward()

View File

@@ -1,12 +1,8 @@
import logging import logging
# import gc
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
from async_stream import AsyncStream
if __name__ == "app.src.gen3plus.connection_g3p": from gen3plus.solarman_v5 import SolarmanV5
from app.src.async_stream import AsyncStream
from app.src.gen3plus.solarman_v5 import SolarmanV5
else: # pragma: no cover
from async_stream import AsyncStream
from gen3plus.solarman_v5 import SolarmanV5
logger = logging.getLogger('conn') logger = logging.getLogger('conn')
@@ -20,7 +16,7 @@ class ConnectionG3P(AsyncStream, SolarmanV5):
AsyncStream.__init__(self, reader, writer, addr) AsyncStream.__init__(self, reader, writer, addr)
SolarmanV5.__init__(self, server_side, client_mode) SolarmanV5.__init__(self, server_side, client_mode)
self.remote_stream: 'ConnectionG3P' = remote_stream self.remoteStream: 'ConnectionG3P' = remote_stream
''' '''
Our puplic methods Our puplic methods
@@ -31,10 +27,10 @@ class ConnectionG3P(AsyncStream, SolarmanV5):
# logger.info(f'AsyncStream refs: {gc.get_referrers(self)}') # logger.info(f'AsyncStream refs: {gc.get_referrers(self)}')
async def async_create_remote(self) -> None: async def async_create_remote(self) -> None:
pass # virtual interface # pragma: no cover pass
async def async_publ_mqtt(self) -> None: async def async_publ_mqtt(self) -> None:
pass # virtual interface # pragma: no cover pass
def healthy(self) -> bool: def healthy(self) -> bool:
logger.debug('ConnectionG3P healthy()') logger.debug('ConnectionG3P healthy()')

View File

@@ -20,11 +20,9 @@ class RegisterMap:
0x4102001c: {'reg': Register.SIGNAL_STRENGTH, 'fmt': '<B', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501 0x4102001c: {'reg': Register.SIGNAL_STRENGTH, 'fmt': '<B', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501
0x4102001e: {'reg': Register.CHIP_MODEL, 'fmt': '!40s'}, # noqa: E501 0x4102001e: {'reg': Register.CHIP_MODEL, 'fmt': '!40s'}, # noqa: E501
0x4102004c: {'reg': Register.IP_ADDRESS, 'fmt': '!16s'}, # noqa: E501 0x4102004c: {'reg': Register.IP_ADDRESS, 'fmt': '!16s'}, # noqa: E501
0x4102005f: {'reg': Register.SENSOR_LIST, 'fmt': '<H', 'eval': "f'{result:04x}'"}, # noqa: E501
0x41020064: {'reg': Register.COLLECTOR_FW_VERSION, 'fmt': '!40s'}, # noqa: E501 0x41020064: {'reg': Register.COLLECTOR_FW_VERSION, 'fmt': '!40s'}, # noqa: E501
0x4201000c: {'reg': Register.SENSOR_LIST, 'fmt': '<H', 'eval': "f'{result:04x}'"}, # noqa: E501 0x4201001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501
0x4201001c: {'reg': Register.POWER_ON_TIME, 'fmt': '<H', 'ratio': 1, 'dep': ProxyMode.SERVER}, # noqa: E501, or packet number
0x42010020: {'reg': Register.SERIAL_NUMBER, 'fmt': '!16s'}, # noqa: E501 0x42010020: {'reg': Register.SERIAL_NUMBER, 'fmt': '!16s'}, # noqa: E501
0x420100c0: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501 0x420100c0: {'reg': Register.INVERTER_STATUS, 'fmt': '!H'}, # noqa: E501
0x420100d0: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501 0x420100d0: {'reg': Register.VERSION, 'fmt': '!H', 'eval': "f'V{(result>>12)}.{(result>>8)&0xf}.{(result>>4)&0xf}{result&0xf}'"}, # noqa: E501
@@ -120,7 +118,15 @@ class InfosG3P(Infos):
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
info_id = row['reg'] info_id = row['reg']
result = self.__get_value(buf, addr, row) fmt = row['fmt']
res = struct.unpack_from(fmt, buf, addr)
result = res[0]
if isinstance(result, (bytearray, bytes)):
result = result.decode().split('\x00')[0]
if 'eval' in row:
result = eval(row['eval'])
if 'ratio' in row:
result = round(result * row['ratio'], 2)
keys, level, unit, must_incr = self._key_obj(info_id) keys, level, unit, must_incr = self._key_obj(info_id)
@@ -134,16 +140,3 @@ class InfosG3P(Infos):
if update: if update:
self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}' self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}'
f' : {result}{unit}') f' : {result}{unit}')
def __get_value(self, buf, idx, row):
'''Get a value from buf and interpret as in row'''
fmt = row['fmt']
res = struct.unpack_from(fmt, buf, idx)
result = res[0]
if isinstance(result, (bytearray, bytes)):
result = result.decode().split('\x00')[0]
if 'eval' in row:
result = eval(row['eval'])
if 'ratio' in row:
result = round(result * row['ratio'], 2)
return result

View File

@@ -3,20 +3,15 @@ import traceback
import json import json
import asyncio import asyncio
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
from config import Config
from inverter import Inverter
from gen3plus.connection_g3p import ConnectionG3P
from aiomqtt import MqttCodeError from aiomqtt import MqttCodeError
from infos import Infos
if __name__ == "app.src.gen3plus.inverter_g3p": # import gc
from app.src.config import Config
from app.src.inverter import Inverter
from app.src.gen3plus.connection_g3p import ConnectionG3P
from app.src.infos import Infos
else: # pragma: no cover
from config import Config
from inverter import Inverter
from gen3plus.connection_g3p import ConnectionG3P
from infos import Infos
# logger = logging.getLogger('conn')
logger_mqtt = logging.getLogger('mqtt') logger_mqtt = logging.getLogger('mqtt')
@@ -67,11 +62,11 @@ class InverterG3P(Inverter, ConnectionG3P):
logging.info(f'[{self.node_id}] Connect to {addr}') logging.info(f'[{self.node_id}] Connect to {addr}')
connect = asyncio.open_connection(host, port) connect = asyncio.open_connection(host, port)
reader, writer = await connect reader, writer = await connect
self.remote_stream = ConnectionG3P(reader, writer, addr, self, self.remoteStream = ConnectionG3P(reader, writer, addr, self,
server_side=False, server_side=False,
client_mode=False) client_mode=False)
logging.info(f'[{self.remote_stream.node_id}:' logging.info(f'[{self.remoteStream.node_id}:'
f'{self.remote_stream.conn_no}] ' f'{self.remoteStream.conn_no}] '
f'Connected to {addr}') f'Connected to {addr}')
asyncio.create_task(self.client_loop(addr)) asyncio.create_task(self.client_loop(addr))

View File

@@ -1,4 +1,5 @@
import struct import struct
# import json
import logging import logging
import time import time
import asyncio import asyncio
@@ -18,6 +19,7 @@ else: # pragma: no cover
from my_timer import Timer from my_timer import Timer
from gen3plus.infos_g3p import InfosG3P from gen3plus.infos_g3p import InfosG3P
from infos import Register from infos import Register
# import traceback
logger = logging.getLogger('msg') logger = logging.getLogger('msg')
@@ -57,11 +59,9 @@ class SolarmanV5(Message):
'''regular Modbus polling time in server mode''' '''regular Modbus polling time in server mode'''
MB_CLIENT_DATA_UP = 30 MB_CLIENT_DATA_UP = 30
'''Data up time in client mode''' '''Data up time in client mode'''
HDR_FMT = '<BLLL'
'''format string for packing of the header'''
def __init__(self, server_side: bool, client_mode: bool): def __init__(self, server_side: bool, client_mode: bool):
super().__init__(server_side, self.send_modbus_cb, mb_timeout=8) super().__init__(server_side, self.send_modbus_cb, mb_timeout=5)
self.header_len = 11 # overwrite construcor in class Message self.header_len = 11 # overwrite construcor in class Message
self.control = 0 self.control = 0
@@ -135,10 +135,9 @@ class SolarmanV5(Message):
self.node_id = 'G3P' # will be overwritten in __set_serial_no self.node_id = 'G3P' # will be overwritten in __set_serial_no
self.mb_timer = Timer(self.mb_timout_cb, self.node_id) self.mb_timer = Timer(self.mb_timout_cb, self.node_id)
self.mb_timeout = self.MB_REGULAR_TIMEOUT self.mb_timeout = self.MB_REGULAR_TIMEOUT
self.mb_first_timeout = self.MB_START_TIMEOUT self.mb_start_timeout = self.MB_START_TIMEOUT
'''timer value for next Modbus polling request''' '''timer value for next Modbus polling request'''
self.modbus_polling = False self.modbus_polling = False
self.sensor_list = 0x0000
''' '''
Our puplic methods Our puplic methods
@@ -172,7 +171,7 @@ class SolarmanV5(Message):
self.db.set_db_def_value(Register.POLLING_INTERVAL, self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout) self.mb_timeout)
self.db.set_db_def_value(Register.HEARTBEAT_INTERVAL, self.db.set_db_def_value(Register.HEARTBEAT_INTERVAL,
120) 120) # fixme
self.new_data['controller'] = True self.new_data['controller'] = True
self.state = State.up self.state = State.up
@@ -183,23 +182,16 @@ class SolarmanV5(Message):
if self.state is not State.up: if self.state is not State.up:
self.state = State.up self.state = State.up
if (self.modbus_polling): if (self.modbus_polling):
self.mb_timer.start(self.mb_first_timeout) self.mb_timer.start(self.mb_start_timeout)
self.db.set_db_def_value(Register.POLLING_INTERVAL, self.db.set_db_def_value(Register.POLLING_INTERVAL,
self.mb_timeout) self.mb_timeout)
def __set_config_parms(self, inv: dict):
'''init connection with params from the configuration'''
self.node_id = inv['node_id']
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
self.sensor_list = inv['sensor_list']
def __set_serial_no(self, snr: int): def __set_serial_no(self, snr: int):
'''check the serial number and configure the inverter connection'''
serial_no = str(snr) serial_no = str(snr)
if self.unique_id == serial_no: if self.unique_id == serial_no:
logger.debug(f'SerialNo: {serial_no}') logger.debug(f'SerialNo: {serial_no}')
else: else:
found = False
inverters = Config.get('inverters') inverters = Config.get('inverters')
# logger.debug(f'Inverters: {inverters}') # logger.debug(f'Inverters: {inverters}')
@@ -207,11 +199,14 @@ class SolarmanV5(Message):
# logger.debug(f'key: {key} -> {inv}') # logger.debug(f'key: {key} -> {inv}')
if (type(inv) is dict and 'monitor_sn' in inv if (type(inv) is dict and 'monitor_sn' in inv
and inv['monitor_sn'] == snr): and inv['monitor_sn'] == snr):
self.__set_config_parms(inv) found = True
self.db.set_pv_module_details(inv) self.node_id = inv['node_id']
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
break self.db.set_pv_module_details(inv)
else:
if not found:
self.node_id = '' self.node_id = ''
self.sug_area = '' self.sug_area = ''
if 'allow_all' not in inverters or not inverters['allow_all']: if 'allow_all' not in inverters or not inverters['allow_all']:
@@ -219,7 +214,7 @@ class SolarmanV5(Message):
self.unique_id = None self.unique_id = None
logger.warning(f'ignore message from unknow inverter! (SerialNo: {serial_no})') # noqa: E501 logger.warning(f'ignore message from unknow inverter! (SerialNo: {serial_no})') # noqa: E501
return return
logger.warning(f'SerialNo {serial_no} not known but accepted!') logger.debug(f'SerialNo {serial_no} not known but accepted!')
self.unique_id = serial_no self.unique_id = serial_no
@@ -232,25 +227,23 @@ class SolarmanV5(Message):
if self.header_valid and len(self._recv_buffer) >= \ if self.header_valid and len(self._recv_buffer) >= \
(self.header_len + self.data_len+2): (self.header_len + self.data_len+2):
self.__process_complete_received_msg() log_lvl = self.log_lvl.get(self.control, logging.WARNING)
if callable(log_lvl):
log_lvl = log_lvl()
hex_dump_memory(log_lvl, f'Received from {self.addr}:',
self._recv_buffer, self.header_len +
self.data_len+2)
if self.__trailer_is_ok(self._recv_buffer, self.header_len
+ self.data_len + 2):
if self.state == State.init:
self.state = State.received
self.__set_serial_no(self.snr)
self.__dispatch_msg()
self.__flush_recv_msg() self.__flush_recv_msg()
else: else:
return 0 # wait 0s before sending a response return 0 # wait 0s before sending a response
def __process_complete_received_msg(self):
log_lvl = self.log_lvl.get(self.control, logging.WARNING)
if callable(log_lvl):
log_lvl = log_lvl()
hex_dump_memory(log_lvl, f'Received from {self.addr}:',
self._recv_buffer, self.header_len +
self.data_len+2)
if self.__trailer_is_ok(self._recv_buffer, self.header_len
+ self.data_len + 2):
if self.state == State.init:
self.state = State.received
self.__set_serial_no(self.snr)
self.__dispatch_msg()
def forward(self, buffer, buflen) -> None: def forward(self, buffer, buflen) -> None:
'''add the actual receive msg to the forwarding queue''' '''add the actual receive msg to the forwarding queue'''
if self.no_forwarding: if self.no_forwarding:
@@ -265,6 +258,7 @@ class SolarmanV5(Message):
logger.info(self.__flow_str(self.server_side, 'forwrd') + logger.info(self.__flow_str(self.server_side, 'forwrd') +
f' Ctl: {int(self.control):#04x}' f' Ctl: {int(self.control):#04x}'
f' Msg: {fnc.__name__!r}') f' Msg: {fnc.__name__!r}')
return
def _init_new_client_conn(self) -> bool: def _init_new_client_conn(self) -> bool:
return False return False
@@ -318,6 +312,7 @@ class SolarmanV5(Message):
self._recv_buffer = bytearray() self._recv_buffer = bytearray()
return return
self.header_valid = True self.header_valid = True
return
def __trailer_is_ok(self, buf: bytes, buf_len: int) -> bool: def __trailer_is_ok(self, buf: bytes, buf_len: int) -> bool:
crc = buf[self.data_len+11] crc = buf[self.data_len+11]
@@ -410,7 +405,7 @@ class SolarmanV5(Message):
return return
self.__build_header(0x4510) self.__build_header(0x4510)
self._send_buffer += struct.pack('<BHLLL', self.MB_RTU_CMD, self._send_buffer += struct.pack('<BHLLL', self.MB_RTU_CMD,
self.sensor_list, 0, 0, 0) 0x2b0, 0, 0, 0)
self._send_buffer += pdu self._send_buffer += pdu
self.__finish_send_msg() self.__finish_send_msg()
hex_dump_memory(log_lvl, f'Send Modbus {state}:{self.addr}:', hex_dump_memory(log_lvl, f'Send Modbus {state}:{self.addr}:',
@@ -441,15 +436,15 @@ class SolarmanV5(Message):
return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \ return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \
cmd.startswith(tuple(self.at_acl[connection]['block'])) cmd.startswith(tuple(self.at_acl[connection]['block']))
async def send_at_cmd(self, at_cmd: str) -> None: async def send_at_cmd(self, AT_cmd: str) -> None:
if self.state != State.up: if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore AT+ cmd,' logger.warning(f'[{self.node_id}] ignore AT+ cmd,'
' as the state is not UP') ' as the state is not UP')
return return
at_cmd = at_cmd.strip() AT_cmd = AT_cmd.strip()
if self.at_cmd_forbidden(cmd=at_cmd, connection='mqtt'): if self.at_cmd_forbidden(cmd=AT_cmd, connection='mqtt'):
data_json = f'\'{at_cmd}\' is forbidden' data_json = f'\'{AT_cmd}\' is forbidden'
node_id = self.node_id node_id = self.node_id
key = 'at_resp' key = 'at_resp'
logger.info(f'{key}: {data_json}') logger.info(f'{key}: {data_json}')
@@ -458,9 +453,9 @@ class SolarmanV5(Message):
self.forward_at_cmd_resp = False self.forward_at_cmd_resp = False
self.__build_header(0x4510) self.__build_header(0x4510)
self._send_buffer += struct.pack(f'<BHLLL{len(at_cmd)}sc', self.AT_CMD, self._send_buffer += struct.pack(f'<BHLLL{len(AT_cmd)}sc', self.AT_CMD,
0x0002, 0, 0, 0, 2, 0, 0, 0, AT_cmd.encode('utf-8'),
at_cmd.encode('utf-8'), b'\r') b'\r')
self.__finish_send_msg() self.__finish_send_msg()
try: try:
await self.async_write('Send AT Command:') await self.async_write('Send AT Command:')
@@ -472,19 +467,19 @@ class SolarmanV5(Message):
def __build_model_name(self): def __build_model_name(self):
db = self.db db = self.db
max_pow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0) MaxPow = db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
rated = db.get_db_value(Register.RATED_POWER, 0) Rated = db.get_db_value(Register.RATED_POWER, 0)
model = None Model = None
if max_pow == 2000: if MaxPow == 2000:
if rated == 800 or rated == 600: if Rated == 800 or Rated == 600:
model = f'TSOL-MS{max_pow}({rated})' Model = f'TSOL-MS{MaxPow}({Rated})'
else: else:
model = f'TSOL-MS{max_pow}' Model = f'TSOL-MS{MaxPow}'
elif max_pow == 1800 or max_pow == 1600: elif MaxPow == 1800 or MaxPow == 1600:
model = f'TSOL-MS{max_pow}' Model = f'TSOL-MS{MaxPow}'
if model: if Model:
logger.info(f'Model: {model}') logger.info(f'Model: {Model}')
self.db.set_db_def_value(Register.EQUIPMENT_MODEL, model) self.db.set_db_def_value(Register.EQUIPMENT_MODEL, Model)
def __process_data(self, ftype, ts): def __process_data(self, ftype, ts):
inv_update = False inv_update = False
@@ -509,7 +504,7 @@ class SolarmanV5(Message):
def msg_dev_ind(self): def msg_dev_ind(self):
data = self._recv_buffer[self.header_len:] data = self._recv_buffer[self.header_len:]
result = struct.unpack_from(self.HDR_FMT, data, 0) result = struct.unpack_from('<BLLL', data, 0)
ftype = result[0] # always 2 ftype = result[0] # always 2
total = result[1] total = result[1]
tim = result[2] tim = result[2]
@@ -523,8 +518,6 @@ class SolarmanV5(Message):
else: else:
ts = None ts = None
self.__process_data(ftype, ts) self.__process_data(ftype, ts)
self.sensor_list = int(self.db.get_db_value(Register.SENSOR_LIST, 0),
16)
self.__forward_msg() self.__forward_msg()
self.__send_ack_rsp(0x1110, ftype) self.__send_ack_rsp(0x1110, ftype)
@@ -532,16 +525,12 @@ class SolarmanV5(Message):
data = self._recv_buffer data = self._recv_buffer
result = struct.unpack_from('<BHLLLHL', data, self.header_len) result = struct.unpack_from('<BHLLLHL', data, self.header_len)
ftype = result[0] # 1 or 0x81 ftype = result[0] # 1 or 0x81
sensor = result[1]
total = result[2] total = result[2]
tim = result[3] tim = result[3]
if 1 == ftype: if 1 == ftype:
self.time_ofs = result[4] self.time_ofs = result[4]
unkn = result[5] unkn = result[5]
cnt = result[6] cnt = result[6]
if sensor != self.sensor_list:
logger.warning(f'Unexpected Sensor-List:{sensor:04x}'
f' (!={self.sensor_list:04x})')
logger.info(f'ftype:{ftype:02x} timer:{tim:08x}s' logger.info(f'ftype:{ftype:02x} timer:{tim:08x}s'
f' ??: {unkn:04x} cnt:{cnt}') f' ??: {unkn:04x} cnt:{cnt}')
if self.time_ofs: if self.time_ofs:
@@ -558,7 +547,7 @@ class SolarmanV5(Message):
def msg_sync_start(self): def msg_sync_start(self):
data = self._recv_buffer[self.header_len:] data = self._recv_buffer[self.header_len:]
result = struct.unpack_from(self.HDR_FMT, data, 0) result = struct.unpack_from('<BLLL', data, 0)
ftype = result[0] ftype = result[0]
total = result[1] total = result[1]
self.time_ofs = result[3] self.time_ofs = result[3]
@@ -575,17 +564,17 @@ class SolarmanV5(Message):
result = struct.unpack_from('<B', data, 0) result = struct.unpack_from('<B', data, 0)
ftype = result[0] ftype = result[0]
if ftype == self.AT_CMD: if ftype == self.AT_CMD:
at_cmd = data[15:].decode() AT_cmd = data[15:].decode()
if self.at_cmd_forbidden(cmd=at_cmd, connection='tsun'): if self.at_cmd_forbidden(cmd=AT_cmd, connection='tsun'):
self.inc_counter('AT_Command_Blocked') self.inc_counter('AT_Command_Blocked')
return return
self.inc_counter('AT_Command') self.inc_counter('AT_Command')
self.forward_at_cmd_resp = True self.forward_at_cmd_resp = True
elif ftype == self.MB_RTU_CMD: elif ftype == self.MB_RTU_CMD:
if self.remote_stream.mb.recv_req(data[15:], if self.remoteStream.mb.recv_req(data[15:],
self.remote_stream. self.remoteStream.
__forward_msg): __forward_msg):
self.inc_counter('Modbus_Command') self.inc_counter('Modbus_Command')
else: else:
logger.error('Invalid Modbus Msg') logger.error('Invalid Modbus Msg')
@@ -604,9 +593,9 @@ class SolarmanV5(Message):
if self.forward_at_cmd_resp: if self.forward_at_cmd_resp:
return logging.INFO return logging.INFO
return logging.DEBUG return logging.DEBUG
elif ftype == self.MB_RTU_CMD \ elif ftype == self.MB_RTU_CMD:
and self.server_side: if self.server_side:
return self.mb.last_log_lvl return self.mb.last_log_lvl
return logging.WARNING return logging.WARNING
@@ -623,30 +612,28 @@ class SolarmanV5(Message):
self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501 self.publish_mqtt(f'{self.entity_prfx}{node_id}{key}', data_json) # noqa: E501
return return
elif ftype == self.MB_RTU_CMD: elif ftype == self.MB_RTU_CMD:
self.__modbus_command_rsp(data) valid = data[1]
modbus_msg_len = self.data_len - 14
# logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}')
if valid == 1 and modbus_msg_len > 4:
# logger.info(f'first byte modbus:{data[14]}')
inv_update = False
self.modbus_elms = 0
for key, update, _ in self.mb.recv_resp(self.db, data[14:],
self.node_id):
self.modbus_elms += 1
if update:
if key == 'inverter':
inv_update = True
self._set_mqtt_timestamp(key, self._timestamp())
self.new_data[key] = True
if inv_update:
self.__build_model_name()
return return
self.__forward_msg() self.__forward_msg()
def __modbus_command_rsp(self, data):
'''precess MODBUS RTU response'''
valid = data[1]
modbus_msg_len = self.data_len - 14
# logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}')
if valid == 1 and modbus_msg_len > 4:
# logger.info(f'first byte modbus:{data[14]}')
inv_update = False
self.modbus_elms = 0
for key, update, _ in self.mb.recv_resp(self.db, data[14:],
self.node_id):
self.modbus_elms += 1
if update:
if key == 'inverter':
inv_update = True
self._set_mqtt_timestamp(key, self._timestamp())
self.new_data[key] = True
if inv_update:
self.__build_model_name()
def msg_hbeat_ind(self): def msg_hbeat_ind(self):
data = self._recv_buffer[self.header_len:] data = self._recv_buffer[self.header_len:]
result = struct.unpack_from('<B', data, 0) result = struct.unpack_from('<B', data, 0)
@@ -658,7 +645,7 @@ class SolarmanV5(Message):
def msg_sync_end(self): def msg_sync_end(self):
data = self._recv_buffer[self.header_len:] data = self._recv_buffer[self.header_len:]
result = struct.unpack_from(self.HDR_FMT, data, 0) result = struct.unpack_from('<BLLL', data, 0)
ftype = result[0] ftype = result[0]
total = result[1] total = result[1]
self.time_ofs = result[3] self.time_ofs = result[3]

View File

@@ -96,7 +96,6 @@ class Register(Enum):
HEARTBEAT_INTERVAL = 406 HEARTBEAT_INTERVAL = 406
IP_ADDRESS = 407 IP_ADDRESS = 407
POLLING_INTERVAL = 408 POLLING_INTERVAL = 408
SENSOR_LIST = 409
EVENT_401 = 500 EVENT_401 = 500
EVENT_402 = 501 EVENT_402 = 501
EVENT_403 = 502 EVENT_403 = 502
@@ -131,16 +130,16 @@ class ClrAtMidnight:
return return
prfx += f'{keys[0]}' prfx += f'{keys[0]}'
db_dict = cls.db dict = cls.db
if prfx not in db_dict: if prfx not in dict:
db_dict[prfx] = {} dict[prfx] = {}
db_dict = db_dict[prfx] dict = dict[prfx]
for key in keys[1:-1]: for key in keys[1:-1]:
if key not in db_dict: if key not in dict:
db_dict[key] = {} dict[key] = {}
db_dict = db_dict[key] dict = dict[key]
db_dict[keys[-1]] = 0 dict[keys[-1]] = 0
@classmethod @classmethod
def elm(cls) -> Generator[tuple[str, dict], None, None]: def elm(cls) -> Generator[tuple[str, dict], None, None]:
@@ -150,21 +149,10 @@ class ClrAtMidnight:
class Infos: class Infos:
LIGHTNING = 'mdi:lightning-bolt'
COUNTER = 'mdi:counter'
GAUGE = 'mdi:gauge'
SOLAR_POWER_VAR = 'mdi:solar-power-variant'
SOLAR_POWER = 'mdi:solar-power'
WIFI = 'mdi:wifi'
UPDATE = 'mdi:update'
DAILY_GEN = 'Daily Generation'
TOTAL_GEN = 'Total Generation'
FMT_INT = '| int'
FMT_FLOAT = '| float'
FMT_STRING_SEC = '| string + " s"'
stat = {} stat = {}
app_name = os.getenv('SERVICE_NAME', 'proxy') app_name = os.getenv('SERVICE_NAME', 'proxy')
version = os.getenv('VERSION', 'unknown') version = os.getenv('VERSION', 'unknown')
new_stat_data = {} new_stat_data = {}
@classmethod @classmethod
@@ -230,9 +218,9 @@ class Infos:
Register.SERIAL_NUMBER: {'name': ['inverter', 'Serial_Number'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.SERIAL_NUMBER: {'name': ['inverter', 'Serial_Number'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.EQUIPMENT_MODEL: {'name': ['inverter', 'Equipment_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EQUIPMENT_MODEL: {'name': ['inverter', 'Equipment_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.NO_INPUTS: {'name': ['inverter', 'No_Inputs'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.NO_INPUTS: {'name': ['inverter', 'No_Inputs'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'val_tpl': __designed_power_val_tpl, 'name': 'Max Designed Power', 'icon': LIGHTNING, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.MAX_DESIGNED_POWER: {'name': ['inverter', 'Max_Designed_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'designed_power_', 'val_tpl': __designed_power_val_tpl, 'name': 'Max Designed Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.RATED_POWER: {'name': ['inverter', 'Rated_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'rated_power_', 'val_tpl': __rated_power_val_tpl, 'name': 'Rated Power', 'icon': LIGHTNING, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.RATED_POWER: {'name': ['inverter', 'Rated_Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'rated_power_', 'val_tpl': __rated_power_val_tpl, 'name': 'Rated Power', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.OUTPUT_COEFFICIENT: {'name': ['inverter', 'Output_Coefficient'], 'level': logging.INFO, 'unit': '%', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'output_coef_', 'val_tpl': __output_coef_val_tpl, 'name': 'Output Coefficient', 'icon': LIGHTNING, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.OUTPUT_COEFFICIENT: {'name': ['inverter', 'Output_Coefficient'], 'level': logging.INFO, 'unit': '%', 'ha': {'dev': 'inverter', 'dev_cla': None, 'stat_cla': None, 'id': 'output_coef_', 'val_tpl': __output_coef_val_tpl, 'name': 'Output Coefficient', 'icon': 'mdi:lightning-bolt', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV1_MANUFACTURER: {'name': ['inverter', 'PV1_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PV1_MANUFACTURER: {'name': ['inverter', 'PV1_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.PV1_MODEL: {'name': ['inverter', 'PV1_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PV1_MODEL: {'name': ['inverter', 'PV1_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
Register.PV2_MANUFACTURER: {'name': ['inverter', 'PV2_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PV2_MANUFACTURER: {'name': ['inverter', 'PV2_Manufacturer'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
@@ -247,19 +235,19 @@ class Infos:
Register.PV6_MODEL: {'name': ['inverter', 'PV6_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.PV6_MODEL: {'name': ['inverter', 'PV6_Model'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
# proxy: # proxy:
Register.INVERTER_CNT: {'name': ['proxy', 'Inverter_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_count_', 'fmt': FMT_INT, 'name': 'Active Inverter Connections', 'icon': COUNTER}}, # noqa: E501 Register.INVERTER_CNT: {'name': ['proxy', 'Inverter_Cnt'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_count_', 'fmt': '| int', 'name': 'Active Inverter Connections', 'icon': 'mdi:counter'}}, # noqa: E501
Register.UNKNOWN_SNR: {'name': ['proxy', 'Unknown_SNR'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_snr_', 'fmt': FMT_INT, 'name': 'Unknown Serial No', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.UNKNOWN_SNR: {'name': ['proxy', 'Unknown_SNR'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_snr_', 'fmt': '| int', 'name': 'Unknown Serial No', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': FMT_INT, 'name': 'Unknown Msg Type', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.UNKNOWN_MSG: {'name': ['proxy', 'Unknown_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_msg_', 'fmt': '| int', 'name': 'Unknown Msg Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.INVALID_DATA_TYPE: {'name': ['proxy', 'Invalid_Data_Type'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_data_type_', 'fmt': FMT_INT, 'name': 'Invalid Data Type', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.INVALID_DATA_TYPE: {'name': ['proxy', 'Invalid_Data_Type'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_data_type_', 'fmt': '| int', 'name': 'Invalid Data Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.INTERNAL_ERROR: {'name': ['proxy', 'Internal_Error'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'intern_err_', 'fmt': FMT_INT, 'name': 'Internal Error', 'icon': COUNTER, 'ent_cat': 'diagnostic', 'en': False}}, # noqa: E501 Register.INTERNAL_ERROR: {'name': ['proxy', 'Internal_Error'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'intern_err_', 'fmt': '| int', 'name': 'Internal Error', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic', 'en': False}}, # noqa: E501
Register.UNKNOWN_CTRL: {'name': ['proxy', 'Unknown_Ctrl'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_ctrl_', 'fmt': FMT_INT, 'name': 'Unknown Control Type', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.UNKNOWN_CTRL: {'name': ['proxy', 'Unknown_Ctrl'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'unknown_ctrl_', 'fmt': '| int', 'name': 'Unknown Control Type', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.OTA_START_MSG: {'name': ['proxy', 'OTA_Start_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'ota_start_cmd_', 'fmt': FMT_INT, 'name': 'OTA Start Cmd', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.OTA_START_MSG: {'name': ['proxy', 'OTA_Start_Msg'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'ota_start_cmd_', 'fmt': '| int', 'name': 'OTA Start Cmd', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.SW_EXCEPTION: {'name': ['proxy', 'SW_Exception'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'sw_exception_', 'fmt': FMT_INT, 'name': 'Internal SW Exception', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.SW_EXCEPTION: {'name': ['proxy', 'SW_Exception'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'sw_exception_', 'fmt': '| int', 'name': 'Internal SW Exception', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.INVALID_MSG_FMT: {'name': ['proxy', 'Invalid_Msg_Format'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_msg_fmt_', 'fmt': FMT_INT, 'name': 'Invalid Message Format', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.INVALID_MSG_FMT: {'name': ['proxy', 'Invalid_Msg_Format'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_msg_fmt_', 'fmt': '| int', 'name': 'Invalid Message Format', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.AT_COMMAND: {'name': ['proxy', 'AT_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_', 'fmt': FMT_INT, 'name': 'AT Command', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.AT_COMMAND: {'name': ['proxy', 'AT_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_', 'fmt': '| int', 'name': 'AT Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.AT_COMMAND_BLOCKED: {'name': ['proxy', 'AT_Command_Blocked'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_blocked_', 'fmt': FMT_INT, 'name': 'AT Command Blocked', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.AT_COMMAND_BLOCKED: {'name': ['proxy', 'AT_Command_Blocked'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'at_cmd_blocked_', 'fmt': '| int', 'name': 'AT Command Blocked', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.MODBUS_COMMAND: {'name': ['proxy', 'Modbus_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'modbus_cmd_', 'fmt': FMT_INT, 'name': 'Modbus Command', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.MODBUS_COMMAND: {'name': ['proxy', 'Modbus_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'modbus_cmd_', 'fmt': '| int', 'name': 'Modbus Command', 'icon': 'mdi:counter', 'ent_cat': 'diagnostic'}}, # noqa: E501
# 0xffffff03: {'name':['proxy', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha':{'dev':'proxy', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id':'proxy_volt_', 'fmt':FMT_FLOAT,'name': 'Grid Voltage'}}, # noqa: E501 # 0xffffff03: {'name':['proxy', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha':{'dev':'proxy', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id':'proxy_volt_', 'fmt':'| float','name': 'Grid Voltage'}}, # noqa: E501
# events # events
Register.EVENT_401: {'name': ['events', '401_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501 Register.EVENT_401: {'name': ['events', '401_'], 'level': logging.DEBUG, 'unit': ''}, # noqa: E501
@@ -281,61 +269,60 @@ class Infos:
# grid measures: # grid measures:
Register.TS_GRID: {'name': ['grid', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 Register.TS_GRID: {'name': ['grid', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
Register.GRID_VOLTAGE: {'name': ['grid', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'inverter', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'out_volt_', 'fmt': FMT_FLOAT, 'name': 'Grid Voltage', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.GRID_VOLTAGE: {'name': ['grid', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'inverter', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'out_volt_', 'fmt': '| float', 'name': 'Grid Voltage', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.GRID_CURRENT: {'name': ['grid', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'inverter', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'out_cur_', 'fmt': FMT_FLOAT, 'name': 'Grid Current', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.GRID_CURRENT: {'name': ['grid', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'inverter', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'out_cur_', 'fmt': '| float', 'name': 'Grid Current', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.GRID_FREQUENCY: {'name': ['grid', 'Frequency'], 'level': logging.DEBUG, 'unit': 'Hz', 'ha': {'dev': 'inverter', 'dev_cla': 'frequency', 'stat_cla': 'measurement', 'id': 'out_freq_', 'fmt': FMT_FLOAT, 'name': 'Grid Frequency', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.GRID_FREQUENCY: {'name': ['grid', 'Frequency'], 'level': logging.DEBUG, 'unit': 'Hz', 'ha': {'dev': 'inverter', 'dev_cla': 'frequency', 'stat_cla': 'measurement', 'id': 'out_freq_', 'fmt': '| float', 'name': 'Grid Frequency', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.OUTPUT_POWER: {'name': ['grid', 'Output_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'out_power_', 'fmt': FMT_FLOAT, 'name': 'Power'}}, # noqa: E501 Register.OUTPUT_POWER: {'name': ['grid', 'Output_Power'], 'level': logging.INFO, 'unit': 'W', 'ha': {'dev': 'inverter', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'out_power_', 'fmt': '| float', 'name': 'Power'}}, # noqa: E501
Register.INVERTER_TEMP: {'name': ['env', 'Inverter_Temp'], 'level': logging.DEBUG, 'unit': '°C', 'ha': {'dev': 'inverter', 'dev_cla': 'temperature', 'stat_cla': 'measurement', 'id': 'temp_', 'fmt': FMT_INT, 'name': 'Temperature'}}, # noqa: E501 Register.INVERTER_TEMP: {'name': ['env', 'Inverter_Temp'], 'level': logging.DEBUG, 'unit': '°C', 'ha': {'dev': 'inverter', 'dev_cla': 'temperature', 'stat_cla': 'measurement', 'id': 'temp_', 'fmt': '| int', 'name': 'Temperature'}}, # noqa: E501
Register.INVERTER_STATUS: {'name': ['env', 'Inverter_Status'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_status_', 'name': 'Inverter Status', 'val_tpl': __status_type_val_tpl, 'icon': 'mdi:power'}}, # noqa: E501 Register.INVERTER_STATUS: {'name': ['env', 'Inverter_Status'], 'level': logging.INFO, 'unit': '', 'ha': {'dev': 'inverter', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'inv_status_', 'name': 'Inverter Status', 'val_tpl': __status_type_val_tpl, 'icon': 'mdi:power'}}, # noqa: E501
# input measures: # input measures:
Register.TS_INPUT: {'name': ['input', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 Register.TS_INPUT: {'name': ['input', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
Register.PV1_VOLTAGE: {'name': ['input', 'pv1', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv1', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv1_', 'val_tpl': "{{ (value_json['pv1']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV1_VOLTAGE: {'name': ['input', 'pv1', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv1', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv1_', 'val_tpl': "{{ (value_json['pv1']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV1_CURRENT: {'name': ['input', 'pv1', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv1', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv1_', 'val_tpl': "{{ (value_json['pv1']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV1_CURRENT: {'name': ['input', 'pv1', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv1', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv1_', 'val_tpl': "{{ (value_json['pv1']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501 Register.PV1_POWER: {'name': ['input', 'pv1', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv1', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv1_', 'val_tpl': "{{ (value_json['pv1']['Power'] | float)}}"}}, # noqa: E501
Register.PV2_VOLTAGE: {'name': ['input', 'pv2', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv2', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv2_', 'val_tpl': "{{ (value_json['pv2']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV2_VOLTAGE: {'name': ['input', 'pv2', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv2', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv2_', 'val_tpl': "{{ (value_json['pv2']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV2_CURRENT: {'name': ['input', 'pv2', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv2', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv2_', 'val_tpl': "{{ (value_json['pv2']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV2_CURRENT: {'name': ['input', 'pv2', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv2', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv2_', 'val_tpl': "{{ (value_json['pv2']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501 Register.PV2_POWER: {'name': ['input', 'pv2', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv2', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv2_', 'val_tpl': "{{ (value_json['pv2']['Power'] | float)}}"}}, # noqa: E501
Register.PV3_VOLTAGE: {'name': ['input', 'pv3', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv3', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv3_', 'val_tpl': "{{ (value_json['pv3']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV3_VOLTAGE: {'name': ['input', 'pv3', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv3', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv3_', 'val_tpl': "{{ (value_json['pv3']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV3_CURRENT: {'name': ['input', 'pv3', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv3', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv3_', 'val_tpl': "{{ (value_json['pv3']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV3_CURRENT: {'name': ['input', 'pv3', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv3', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv3_', 'val_tpl': "{{ (value_json['pv3']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501 Register.PV3_POWER: {'name': ['input', 'pv3', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv3', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv3_', 'val_tpl': "{{ (value_json['pv3']['Power'] | float)}}"}}, # noqa: E501
Register.PV4_VOLTAGE: {'name': ['input', 'pv4', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv4', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv4_', 'val_tpl': "{{ (value_json['pv4']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV4_VOLTAGE: {'name': ['input', 'pv4', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv4', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv4_', 'val_tpl': "{{ (value_json['pv4']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV4_CURRENT: {'name': ['input', 'pv4', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv4', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv4_', 'val_tpl': "{{ (value_json['pv4']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV4_CURRENT: {'name': ['input', 'pv4', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv4', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv4_', 'val_tpl': "{{ (value_json['pv4']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501 Register.PV4_POWER: {'name': ['input', 'pv4', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv4', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv4_', 'val_tpl': "{{ (value_json['pv4']['Power'] | float)}}"}}, # noqa: E501
Register.PV5_VOLTAGE: {'name': ['input', 'pv5', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv5', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv5_', 'val_tpl': "{{ (value_json['pv5']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV5_VOLTAGE: {'name': ['input', 'pv5', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv5', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv5_', 'val_tpl': "{{ (value_json['pv5']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV5_CURRENT: {'name': ['input', 'pv5', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv5', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv5_', 'val_tpl': "{{ (value_json['pv5']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV5_CURRENT: {'name': ['input', 'pv5', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv5', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv5_', 'val_tpl': "{{ (value_json['pv5']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501 Register.PV5_POWER: {'name': ['input', 'pv5', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv5', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv5_', 'val_tpl': "{{ (value_json['pv5']['Power'] | float)}}"}}, # noqa: E501
Register.PV6_VOLTAGE: {'name': ['input', 'pv6', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv6', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv6_', 'val_tpl': "{{ (value_json['pv6']['Voltage'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV6_VOLTAGE: {'name': ['input', 'pv6', 'Voltage'], 'level': logging.DEBUG, 'unit': 'V', 'ha': {'dev': 'input_pv6', 'dev_cla': 'voltage', 'stat_cla': 'measurement', 'id': 'volt_pv6_', 'val_tpl': "{{ (value_json['pv6']['Voltage'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV6_CURRENT: {'name': ['input', 'pv6', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv6', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv6_', 'val_tpl': "{{ (value_json['pv6']['Current'] | float)}}", 'icon': GAUGE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.PV6_CURRENT: {'name': ['input', 'pv6', 'Current'], 'level': logging.DEBUG, 'unit': 'A', 'ha': {'dev': 'input_pv6', 'dev_cla': 'current', 'stat_cla': 'measurement', 'id': 'cur_pv6_', 'val_tpl': "{{ (value_json['pv6']['Current'] | float)}}", 'icon': 'mdi:gauge', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501 Register.PV6_POWER: {'name': ['input', 'pv6', 'Power'], 'level': logging.DEBUG, 'unit': 'W', 'ha': {'dev': 'input_pv6', 'dev_cla': 'power', 'stat_cla': 'measurement', 'id': 'power_pv6_', 'val_tpl': "{{ (value_json['pv6']['Power'] | float)}}"}}, # noqa: E501
Register.PV1_DAILY_GENERATION: {'name': ['input', 'pv1', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv1_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv1']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 Register.PV1_DAILY_GENERATION: {'name': ['input', 'pv1', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv1_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv1']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV1_TOTAL_GENERATION: {'name': ['input', 'pv1', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv1_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv1']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 Register.PV1_TOTAL_GENERATION: {'name': ['input', 'pv1', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv1', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv1_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv1']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
Register.PV2_DAILY_GENERATION: {'name': ['input', 'pv2', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv2_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv2']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 Register.PV2_DAILY_GENERATION: {'name': ['input', 'pv2', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv2_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv2']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV2_TOTAL_GENERATION: {'name': ['input', 'pv2', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv2_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv2']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 Register.PV2_TOTAL_GENERATION: {'name': ['input', 'pv2', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv2', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv2_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv2']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
Register.PV3_DAILY_GENERATION: {'name': ['input', 'pv3', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv3', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv3_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv3']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 Register.PV3_DAILY_GENERATION: {'name': ['input', 'pv3', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv3', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv3_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv3']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV3_TOTAL_GENERATION: {'name': ['input', 'pv3', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv3', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv3_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv3']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 Register.PV3_TOTAL_GENERATION: {'name': ['input', 'pv3', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv3', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv3_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv3']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
Register.PV4_DAILY_GENERATION: {'name': ['input', 'pv4', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv4', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv4_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv4']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 Register.PV4_DAILY_GENERATION: {'name': ['input', 'pv4', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv4', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv4_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv4']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV4_TOTAL_GENERATION: {'name': ['input', 'pv4', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv4', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv4_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv4']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 Register.PV4_TOTAL_GENERATION: {'name': ['input', 'pv4', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv4', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv4_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv4']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
Register.PV5_DAILY_GENERATION: {'name': ['input', 'pv5', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv5', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv5_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv5']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 Register.PV5_DAILY_GENERATION: {'name': ['input', 'pv5', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv5', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv5_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv5']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV5_TOTAL_GENERATION: {'name': ['input', 'pv5', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv5', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv5_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv5']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 Register.PV5_TOTAL_GENERATION: {'name': ['input', 'pv5', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv5', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv5_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv5']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
Register.PV6_DAILY_GENERATION: {'name': ['input', 'pv6', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv6_', 'name': DAILY_GEN, 'val_tpl': "{{ (value_json['pv6']['Daily_Generation'] | float)}}", 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 Register.PV6_DAILY_GENERATION: {'name': ['input', 'pv6', 'Daily_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_pv6_', 'name': 'Daily Generation', 'val_tpl': "{{ (value_json['pv6']['Daily_Generation'] | float)}}", 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.PV6_TOTAL_GENERATION: {'name': ['input', 'pv6', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv6_', 'name': TOTAL_GEN, 'val_tpl': "{{ (value_json['pv6']['Total_Generation'] | float)}}", 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 Register.PV6_TOTAL_GENERATION: {'name': ['input', 'pv6', 'Total_Generation'], 'level': logging.DEBUG, 'unit': 'kWh', 'ha': {'dev': 'input_pv6', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_pv6_', 'name': 'Total Generation', 'val_tpl': "{{ (value_json['pv6']['Total_Generation'] | float)}}", 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
# total: # total:
Register.TS_TOTAL: {'name': ['total', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501 Register.TS_TOTAL: {'name': ['total', 'Timestamp'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
Register.DAILY_GENERATION: {'name': ['total', 'Daily_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_', 'fmt': FMT_FLOAT, 'name': DAILY_GEN, 'icon': SOLAR_POWER_VAR, 'must_incr': True}}, # noqa: E501 Register.DAILY_GENERATION: {'name': ['total', 'Daily_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total_increasing', 'id': 'daily_gen_', 'fmt': '| float', 'name': 'Daily Generation', 'icon': 'mdi:solar-power-variant', 'must_incr': True}}, # noqa: E501
Register.TOTAL_GENERATION: {'name': ['total', 'Total_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_', 'fmt': FMT_FLOAT, 'name': TOTAL_GEN, 'icon': SOLAR_POWER, 'must_incr': True}}, # noqa: E501 Register.TOTAL_GENERATION: {'name': ['total', 'Total_Generation'], 'level': logging.INFO, 'unit': 'kWh', 'ha': {'dev': 'inverter', 'dev_cla': 'energy', 'stat_cla': 'total', 'id': 'total_gen_', 'fmt': '| float', 'name': 'Total Generation', 'icon': 'mdi:solar-power', 'must_incr': True}}, # noqa: E501
# controller: # controller:
Register.SIGNAL_STRENGTH: {'name': ['controller', 'Signal_Strength'], 'level': logging.DEBUG, 'unit': '%', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': 'measurement', 'id': 'signal_', 'fmt': FMT_INT, 'name': 'Signal Strength', 'icon': WIFI}}, # noqa: E501 Register.SIGNAL_STRENGTH: {'name': ['controller', 'Signal_Strength'], 'level': logging.DEBUG, 'unit': '%', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': 'measurement', 'id': 'signal_', 'fmt': '| int', 'name': 'Signal Strength', 'icon': 'mdi:wifi'}}, # noqa: E501
Register.POWER_ON_TIME: {'name': ['controller', 'Power_On_Time'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': 'duration', 'stat_cla': 'measurement', 'id': 'power_on_time_', 'fmt': FMT_INT, 'name': 'Power on Time', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.POWER_ON_TIME: {'name': ['controller', 'Power_On_Time'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': 'duration', 'stat_cla': 'measurement', 'id': 'power_on_time_', 'fmt': '| int', 'name': 'Power on Time', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.COLLECT_INTERVAL: {'name': ['controller', 'Collect_Interval'], 'level': logging.DEBUG, 'unit': 'min', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_collect_intval_', 'fmt': '| string + " min"', 'name': 'Data Collect Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.COLLECT_INTERVAL: {'name': ['controller', 'Collect_Interval'], 'level': logging.DEBUG, 'unit': 'min', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_collect_intval_', 'fmt': '| string + " min"', 'name': 'Data Collect Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.CONNECT_COUNT: {'name': ['controller', 'Connect_Count'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'connect_count_', 'fmt': FMT_INT, 'name': 'Connect Count', 'icon': COUNTER, 'comp': 'sensor', 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.CONNECT_COUNT: {'name': ['controller', 'Connect_Count'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'connect_count_', 'fmt': '| int', 'name': 'Connect Count', 'icon': 'mdi:counter', 'comp': 'sensor', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.COMMUNICATION_TYPE: {'name': ['controller', 'Communication_Type'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'comm_type_', 'name': 'Communication Type', 'val_tpl': __comm_type_val_tpl, 'comp': 'sensor', 'icon': WIFI}}, # noqa: E501 Register.COMMUNICATION_TYPE: {'name': ['controller', 'Communication_Type'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'comm_type_', 'name': 'Communication Type', 'val_tpl': __comm_type_val_tpl, 'comp': 'sensor', 'icon': 'mdi:wifi'}}, # noqa: E501
Register.DATA_UP_INTERVAL: {'name': ['controller', 'Data_Up_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_up_intval_', 'fmt': FMT_STRING_SEC, 'name': 'Data Up Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.DATA_UP_INTERVAL: {'name': ['controller', 'Data_Up_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'data_up_intval_', 'fmt': '| string + " s"', 'name': 'Data Up Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.HEARTBEAT_INTERVAL: {'name': ['controller', 'Heartbeat_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'heartbeat_intval_', 'fmt': FMT_STRING_SEC, 'name': 'Heartbeat Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.HEARTBEAT_INTERVAL: {'name': ['controller', 'Heartbeat_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'heartbeat_intval_', 'fmt': '| string + " s"', 'name': 'Heartbeat Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.IP_ADDRESS: {'name': ['controller', 'IP_Address'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_address_', 'fmt': '| string', 'name': 'IP Address', 'icon': WIFI, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.IP_ADDRESS: {'name': ['controller', 'IP_Address'], 'level': logging.DEBUG, 'unit': '', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'ip_address_', 'fmt': '| string', 'name': 'IP Address', 'icon': 'mdi:wifi', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.POLLING_INTERVAL: {'name': ['controller', 'Polling_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'polling_intval_', 'fmt': FMT_STRING_SEC, 'name': 'Polling Interval', 'icon': UPDATE, 'ent_cat': 'diagnostic'}}, # noqa: E501 Register.POLLING_INTERVAL: {'name': ['controller', 'Polling_Interval'], 'level': logging.DEBUG, 'unit': 's', 'ha': {'dev': 'controller', 'dev_cla': None, 'stat_cla': None, 'id': 'polling_intval_', 'fmt': '| string + " s"', 'name': 'Polling Interval', 'icon': 'mdi:update', 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.SENSOR_LIST: {'name': ['controller', 'Sensor_List'], 'level': logging.INFO, 'unit': ''}, # noqa: E501
} }
@property @property
@@ -346,7 +333,7 @@ class Infos:
def info_defs(self) -> dict: def info_defs(self) -> dict:
return self.__info_defs return self.__info_defs
def dev_value(self, idx: str | int) -> str | int | float | dict | None: def dev_value(self, idx: str | int) -> str | int | float | None:
'''returns the stored device value from our database '''returns the stored device value from our database
idx:int ==> lookup the value in the database and return it as str, idx:int ==> lookup the value in the database and return it as str,
@@ -359,29 +346,29 @@ class Infos:
elif idx in self.info_defs: elif idx in self.info_defs:
row = self.info_defs[idx] row = self.info_defs[idx]
if 'singleton' in row and row['singleton']: if 'singleton' in row and row['singleton']:
db_dict = self.stat dict = self.stat
else: else:
db_dict = self.db dict = self.db
keys = row['name'] keys = row['name']
for key in keys: for key in keys:
if key not in db_dict: if key not in dict:
return None # value not found in the database return None # value not found in the database
db_dict = db_dict[key] dict = dict[key]
return db_dict # value of the reqeusted entry return dict # value of the reqeusted entry
return None # unknwon idx, not in info_defs return None # unknwon idx, not in info_defs
def inc_counter(self, counter: str) -> None: def inc_counter(self, counter: str) -> None:
'''inc proxy statistic counter''' '''inc proxy statistic counter'''
db_dict = self.stat['proxy'] dict = self.stat['proxy']
db_dict[counter] += 1 dict[counter] += 1
def dec_counter(self, counter: str) -> None: def dec_counter(self, counter: str) -> None:
'''dec proxy statistic counter''' '''dec proxy statistic counter'''
db_dict = self.stat['proxy'] dict = self.stat['proxy']
db_dict[counter] -= 1 dict[counter] -= 1
def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \ def ha_proxy_confs(self, ha_prfx: str, node_id: str, snr: str) \
-> Generator[tuple[str, str, str, str], None, None]: -> Generator[tuple[str, str, str, str], None, None]:
@@ -427,119 +414,99 @@ class Infos:
return None return None
elif singleton: elif singleton:
return None return None
prfx = ha_prfx + node_id
# check if we have details for home assistant # check if we have details for home assistant
if 'ha' in row: if 'ha' in row:
return self.__ha_conf(row, key, ha_prfx, node_id, snr, sug_area) ha = row['ha']
return None if 'comp' in ha:
component = ha['comp']
def __ha_conf(self, row, key, ha_prfx, node_id, snr, else:
sug_area: str) -> tuple[str, str, str, str] | None: component = 'sensor'
ha = row['ha'] attr = {}
if 'comp' in ha: if 'name' in ha:
component = ha['comp'] attr['name'] = ha['name']
else: else:
component = 'sensor' attr['name'] = row['name'][-1]
attr = self.__build_attr(row, key, ha_prfx, node_id, snr) attr['stat_t'] = prfx + row['name'][0]
if 'dev' in ha: attr['dev_cla'] = ha['dev_cla']
device = self.info_devs[ha['dev']] attr['stat_cla'] = ha['stat_cla']
if 'dep' in device and self.ignore_this_device(device['dep']): # noqa: E501 attr['uniq_id'] = ha['id']+snr
return None if 'val_tpl' in ha:
attr['dev'] = self.__build_dev(device, key, ha, snr, attr['val_tpl'] = ha['val_tpl']
sug_area) elif 'fmt' in ha:
attr['o'] = self.__build_origin() attr['val_tpl'] = '{{value_json' + f"['{row['name'][-1]}'] {ha['fmt']}" + '}}' # eg. 'val_tpl': "{{ value_json['Output_Power']|float }} # noqa: E501
else:
self.inc_counter('Internal_Error')
logging.error(f"Infos.info_defs: the row for {key} "
"missing 'dev' value for ha register")
return json.dumps(attr), component, node_id, attr['uniq_id']
def __build_attr(self, row, key, ha_prfx, node_id, snr):
attr = {}
ha = row['ha']
if 'name' in ha:
attr['name'] = ha['name']
else:
attr['name'] = row['name'][-1]
prfx = ha_prfx + node_id
attr['stat_t'] = prfx + row['name'][0]
attr['dev_cla'] = ha['dev_cla']
attr['stat_cla'] = ha['stat_cla']
attr['uniq_id'] = ha['id']+snr
if 'val_tpl' in ha:
attr['val_tpl'] = ha['val_tpl']
elif 'fmt' in ha:
attr['val_tpl'] = '{{value_json' + f"['{row['name'][-1]}'] {ha['fmt']}" + '}}' # eg. 'val_tpl': "{{ value_json['Output_Power']|float }} # noqa: E501
else:
self.inc_counter('Internal_Error')
logging.error(f"Infos.info_defs: the row for {key} do"
" not have a 'val_tpl' nor a 'fmt' value")
# add unit_of_meas only, if status_class isn't none. If
# status_cla is None we want a number format and not line
# graph in home assistant. A unit will change the number
# format to a line graph
if 'unit' in row and attr['stat_cla'] is not None:
attr['unit_of_meas'] = row['unit'] # 'unit_of_meas'
if 'icon' in ha:
attr['ic'] = ha['icon'] # icon for the entity
if 'nat_prc' in ha: # pragma: no cover
attr['sug_dsp_prc'] = ha['nat_prc'] # precison of floats
if 'ent_cat' in ha:
attr['ent_cat'] = ha['ent_cat'] # diagnostic, config
# enabled_by_default is deactivated, since it avoid the via
# setup of the devices. It seems, that there is a bug in home
# assistant. tested with 'Home Assistant 2023.10.4'
# if 'en' in ha: # enabled_by_default
# attr['en'] = ha['en']
return attr
def __build_dev(self, device, key, ha, snr, sug_area):
dev = {}
singleton = 'singleton' in device and device['singleton']
# the same name for 'name' and 'suggested area', so we get
# dedicated devices in home assistant with short value
# name and headline
if (sug_area == '' or singleton):
dev['name'] = device['name']
dev['sa'] = device['name']
else:
dev['name'] = device['name']+' - '+sug_area
dev['sa'] = device['name']+' - '+sug_area
self.__add_via_dev(dev, device, key, snr)
for key in ('mdl', 'mf', 'sw', 'hw'): # add optional
# values fpr 'modell', 'manufacturer', 'sw version' and
# 'hw version'
if key in device:
data = self.dev_value(device[key])
if data is not None:
dev[key] = data
if singleton:
dev['ids'] = [f"{ha['dev']}"]
else:
dev['ids'] = [f"{ha['dev']}_{snr}"]
return dev
def __add_via_dev(self, dev, device, key, snr):
if 'via' in device: # add the link to the parent device
via = device['via']
if via in self.info_devs:
via_dev = self.info_devs[via]
if 'singleton' in via_dev and via_dev['singleton']:
dev['via_device'] = via
else:
dev['via_device'] = f"{via}_{snr}"
else: else:
self.inc_counter('Internal_Error') self.inc_counter('Internal_Error')
logging.error(f"Infos.info_defs: the row for " logging.error(f"Infos.info_defs: the row for {key} do"
f"{key} has an invalid via value: " " not have a 'val_tpl' nor a 'fmt' value")
f"{via}") # add unit_of_meas only, if status_class isn't none. If
# status_cla is None we want a number format and not line
def __build_origin(self): # graph in home assistant. A unit will change the number
origin = {} # format to a line graph
origin['name'] = self.app_name if 'unit' in row and attr['stat_cla'] is not None:
origin['sw'] = self.version attr['unit_of_meas'] = row['unit'] # 'unit_of_meas'
return origin if 'icon' in ha:
attr['ic'] = ha['icon'] # icon for the entity
if 'nat_prc' in ha: # pragma: no cover
attr['sug_dsp_prc'] = ha['nat_prc'] # precison of floats
if 'ent_cat' in ha:
attr['ent_cat'] = ha['ent_cat'] # diagnostic, config
# enabled_by_default is deactivated, since it avoid the via
# setup of the devices. It seems, that there is a bug in home
# assistant. tested with 'Home Assistant 2023.10.4'
# if 'en' in ha: # enabled_by_default
# attr['en'] = ha['en']
if 'dev' in ha:
device = self.info_devs[ha['dev']]
if 'dep' in device and self.ignore_this_device(device['dep']): # noqa: E501
return None
dev = {}
# the same name for 'name' and 'suggested area', so we get
# dedicated devices in home assistant with short value
# name and headline
if (sug_area == '' or
('singleton' in device and device['singleton'])):
dev['name'] = device['name']
dev['sa'] = device['name']
else:
dev['name'] = device['name']+' - '+sug_area
dev['sa'] = device['name']+' - '+sug_area
if 'via' in device: # add the link to the parent device
via = device['via']
if via in self.info_devs:
via_dev = self.info_devs[via]
if 'singleton' in via_dev and via_dev['singleton']:
dev['via_device'] = via
else:
dev['via_device'] = f"{via}_{snr}"
else:
self.inc_counter('Internal_Error')
logging.error(f"Infos.info_defs: the row for "
f"{key} has an invalid via value: "
f"{via}")
for key in ('mdl', 'mf', 'sw', 'hw'): # add optional
# values fpr 'modell', 'manufacturer', 'sw version' and
# 'hw version'
if key in device:
data = self.dev_value(device[key])
if data is not None:
dev[key] = data
if 'singleton' in device and device['singleton']:
dev['ids'] = [f"{ha['dev']}"]
else:
dev['ids'] = [f"{ha['dev']}_{snr}"]
attr['dev'] = dev
origin = {}
origin['name'] = self.app_name
origin['sw'] = self.version
attr['o'] = origin
else:
self.inc_counter('Internal_Error')
logging.error(f"Infos.info_defs: the row for {key} "
"missing 'dev' value for ha register")
return json.dumps(attr), component, node_id, attr['uniq_id']
return None
def ha_remove(self, key, node_id, snr) -> tuple[str, str, str, str] | None: def ha_remove(self, key, node_id, snr) -> tuple[str, str, str, str] | None:
'''Method to build json unregister struct for home-assistant '''Method to build json unregister struct for home-assistant
@@ -558,8 +525,9 @@ class Infos:
return None return None
row = self.info_defs[key] row = self.info_defs[key]
if 'singleton' in row and row['singleton']: if 'singleton' in row:
return None if row['singleton']:
return None
# check if we have details for home assistant # check if we have details for home assistant
if 'ha' in row: if 'ha' in row:
@@ -574,7 +542,7 @@ class Infos:
return json.dumps(attr), component, node_id, uniq_id return json.dumps(attr), component, node_id, uniq_id
return None return None
def _key_obj(self, id: Register) -> tuple: def _key_obj(self, id: Register) -> list:
d = self.info_defs.get(id, {'name': None, 'level': logging.DEBUG, d = self.info_defs.get(id, {'name': None, 'level': logging.DEBUG,
'unit': ''}) 'unit': ''})
if 'ha' in d and 'must_incr' in d['ha']: if 'ha' in d and 'must_incr' in d['ha']:
@@ -586,21 +554,21 @@ class Infos:
def update_db(self, keys: list, must_incr: bool, result): def update_db(self, keys: list, must_incr: bool, result):
name = '' name = ''
db_dict = self.db dict = self.db
for key in keys[:-1]: for key in keys[:-1]:
if key not in db_dict: if key not in dict:
db_dict[key] = {} dict[key] = {}
db_dict = db_dict[key] dict = dict[key]
name += key + '.' name += key + '.'
if keys[-1] not in db_dict: if keys[-1] not in dict:
update = (not must_incr or result > 0) update = (not must_incr or result > 0)
else: else:
if must_incr: if must_incr:
update = db_dict[keys[-1]] < result update = dict[keys[-1]] < result
else: else:
update = db_dict[keys[-1]] != result update = dict[keys[-1]] != result
if update: if update:
db_dict[keys[-1]] = result dict[keys[-1]] = result
name += keys[-1] name += keys[-1]
return name, update return name, update
@@ -654,13 +622,13 @@ class Infos:
return True return True
if 'gte' in dep: if 'gte' in dep:
return value < dep['gte'] return not value >= dep['gte']
elif 'less_eq' in dep: elif 'less_eq' in dep:
return value > dep['less_eq'] return not value <= dep['less_eq']
return True return True
def set_pv_module_details(self, inv: dict) -> None: def set_pv_module_details(self, inv: dict) -> None:
pvs = {'pv1': {'manufacturer': Register.PV1_MANUFACTURER, 'model': Register.PV1_MODEL}, # noqa: E501 map = {'pv1': {'manufacturer': Register.PV1_MANUFACTURER, 'model': Register.PV1_MODEL}, # noqa: E501
'pv2': {'manufacturer': Register.PV2_MANUFACTURER, 'model': Register.PV2_MODEL}, # noqa: E501 'pv2': {'manufacturer': Register.PV2_MANUFACTURER, 'model': Register.PV2_MODEL}, # noqa: E501
'pv3': {'manufacturer': Register.PV3_MANUFACTURER, 'model': Register.PV3_MODEL}, # noqa: E501 'pv3': {'manufacturer': Register.PV3_MANUFACTURER, 'model': Register.PV3_MODEL}, # noqa: E501
'pv4': {'manufacturer': Register.PV4_MANUFACTURER, 'model': Register.PV4_MODEL}, # noqa: E501 'pv4': {'manufacturer': Register.PV4_MANUFACTURER, 'model': Register.PV4_MODEL}, # noqa: E501
@@ -668,7 +636,7 @@ class Infos:
'pv6': {'manufacturer': Register.PV6_MANUFACTURER, 'model': Register.PV6_MODEL} # noqa: E501 'pv6': {'manufacturer': Register.PV6_MANUFACTURER, 'model': Register.PV6_MODEL} # noqa: E501
} }
for key, reg in pvs.items(): for key, reg in map.items():
if key in inv: if key in inv:
if 'manufacturer' in inv[key]: if 'manufacturer' in inv[key]:
self.set_db_def_value(reg['manufacturer'], self.set_db_def_value(reg['manufacturer'],

View File

@@ -1,15 +1,11 @@
import asyncio import asyncio
import logging import logging
import json import json
if __name__ == "app.src.inverter": from config import Config
from app.src.config import Config from mqtt import Mqtt
from app.src.mqtt import Mqtt from infos import Infos
from app.src.infos import Infos
else: # pragma: no cover
from config import Config
from mqtt import Mqtt
from infos import Infos
# logger = logging.getLogger('conn')
logger_mqtt = logging.getLogger('mqtt') logger_mqtt = logging.getLogger('mqtt')
@@ -76,7 +72,7 @@ class Inverter():
Infos.new_stat_data[key] = False Infos.new_stat_data[key] = False
@classmethod @classmethod
def class_close(cls, loop) -> None: # pragma: no cover def class_close(cls, loop) -> None:
logging.debug('Inverter.class_close') logging.debug('Inverter.class_close')
logging.info('Close MQTT Task') logging.info('Close MQTT Task')
loop.run_until_complete(cls.mqtt.close()) loop.run_until_complete(cls.mqtt.close())

View File

@@ -14,25 +14,6 @@ else: # pragma: no cover
logger = logging.getLogger('msg') logger = logging.getLogger('msg')
def __hex_val(n, data, data_len):
line = ''
for j in range(n-16, n):
if j >= data_len:
break
line += '%02x ' % abs(data[j])
return line
def __asc_val(n, data, data_len):
line = ''
for j in range(n-16, n):
if j >= data_len:
break
c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.'
line += '%c' % c
return line
def hex_dump_memory(level, info, data, data_len): def hex_dump_memory(level, info, data, data_len):
n = 0 n = 0
lines = [] lines = []
@@ -45,9 +26,20 @@ def hex_dump_memory(level, info, data, data_len):
line = ' ' line = ' '
line += '%04x | ' % (i) line += '%04x | ' % (i)
n += 16 n += 16
line += __hex_val(n, data, data_len)
for j in range(n-16, n):
if j >= data_len:
break
line += '%02x ' % abs(data[j])
line += ' ' * (3 * 16 + 9 - len(line)) + ' | ' line += ' ' * (3 * 16 + 9 - len(line)) + ' | '
line += __asc_val(n, data, data_len)
for j in range(n-16, n):
if j >= data_len:
break
c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.'
line += '%c' % c
lines.append(line) lines.append(line)
tracer.log(level, '\n'.join(lines)) tracer.log(level, '\n'.join(lines))
@@ -111,10 +103,11 @@ class Message(metaclass=IterRegistry):
def _update_header(self, _forward_buffer): def _update_header(self, _forward_buffer):
'''callback for updating the header of the forward buffer''' '''callback for updating the header of the forward buffer'''
pass # pragma: no cover return # pragma: no cover
def _set_mqtt_timestamp(self, key, ts: float | None): def _set_mqtt_timestamp(self, key, ts: float | None):
if key not in self.new_data or \ if type(ts) is not None and \
key not in self.new_data or \
not self.new_data[key]: not self.new_data[key]:
if key == 'grid': if key == 'grid':
info_id = Register.TS_GRID info_id = Register.TS_GRID
@@ -135,7 +128,7 @@ class Message(metaclass=IterRegistry):
if self.mb: if self.mb:
self.mb.close() self.mb.close()
self.mb = None self.mb = None
# pragma: no cover pass # pragma: no cover
def inc_counter(self, counter: str) -> None: def inc_counter(self, counter: str) -> None:
self.db.inc_counter(counter) self.db.inc_counter(counter)

View File

@@ -39,7 +39,7 @@ class Modbus():
'''Modbus function code: Write Single Register''' '''Modbus function code: Write Single Register'''
__crc_tab = [] __crc_tab = []
mb_reg_mapping = { map = {
0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501 0x2007: {'reg': Register.MAX_DESIGNED_POWER, 'fmt': '!H', 'ratio': 1}, # noqa: E501
0x202c: {'reg': Register.OUTPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501 0x202c: {'reg': Register.OUTPUT_COEFFICIENT, 'fmt': '!H', 'ratio': 100/1024}, # noqa: E501
@@ -106,7 +106,6 @@ class Modbus():
self.loop = asyncio.get_event_loop() self.loop = asyncio.get_event_loop()
self.req_pend = False self.req_pend = False
self.tim = None self.tim = None
self.node_id = ''
def close(self): def close(self):
"""free the queue and erase the callback handlers""" """free the queue and erase the callback handlers"""
@@ -139,7 +138,7 @@ class Modbus():
if self.que.qsize() == 1: if self.que.qsize() == 1:
self.__send_next_from_que() self.__send_next_from_que()
def recv_req(self, buf: bytes, def recv_req(self, buf: bytearray,
rsp_handler: Callable[[None], None] = None) -> bool: rsp_handler: Callable[[None], None] = None) -> bool:
"""Add the received Modbus RTU request to the tx queue """Add the received Modbus RTU request to the tx queue
@@ -164,7 +163,7 @@ class Modbus():
return True return True
def recv_resp(self, info_db, buf: bytes, node_id: str) -> \ def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \
Generator[tuple[str, bool, int | float | str], None, None]: Generator[tuple[str, bool, int | float | str], None, None]:
"""Generator which check and parse a received MODBUS response. """Generator which check and parse a received MODBUS response.
@@ -181,20 +180,58 @@ class Modbus():
5: No MODBUS request pending 5: No MODBUS request pending
""" """
# logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}')
self.node_id = node_id if not self.req_pend:
self.err = 5
fcode = buf[1]
data_available = self.last_addr == self.INV_ADDR and \
(fcode == 3 or fcode == 4)
if self.__resp_error_check(buf, data_available):
return return
if not self.__check_crc(buf):
if data_available: logger.error(f'[{node_id}] Modbus resp: CRC error')
self.err = 1
return
if buf[0] != self.last_addr:
logger.info(f'[{node_id}] Modbus resp: Wrong addr {buf[0]}')
self.err = 2
return
fcode = buf[1]
if fcode != self.last_fcode:
logger.info(f'[{node_id}] Modbus: Wrong fcode {fcode}'
f' != {self.last_fcode}')
self.err = 3
return
if self.last_addr == self.INV_ADDR and \
(fcode == 3 or fcode == 4):
elmlen = buf[2] >> 1 elmlen = buf[2] >> 1
if elmlen != self.last_len:
logger.info(f'[{node_id}] Modbus: len error {elmlen}'
f' != {self.last_len}')
self.err = 4
return
first_reg = self.last_reg # save last_reg before sending next pdu first_reg = self.last_reg # save last_reg before sending next pdu
self.__stop_timer() # stop timer and send next pdu self.__stop_timer() # stop timer and send next pdu
yield from self.__process_data(info_db, buf, first_reg, elmlen)
for i in range(0, elmlen):
addr = first_reg+i
if addr in self.map:
row = self.map[addr]
info_id = row['reg']
fmt = row['fmt']
val = struct.unpack_from(fmt, buf, 3+2*i)
result = val[0]
if 'eval' in row:
result = eval(row['eval'])
if 'ratio' in row:
result = round(result * row['ratio'], 2)
keys, level, unit, must_incr = info_db._key_obj(info_id)
if keys:
name, update = info_db.update_db(keys, must_incr,
result)
yield keys[0], update, result
if update:
info_db.tracer.log(level,
f'[{node_id}] MODBUS: {name}'
f' : {result}{unit}')
else: else:
self.__stop_timer() self.__stop_timer()
@@ -203,64 +240,6 @@ class Modbus():
self.rsp_handler() self.rsp_handler()
self.__send_next_from_que() self.__send_next_from_que()
def __resp_error_check(self, buf: bytes, data_available: bool) -> bool:
'''Check the MODBUS response for errors, returns True if one accure'''
if not self.req_pend:
self.err = 5
return True
if not self.__check_crc(buf):
logger.error(f'[{self.node_id}] Modbus resp: CRC error')
self.err = 1
return True
if buf[0] != self.last_addr:
logger.info(f'[{self.node_id}] Modbus resp: Wrong addr {buf[0]}')
self.err = 2
return True
fcode = buf[1]
if fcode != self.last_fcode:
logger.info(f'[{self.node_id}] Modbus: Wrong fcode {fcode}'
f' != {self.last_fcode}')
self.err = 3
return True
if data_available:
elmlen = buf[2] >> 1
if elmlen != self.last_len:
logger.info(f'[{self.node_id}] Modbus: len error {elmlen}'
f' != {self.last_len}')
self.err = 4
return True
return False
def __get_value(self, buf: bytes, idx: int, row: dict):
'''get a value from the received buffer'''
val = struct.unpack_from(row['fmt'], buf, idx)
result = val[0]
if 'eval' in row:
result = eval(row['eval'])
if 'ratio' in row:
result = round(result * row['ratio'], 2)
return result
def __process_data(self, info_db, buf: bytes, first_reg, elmlen):
'''Generator over received registers, updates the db'''
for i in range(0, elmlen):
addr = first_reg+i
if addr in self.mb_reg_mapping:
row = self.mb_reg_mapping[addr]
info_id = row['reg']
keys, level, unit, must_incr = info_db._key_obj(info_id)
if keys:
result = self.__get_value(buf, 3+2*i, row)
name, update = info_db.update_db(keys, must_incr,
result)
yield keys[0], update, result
if update:
info_db.tracer.log(level,
f'[{self.node_id}] MODBUS: {name}'
f' : {result}{unit}')
''' '''
MODBUS response timer MODBUS response timer
''' '''
@@ -288,10 +267,7 @@ class Modbus():
self.__start_timer() self.__start_timer()
self.snd_handler(self.last_req, self.last_log_lvl, state='Retrans') self.snd_handler(self.last_req, self.last_log_lvl, state='Retrans')
else: else:
logger.info(f'[{self.node_id}] Modbus timeout ' logger.info(f'Modbus timeout {self}')
f'(FCode: {self.last_fcode} '
f'Reg: 0x{self.last_reg:04x}, '
f'{self.last_len})')
self.counter['timeouts'] += 1 self.counter['timeouts'] += 1
self.__send_next_from_que() self.__send_next_from_que()
@@ -320,11 +296,11 @@ class Modbus():
''' '''
Helper function for CRC-16 handling Helper function for CRC-16 handling
''' '''
def __check_crc(self, msg: bytes) -> bool: def __check_crc(self, msg: bytearray) -> bool:
'''Check CRC-16 and returns True if valid''' '''Check CRC-16 and returns True if valid'''
return 0 == self.__calc_crc(msg) return 0 == self.__calc_crc(msg)
def __calc_crc(self, buffer: bytes) -> int: def __calc_crc(self, buffer: bytearray) -> int:
'''Build CRC-16 for buffer and returns it''' '''Build CRC-16 for buffer and returns it'''
crc = CRC_INIT crc = CRC_INIT

View File

@@ -1,13 +1,10 @@
import logging import logging
import traceback import traceback
import asyncio import asyncio
from config import Config
if __name__ == "app.src.modbus_tcp": # import gc
from app.src.config import Config from gen3plus.inverter_g3p import InverterG3P
from app.src.gen3plus.inverter_g3p import InverterG3P
else: # pragma: no cover
from config import Config
from gen3plus.inverter_g3p import InverterG3P
logger = logging.getLogger('conn') logger = logging.getLogger('conn')
@@ -38,9 +35,7 @@ class ModbusConn():
class ModbusTcp(): class ModbusTcp():
def __init__(self, loop, tim_restart=10) -> None: def __init__(self, loop) -> None:
self.tim_restart = tim_restart
inverters = Config.get('inverters') inverters = Config.get('inverters')
# logging.info(f'Inverters: {inverters}') # logging.info(f'Inverters: {inverters}')
@@ -71,14 +66,11 @@ class ModbusTcp():
logging.debug(f'Inv-conn:{error}') logging.debug(f'Inv-conn:{error}')
except OSError as error: except OSError as error:
if error.errno == 113: logging.info(f'os-error: {error}')
logging.debug(f'os-error:{error}')
else:
logging.info(f'os-error: {error}')
except Exception: except Exception:
logging.error( logging.error(
f"ModbusTcpCreate: Exception for {(host, port)}:\n" f"ModbusTcpCreate: Exception for {(host,port)}:\n"
f"{traceback.format_exc()}") f"{traceback.format_exc()}")
await asyncio.sleep(self.tim_restart) await asyncio.sleep(10)

View File

@@ -2,40 +2,26 @@ import asyncio
import logging import logging
import aiomqtt import aiomqtt
import traceback import traceback
if __name__ == "app.src.mqtt": from modbus import Modbus
from app.src.modbus import Modbus from messages import Message
from app.src.messages import Message from config import Config
from app.src.config import Config from singleton import Singleton
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') logger_mqtt = logging.getLogger('mqtt')
class Mqtt(metaclass=Singleton): class Mqtt(metaclass=Singleton):
__client = None __client = None
__cb_mqtt_is_up = None __cb_MqttIsUp = None
def __init__(self, cb_mqtt_is_up): def __init__(self, cb_MqttIsUp):
logger_mqtt.debug('MQTT: __init__') logger_mqtt.debug('MQTT: __init__')
if cb_mqtt_is_up: if cb_MqttIsUp:
self.__cb_mqtt_is_up = cb_mqtt_is_up self.__cb_MqttIsUp = cb_MqttIsUp
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
self.task = loop.create_task(self.__loop()) self.task = loop.create_task(self.__loop())
self.ha_restarts = 0 self.ha_restarts = 0
ha = Config.get('ha')
self.ha_status_topic = f"{ha['auto_conf_prefix']}/status"
self.mb_rated_topic = f"{ha['entity_prefix']}/+/rated_load"
self.mb_out_coeff_topic = f"{ha['entity_prefix']}/+/out_coeff"
self.mb_reads_topic = f"{ha['entity_prefix']}/+/modbus_read_regs"
self.mb_inputs_topic = f"{ha['entity_prefix']}/+/modbus_read_inputs"
self.mb_at_cmd_topic = f"{ha['entity_prefix']}/+/at_cmd"
@property @property
def ha_restarts(self): def ha_restarts(self):
return self._ha_restarts return self._ha_restarts
@@ -63,6 +49,7 @@ class Mqtt(metaclass=Singleton):
async def __loop(self) -> None: async def __loop(self) -> None:
mqtt = Config.get('mqtt') mqtt = Config.get('mqtt')
ha = Config.get('ha')
logger_mqtt.info(f'start MQTT: host:{mqtt["host"]} port:' logger_mqtt.info(f'start MQTT: host:{mqtt["host"]} port:'
f'{mqtt["port"]} ' f'{mqtt["port"]} '
f'user:{mqtt["user"]}') f'user:{mqtt["user"]}')
@@ -72,24 +59,66 @@ class Mqtt(metaclass=Singleton):
password=mqtt['passwd']) password=mqtt['passwd'])
interval = 5 # Seconds interval = 5 # Seconds
ha_status_topic = f"{ha['auto_conf_prefix']}/status"
mb_rated_topic = "tsun/+/rated_load" # fixme
mb_out_coeff_topic = "tsun/+/out_coeff" # fixme
mb_reads_topic = "tsun/+/modbus_read_regs" # fixme
mb_inputs_topic = "tsun/+/modbus_read_inputs" # fixme
mb_at_cmd_topic = "tsun/+/at_cmd" # fixme
while True: while True:
try: try:
async with self.__client: async with self.__client:
logger_mqtt.info('MQTT broker connection established') logger_mqtt.info('MQTT broker connection established')
if self.__cb_mqtt_is_up: if self.__cb_MqttIsUp:
await self.__cb_mqtt_is_up() await self.__cb_MqttIsUp()
await self.__client.subscribe(self.ha_status_topic) # async with self.__client.messages() as messages:
await self.__client.subscribe(self.mb_rated_topic) await self.__client.subscribe(ha_status_topic)
await self.__client.subscribe(self.mb_out_coeff_topic) await self.__client.subscribe(mb_rated_topic)
await self.__client.subscribe(self.mb_reads_topic) await self.__client.subscribe(mb_out_coeff_topic)
await self.__client.subscribe(self.mb_inputs_topic) await self.__client.subscribe(mb_reads_topic)
await self.__client.subscribe(self.mb_at_cmd_topic) await self.__client.subscribe(mb_inputs_topic)
await self.__client.subscribe(mb_at_cmd_topic)
async for message in self.__client.messages: async for message in self.__client.messages:
await self.dispatch_msg(message) if message.topic.matches(ha_status_topic):
status = message.payload.decode("UTF-8")
logger_mqtt.info('Home-Assistant Status:'
f' {status}')
if status == 'online':
self.ha_restarts += 1
await self.__cb_MqttIsUp()
if message.topic.matches(mb_rated_topic):
await self.modbus_cmd(message,
Modbus.WRITE_SINGLE_REG,
1, 0x2008)
if message.topic.matches(mb_out_coeff_topic):
payload = message.payload.decode("UTF-8")
val = round(float(payload) * 1024/100)
if val < 0 or val > 1024:
logger_mqtt.error('out_coeff: value must be in'
'the range 0..100,'
f' got: {payload}')
else:
await self.modbus_cmd(message,
Modbus.WRITE_SINGLE_REG,
0, 0x202c, val)
if message.topic.matches(mb_reads_topic):
await self.modbus_cmd(message,
Modbus.READ_REGS, 2)
if message.topic.matches(mb_inputs_topic):
await self.modbus_cmd(message,
Modbus.READ_INPUTS, 2)
if message.topic.matches(mb_at_cmd_topic):
await self.at_cmd(message)
except aiomqtt.MqttError: except aiomqtt.MqttError:
if Config.is_default('mqtt'): if Config.is_default('mqtt'):
@@ -113,76 +142,46 @@ class Mqtt(metaclass=Singleton):
f"Exception:\n" f"Exception:\n"
f"{traceback.format_exc()}") f"{traceback.format_exc()}")
async def dispatch_msg(self, message):
if message.topic.matches(self.ha_status_topic):
status = message.payload.decode("UTF-8")
logger_mqtt.info('Home-Assistant Status:'
f' {status}')
if status == 'online':
self.ha_restarts += 1
await self.__cb_mqtt_is_up()
if message.topic.matches(self.mb_rated_topic):
await self.modbus_cmd(message,
Modbus.WRITE_SINGLE_REG,
1, 0x2008)
if message.topic.matches(self.mb_out_coeff_topic):
payload = message.payload.decode("UTF-8")
try:
val = round(float(payload) * 1024/100)
if val < 0 or val > 1024:
logger_mqtt.error('out_coeff: value must be in'
'the range 0..100,'
f' got: {payload}')
else:
await self.modbus_cmd(message,
Modbus.WRITE_SINGLE_REG,
0, 0x202c, val)
except Exception:
pass
if message.topic.matches(self.mb_reads_topic):
await self.modbus_cmd(message,
Modbus.READ_REGS, 2)
if message.topic.matches(self.mb_inputs_topic):
await self.modbus_cmd(message,
Modbus.READ_INPUTS, 2)
if message.topic.matches(self.mb_at_cmd_topic):
await self.at_cmd(message)
def each_inverter(self, message, func_name: str): def each_inverter(self, message, func_name: str):
topic = str(message.topic) topic = str(message.topic)
node_id = topic.split('/')[1] + '/' node_id = topic.split('/')[1] + '/'
found = False
for m in Message: for m in Message:
if m.server_side and (m.node_id == node_id): if m.server_side and (m.node_id == node_id):
found = True
logger_mqtt.debug(f'Found: {node_id}') logger_mqtt.debug(f'Found: {node_id}')
fnc = getattr(m, func_name, None) fnc = getattr(m, func_name, None)
if callable(fnc): if callable(fnc):
yield fnc yield fnc
else: else:
logger_mqtt.warning(f'Cmd not supported by: {node_id}') logger_mqtt.warning(f'Cmd not supported by: {node_id}')
break
else: if not found:
logger_mqtt.warning(f'Node_id: {node_id} not found') logger_mqtt.warning(f'Node_id: {node_id} not found')
async def modbus_cmd(self, message, func, params=0, addr=0, val=0): async def modbus_cmd(self, message, func, params=0, addr=0, val=0):
topic = str(message.topic)
node_id = topic.split('/')[1] + '/'
# refactor into a loop over a table
payload = message.payload.decode("UTF-8") payload = message.payload.decode("UTF-8")
for fnc in self.each_inverter(message, "send_modbus_cmd"): logger_mqtt.info(f'MODBUS via MQTT: {topic} = {payload}')
res = payload.split(',') for m in Message:
if params > 0 and params != len(res): if m.server_side and (m.node_id == node_id):
logger_mqtt.error(f'Parameter expected: {params}, ' logger_mqtt.debug(f'Found: {node_id}')
f'got: {len(res)}') fnc = getattr(m, "send_modbus_cmd", None)
return res = payload.split(',')
if params == 1: if params > 0 and params != len(res):
val = int(payload) logger_mqtt.error(f'Parameter expected: {params}, '
elif params == 2: f'got: {len(res)}')
addr = int(res[0], base=16) return
val = int(res[1]) # lenght
await fnc(func, addr, val, logging.INFO) if callable(fnc):
if params == 1:
val = int(payload)
elif params == 2:
addr = int(res[0], base=16)
val = int(res[1]) # lenght
await fnc(func, addr, val, logging.INFO)
async def at_cmd(self, message): async def at_cmd(self, message):
payload = message.payload.decode("UTF-8") payload = message.payload.decode("UTF-8")

View File

@@ -1,14 +1,9 @@
from weakref import WeakValueDictionary
class Singleton(type): class Singleton(type):
_instances = WeakValueDictionary() _instances = {}
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
# logger_mqtt.debug('singleton: __call__') # logger_mqtt.debug('singleton: __call__')
if cls not in cls._instances: if cls not in cls._instances:
instance = super(Singleton, cls._instances[cls] = super(Singleton,
cls).__call__(*args, **kwargs) cls).__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls] return cls._instances[cls]

View File

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

View File

@@ -1,6 +1,6 @@
# test_with_pytest.py # test_with_pytest.py
import pytest import pytest
import json, math import json
import logging import logging
from app.src.infos import Register, ClrAtMidnight from app.src.infos import Register, ClrAtMidnight
from app.src.infos import Infos from app.src.infos import Infos
@@ -77,7 +77,7 @@ def test_table_definition():
for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'):
pass # sideeffect is calling generator i.ha_proxy_confs() pass
val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter val = i.dev_value(Register.INTERNAL_ERROR) # check internal error counter
assert val == 0 assert val == 0
@@ -222,24 +222,24 @@ def test_get_value():
i.set_db_def_value(Register.PV2_VOLTAGE, 30.3) i.set_db_def_value(Register.PV2_VOLTAGE, 30.3)
assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
assert math.isclose(30.3,i.get_db_value(Register.PV2_VOLTAGE, None), rel_tol=1e-09, abs_tol=1e-09) assert 30.3 == i.get_db_value(Register.PV2_VOLTAGE, None)
def test_update_value(): def test_update_value():
i = Infos() i = Infos()
assert None == i.get_db_value(Register.PV1_VOLTAGE, None) assert None == i.get_db_value(Register.PV1_VOLTAGE, None)
keys = i.info_defs[Register.PV1_VOLTAGE]['name'] keys = i.info_defs[Register.PV1_VOLTAGE]['name']
_, update = i.update_db(keys, True, 30) name, update = i.update_db(keys, True, 30)
assert update == True assert update == True
assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
keys = i.info_defs[Register.PV1_VOLTAGE]['name'] keys = i.info_defs[Register.PV1_VOLTAGE]['name']
_, update = i.update_db(keys, True, 30) name, update = i.update_db(keys, True, 30)
assert update == False assert update == False
assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 30 == i.get_db_value(Register.PV1_VOLTAGE, None)
keys = i.info_defs[Register.PV1_VOLTAGE]['name'] keys = i.info_defs[Register.PV1_VOLTAGE]['name']
_, update = i.update_db(keys, False, 29) name, update = i.update_db(keys, False, 29)
assert update == True assert update == True
assert 29 == i.get_db_value(Register.PV1_VOLTAGE, None) assert 29 == i.get_db_value(Register.PV1_VOLTAGE, None)

View File

@@ -4,7 +4,7 @@ from app.src.infos import Register, ClrAtMidnight
from app.src.gen3.infos_g3 import InfosG3 from app.src.gen3.infos_g3 import InfosG3
@pytest.fixture @pytest.fixture
def contr_data_seq(): # Get Time Request message def ContrDataSeq(): # Get Time Request message
msg = b'\x00\x00\x00\x15\x00\x09\x2b\xa8\x54\x10\x52\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x30\x36\x00\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f' msg = b'\x00\x00\x00\x15\x00\x09\x2b\xa8\x54\x10\x52\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x30\x36\x00\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f'
msg += b'\x6e\x00\x09\x2f\x90\x54\x0b\x52\x53\x57\x2d\x31\x2d\x31\x30\x30\x30\x31\x00\x09\x5a\x88\x54\x0f\x74\x2e\x72\x61\x79\x6d\x6f\x6e\x69\x6f\x74\x2e\x63\x6f\x6d\x00\x09\x5a\xec\x54' msg += b'\x6e\x00\x09\x2f\x90\x54\x0b\x52\x53\x57\x2d\x31\x2d\x31\x30\x30\x30\x31\x00\x09\x5a\x88\x54\x0f\x74\x2e\x72\x61\x79\x6d\x6f\x6e\x69\x6f\x74\x2e\x63\x6f\x6d\x00\x09\x5a\xec\x54'
msg += b'\x1c\x6c\x6f\x67\x67\x65\x72\x2e\x74\x61\x6c\x65\x6e\x74\x2d\x6d\x6f\x6e\x69\x74\x6f\x72\x69\x6e\x67\x2e\x63\x6f\x6d\x00\x0d\x00\x20\x49\x00\x00\x00\x01\x00\x0c\x35\x00\x49\x00' msg += b'\x1c\x6c\x6f\x67\x67\x65\x72\x2e\x74\x61\x6c\x65\x6e\x74\x2d\x6d\x6f\x6e\x69\x74\x6f\x72\x69\x6e\x67\x2e\x63\x6f\x6d\x00\x0d\x00\x20\x49\x00\x00\x00\x01\x00\x0c\x35\x00\x49\x00'
@@ -14,7 +14,7 @@ def contr_data_seq(): # Get Time Request message
return msg return msg
@pytest.fixture @pytest.fixture
def contr2_data_seq(): # Get Time Request message def Contr2DataSeq(): # Get Time Request message
msg = b'\x00\x00\x00\x39\x00\x09\x2b\xa8\x54\x10\x52' msg = b'\x00\x00\x00\x39\x00\x09\x2b\xa8\x54\x10\x52'
msg += b'\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x32\x30\x00' msg += b'\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x32\x30\x00'
msg += b'\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f\x6e\x00\x09\x2f\x90\x54' msg += b'\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f\x6e\x00\x09\x2f\x90\x54'
@@ -94,19 +94,19 @@ def contr2_data_seq(): # Get Time Request message
return msg return msg
@pytest.fixture @pytest.fixture
def inv_data_seq(): # Data indication from the controller def InvDataSeq(): # Data indication from the controller
msg = b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x54\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28' msg = b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x54\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28'
msg += b'\x54\x10T170000000000001\x00\x00\x00\x32\x54\x0a\x54\x53\x4f\x4c\x2d\x4d\x53\x36\x30\x30\x00\x00\x00\x3c\x54\x05\x41\x2c\x42\x2c\x43' msg += b'\x54\x10T170000000000001\x00\x00\x00\x32\x54\x0a\x54\x53\x4f\x4c\x2d\x4d\x53\x36\x30\x30\x00\x00\x00\x3c\x54\x05\x41\x2c\x42\x2c\x43'
return msg return msg
@pytest.fixture @pytest.fixture
def invalid_data_seq(): # Data indication from the controller def InvalidDataSeq(): # Data indication from the controller
msg = b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x64\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28' msg = b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x64\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28'
msg += b'\x54\x10T170000000000001\x00\x00\x00\x32\x54\x0a\x54\x53\x4f\x4c\x2d\x4d\x53\x36\x30\x30\x00\x00\x00\x3c\x54\x05\x41\x2c\x42\x2c\x43' msg += b'\x54\x10T170000000000001\x00\x00\x00\x32\x54\x0a\x54\x53\x4f\x4c\x2d\x4d\x53\x36\x30\x30\x00\x00\x00\x3c\x54\x05\x41\x2c\x42\x2c\x43'
return msg return msg
@pytest.fixture @pytest.fixture
def inv_data_seq2(): # Data indication from the controller def InvDataSeq2(): # Data indication from the controller
msg = b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00' msg = b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00'
msg += b'\x00\x00\x01\x92\x53\x00\x00\x00\x00\x01\x93\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x95\x53\x00\x00\x00\x00\x01\x96\x53\x00\x00\x00\x00\x01\x97\x53\x00' msg += b'\x00\x00\x01\x92\x53\x00\x00\x00\x00\x01\x93\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x95\x53\x00\x00\x00\x00\x01\x96\x53\x00\x00\x00\x00\x01\x97\x53\x00'
msg += b'\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\x99\x53\x00\x00\x00\x00\x01\x9a\x53\x00\x00\x00\x00\x01\x9b\x53\x00\x00\x00\x00\x01\x9c\x53\x00\x00\x00\x00\x01\x9d\x53' msg += b'\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\x99\x53\x00\x00\x00\x00\x01\x9a\x53\x00\x00\x00\x00\x01\x9b\x53\x00\x00\x00\x00\x01\x9c\x53\x00\x00\x00\x00\x01\x9d\x53'
@@ -141,7 +141,7 @@ def inv_data_seq2(): # Data indication from the controller
return msg return msg
@pytest.fixture @pytest.fixture
def inv_data_new(): # Data indication from DSP V5.0.17 def InvDataNew(): # Data indication from DSP V5.0.17
msg = b'\x00\x00\x00\xa3\x00\x00\x00\x00\x53\x00\x00' msg = b'\x00\x00\x00\xa3\x00\x00\x00\x00\x53\x00\x00'
msg += b'\x00\x00\x00\x80\x53\x00\x00\x00\x00\x01\x04\x53\x00\x00\x00\x00' msg += b'\x00\x00\x00\x80\x53\x00\x00\x00\x00\x01\x04\x53\x00\x00\x00\x00'
msg += b'\x01\x90\x41\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00' msg += b'\x01\x90\x41\x00\x00\x01\x91\x53\x00\x00\x00\x00\x01\x90\x53\x00'
@@ -217,7 +217,7 @@ def inv_data_new(): # Data indication from DSP V5.0.17
return msg return msg
@pytest.fixture @pytest.fixture
def inv_data_seq2_zero(): # Data indication from the controller def InvDataSeq2_Zero(): # Data indication from the controller
msg = b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00' msg = b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00'
msg += b'\x00\x00\x01\x92\x53\x00\x00\x00\x00\x01\x93\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x95\x53\x00\x00\x00\x00\x01\x96\x53\x00\x00\x00\x00\x01\x97\x53\x00' msg += b'\x00\x00\x01\x92\x53\x00\x00\x00\x00\x01\x93\x53\x00\x00\x00\x00\x01\x94\x53\x00\x00\x00\x00\x01\x95\x53\x00\x00\x00\x00\x01\x96\x53\x00\x00\x00\x00\x01\x97\x53\x00'
msg += b'\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\x99\x53\x00\x00\x00\x00\x01\x9a\x53\x00\x00\x00\x00\x01\x9b\x53\x00\x00\x00\x00\x01\x9c\x53\x00\x00\x00\x00\x01\x9d\x53' msg += b'\x00\x00\x00\x01\x98\x53\x00\x00\x00\x00\x01\x99\x53\x00\x00\x00\x00\x01\x9a\x53\x00\x00\x00\x00\x01\x9b\x53\x00\x00\x00\x00\x01\x9c\x53\x00\x00\x00\x00\x01\x9d\x53'
@@ -252,37 +252,37 @@ def inv_data_seq2_zero(): # Data indication from the controller
return msg return msg
def test_parse_control(contr_data_seq): def test_parse_control(ContrDataSeq):
i = InfosG3() i = InfosG3()
for key, result in i.parse (contr_data_seq): for key, result in i.parse (ContrDataSeq):
pass # side effect in calling i.parse() pass
assert json.dumps(i.db) == json.dumps( assert json.dumps(i.db) == json.dumps(
{"collector": {"Collector_Fw_Version": "RSW_400_V1.00.06", "Chip_Type": "Raymon", "Chip_Model": "RSW-1-10001", "Trace_URL": "t.raymoniot.com", "Logger_URL": "logger.talent-monitoring.com"}, "controller": {"Collect_Interval": 1, "Signal_Strength": 100, "Power_On_Time": 29, "Communication_Type": 1, "Connect_Count": 1, "Data_Up_Interval": 300}}) {"collector": {"Collector_Fw_Version": "RSW_400_V1.00.06", "Chip_Type": "Raymon", "Chip_Model": "RSW-1-10001", "Trace_URL": "t.raymoniot.com", "Logger_URL": "logger.talent-monitoring.com"}, "controller": {"Collect_Interval": 1, "Signal_Strength": 100, "Power_On_Time": 29, "Communication_Type": 1, "Connect_Count": 1, "Data_Up_Interval": 300}})
def test_parse_control2(contr2_data_seq): def test_parse_control2(Contr2DataSeq):
i = InfosG3() i = InfosG3()
for key, result in i.parse (contr2_data_seq): for key, result in i.parse (Contr2DataSeq):
pass # side effect in calling i.parse() pass
assert json.dumps(i.db) == json.dumps( assert json.dumps(i.db) == json.dumps(
{"collector": {"Collector_Fw_Version": "RSW_400_V1.00.20", "Chip_Type": "Raymon", "Chip_Model": "RSW-1-10001", "Trace_URL": "t.raymoniot.com", "Logger_URL": "logger.talent-monitoring.com"}, "controller": {"Collect_Interval": 1, "Signal_Strength": 16, "Power_On_Time": 334, "Communication_Type": 1, "Connect_Count": 1, "Data_Up_Interval": 300}}) {"collector": {"Collector_Fw_Version": "RSW_400_V1.00.20", "Chip_Type": "Raymon", "Chip_Model": "RSW-1-10001", "Trace_URL": "t.raymoniot.com", "Logger_URL": "logger.talent-monitoring.com"}, "controller": {"Collect_Interval": 1, "Signal_Strength": 16, "Power_On_Time": 334, "Communication_Type": 1, "Connect_Count": 1, "Data_Up_Interval": 300}})
def test_parse_inverter(inv_data_seq): def test_parse_inverter(InvDataSeq):
i = InfosG3() i = InfosG3()
for key, result in i.parse (inv_data_seq): for key, result in i.parse (InvDataSeq):
pass # side effect in calling i.parse() pass
assert json.dumps(i.db) == json.dumps( assert json.dumps(i.db) == json.dumps(
{"inverter": {"Product_Name": "Microinv", "Manufacturer": "TSUN", "Version": "V5.0.11", "Serial_Number": "T170000000000001", "Equipment_Model": "TSOL-MS600"}}) {"inverter": {"Product_Name": "Microinv", "Manufacturer": "TSUN", "Version": "V5.0.11", "Serial_Number": "T170000000000001", "Equipment_Model": "TSOL-MS600"}})
def test_parse_cont_and_invert(contr_data_seq, inv_data_seq): def test_parse_cont_and_invert(ContrDataSeq, InvDataSeq):
i = InfosG3() i = InfosG3()
for key, result in i.parse (contr_data_seq): for key, result in i.parse (ContrDataSeq):
pass # side effect in calling i.parse() pass
for key, result in i.parse (inv_data_seq): for key, result in i.parse (InvDataSeq):
pass # side effect in calling i.parse() pass
assert json.dumps(i.db) == json.dumps( assert json.dumps(i.db) == json.dumps(
{ {
@@ -290,7 +290,7 @@ def test_parse_cont_and_invert(contr_data_seq, inv_data_seq):
"inverter": {"Product_Name": "Microinv", "Manufacturer": "TSUN", "Version": "V5.0.11", "Serial_Number": "T170000000000001", "Equipment_Model": "TSOL-MS600"}}) "inverter": {"Product_Name": "Microinv", "Manufacturer": "TSUN", "Version": "V5.0.11", "Serial_Number": "T170000000000001", "Equipment_Model": "TSOL-MS600"}})
def test_build_ha_conf1(contr_data_seq): def test_build_ha_conf1(ContrDataSeq):
i = InfosG3() i = InfosG3()
i.static_init() # initialize counter i.static_init() # initialize counter
@@ -325,11 +325,7 @@ def test_build_ha_conf1(contr_data_seq):
assert tests==4 assert tests==4
def test_build_ha_conf2(contr_data_seq):
i = InfosG3()
i.static_init() # initialize counter
tests = 0
for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'):
if id == 'out_power_123': if id == 'out_power_123':
@@ -348,16 +344,16 @@ def test_build_ha_conf2(contr_data_seq):
assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}}) assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1 tests +=1
assert tests==1 assert tests==5
def test_build_ha_conf3(contr_data_seq, inv_data_seq, inv_data_seq2): def test_build_ha_conf2(ContrDataSeq, InvDataSeq, InvDataSeq2):
i = InfosG3() i = InfosG3()
for key, result in i.parse (contr_data_seq): for key, result in i.parse (ContrDataSeq):
pass # side effect in calling i.parse() pass
for key, result in i.parse (inv_data_seq): for key, result in i.parse (InvDataSeq):
pass # side effect in calling i.parse() pass
for key, result in i.parse (inv_data_seq2): for key, result in i.parse (InvDataSeq2):
pass # side effect in calling i.parse() pass
tests = 0 tests = 0
for d_json, comp, node_id, id in i.ha_confs(ha_prfx="tsun/", node_id="garagendach/", snr='123', sug_area = 'roof'): for d_json, comp, node_id, id in i.ha_confs(ha_prfx="tsun/", node_id="garagendach/", snr='123', sug_area = 'roof'):
@@ -388,10 +384,10 @@ def test_build_ha_conf3(contr_data_seq, inv_data_seq, inv_data_seq2):
tests +=1 tests +=1
assert tests==5 assert tests==5
def test_must_incr_total(inv_data_seq2, inv_data_seq2_zero): def test_must_incr_total(InvDataSeq2, InvDataSeq2_Zero):
i = InfosG3() i = InfosG3()
tests = 0 tests = 0
for key, update in i.parse (inv_data_seq2): for key, update in i.parse (InvDataSeq2):
if key == 'total' or key == 'inverter' or key == 'env': if key == 'total' or key == 'inverter' or key == 'env':
assert update == True assert update == True
tests +=1 tests +=1
@@ -400,8 +396,11 @@ def test_must_incr_total(inv_data_seq2, inv_data_seq2_zero):
assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 64.5, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 45.7, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}}) assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 64.5, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 45.7, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}})
assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 23}) assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 23})
tests = 0 tests = 0
for key, update in i.parse (inv_data_seq2): for key, update in i.parse (InvDataSeq2):
if key == 'total' or key == 'env': if key == 'total':
assert update == False
tests +=1
elif key == 'env':
assert update == False assert update == False
tests +=1 tests +=1
@@ -412,7 +411,7 @@ def test_must_incr_total(inv_data_seq2, inv_data_seq2_zero):
assert json.dumps(i.db['inverter']) == json.dumps({"Rated_Power": 600, "No_Inputs": 2}) assert json.dumps(i.db['inverter']) == json.dumps({"Rated_Power": 600, "No_Inputs": 2})
tests = 0 tests = 0
for key, update in i.parse (inv_data_seq2_zero): for key, update in i.parse (InvDataSeq2_Zero):
if key == 'total': if key == 'total':
assert update == False assert update == False
tests +=1 tests +=1
@@ -425,10 +424,10 @@ def test_must_incr_total(inv_data_seq2, inv_data_seq2_zero):
assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 0.0, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 0.0, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}}) assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 0.0, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 0.0, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}})
assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 0}) assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 0})
def test_must_incr_total2(inv_data_seq2, inv_data_seq2_zero): def test_must_incr_total2(InvDataSeq2, InvDataSeq2_Zero):
i = InfosG3() i = InfosG3()
tests = 0 tests = 0
for key, update in i.parse (inv_data_seq2_zero): for key, update in i.parse (InvDataSeq2_Zero):
if key == 'total': if key == 'total':
assert update == False assert update == False
tests +=1 tests +=1
@@ -442,8 +441,11 @@ def test_must_incr_total2(inv_data_seq2, inv_data_seq2_zero):
assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 0}) assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 0})
tests = 0 tests = 0
for key, update in i.parse (inv_data_seq2_zero): for key, update in i.parse (InvDataSeq2_Zero):
if key == 'total' or key == 'env': if key == 'total':
assert update == False
tests +=1
elif key == 'env':
assert update == False assert update == False
tests +=1 tests +=1
@@ -453,8 +455,11 @@ def test_must_incr_total2(inv_data_seq2, inv_data_seq2_zero):
assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 0}) assert json.dumps(i.db['env']) == json.dumps({"Inverter_Temp": 0})
tests = 0 tests = 0
for key, update in i.parse (inv_data_seq2): for key, update in i.parse (InvDataSeq2):
if key == 'total' or key == 'env': if key == 'total':
assert update == True
tests +=1
elif key == 'env':
assert update == True assert update == True
tests +=1 tests +=1
@@ -462,10 +467,10 @@ def test_must_incr_total2(inv_data_seq2, inv_data_seq2_zero):
assert json.dumps(i.db['total']) == json.dumps({'Daily_Generation': 1.7, 'Total_Generation': 17.36}) assert json.dumps(i.db['total']) == json.dumps({'Daily_Generation': 1.7, 'Total_Generation': 17.36})
assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 64.5, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 45.7, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}}) assert json.dumps(i.db['input']) == json.dumps({"pv1": {"Voltage": 33.6, "Current": 1.91, "Power": 64.5, "Daily_Generation": 1.08, "Total_Generation": 9.74}, "pv2": {"Voltage": 33.5, "Current": 1.36, "Power": 45.7, "Daily_Generation": 0.62, "Total_Generation": 7.62}, "pv3": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}, "pv4": {"Voltage": 0.0, "Current": 0.0, "Power": 0.0}})
def test_new_data_types(inv_data_new): def test_new_data_types(InvDataNew):
i = InfosG3() i = InfosG3()
tests = 0 tests = 0
for key, update in i.parse (inv_data_new): for key, update in i.parse (InvDataNew):
if key == 'events': if key == 'events':
tests +=1 tests +=1
elif key == 'inverter': elif key == 'inverter':
@@ -482,7 +487,7 @@ def test_new_data_types(inv_data_new):
assert json.dumps(i.db['input']) == json.dumps({"pv1": {}}) assert json.dumps(i.db['input']) == json.dumps({"pv1": {}})
assert json.dumps(i.db['events']) == json.dumps({"401_": 0, "404_": 0, "405_": 0, "408_": 0, "409_No_Utility": 0, "406_": 0, "416_": 0}) assert json.dumps(i.db['events']) == json.dumps({"401_": 0, "404_": 0, "405_": 0, "408_": 0, "409_No_Utility": 0, "406_": 0, "416_": 0})
def test_invalid_data_type(invalid_data_seq): def test_invalid_data_type(InvalidDataSeq):
i = InfosG3() i = InfosG3()
i.static_init() # initialize counter i.static_init() # initialize counter
@@ -490,8 +495,8 @@ def test_invalid_data_type(invalid_data_seq):
assert val == 0 assert val == 0
for key, result in i.parse (invalid_data_seq): for key, result in i.parse (InvalidDataSeq):
pass # side effect in calling i.parse() pass
assert json.dumps(i.db) == json.dumps({"inverter": {"Product_Name": "Microinv"}}) assert json.dumps(i.db) == json.dumps({"inverter": {"Product_Name": "Microinv"}})
val = i.dev_value(Register.INVALID_DATA_TYPE) # check invalid data type counter val = i.dev_value(Register.INVALID_DATA_TYPE) # check invalid data type counter

View File

@@ -1,33 +1,18 @@
# test_with_pytest.py # test_with_pytest.py
import pytest, json, math, random import pytest, json
from app.src.infos import Register from app.src.infos import Register
from app.src.gen3plus.infos_g3p import InfosG3P from app.src.gen3plus.infos_g3p import InfosG3P
from app.src.gen3plus.infos_g3p import RegisterMap from app.src.gen3plus.infos_g3p import RegisterMap
@pytest.fixture(scope="session")
def str_test_ip():
ip = ".".join(str(random.randint(1, 254)) for _ in range(4))
print(f'random_ip: {ip}')
return ip
@pytest.fixture(scope="session")
def bytes_test_ip(str_test_ip):
ip = bytes(str.encode(str_test_ip))
l = len(ip)
if l < 16:
ip = ip + bytearray(16-l)
print(f'random_ip: {ip}')
return ip
@pytest.fixture @pytest.fixture
def device_data(bytes_test_ip): # 0x4110 ftype: 0x02 def DeviceData(): # 0x4110 ftype: 0x02
msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xba\xd2\x00\x00' msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xba\xd2\x00\x00'
msg += b'\x19\x00\x00\x00\x00\x00\x00\x00\x05\x3c\x78\x01\x64\x01\x4c\x53' msg += b'\x19\x00\x00\x00\x00\x00\x00\x00\x05\x3c\x78\x01\x64\x01\x4c\x53'
msg += b'\x57\x35\x42\x4c\x45\x5f\x31\x37\x5f\x30\x32\x42\x30\x5f\x31\x2e' msg += b'\x57\x35\x42\x4c\x45\x5f\x31\x37\x5f\x30\x32\x42\x30\x5f\x31\x2e'
msg += b'\x30\x35\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x30\x35\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x40\x2a\x8f\x4f\x51\x54' + bytes_test_ip msg += b'\x00\x00\x00\x00\x00\x00\x40\x2a\x8f\x4f\x51\x54\x31\x39\x32\x2e'
msg += b'\x0f\x00\x01\xb0' msg += b'\x31\x36\x38\x2e\x38\x30\x2e\x34\x39\x00\x00\x00\x0f\x00\x01\xb0'
msg += b'\x02\x0f\x00\xff\x56\x31\x2e\x31\x2e\x30\x30\x2e\x30\x42\x00\x00' msg += b'\x02\x0f\x00\xff\x56\x31\x2e\x31\x2e\x30\x30\x2e\x30\x42\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xfe\x00\x00' msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xfe\x00\x00'
@@ -39,7 +24,7 @@ def device_data(bytes_test_ip): # 0x4110 ftype: 0x02
return msg return msg
@pytest.fixture @pytest.fixture
def inverter_data(): # 0x4210 ftype: 0x01 def InverterData(): # 0x4210 ftype: 0x01
msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\xb0\x02\xbc\xc8' msg = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\xb0\x02\xbc\xc8'
msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x47\xe4\x33\x01\x00\x03\x08\x00\x00' msg += b'\x24\x32\x6c\x1f\x00\x00\xa0\x47\xe4\x33\x01\x00\x03\x08\x00\x00'
msg += b'\x59\x31\x37\x45\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x45' msg += b'\x59\x31\x37\x45\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x45'
@@ -78,26 +63,26 @@ def test_default_db():
"collector": {"Chip_Type": "IGEN TECH"}, "collector": {"Chip_Type": "IGEN TECH"},
}) })
def test_parse_4110(str_test_ip, device_data: bytes): def test_parse_4110(DeviceData: bytes):
i = InfosG3P(client_mode=False) i = InfosG3P(client_mode=False)
i.db.clear() i.db.clear()
for key, update in i.parse (device_data, 0x41, 2): for key, update in i.parse (DeviceData, 0x41, 2):
pass # side effect is calling generator i.parse() pass
assert json.dumps(i.db) == json.dumps({ assert json.dumps(i.db) == json.dumps({
'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": str_test_ip, "Sensor_List": "02b0"}, 'controller': {"Data_Up_Interval": 300, "Collect_Interval": 1, "Heartbeat_Interval": 120, "Signal_Strength": 100, "IP_Address": "192.168.80.49"},
'collector': {"Chip_Model": "LSW5BLE_17_02B0_1.05", "Collector_Fw_Version": "V1.1.00.0B"}, 'collector': {"Chip_Model": "LSW5BLE_17_02B0_1.05", "Collector_Fw_Version": "V1.1.00.0B"},
}) })
def test_parse_4210(inverter_data: bytes): def test_parse_4210(InverterData: bytes):
i = InfosG3P(client_mode=False) i = InfosG3P(client_mode=False)
i.db.clear() i.db.clear()
for key, update in i.parse (inverter_data, 0x42, 1): for key, update in i.parse (InverterData, 0x42, 1):
pass # side effect is calling generator i.parse() pass
assert json.dumps(i.db) == json.dumps({ assert json.dumps(i.db) == json.dumps({
"controller": {"Sensor_List": "02b0", "Power_On_Time": 2051}, "controller": {"Power_On_Time": 2051},
"inverter": {"Serial_Number": "Y17E00000000000E", "Version": "V4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000}, "inverter": {"Serial_Number": "Y17E00000000000E", "Version": "V4.0.10", "Rated_Power": 600, "Max_Designed_Power": 2000},
"env": {"Inverter_Status": 1, "Inverter_Temp": 14}, "env": {"Inverter_Status": 1, "Inverter_Temp": 14},
"grid": {"Voltage": 224.8, "Current": 0.73, "Frequency": 50.05, "Output_Power": 165.8}, "grid": {"Voltage": 224.8, "Current": 0.73, "Frequency": 50.05, "Output_Power": 165.8},
@@ -154,11 +139,7 @@ def test_build_ha_conf1():
assert tests==7 assert tests==7
def test_build_ha_conf2():
i = InfosG3P(client_mode=False)
i.static_init() # initialize counter
tests = 0
for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'):
if id == 'out_power_123': if id == 'out_power_123':
@@ -180,9 +161,9 @@ def test_build_ha_conf2():
assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}}) assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1 tests +=1
assert tests==1 assert tests==8
def test_build_ha_conf3(): def test_build_ha_conf2():
i = InfosG3P(client_mode=True) i = InfosG3P(client_mode=True)
i.static_init() # initialize counter i.static_init() # initialize counter
@@ -228,11 +209,7 @@ def test_build_ha_conf3():
assert tests==7 assert tests==7
def test_build_ha_conf4():
i = InfosG3P(client_mode=True)
i.static_init() # initialize counter
tests = 0
for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'): for d_json, comp, node_id, id in i.ha_proxy_confs(ha_prfx="tsun/", node_id = 'proxy/', snr = '456'):
if id == 'out_power_123': if id == 'out_power_123':
@@ -254,29 +231,30 @@ def test_build_ha_conf4():
assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}}) assert d_json == json.dumps({"name": "Active Inverter Connections", "stat_t": "tsun/proxy/proxy", "dev_cla": None, "stat_cla": None, "uniq_id": "inv_count_456", "val_tpl": "{{value_json['Inverter_Cnt'] | int}}", "ic": "mdi:counter", "dev": {"name": "Proxy", "sa": "Proxy", "mdl": "proxy", "mf": "Stefan Allius", "sw": "unknown", "ids": ["proxy"]}, "o": {"name": "proxy", "sw": "unknown"}})
tests +=1 tests +=1
assert tests==1 assert tests==8
def test_exception_and_eval(inverter_data: bytes): def test_exception_and_eval(InverterData: bytes):
# add eval to convert temperature from °F to °C # add eval to convert temperature from °F to °C
RegisterMap.map[0x420100d8]['eval'] = '(result-32)/1.8' RegisterMap.map[0x420100d8]['eval'] = '(result-32)/1.8'
# map PV1_VOLTAGE to invalid register # map PV1_VOLTAGE to invalid register
RegisterMap.map[0x420100e0]['reg'] = Register.TEST_REG2 RegisterMap.map[0x420100e0]['reg'] = Register.TEST_REG2
# set invalid maping entry for OUTPUT_POWER (string instead of dict type) # set invalid maping entry for OUTPUT_POWER (string instead of dict type)
backup = RegisterMap.map[0x420100de] Backup = RegisterMap.map[0x420100de]
RegisterMap.map[0x420100de] = 'invalid_entry' RegisterMap.map[0x420100de] = 'invalid_entry'
i = InfosG3P(client_mode=False) i = InfosG3P(client_mode=False)
# i.db.clear() # i.db.clear()
for key, update in i.parse (inverter_data, 0x42, 1): for key, update in i.parse (InverterData, 0x42, 1):
pass # side effect is calling generator i.parse() pass
assert math.isclose(12.2222, round (i.get_db_value(Register.INVERTER_TEMP, 0),4), rel_tol=1e-09, abs_tol=1e-09) assert 12.2222 == round (i.get_db_value(Register.INVERTER_TEMP, 0),4)
del RegisterMap.map[0x420100d8]['eval'] # remove eval del RegisterMap.map[0x420100d8]['eval'] # remove eval
RegisterMap.map[0x420100e0]['reg'] = Register.PV1_VOLTAGE # reset mapping RegisterMap.map[0x420100e0]['reg'] = Register.PV1_VOLTAGE # reset mapping
RegisterMap.map[0x420100de] = backup # reset mapping RegisterMap.map[0x420100de] = Backup # reset mapping
for key, update in i.parse (inverter_data, 0x42, 1): for key, update in i.parse (InverterData, 0x42, 1):
pass # side effect is calling generator i.parse() pass
assert 54 == i.get_db_value(Register.INVERTER_TEMP, 0) assert 54 == i.get_db_value(Register.INVERTER_TEMP, 0)

View File

@@ -1,90 +0,0 @@
# test_with_pytest.py
import pytest
import asyncio
import aiomqtt
import logging
from mock import patch, Mock
from app.src.singleton import Singleton
from app.src.inverter import Inverter
from app.src.mqtt import Mqtt
from app.src.gen3plus.solarman_v5 import SolarmanV5
from app.src.config import Config
pytest_plugins = ('pytest_asyncio',)
@pytest.fixture(scope="module", autouse=True)
def module_init():
def new_init(cls, cb_mqtt_is_up):
cb_mqtt_is_up()
Singleton._instances.clear()
with patch.object(Mqtt, '__init__', new_init):
yield
@pytest.fixture(scope="module")
def test_port():
return 1883
@pytest.fixture(scope="module")
def test_hostname():
# if getenv("GITHUB_ACTIONS") == "true":
# return 'mqtt'
# else:
return 'test.mosquitto.org'
@pytest.fixture
def config_conn(test_hostname, test_port):
Config.act_config = {
'mqtt':{
'host': test_hostname,
'port': test_port,
'user': '',
'passwd': ''
},
'ha':{
'auto_conf_prefix': 'homeassistant',
'discovery_prefix': 'homeassistant',
'entity_prefix': 'tsun',
'proxy_node_id': 'test_1',
'proxy_unique_id': ''
},
'inverters': {
'allow_all': True,
"R170000000000001":{
'node_id': 'inv_1'
}
}
}
@pytest.mark.asyncio
async def test_inverter_cb(config_conn):
_ = config_conn
with patch.object(Inverter, '_cb_mqtt_is_up', wraps=Inverter._cb_mqtt_is_up) as spy:
print('call Inverter.class_init')
Inverter.class_init()
assert 'homeassistant/' == Inverter.discovery_prfx
assert 'tsun/' == Inverter.entity_prfx
assert 'test_1/' == Inverter.proxy_node_id
spy.assert_called_once()
@pytest.mark.asyncio
async def test_mqtt_is_up(config_conn):
_ = config_conn
with patch.object(Mqtt, 'publish') as spy:
Inverter.class_init()
await Inverter._cb_mqtt_is_up()
spy.assert_called()
@pytest.mark.asyncio
async def test_mqtt_proxy_statt_invalid(config_conn):
_ = config_conn
with patch.object(Mqtt, 'publish') as spy:
Inverter.class_init()
await Inverter._async_publ_mqtt_proxy_stat('InValId_kEy')
spy.assert_not_called()

View File

@@ -5,6 +5,7 @@ from app.src.modbus import Modbus
from app.src.infos import Infos, Register from app.src.infos import Infos, Register
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ('pytest_asyncio',)
# pytestmark = pytest.mark.asyncio(scope="module")
class ModbusTestHelper(Modbus): class ModbusTestHelper(Modbus):
def __init__(self): def __init__(self):
@@ -75,8 +76,8 @@ def test_recv_resp_crc_err():
mb.req_pend = True mb.req_pend = True
mb.last_addr = 1 mb.last_addr = 1
mb.last_fcode = 3 mb.last_fcode = 3
mb.last_reg = 0x300e mb.last_reg == 0x300e
mb.last_len = 2 mb.last_len == 2
# check matching response, but with CRC error # check matching response, but with CRC error
call = 0 call = 0
for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'): for key, update, val in mb.recv_resp(mb.db, b'\x01\x03\x04\x01\x2c\x00\x46\xbb\xf3', 'test'):
@@ -95,8 +96,8 @@ def test_recv_resp_invalid_addr():
# simulate a transmitted request # simulate a transmitted request
mb.last_addr = 1 mb.last_addr = 1
mb.last_fcode = 3 mb.last_fcode = 3
mb.last_reg = 0x300e mb.last_reg == 0x300e
mb.last_len = 2 mb.last_len == 2
# check not matching response, with wrong server addr # check not matching response, with wrong server addr
call = 0 call = 0
@@ -246,7 +247,7 @@ def test_queue2():
assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'): for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
pass # call generator mb.recv_resp() pass
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
assert mb.send_calls == 3 assert mb.send_calls == 3
@@ -297,7 +298,7 @@ def test_queue3():
assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b' assert mb.pdu == b'\x01\x06\x20\x08\x00\x04\x02\x0b'
for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'): for key, update, val in mb.recv_resp(mb.db, b'\x01\x06\x20\x08\x00\x04\x02\x0b', 'test'):
pass # no code in loop is OK; calling the generator is the purpose pass
assert 0 == mb.err assert 0 == mb.err
assert mb.recv_responses == 2 assert mb.recv_responses == 2
@@ -363,11 +364,13 @@ async def test_timeout():
assert mb.retry_cnt == 0 assert mb.retry_cnt == 0
assert mb.send_calls == 4 assert mb.send_calls == 4
# assert mb.counter == {}
def test_recv_unknown_data(): def test_recv_unknown_data():
'''Receive a response with an unknwon register''' '''Receive a response with an unknwon register'''
mb = ModbusTestHelper() mb = ModbusTestHelper()
assert 0x9000 not in mb.mb_reg_mapping assert 0x9000 not in mb.map
mb.mb_reg_mapping[0x9000] = {'reg': Register.TEST_REG1, 'fmt': '!H', 'ratio': 1} mb.map[0x9000] = {'reg': Register.TEST_REG1, 'fmt': '!H', 'ratio': 1}
mb.build_msg(1,3,0x9000,2) mb.build_msg(1,3,0x9000,2)
@@ -379,7 +382,7 @@ def test_recv_unknown_data():
assert 0 == call assert 0 == call
assert not mb.req_pend assert not mb.req_pend
del mb.mb_reg_mapping[0x9000] del mb.map[0x9000]
def test_close(): def test_close():
'''Check queue handling for build_msg() calls''' '''Check queue handling for build_msg() calls'''

View File

@@ -1,234 +0,0 @@
# test_with_pytest.py
import pytest
import asyncio
from mock import patch
from enum import Enum
from app.src.singleton import Singleton
from app.src.config import Config
from app.src.infos import Infos
from app.src.mqtt import Mqtt
from app.src.messages import Message, State
from app.src.inverter import Inverter
from app.src.modbus_tcp import ModbusConn, ModbusTcp
pytest_plugins = ('pytest_asyncio',)
# initialize the proxy statistics
Infos.static_init()
@pytest.fixture(scope="module", autouse=True)
def module_init():
Singleton._instances.clear()
yield
@pytest.fixture(scope="module")
def test_port():
return 1883
@pytest.fixture(scope="module")
def test_hostname():
# if getenv("GITHUB_ACTIONS") == "true":
# return 'mqtt'
# else:
return 'test.mosquitto.org'
@pytest.fixture
def config_conn(test_hostname, test_port):
Config.act_config = {
'mqtt':{
'host': test_hostname,
'port': test_port,
'user': '',
'passwd': ''
},
'ha':{
'auto_conf_prefix': 'homeassistant',
'discovery_prefix': 'homeassistant',
'entity_prefix': 'tsun',
'proxy_node_id': 'test_1',
'proxy_unique_id': ''
},
'inverters':{
'allow_all': True,
"R170000000000001":{
'node_id': 'inv_1'
},
"Y170000000000001":{
'node_id': 'inv_2',
'monitor_sn': 2000000000,
'modbus_polling': True,
'suggested_area': "",
'sensor_list': 0x2b0,
'client_mode':{
'host': '192.168.0.1',
'port': 8899
}
}
}
}
class TestType(Enum):
RD_TEST_0_BYTES = 1
RD_TEST_TIMEOUT = 2
test = TestType.RD_TEST_0_BYTES
class FakeReader():
def __init__(self):
self.on_recv = asyncio.Event()
async def read(self, max_len: int):
await self.on_recv.wait()
if test == TestType.RD_TEST_0_BYTES:
return b''
elif test == TestType.RD_TEST_TIMEOUT:
raise TimeoutError
def feed_eof(self):
return
class FakeWriter():
def write(self, buf: bytes):
return
def get_extra_info(self, sel: str):
if sel == 'peername':
return 'remote.intern'
elif sel == 'sockname':
return 'sock:1234'
assert False
def is_closing(self):
return False
def close(self):
return
async def wait_closed(self):
return
@pytest.fixture
def patch_open():
async def new_conn(conn):
await asyncio.sleep(0)
return FakeReader(), FakeWriter()
def new_open(host: str, port: int):
global test
if test == TestType.RD_TEST_TIMEOUT:
raise TimeoutError
return new_conn(None)
with patch.object(asyncio, 'open_connection', new_open) as conn:
yield conn
@pytest.fixture
def patch_no_mqtt():
with patch.object(Mqtt, 'publish') as conn:
yield conn
@pytest.mark.asyncio
async def test_modbus_conn(patch_open):
_ = patch_open
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
async with ModbusConn('test.local', 1234) as stream:
assert stream.node_id == 'G3P'
assert stream.addr == ('test.local', 1234)
assert type(stream.reader) is FakeReader
assert type(stream.writer) is FakeWriter
assert Infos.stat['proxy']['Inverter_Cnt'] == 1
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
@pytest.mark.asyncio
async def test_modbus_no_cnf():
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
loop = asyncio.get_event_loop()
ModbusTcp(loop)
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
@pytest.mark.asyncio
async def test_modbus_cnf1(config_conn, patch_open):
_ = config_conn
_ = patch_open
global test
assert asyncio.get_running_loop()
Inverter.class_init()
test = TestType.RD_TEST_TIMEOUT
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
loop = asyncio.get_event_loop()
ModbusTcp(loop)
await asyncio.sleep(0.01)
for m in Message:
if (m.node_id == 'inv_2'):
assert False
await asyncio.sleep(0.01)
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
@pytest.mark.asyncio
async def test_modbus_cnf2(config_conn, patch_no_mqtt, patch_open):
_ = config_conn
_ = patch_open
_ = patch_no_mqtt
global test
assert asyncio.get_running_loop()
Inverter.class_init()
test = TestType.RD_TEST_0_BYTES
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
ModbusTcp(asyncio.get_event_loop())
await asyncio.sleep(0.01)
test = 0
for m in Message:
if (m.node_id == 'inv_2'):
test += 1
assert Infos.stat['proxy']['Inverter_Cnt'] == 1
m.shutdown_started = True
m.reader.on_recv.set()
del m
assert 1 == test
await asyncio.sleep(0.01)
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
# check that the connection is released
for m in Message:
if (m.node_id == 'inv_2'):
assert False
@pytest.mark.asyncio
async def test_modbus_cnf3(config_conn, patch_no_mqtt, patch_open):
_ = config_conn
_ = patch_open
_ = patch_no_mqtt
global test
assert asyncio.get_running_loop()
Inverter.class_init()
test = TestType.RD_TEST_0_BYTES
assert Infos.stat['proxy']['Inverter_Cnt'] == 0
ModbusTcp(asyncio.get_event_loop(), tim_restart= 0)
await asyncio.sleep(0.01)
test = 0
for m in Message:
if (m.node_id == 'inv_2'):
assert Infos.stat['proxy']['Inverter_Cnt'] == 1
test += 1
if test == 1:
m.shutdown_started = False
m.reader.on_recv.set()
await asyncio.sleep(0.1)
assert m.state == State.closed
await asyncio.sleep(0.1)
else:
m.shutdown_started = True
m.reader.on_recv.set()
del m
assert 2 == test
await asyncio.sleep(0.01)
assert Infos.stat['proxy']['Inverter_Cnt'] == 0

View File

@@ -1,254 +0,0 @@
# test_with_pytest.py
import pytest
import asyncio
import aiomqtt
import logging
from mock import patch, Mock
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
pytest_plugins = ('pytest_asyncio',)
@pytest.fixture(scope="module", autouse=True)
def module_init():
Singleton._instances.clear()
yield
@pytest.fixture(scope="module")
def test_port():
return 1883
@pytest.fixture(scope="module")
def test_hostname():
# if getenv("GITHUB_ACTIONS") == "true":
# return 'mqtt'
# else:
return 'test.mosquitto.org'
@pytest.fixture
def config_mqtt_conn(test_hostname, test_port):
Config.act_config = {'mqtt':{'host': test_hostname, 'port': test_port, 'user': '', 'passwd': ''},
'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'}
}
@pytest.fixture
def config_no_conn(test_port):
Config.act_config = {'mqtt':{'host': "", 'port': test_port, 'user': '', 'passwd': ''},
'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'}
}
@pytest.fixture
def spy_at_cmd():
conn = SolarmanV5(server_side=True, client_mode= False)
conn.node_id = 'inv_2/'
with patch.object(conn, 'send_at_cmd', wraps=conn.send_at_cmd) as wrapped_conn:
yield wrapped_conn
conn.close()
@pytest.fixture
def spy_modbus_cmd():
conn = SolarmanV5(server_side=True, client_mode= False)
conn.node_id = 'inv_1/'
with patch.object(conn, 'send_modbus_cmd', wraps=conn.send_modbus_cmd) as wrapped_conn:
yield wrapped_conn
conn.close()
@pytest.fixture
def spy_modbus_cmd_client():
conn = SolarmanV5(server_side=False, client_mode= False)
conn.node_id = 'inv_1/'
with patch.object(conn, 'send_modbus_cmd', wraps=conn.send_modbus_cmd) as wrapped_conn:
yield wrapped_conn
conn.close()
def test_native_client(test_hostname, test_port):
"""Sanity check: Make sure the paho-mqtt client can connect to the test
MQTT server.
"""
import paho.mqtt.client as mqtt
import threading
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)
assert on_connect.wait(5)
finally:
c.loop_stop()
@pytest.mark.asyncio
async def test_mqtt_no_config(config_no_conn):
_ = config_no_conn
assert asyncio.get_running_loop()
on_connect = asyncio.Event()
async def cb():
on_connect.set()
try:
m = Mqtt(cb)
assert m.task
await asyncio.sleep(0)
assert not on_connect.is_set()
try:
await m.publish('homeassistant/status', 'online')
assert False
except Exception:
pass
except TimeoutError:
assert False
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
spy = spy_modbus_cmd
try:
m = Mqtt(None)
msg = aiomqtt.Message(topic= 'tsun/inv_1/rated_load', payload= b'2', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_awaited_once_with(Modbus.WRITE_SINGLE_REG, 0x2008, 2, logging.INFO)
spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_1/out_coeff', payload= b'100', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_awaited_once_with(Modbus.WRITE_SINGLE_REG, 0x202c, 1024, logging.INFO)
spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_1/out_coeff', payload= b'50', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_awaited_once_with(Modbus.WRITE_SINGLE_REG, 0x202c, 512, logging.INFO)
spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_1/modbus_read_regs', payload= b'0x3000, 10', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_awaited_once_with(Modbus.READ_REGS, 0x3000, 10, logging.INFO)
spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_1/modbus_read_inputs', payload= b'0x3000, 10', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_awaited_once_with(Modbus.READ_INPUTS, 0x3000, 10, logging.INFO)
finally:
await m.close()
@pytest.mark.asyncio
async def test_msg_dispatch_err(config_mqtt_conn, spy_modbus_cmd):
_ = config_mqtt_conn
spy = spy_modbus_cmd
try:
m = Mqtt(None)
# test out of range param
msg = aiomqtt.Message(topic= 'tsun/inv_1/out_coeff', payload= b'-1', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called()
# test unknown node_id
spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_2/out_coeff', payload= b'2', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called()
# test invalid fload param
spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_1/out_coeff', payload= b'2, 3', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called()
spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_1/modbus_read_regs', payload= b'0x3000, 10, 7', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called()
finally:
await m.close()
@pytest.mark.asyncio
async def test_msg_ignore_client_conn(config_mqtt_conn, spy_modbus_cmd_client):
'''don't call function if connnection is not in server mode'''
_ = config_mqtt_conn
spy = spy_modbus_cmd_client
try:
m = Mqtt(None)
msg = aiomqtt.Message(topic= 'tsun/inv_1/rated_load', payload= b'2', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called()
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'''
_ = config_mqtt_conn
try:
m = Mqtt(None)
msg = aiomqtt.Message(topic= 'tsun/inv_1/rated_load', payload= b'2', qos= 0, retain = False, mid= 0, properties= None)
for _ in m.each_inverter(msg, 'unkown_fnc'):
assert False
finally:
await m.close()
@pytest.mark.asyncio
async def test_at_cmd_dispatch(config_mqtt_conn, spy_at_cmd):
_ = config_mqtt_conn
spy = spy_at_cmd
try:
m = Mqtt(None)
msg = aiomqtt.Message(topic= 'tsun/inv_2/at_cmd', payload= b'AT+', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_awaited_once_with('AT+')
finally:
await m.close()

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
-r ./app/requirements-test.txt

View File

@@ -1,26 +0,0 @@
sonar.projectKey=s-allius_tsun-gen3-proxy
sonar.organization=s-allius
# This is the name and version displayed in the SonarCloud UI.
sonar.projectName=tsun-gen3-proxy
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
sonar.sources=app/src/
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8
sonar.python.version=3.12
sonar.tests=system_tests/,app/tests/
sonar.exclusions=**/.vscode/**/*
# Name your criteria
sonar.issue.ignore.multicriteria=e1,e2
# python:S905 : Remove or refactor this statement; it has no side effects
sonar.issue.ignore.multicriteria.e1.ruleKey=python:S905
sonar.issue.ignore.multicriteria.e1.resourceKey=app/tests/*.py
sonar.issue.ignore.multicriteria.e2.ruleKey=python:S905
sonar.issue.ignore.multicriteria.e2.resourceKey=systems_tests/*.py

View File

@@ -1,6 +1,8 @@
# test_with_pytest.py and scapy # test_with_pytest.py and scapy
# #
import pytest, socket, time import pytest, socket, time
#from scapy.all import *
#from scapy.layers.inet import IP, TCP, TCP_client
def get_sn() -> bytes: def get_sn() -> bytes:
return b'R170000000000001' return b'R170000000000001'
@@ -13,31 +15,31 @@ def get_invalid_sn():
@pytest.fixture @pytest.fixture
def msg_contact_info(): # Contact Info message def MsgContactInfo(): # Contact Info message
return b'\x00\x00\x00\x2c\x10'+get_sn()+b'\x91\x00\x08solarhub\x0fsolarhub\x40123456' return b'\x00\x00\x00\x2c\x10'+get_sn()+b'\x91\x00\x08solarhub\x0fsolarhub\x40123456'
@pytest.fixture @pytest.fixture
def msg_contact_resp(): # Contact Response message def MsgContactResp(): # Contact Response message
return b'\x00\x00\x00\x14\x10'+get_sn()+b'\x91\x00\x01' return b'\x00\x00\x00\x14\x10'+get_sn()+b'\x91\x00\x01'
@pytest.fixture @pytest.fixture
def msg_contact_info2(): # Contact Info message def MsgContactInfo2(): # Contact Info message
return b'\x00\x00\x00\x2c\x10'+get_invalid_sn()+b'\x91\x00\x08solarhub\x0fsolarhub\x40123456' return b'\x00\x00\x00\x2c\x10'+get_invalid_sn()+b'\x91\x00\x08solarhub\x0fsolarhub\x40123456'
@pytest.fixture @pytest.fixture
def msg_contact_resp2(): # Contact Response message def MsgContactResp2(): # Contact Response message
return b'\x00\x00\x00\x14\x10'+get_invalid_sn()+b'\x91\x00\x01' return b'\x00\x00\x00\x14\x10'+get_invalid_sn()+b'\x91\x00\x01'
@pytest.fixture @pytest.fixture
def msg_timestamp_req(): # Get Time Request message def MsgTimeStampReq(): # Get Time Request message
return b'\x00\x00\x00\x13\x10'+get_sn()+b'\x91\x22' return b'\x00\x00\x00\x13\x10'+get_sn()+b'\x91\x22'
@pytest.fixture @pytest.fixture
def msg_timestamp_resp(): # Get Time Resonse message def MsgTimeStampResp(): # Get Time Resonse message
return b'\x00\x00\x00\x1b\x10'+get_sn()+b'\x99\x22\x00\x00\x01\x89\xc6\x63\x4d\x80' return b'\x00\x00\x00\x1b\x10'+get_sn()+b'\x99\x22\x00\x00\x01\x89\xc6\x63\x4d\x80'
@pytest.fixture @pytest.fixture
def msg_controller_ind(): # Data indication from the controller def MsgContollerInd(): # Data indication from the controller
msg = b'\x00\x00\x01\x2f\x10'+ get_sn() + b'\x91\x71\x0e\x10\x00\x00\x10'+get_sn() msg = b'\x00\x00\x01\x2f\x10'+ get_sn() + b'\x91\x71\x0e\x10\x00\x00\x10'+get_sn()
msg += b'\x01\x00\x00\x01\x89\xc6\x63\x55\x50' msg += b'\x01\x00\x00\x01\x89\xc6\x63\x55\x50'
msg += b'\x00\x00\x00\x15\x00\x09\x2b\xa8\x54\x10\x52\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x30\x36\x00\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f' msg += b'\x00\x00\x00\x15\x00\x09\x2b\xa8\x54\x10\x52\x53\x57\x5f\x34\x30\x30\x5f\x56\x31\x2e\x30\x30\x2e\x30\x36\x00\x09\x27\xc0\x54\x06\x52\x61\x79\x6d\x6f'
@@ -49,7 +51,7 @@ def msg_controller_ind(): # Data indication from the controller
return msg return msg
@pytest.fixture @pytest.fixture
def msg_inv_data(): # Data indication from the controller def MsgInvData(): # Data indication from the controller
msg = b'\x00\x00\x00\x8b\x10'+ get_sn() + b'\x91\x04\x01\x90\x00\x01\x10'+get_inv_no() msg = b'\x00\x00\x00\x8b\x10'+ get_sn() + b'\x91\x04\x01\x90\x00\x01\x10'+get_inv_no()
msg += b'\x01\x00\x00\x01\x89\xc6\x63\x61\x08' msg += b'\x01\x00\x00\x01\x89\xc6\x63\x61\x08'
msg += b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x54\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28' msg += b'\x00\x00\x00\x06\x00\x00\x00\x0a\x54\x08\x4d\x69\x63\x72\x6f\x69\x6e\x76\x00\x00\x00\x14\x54\x04\x54\x53\x55\x4e\x00\x00\x00\x1E\x54\x07\x56\x35\x2e\x30\x2e\x31\x31\x00\x00\x00\x28'
@@ -57,7 +59,7 @@ def msg_inv_data(): # Data indication from the controller
return msg return msg
@pytest.fixture @pytest.fixture
def msg_inverter_ind(): # Data indication from the inverter def MsgInverterInd(): # Data indication from the inverter
msg = b'\x00\x00\x05\x02\x10'+ get_sn() + b'\x91\x04\x01\x90\x00\x01\x10'+get_inv_no() msg = b'\x00\x00\x05\x02\x10'+ get_sn() + b'\x91\x04\x01\x90\x00\x01\x10'+get_inv_no()
msg += b'\x01\x00\x00\x01\x89\xc6\x63\x61\x08' msg += b'\x01\x00\x00\x01\x89\xc6\x63\x61\x08'
msg += b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00' msg += b'\x00\x00\x00\xa3\x00\x00\x00\x64\x53\x00\x01\x00\x00\x00\xc8\x53\x00\x02\x00\x00\x01\x2c\x53\x00\x00\x00\x00\x01\x90\x49\x00\x00\x00\x00\x00\x00\x01\x91\x53\x00\x00'
@@ -94,7 +96,7 @@ def msg_inverter_ind(): # Data indication from the inverter
return msg return msg
@pytest.fixture @pytest.fixture
def msg_ota_update_req(): # Over the air update request from talent cloud def MsgOtaUpdateReq(): # Over the air update request from talent cloud
msg = b'\x00\x00\x01\x16\x10'+ get_sn() + b'\x70\x13\x01\x02\x76\x35' msg = b'\x00\x00\x01\x16\x10'+ get_sn() + b'\x70\x13\x01\x02\x76\x35'
msg += b'\x70\x68\x74\x74\x70' msg += b'\x70\x68\x74\x74\x70'
msg += b'\x3a\x2f\x2f\x77\x77\x77\x2e\x74\x61\x6c\x65\x6e\x74\x2d\x6d\x6f' msg += b'\x3a\x2f\x2f\x77\x77\x77\x2e\x74\x61\x6c\x65\x6e\x74\x2d\x6d\x6f'
@@ -117,8 +119,10 @@ def msg_ota_update_req(): # Over the air update request from talent cloud
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def client_connection(): def ClientConnection():
#host = '172.16.30.7'
host = 'logger.talent-monitoring.com' host = 'logger.talent-monitoring.com'
#host = '127.0.0.1'
port = 5005 port = 5005
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port)) s.connect((host, port))
@@ -127,8 +131,10 @@ def client_connection():
time.sleep(2.5) time.sleep(2.5)
s.close() s.close()
def tempclient_connection(): def tempClientConnection():
#host = '172.16.30.7'
host = 'logger.talent-monitoring.com' host = 'logger.talent-monitoring.com'
#host = '127.0.0.1'
port = 5005 port = 5005
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port)) s.connect((host, port))
@@ -138,99 +144,100 @@ def tempclient_connection():
def test_open_close(): def test_open_close():
try: try:
for _ in tempclient_connection(): for s in tempClientConnection():
pass # test side effect of generator pass
except Exception: except:
assert False assert False
assert True
def test_send_contact_info1(client_connection, msg_contact_info, msg_contact_resp): def test_send_contact_info1(ClientConnection, MsgContactInfo, MsgContactResp):
s = client_connection s = ClientConnection
try: try:
s.sendall(msg_contact_info) s.sendall(MsgContactInfo)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
assert data == msg_contact_resp assert data == MsgContactResp
def test_send_contact_info2(client_connection, msg_contact_info2, msg_contact_info, msg_contact_resp): def test_send_contact_info2(ClientConnection, MsgContactInfo2, MsgContactInfo, MsgContactResp):
s = client_connection s = ClientConnection
try: try:
s.sendall(msg_contact_info2) s.sendall(MsgContactInfo2)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass assert True
else: else:
assert False assert False
try: try:
s.sendall(msg_contact_info) s.sendall(MsgContactInfo)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
assert data == msg_contact_resp assert data == MsgContactResp
def test_send_contact_info3(client_connection, msg_contact_info, msg_contact_resp, msg_timestamp_req): def test_send_contact_info3(ClientConnection, MsgContactInfo, MsgContactResp, MsgTimeStampReq):
s = client_connection s = ClientConnection
try: try:
s.sendall(msg_contact_info) s.sendall(MsgContactInfo)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
assert data == msg_contact_resp assert data == MsgContactResp
try: try:
s.sendall(msg_timestamp_req) s.sendall(MsgTimeStampReq)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
def test_send_contact_resp(client_connection, msg_contact_resp): def test_send_contact_resp(ClientConnection, MsgContactResp):
s = client_connection s = ClientConnection
try: try:
s.sendall(msg_contact_resp) s.sendall(MsgContactResp)
data = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass assert True
else: else:
assert data == b'' assert data == b''
def test_send_ctrl_data(client_connection, msg_timestamp_req, msg_timestamp_resp, msg_controller_ind): def test_send_ctrl_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgContollerInd):
s = client_connection s = ClientConnection
try: try:
s.sendall(msg_timestamp_req) s.sendall(MsgTimeStampReq)
_ = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
# time.sleep(2.5) # time.sleep(2.5)
# assert data == msg_timestamp_resp # assert data == MsgTimeStampResp
try: try:
s.sendall(msg_controller_ind) s.sendall(MsgContollerInd)
_ = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
def test_send_inv_data(client_connection, msg_timestamp_req, msg_timestamp_resp, msg_inv_data, msg_inverter_ind): def test_send_inv_data(ClientConnection, MsgTimeStampReq, MsgTimeStampResp, MsgInvData, MsgInverterInd):
s = client_connection s = ClientConnection
try: try:
s.sendall(msg_timestamp_req) s.sendall(MsgTimeStampReq)
_ = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
# time.sleep(32.5) # time.sleep(32.5)
# assert data == msg_timestamp_resp # assert data == MsgTimeStampResp
try: try:
s.sendall(msg_inv_data) s.sendall(MsgInvData)
_ = s.recv(1024) data = s.recv(1024)
s.sendall(msg_inverter_ind) s.sendall(MsgInverterInd)
_ = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass
def test_ota_req(client_connection, msg_ota_update_req): def test_ota_req(ClientConnection, MsgOtaUpdateReq):
s = client_connection s = ClientConnection
try: try:
s.sendall(msg_ota_update_req) s.sendall(MsgOtaUpdateReq)
_ = s.recv(1024) data = s.recv(1024)
except TimeoutError: except TimeoutError:
pass pass

View File

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