Compare commits

..

1 Commits

Author SHA1 Message Date
Stefan Allius
b16a4ad549 enforce numbered release candidates 2025-04-06 22:23:56 +02:00
14 changed files with 84 additions and 109 deletions

View File

@@ -1 +1 @@
3.13.3 3.13.2

View File

@@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
## [0.13.0] - 2025-04-13
- update dependency python to 3.13 - update dependency python to 3.13
- add initial support for TSUN MS-3000 - add initial support for TSUN MS-3000
- add initial apparmor support [#293](https://github.com/s-allius/tsun-gen3-proxy/issues/293) - 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. 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-env#docker-compose-environment-variables) 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).
## Proxy Configuration ## Proxy Configuration

View File

@@ -1 +1 @@
0.14.0 0.13.0

View File

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

View File

@@ -382,7 +382,7 @@ class Infos:
__mppt2_status_type_val_tpl = "{%set mppt_status = ['Standby', 'On', 'Off'] %}{{mppt_status[value_json['pv2']['MPPT-Status']|int(0)]|default(value_json['pv2']['MPPT-Status'])}}" # noqa: E501 __mppt2_status_type_val_tpl = "{%set mppt_status = ['Standby', 'On', 'Off'] %}{{mppt_status[value_json['pv2']['MPPT-Status']|int(0)]|default(value_json['pv2']['MPPT-Status'])}}" # noqa: E501
__supply_status_type_val_tpl = "{%set supply_status = ['Idle', 'Power-Supply'] %}{{supply_status[value_json['out']['Suppl_State']|int(0)]|default(value_json['out']['Suppl_State'])}}" # noqa: E501 __supply_status_type_val_tpl = "{%set supply_status = ['Idle', 'Power-Supply'] %}{{supply_status[value_json['out']['Suppl_State']|int(0)]|default(value_json['out']['Suppl_State'])}}" # noqa: E501
__batt_status_type_val_tpl = "{%set batt_status = ['Discharging', 'Static', 'Loading'] %}{{batt_status[value_json['batt']['Batt_State']|int(0)]|default(value_json['batt']['Batt_State'])}}" # noqa: E501 __batt_status_type_val_tpl = "{%set batt_status = ['Discharging', 'Static', 'Loading'] %}{{batt_status[value_json['batt']['Batt_State']|int(0)]|default(value_json['batt']['Batt_State'])}}" # noqa: E501
__out_status_type_val_tpl = "{%set out_status = ['Standby', 'On', 'Off'] %}{{out_status[value_json['out']['Out_Status']|int(0)]|default(value_json['out']['Out_Status'])}}" # noqa: E501 __out_status_type_val_tpl = "{%set out_status = ['Standby', 'On'] %}{{out_status[value_json['out']['Out_Status']|int(0)]|default(value_json['out']['Out_Status'])}}" # noqa: E501
__rated_power_val_tpl = "{% if 'Rated_Power' in value_json and value_json['Rated_Power'] != None %}{{value_json['Rated_Power']|string() +' W'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501 __rated_power_val_tpl = "{% if 'Rated_Power' in value_json and value_json['Rated_Power'] != None %}{{value_json['Rated_Power']|string() +' W'}}{% else %}{{ this.state }}{% endif %}" # noqa: E501
__designed_power_val_tpl = ''' __designed_power_val_tpl = '''
{% if 'Max_Designed_Power' in value_json and {% if 'Max_Designed_Power' in value_json and

View File

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

View File

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

View File

@@ -3,9 +3,7 @@ import pytest
import logging import logging
import os import os
from mock import patch from mock import patch
from server import get_log_level, app, ProxyState from server import get_log_level
pytest_plugins = ('pytest_asyncio',)
def test_get_log_level(): def test_get_log_level():
@@ -32,47 +30,3 @@ def test_get_log_level():
with patch.dict(os.environ, {'LOG_LVL': 'UNKNOWN'}): with patch.dict(os.environ, {'LOG_LVL': 'UNKNOWN'}):
log_lvl = get_log_level() log_lvl = get_log_level()
assert log_lvl == None assert log_lvl == None
@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"
@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

@@ -32,7 +32,7 @@ rel : STAGE=rel
export BUILD_DATE := ${shell date -Iminutes} export BUILD_DATE := ${shell date -Iminutes}
debug dev : BUILD_ID := ${shell date +'%y%m%d%H%M'} BUILD_ID := ${shell date +'%y%m%d%H%M'}
VERSION := $(shell cat $(SRC)/.version) VERSION := $(shell cat $(SRC)/.version)
export MAJOR := $(shell echo $(VERSION) | cut -f1 -d.) export MAJOR := $(shell echo $(VERSION) | cut -f1 -d.)

View File

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

View File

@@ -50,7 +50,7 @@ Example add-on configuration after installation:
```yaml ```yaml
inverters: inverters:
- serial: R17E000000000000 - serial: R17E760702080400
node_id: PV-Garage node_id: PV-Garage
suggested_area: Garage suggested_area: Garage
modbus_polling: false modbus_polling: false
@@ -89,7 +89,7 @@ Example add-on configuration for GEN3PLUS energie storages:
```yaml ```yaml
batteries: batteries:
- serial: 4100000000000000 - serial: 4100000000000000
monitor_sn: 3000000000 monitor_sn: 2300000000
node_id: bat_1 node_id: bat_1
suggested_area: Garage suggested_area: Garage
modbus_polling: false modbus_polling: false
@@ -174,4 +174,4 @@ SOFTWARE.
[AdGuard]: https://github.com/hassio-addons/addon-adguard-home [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-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 [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-addon [configdetails]: https://github.com/s-allius/tsun-gen3-proxy/wiki/Configuration-toml

View File

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

View File

@@ -6,6 +6,6 @@
"slug": "tsun-proxy", "slug": "tsun-proxy",
"advanced": false, "advanced": false,
"stage": "stable", "stage": "stable",
"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_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_links": "\n[tsunproxy]: https://github.com/s-allius/tsun-gen3-proxy\n" "readme_links": "\n[tsunproxy]: https://github.com/s-allius/tsun-gen3-proxy\n"
} }