Compare commits

...

14 Commits

Author SHA1 Message Date
Stefan Allius
fbca07bd59 use Blueprints 2025-04-16 01:45:10 +02:00
Stefan Allius
e66d2e864b update changelog 2025-04-16 01:05:02 +02:00
Stefan Allius
e19acbc514 add web UI to add-on 2025-04-16 01:02:16 +02:00
Stefan Allius
6798753d84 move home route into web diretory 2025-04-16 01:01:39 +02:00
Stefan Allius
7d5670b6b5 remove aiohttp by quart (#381)
* remove aiohttp by quart

* remove global proxy_is_up

* add unit test for some routes
2025-04-16 00:54:02 +02:00
Stefan Allius
f98273a3eb S allius/issue362 (#379)
* allow serial number starting with `Y00`

* rollback to python version 3.13.2
2025-04-15 17:54:45 +02:00
Stefan Allius
5452b8efc2 Start version 0.14 (#374)
* bump version to 0.14.0

* update changelog

* set BUILD_ID only for dev and debug versions
2025-04-14 00:01:06 +02:00
renovate[bot]
5568a017ec Update python Docker tag to v3.13.3 (#359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-13 23:39:44 +02:00
renovate[bot]
4a70f366f1 Update dependency aiomqtt to v2.3.2 (#358)
* Update dependency aiomqtt to v2.3.2

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Stefan Allius <stefan.allius@t-online.de>
2025-04-13 23:38:38 +02:00
Stefan Allius
aac89065fd Update rel 0.13.0 (#370)
* bump version
2025-04-13 20:56:10 +02:00
Stefan Allius
fe250d478a Fix rel build (#368)
* fix rel build run

* disable cache for rc build

* bump python version to 3.12.10-r0
2025-04-13 20:35:21 +02:00
Stefan Allius
83a723c959 Update rel 0.13.0 (#367)
* fix link

(cherry picked from commit 3d422f9249)

* update compose help link

(cherry picked from commit 6d4ff0d508)

* fix link

(cherry picked from commit 3d422f9249)

* fix rel build run
2025-04-13 19:59:05 +02:00
Stefan Allius
abe6bfb013 update compose help link (#361) 2025-04-10 23:58:59 +02:00
renovate[bot]
31f4f05bed Update ghcr.io/hassio-addons/base Docker tag to v17.2.4 (#355)
* Update ghcr.io/hassio-addons/base Docker tag to v17.2.3

* Update ghcr.io/hassio-addons/base Docker tag to v17.2.4

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Stefan Allius <stefan.allius@t-online.de>
2025-04-08 20:47:11 +02:00
17 changed files with 133 additions and 88 deletions

View File

@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased]
- add web UI to add-on
- allow `Y00` serial numbers for GEN3PLUS devices
## [0.13.0] - 2025-04-13
- update dependency python to 3.13
- add initial support for TSUN MS-3000
- add initial apparmor support [#293](https://github.com/s-allius/tsun-gen3-proxy/issues/293)

View File

@@ -141,7 +141,7 @@ No special configuration is required for the Docker container if it is built and
On the host, two directories (for log files and for config files) must be mapped. If necessary, the UID of the proxy process can be adjusted, which is also the owner of the log and configuration files.
A description of the configuration parameters can be found [here](https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-toml#docker-compose-environment-variables).
A description of the configuration parameters can be found [here](https://github.com/s-allius/tsun-gen3-proxy/wiki/configuration-env#docker-compose-environment-variables)
## Proxy Configuration

View File

@@ -1 +1 @@
0.13.0
0.14.0

View File

@@ -1,4 +1,4 @@
aiomqtt==2.3.1
aiomqtt==2.3.2
schema==0.7.7
aiocron==2.1
aiohttp==3.11.16
quart==0.20

View File

@@ -96,8 +96,8 @@ class Proxy():
Infos.new_stat_data[key] = False
@classmethod
def class_close(cls, loop) -> None: # pragma: no cover
async def class_close(cls, loop) -> None: # pragma: no cover
logging.debug('Proxy.class_close')
logging.info('Close MQTT Task')
loop.run_until_complete(cls.mqtt.close())
await cls.mqtt.close()
cls.mqtt = None

View File

@@ -1,11 +1,10 @@
import logging
import asyncio
import logging.handlers
import signal
import os
import argparse
from asyncio import StreamReader, StreamWriter
from aiohttp import web
from quart import Quart, Response
from logging import config # noqa F401
from proxy import Proxy
from inverter_ifc import InverterIfc
@@ -16,63 +15,51 @@ from cnf.config import Config
from cnf.config_read_env import ConfigReadEnv
from cnf.config_read_toml import ConfigReadToml
from cnf.config_read_json import ConfigReadJson
from web.routes import web_routes
from modbus_tcp import ModbusTcp
routes = web.RouteTableDef()
proxy_is_up = False
class ProxyState:
_is_up = False
@staticmethod
def is_up() -> bool:
return ProxyState._is_up
@staticmethod
def set_up(value: bool):
ProxyState._is_up = value
@routes.get('/')
async def hello(request):
return web.Response(text="Hello, world")
app = Quart(__name__)
app.register_blueprint(web_routes)
@routes.get('/-/ready')
async def ready(request):
if proxy_is_up:
@app.route('/-/ready')
async def ready():
if ProxyState.is_up():
status = 200
text = 'Is ready'
else:
status = 503
text = 'Not ready'
return web.Response(status=status, text=text)
return Response(status=status, response=text)
@routes.get('/-/healthy')
async def healthy(request):
@app.route('/-/healthy')
async def healthy():
if proxy_is_up:
if ProxyState.is_up():
# logging.info('web reqeust healthy()')
for inverter in InverterIfc:
try:
res = inverter.healthy()
if not res:
return web.Response(status=503, text="I have a problem")
return Response(status=503, response="I have a problem")
except Exception as err:
logging.info(f'Exception:{err}')
return web.Response(status=200, text="I'm fine")
async def webserver(addr, port):
'''coro running our webserver'''
app = web.Application()
app.add_routes(routes)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, addr, port)
await site.start()
logging.info(f'HTTP server listen on port: {port}')
try:
# Normal interaction with aiohttp
while True:
await asyncio.sleep(3600) # sleep forever
except asyncio.CancelledError:
logging.info('HTTP server cancelled')
await runner.cleanup()
logging.debug('HTTP cleanup done')
return Response(status=200, response="I'm fine")
async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class):
@@ -82,12 +69,13 @@ async def handle_client(reader: StreamReader, writer: StreamWriter, inv_class):
await inv.local.ifc.server_loop()
async def handle_shutdown(loop, web_task):
@app.after_serving
async def handle_shutdown(): # pragma: no cover
'''Close all TCP connections and stop the event loop'''
logging.info('Shutdown due to SIGTERM')
global proxy_is_up
proxy_is_up = False
loop = asyncio.get_event_loop()
ProxyState.set_up(False)
#
# first, disc all open TCP connections gracefully
@@ -97,24 +85,16 @@ async def handle_shutdown(loop, web_task):
logging.info('Proxy disconnecting done')
#
# second, cancel the web server
#
web_task.cancel()
await web_task
#
# now cancel all remaining (pending) tasks
#
pending = asyncio.all_tasks()
for task in pending:
for task in asyncio.all_tasks():
if task == asyncio.current_task():
continue
task.cancel()
logging.info('Proxy cancelling done')
#
# at last, start a coro for stopping the loop
#
logging.debug("Stop event loop")
loop.stop()
await Proxy.class_close(loop)
def get_log_level() -> int | None:
@@ -214,27 +194,20 @@ def main(): # pragma: no cover
loop.create_task(asyncio.start_server(lambda r, w, i=inv_class:
handle_client(r, w, i),
'0.0.0.0', port))
web_task = loop.create_task(webserver('0.0.0.0', 8127))
#
# Register some UNIX Signal handler for a gracefully server shutdown
# on Docker restart and stop
#
for signame in ('SIGINT', 'SIGTERM'):
loop.add_signal_handler(getattr(signal, signame),
lambda loop=loop: asyncio.create_task(
handle_shutdown(loop, web_task)))
loop.set_debug(log_level == logging.DEBUG)
try:
global proxy_is_up
proxy_is_up = True
loop.run_forever()
ProxyState.set_up(True)
logging.info("Start Quart")
app.run(host='0.0.0.0', port=8127, use_reloader=False, loop=loop)
logging.info("Quart stopped")
except KeyboardInterrupt:
pass
except asyncio.exceptions.CancelledError:
logging.info("Quart cancelled")
finally:
logging.info("Event loop is stopped")
Proxy.class_close(loop)
logging.debug('Close event loop')
loop.close()
logging.info(f'Finally, exit Server "{serv_name}"')

9
app/src/web/routes.py Normal file
View File

@@ -0,0 +1,9 @@
from quart import Blueprint
from quart import Response
web_routes = Blueprint('web_routes', __name__)
@web_routes.route('/')
async def hello():
return Response(response="Hello, world")

View File

@@ -3,7 +3,9 @@ import pytest
import logging
import os
from mock import patch
from server import get_log_level
from server import get_log_level, app, ProxyState
pytest_plugins = ('pytest_asyncio',)
def test_get_log_level():
@@ -30,3 +32,37 @@ def test_get_log_level():
with patch.dict(os.environ, {'LOG_LVL': 'UNKNOWN'}):
log_lvl = get_log_level()
assert log_lvl == None
@pytest.mark.asyncio
async def test_ready():
"""Test the ready route."""
ProxyState.set_up(False)
client = app.test_client()
response = await client.get('/-/ready')
assert response.status_code == 503
result = await response.get_data()
assert result == b"Not ready"
ProxyState.set_up(True)
response = await client.get('/-/ready')
assert response.status_code == 200
result = await response.get_data()
assert result == b"Is ready"
@pytest.mark.asyncio
async def test_healthy():
"""Test the healthy route."""
ProxyState.set_up(False)
client = app.test_client()
response = await client.get('/-/healthy')
assert response.status_code == 200
result = await response.get_data()
assert result == b"I'm fine"
ProxyState.set_up(True)
response = await client.get('/-/healthy')
assert response.status_code == 200
result = await response.get_data()
assert result == b"I'm fine"

View File

@@ -0,0 +1,15 @@
# test_with_pytest.py
import pytest
from server import app
pytest_plugins = ('pytest_asyncio',)
@pytest.mark.asyncio
async def test_home():
"""Test the home route."""
client = app.test_client()
response = await client.get('/')
assert response.status_code == 200
result = await response.get_data()
assert result == b"Hello, world"

View File

@@ -32,7 +32,7 @@ rel : STAGE=rel
export BUILD_DATE := ${shell date -Iminutes}
BUILD_ID := ${shell date +'%y%m%d%H%M'}
debug dev : BUILD_ID := ${shell date +'%y%m%d%H%M'}
VERSION := $(shell cat $(SRC)/.version)
export MAJOR := $(shell echo $(VERSION) | cut -f1 -d.)
@@ -83,7 +83,8 @@ SRC_FILES := $(wildcard $(SRC_PROXY)/*.py)\
$(wildcard $(SRC_PROXY)/cnf/*.py)\
$(wildcard $(SRC_PROXY)/cnf/*.toml)\
$(wildcard $(SRC_PROXY)/gen3/*.py)\
$(wildcard $(SRC_PROXY)/gen3plus/*.py)
$(wildcard $(SRC_PROXY)/gen3plus/*.py)\
$(wildcard $(SRC_PROXY)/web/*.py)
CNF_FILES := $(wildcard $(CNF_PROXY)/*.toml)
# determine destination files

View File

@@ -90,6 +90,7 @@ target "preview" {
target "rc" {
inherits = ["_common", "_prod"]
tags = ["${IMAGE}:rc", "${IMAGE}:${VERSION}"]
no-cache = true
}
target "rel" {

View File

@@ -50,7 +50,7 @@ Example add-on configuration after installation:
```yaml
inverters:
- serial: R17E760702080400
- serial: R17E000000000000
node_id: PV-Garage
suggested_area: Garage
modbus_polling: false
@@ -89,7 +89,7 @@ Example add-on configuration for GEN3PLUS energie storages:
```yaml
batteries:
- serial: 4100000000000000
monitor_sn: 2300000000
monitor_sn: 3000000000
node_id: bat_1
suggested_area: Garage
modbus_polling: false
@@ -174,4 +174,4 @@ SOFTWARE.
[AdGuard]: https://github.com/hassio-addons/addon-adguard-home
[repository-badge]: https://img.shields.io/badge/Add%20repository%20to%20my-Home%20Assistant-41BDF5?logo=home-assistant&style=for-the-badge
[repository-url]: https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fs-allius%2Fha-addons
[configdetails]: https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-toml
[configdetails]: https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-addon

View File

@@ -13,12 +13,12 @@
# 1 Build Base Image #
######################
ARG BUILD_FROM="ghcr.io/hassio-addons/base:17.2.3"
ARG BUILD_FROM="ghcr.io/hassio-addons/base:17.2.4"
# hadolint ignore=DL3006
FROM $BUILD_FROM AS base
# Installiere Python, pip und virtuelle Umgebungstools
RUN apk add --no-cache python3=3.12.9-r0 py3-pip=24.3.1-r0 && \
RUN apk add --no-cache python3=3.12.10-r0 py3-pip=24.3.1-r0 && \
python -m venv /opt/venv && \
. /opt/venv/bin/activate

View File

@@ -10,8 +10,8 @@ configuration:
Weitere wechselrichterspezifische Parameter (z.B. Polling Mode) können im
Konfigurationsblock gesetzt werden.
Die Seriennummer der GEN3 Wechselrichter beginnen mit `R17` oder `R47` und die der GEN3PLUS
Wechselrichter mit `Y17`oder `Y47`!
Die Seriennummer der GEN3 Wechselrichter beginnen mit 'R17' oder 'R47' und die der GEN3PLUS
Wechselrichter mit 'Y00', 'Y17' oder 'Y47'!
Siehe Beispielkonfiguration im Dokumentations-Tab
batteries:
@@ -106,3 +106,4 @@ configuration:
network:
5005/tcp: listening Port für TSUN GEN3 Wechselrichter
10000/tcp: listening Port für TSUN GEN3PLUS Wechselrichter
8127/tcp: Port für das TSUN-Proxy Dashboard

View File

@@ -10,7 +10,7 @@ configuration:
in the configuration block.
The serial numbers of all GEN3 inverters start with `R17` or `R47` and that of the GEN3PLUS
inverters with Y17 or Y47!
inverters with 'Y00', Y17 or Y47!
For reference see example configuration in Documentation Tab
@@ -107,3 +107,4 @@ configuration:
network:
5005/tcp: listening Port for TSUN GEN3 Devices
10000/tcp: listening Port for TSUN GEN3PLUS Devices
8127/tcp: Port for the TSUN-Proxy Dashboard

View File

@@ -23,8 +23,11 @@ services:
ports:
5005/tcp: 5005
10000/tcp: 10000
8127/tcp: 8127
webui: "http://[HOST]:[PORT:8127]/"
watchdog: "http://[HOST]:[PORT:8127]/-/healthy"
ingress: true
ingress_port: 8127
# Definition of parameters in the configuration tab of the addon
# parameters are available within the container as /data/options.json
@@ -32,7 +35,7 @@ watchdog: "http://[HOST]:[PORT:8127]/-/healthy"
schema:
inverters:
- serial: match(^(R17|R47|Y17|Y47).{13}$)
- serial: match(^(R17|R47|Y00|Y17|Y47).{13}$)
monitor_sn: int?
node_id: str
suggested_area: str

View File

@@ -6,6 +6,6 @@
"slug": "tsun-proxy",
"advanced": false,
"stage": "stable",
"readme_dsecr": "Integrates TSUN inverters (e.g. TSOL MS800, MS2000, MS3000) and batteries (TSOL DC1000) into Home Assistant.\n\nIt is based on the [TSUN Proxy][tsunproxy] and enables a reliable connection between TSUN devices and an MQTT broker.\n\nWith the Add-on, you can easily retrieve real-time values such as power, current and daily energy and integrate the inverter into Home Assistant.\nThis works even without an internet connection.\n\nThe optional connection to the TSUN Cloud can be disabled!",
"readme_descr": "Integrates TSUN inverters (e.g. TSOL MS800, MS2000, MS3000) and batteries (TSOL DC1000) into Home Assistant.\n\nIt is based on the [TSUN Proxy][tsunproxy] and enables a reliable connection between TSUN devices and an MQTT broker.\n\nWith the Add-on, you can easily retrieve real-time values such as power, current and daily energy and integrate the inverter into Home Assistant.\nThis works even without an internet connection.\n\nThe optional connection to the TSUN Cloud can be disabled!",
"readme_links": "\n[tsunproxy]: https://github.com/s-allius/tsun-gen3-proxy\n"
}