Compare commits

..

5 Commits

Author SHA1 Message Date
Stefan Allius
264cc52ce7 change the PV module handling
- in default we set the number of modules now to
  two. So with the first data from the inverter
  we only register two modules. After we determine
  the inverter module, the number can increase to
  four and more PV modules will be registered.

  With the default value of 4, we register always
  4 modules and can't reduce the number of areas
  when we detect that the inverter only supoorts
  two PV modules
2025-05-24 22:59:20 +02:00
Stefan Allius
f0c8a851bc increase test coverage 2025-05-22 21:50:38 +02:00
Stefan Allius
df356fdc86 fix unit test 2025-05-22 21:34:55 +02:00
Stefan Allius
a470635a7c Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy into s-allius/issue421 2025-05-22 21:32:14 +02:00
Stefan Allius
e2ff27d58f set no of pv modules for MS800 GEN3PLUS inverters 2025-05-22 21:05:58 +02:00
13 changed files with 50 additions and 206 deletions

View File

@@ -5,7 +5,7 @@ name: Python application
on: on:
push: push:
branches: [ "main", "dev-*", "*/issue*", "releases/*" ] branches: [ "main", "dev-*", "*/issue*" ]
paths-ignore: paths-ignore:
- '**.md' # Do no build on *.md changes - '**.md' # Do no build on *.md changes
- '**.yml' # Do no build on *.yml changes - '**.yml' # Do no build on *.yml changes
@@ -18,7 +18,7 @@ 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-*", "releases/*" ] branches: [ "main", "dev-*" ]
permissions: permissions:
contents: read contents: read

View File

@@ -1 +1 @@
3.13.4 3.13.2

View File

@@ -7,19 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- Update dependency pytest-asyncio to v1
## [0.14.1] - 2025-05-31
- handle missing MQTT addon [#438](https://github.com/s-allius/tsun-gen3-proxy/issues/438)
## [0.14.0] - 2025-05-29
- add-on: bump python to version 3.12.10-r1
- set no of pv modules for MS800 GEN3PLUS inverters - set no of pv modules for MS800 GEN3PLUS inverters
- fix the paths to copy the config.example.toml file during proxy start - fix the paths to copy the config.example.toml file during proxy start
- add MQTT topic `dcu_power` for setting output power on DCUs - add MQTT topic `dcu_power` for setting output power on DCUs
- add MQTT topic `dcu_power` for setting output power on DCUs
- Update ghcr.io/hassio-addons/base Docker tag to v17.2.5 - Update ghcr.io/hassio-addons/base Docker tag to v17.2.5
- fix a lot of pytest-asyncio problems in the unit tests - fix a lot of pytest-asyncio problems in the unit tests
- Cleanup startup code for Quart and the Proxy - Cleanup startup code for Quart and the Proxy

View File

@@ -1 +1 @@
0.15.0 0.14.0

View File

@@ -1,8 +1,8 @@
flake8==7.2.0 flake8==7.2.0
pytest==8.4.0 pytest==8.3.5
pytest-asyncio==1.0.0 pytest-asyncio==0.26.0
pytest-cov==6.2.1 pytest-cov==6.1.1
python-dotenv==1.1.0 python-dotenv==1.1.0
mock==5.2.0 mock==5.2.0
coverage==7.9.0 coverage==7.8.1
jinja2-cli==0.8.2 jinja2-cli==0.8.2

View File

@@ -12,7 +12,7 @@ class Schedule:
count = 0 count = 0
@classmethod @classmethod
def start(cls) -> None: # pragma: no cover def start(cls) -> None:
'''Start the scheduler and schedule the tasks (cron jobs)''' '''Start the scheduler and schedule the tasks (cron jobs)'''
logging.debug("Scheduler init") logging.debug("Scheduler init")
cls.mqtt = Mqtt(None) cls.mqtt = Mqtt(None)
@@ -20,7 +20,7 @@ class Schedule:
crontab('0 0 * * *', func=cls.atmidnight, start=True) crontab('0 0 * * *', func=cls.atmidnight, start=True)
@classmethod @classmethod
async def atmidnight(cls) -> None: # pragma: no cover async def atmidnight(cls) -> None:
'''Clear daily counters at midnight''' '''Clear daily counters at midnight'''
logging.info("Clear daily counters at midnight") logging.info("Clear daily counters at midnight")

View File

@@ -4,10 +4,6 @@ import logging
import os import os
from mock import patch from mock import patch
from server import app, Server, ProxyState, HypercornLogHndl from server import app, Server, ProxyState, HypercornLogHndl
from inverter_base import InverterBase
from gen3.talent import Talent
from test_inverter_base import FakeReader, FakeWriter
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ('pytest_asyncio',)
@@ -112,20 +108,20 @@ class TestServerClass:
assert logging.getLogger('hypercorn.access').level == logging.INFO assert logging.getLogger('hypercorn.access').level == logging.INFO
assert logging.getLogger('hypercorn.error').level == logging.INFO assert logging.getLogger('hypercorn.error').level == logging.INFO
with patch.dict(os.environ, {'LOG_LVL': 'WARN'}): os.environ["LOG_LVL"] = "WARN"
s.parse_args(['--log_backups', '3']) s.parse_args(['--log_backups', '3'])
s.init_logging_system() s.init_logging_system()
assert s.log_backups == 3 assert s.log_backups == 3
assert s.log_level == logging.WARNING assert s.log_level == logging.WARNING
assert logging.handlers.log_backups == 3 assert logging.handlers.log_backups == 3
assert logging.getLogger().level == s.log_level assert logging.getLogger().level == s.log_level
assert logging.getLogger('msg').level == s.log_level assert logging.getLogger('msg').level == s.log_level
assert logging.getLogger('conn').level == s.log_level assert logging.getLogger('conn').level == s.log_level
assert logging.getLogger('data').level == s.log_level assert logging.getLogger('data').level == s.log_level
assert logging.getLogger('tracer').level == s.log_level assert logging.getLogger('tracer').level == s.log_level
assert logging.getLogger('asyncio').level == s.log_level assert logging.getLogger('asyncio').level == s.log_level
assert logging.getLogger('hypercorn.access').level == logging.INFO assert logging.getLogger('hypercorn.access').level == logging.INFO
assert logging.getLogger('hypercorn.error').level == logging.INFO assert logging.getLogger('hypercorn.error').level == logging.INFO
def test_build_config_error(self, caplog): def test_build_config_error(self, caplog):
s = self.FakeServer() s = self.FakeServer()
@@ -206,81 +202,17 @@ class TestApp:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_healthy(self): async def test_healthy(self):
"""Test the healthy route.""" """Test the healthy route."""
reader = FakeReader()
writer = FakeWriter()
with InverterBase(reader, writer, 'tsun', Talent): ProxyState.set_up(False)
ProxyState.set_up(False) client = app.test_client()
client = app.test_client() response = await client.get('/-/healthy')
response = await client.get('/-/healthy') assert response.status_code == 200
assert response.status_code == 200 result = await response.get_data()
result = await response.get_data() assert result == b"I'm fine"
assert result == b"I'm fine"
ProxyState.set_up(True) ProxyState.set_up(True)
response = await client.get('/-/healthy') response = await client.get('/-/healthy')
assert response.status_code == 200 assert response.status_code == 200
result = await response.get_data() result = await response.get_data()
assert result == b"I'm fine" assert result == b"I'm fine"
@pytest.mark.asyncio
async def test_unhealthy(self, monkeypatch, caplog):
"""Test the healthy route."""
def result_false(self):
return False
LOGGER = logging.getLogger("mqtt")
LOGGER.propagate = True
LOGGER.setLevel(logging.INFO)
monkeypatch.setattr(InverterBase, "healthy", result_false)
InverterBase._registry.clear()
reader = FakeReader()
writer = FakeWriter()
with caplog.at_level(logging.INFO) and InverterBase(reader, writer, 'tsun', Talent):
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"
assert "" == caplog.text
ProxyState.set_up(True)
response = await client.get('/-/healthy')
assert response.status_code == 503
result = await response.get_data()
assert result == b"I have a problem"
assert "" == caplog.text
@pytest.mark.asyncio
async def test_healthy_exception(self, monkeypatch, caplog):
"""Test the healthy route."""
def result_except(self):
raise ValueError
LOGGER = logging.getLogger("mqtt")
LOGGER.propagate = True
LOGGER.setLevel(logging.INFO)
monkeypatch.setattr(InverterBase, "healthy", result_except)
InverterBase._registry.clear()
reader = FakeReader()
writer = FakeWriter()
with caplog.at_level(logging.INFO) and InverterBase(reader, writer, 'tsun', Talent):
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"
assert "" == caplog.text
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"
assert "Exception:" in caplog.text

View File

@@ -709,19 +709,6 @@ def msg_modbus_rsp(): # 0x1510
msg += b'\x15' msg += b'\x15'
return msg return msg
@pytest.fixture
def msg_modbus_rsp_mb_4(): # 0x1510, MODBUS Type:4
msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01'
msg += total()
msg += hb()
msg += b'\x0a\xe2\xfa\x33\x01\x04\x28\x40\x10\x08\xd8'
msg += b'\x00\x00\x13\x87\x00\x31\x00\x68\x02\x58\x00\x00\x01\x53\x00\x02'
msg += b'\x00\x00\x01\x52\x00\x02\x00\x00\x01\x53\x00\x03\x00\x00\x00\x04'
msg += b'\x00\x01\x00\x00\x9e\xa4'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
@pytest.fixture @pytest.fixture
def msg_modbus_interim_rsp(): # 0x0510 def msg_modbus_interim_rsp(): # 0x0510
msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01' msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01'
@@ -2254,61 +2241,6 @@ async def test_modbus_scaning(config_tsun_scan, heartbeat_ind_msg, heartbeat_rsp
assert next(m.mb_timer.exp_count) == 3 assert next(m.mb_timer.exp_count) == 3
m.close() m.close()
@pytest.mark.asyncio
async def test_modbus_scaning_inv_rsp(config_tsun_scan, heartbeat_ind_msg, heartbeat_rsp_msg, msg_modbus_rsp_mb_4):
_ = config_tsun_scan
assert asyncio.get_running_loop()
m = MemoryStream(heartbeat_ind_msg, (0x15,0x56,0))
m.append_msg(msg_modbus_rsp_mb_4)
assert m.mb_scan == False
assert asyncio.get_running_loop() == m.mb_timer.loop
m.db.stat['proxy']['Unknown_Ctrl'] = 0
assert m.mb_timer.tim == None
m.read() # read complete msg, and dispatch msg
assert m.mb_scan == True
assert m.mb_start_reg == 0xff80
assert m.mb_step == 0x40
assert m.mb_bytes == 0x14
assert asyncio.get_running_loop() == m.mb_timer.loop
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 1
assert m.snr == 2070233889
assert m.control == 0x4710
assert m.msg_recvd[0]['control']==0x4710
assert m.msg_recvd[0]['seq']=='84:11'
assert m.msg_recvd[0]['data_len']==0x1
assert m.ifc.tx_fifo.get()==heartbeat_rsp_msg
assert m.ifc.fwd_fifo.get()==heartbeat_ind_msg
assert m.db.stat['proxy']['Unknown_Ctrl'] == 0
m.ifc.tx_clear() # clear send buffer for next test
assert isclose(m.mb_timeout, 0.5)
assert next(m.mb_timer.exp_count) == 0
await asyncio.sleep(0.5)
assert m.sent_pdu==b'\xa5\x17\x00\x10E\x12\x84!Ce{\x02\xb0\x02\x00\x00\x00\x00\x00\x00' \
b'\x00\x00\x00\x00\x00\x00\x01\x03\xff\xc0\x00\x14\x75\xed\x33\x15'
assert m.ifc.tx_fifo.get()==b''
m.read() # read complete msg, and dispatch msg
assert not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 2
assert m.msg_recvd[1]['control']==0x1510
assert m.msg_recvd[1]['seq']=='03:03'
assert m.msg_recvd[1]['data_len']==0x3b
assert m.mb.last_addr == 1
assert m.mb.last_fcode == 3
assert m.mb.last_reg == 0xffc0 # mb_start_reg + mb_step
assert m.mb.last_len == 20
assert m.mb.err == 3
assert next(m.mb_timer.exp_count) == 2
m.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_client_mode(my_loop, config_tsun_inv1, str_test_ip): async def test_start_client_mode(my_loop, config_tsun_inv1, str_test_ip):
_ = config_tsun_inv1 _ = config_tsun_inv1
@@ -2662,6 +2594,7 @@ async def test_proxy_dcu_cmd(my_loop, config_tsun_dcu1, patch_open_connection, d
assert l.db.stat['proxy']['AT_Command'] == 0 assert l.db.stat['proxy']['AT_Command'] == 0
assert l.db.stat['proxy']['AT_Command_Blocked'] == 0 assert l.db.stat['proxy']['AT_Command_Blocked'] == 0
assert l.db.stat['proxy']['Modbus_Command'] == 0 assert l.db.stat['proxy']['Modbus_Command'] == 0
assert 2 == l.db.get_db_value(Register.NO_INPUTS, 0)
l.append_msg(dcu_command_rsp_msg) l.append_msg(dcu_command_rsp_msg)
l.read() # read at resp l.read() # read at resp

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: tsun-gen3-proxy 0.14.0\n" "Project-Id-Version: tsun-gen3-proxy 0.14.0\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-05-18 15:30+0200\n" "POT-Creation-Date: 2025-05-13 22:34+0200\n"
"PO-Revision-Date: 2025-04-18 16:24+0200\n" "PO-Revision-Date: 2025-04-18 16:24+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n" "Language: de\n"
@@ -120,7 +120,6 @@ msgid "TSUN Proxy - Log Files"
msgstr "TSUN Proxy - Log Dateien" msgstr "TSUN Proxy - Log Dateien"
#: src/web/templates/page_logging.html.j2:10 #: src/web/templates/page_logging.html.j2:10
#, python-format
msgid "Do you really want to delete the log file: <br>%(file)s ?" msgid "Do you really want to delete the log file: <br>%(file)s ?"
msgstr "Soll die Datei: <br>%(file)s<br>wirklich gelöscht werden?" msgstr "Soll die Datei: <br>%(file)s<br>wirklich gelöscht werden?"

View File

@@ -192,7 +192,7 @@ $(repro_all_subdirs) :
mkdir -p $@ mkdir -p $@
$(repro_all_templates) : $(INST_BASE)/ha_addon_%/config.yaml: $(TEMPL)/config.jinja $(TEMPL)/%_data.json $(SRC)/.version FORCE $(repro_all_templates) : $(INST_BASE)/ha_addon_%/config.yaml: $(TEMPL)/config.jinja $(TEMPL)/%_data.json $(SRC)/.version FORCE
$(JINJA) --strict -D AppVersion=$(VERSION)-$*$(RC) -D BuildID=$(BUILD_ID) $< $(filter %.json,$^) -o $@ $(JINJA) --strict -D AppVersion=$(VERSION)-$* -D BuildID=$(BUILD_ID) $< $(filter %.json,$^) -o $@
$(repro_all_apparmor) : $(INST_BASE)/ha_addon_%/apparmor.txt: $(TEMPL)/apparmor.jinja $(TEMPL)/%_data.json $(repro_all_apparmor) : $(INST_BASE)/ha_addon_%/apparmor.txt: $(TEMPL)/apparmor.jinja $(TEMPL)/%_data.json
$(JINJA) --strict $< $(filter %.json,$^) -o $@ $(JINJA) --strict $< $(filter %.json,$^) -o $@

View File

@@ -18,7 +18,7 @@ ARG BUILD_FROM="ghcr.io/hassio-addons/base:17.2.5"
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-r1 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 && \ python -m venv /opt/venv && \
. /opt/venv/bin/activate . /opt/venv/bin/activate

View File

@@ -1,28 +1,18 @@
#!/usr/bin/with-contenv bashio #!/usr/bin/with-contenv bashio
bashio::log.blue "-----------------------------------------------------------" echo "Add-on environment started"
bashio::log.blue "run.sh: info: setup Add-on environment"
bashio::cache.flush_all echo "check for Home Assistant MQTT"
MQTT_HOST="" MQTT_HOST=$(bashio::services mqtt "host")
if bashio::supervisor.ping; then MQTT_PORT=$(bashio::services mqtt "port")
bashio::log "run.sh: info: check for Home Assistant MQTT service" MQTT_USER=$(bashio::services mqtt "username")
if bashio::services.available mqtt; then MQTT_PASSWORD=$(bashio::services mqtt "password")
MQTT_HOST=$(bashio::services mqtt "host")
MQTT_PORT=$(bashio::services mqtt "port")
MQTT_USER=$(bashio::services mqtt "username")
MQTT_PASSWORD=$(bashio::services mqtt "password")
else
bashio::log.yellow "run.sh: info: Home Assistant MQTT service not available!"
fi
else
bashio::log.red "run.sh: error: Home Assistant Supervisor API not available!"
fi
# if a MQTT was/not found, drop a note # if a MQTT was/not found, drop a note
if [ -z "$MQTT_HOST" ]; then if [ -z "$MQTT_HOST" ]; then
bashio::log.yellow "run.sh: info: MQTT config not found" echo "MQTT not found"
else else
bashio::log.green "run.sh: info: MQTT config found" echo "MQTT found"
export MQTT_HOST export MQTT_HOST
export MQTT_PORT export MQTT_PORT
export MQTT_USER export MQTT_USER
@@ -39,6 +29,5 @@ cd /home/proxy || exit
export VERSION=$(cat /proxy-version.txt) export VERSION=$(cat /proxy-version.txt)
bashio::log.blue "run.sh: info: Start Proxyserver..." echo "Start Proxyserver..."
bashio::log.blue "-----------------------------------------------------------"
python3 server.py --rel_urls --json_config=/data/options.json --log_path=/homeassistant/tsun-proxy/logs/ --config_path=/homeassistant/tsun-proxy/ --log_backups=2 python3 server.py --rel_urls --json_config=/data/options.json --log_path=/homeassistant/tsun-proxy/logs/ --config_path=/homeassistant/tsun-proxy/ --log_backups=2

View File

@@ -2,6 +2,7 @@
{ {
"name": "TSUN-Proxy (Release Candidate)", "name": "TSUN-Proxy (Release Candidate)",
"description": "MQTT Proxy for TSUN Photovoltaic Inverters", "description": "MQTT Proxy for TSUN Photovoltaic Inverters",
"version": "rc",
"image": "ghcr.io/s-allius/tsun-gen3-addon", "image": "ghcr.io/s-allius/tsun-gen3-addon",
"slug": "tsun-proxy-rc", "slug": "tsun-proxy-rc",
"advanced": true, "advanced": true,