Compare commits

..

18 Commits

Author SHA1 Message Date
Stefan Allius
50a7dbca41 undo changes 2025-05-20 23:37:37 +02:00
Stefan Allius
ef889dc53b undo last changes 2025-05-20 23:34:41 +02:00
Stefan Allius
c9ab95a02d Merge branch 'main' of https://github.com/s-allius/tsun-gen3-proxy into s-allius/issue397 2025-05-20 23:29:47 +02:00
Stefan Allius
783bb1c832 change icon for notes 2025-05-20 23:07:33 +02:00
Stefan Allius
f3bc22ef68 add unit tests 2025-05-04 18:44:01 +02:00
Stefan Allius
5e5a2ccdd0 add Notes page and table for important messages 2025-05-04 18:43:52 +02:00
Stefan Allius
09ae4f9bb8 more translations 2025-05-04 18:23:39 +02:00
Stefan Allius
838f6d76d0 translate page titles 2025-05-04 18:23:21 +02:00
Stefan Allius
d693c0b48a rename file 2025-05-04 18:22:30 +02:00
Stefan Allius
9a8ca02b47 add well done message if no errors in the logs 2025-05-04 18:22:00 +02:00
Stefan Allius
7a86f682b9 fix double defined build target 2025-05-04 18:21:05 +02:00
Stefan Allius
0048d71f9d add warnings and error page 2025-05-04 16:08:20 +02:00
Stefan Allius
c6822fd309 add page for warnings and errors 2025-05-04 16:06:52 +02:00
Stefan Allius
298b72bbc9 render warnings/errors as note list 2025-05-04 15:29:47 +02:00
Stefan Allius
089c35f89e create LogHandler for the dashborad
- simple memory log handler which stores the last
  64 warnings/errors for the dashboard
2025-05-04 15:28:28 +02:00
Stefan Allius
ba025eb09b add note list to page 2025-05-04 15:27:45 +02:00
Stefan Allius
ddf8222003 add list of last 3 warnings/errors to page 2025-05-04 15:26:46 +02:00
Stefan Allius
d5b3b804bc add Dashboards log handler to all known loggers 2025-05-04 15:25:30 +02:00
14 changed files with 82 additions and 333 deletions

View File

@@ -7,9 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- add-on: bump python to version 3.12.10-r1
- set no of pv modules for MS800 GEN3PLUS inverters
- 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
- 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

View File

@@ -4,5 +4,5 @@
pytest-cov==6.1.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.8.2 coverage==7.8.0
jinja2-cli==0.8.2 jinja2-cli==0.8.2

View File

@@ -162,8 +162,7 @@ class Config():
) )
@classmethod @classmethod
def init(cls, def_reader: ConfigIfc, log_path: str = '', def init(cls, def_reader: ConfigIfc, log_path: str = '') -> None | str:
cnf_path: str = 'config') -> None | str:
'''Initialise the Proxy-Config '''Initialise the Proxy-Config
Copy the internal default config file into the config directory Copy the internal default config file into the config directory
@@ -174,13 +173,12 @@ and initialise the Config with the default configuration '''
try: try:
# make the default config transparaent by copying it # make the default config transparaent by copying it
# in the config.example file # in the config.example file
logging.info( logging.debug('Copy Default Config to config.example.toml')
f'Copy Default Config to {cnf_path}config.example.toml')
shutil.copy2("cnf/default_config.toml", shutil.copy2("default_config.toml",
cnf_path + "config.example.toml") "config/config.example.toml")
except Exception as e: except Exception:
logging.error(e) pass
# read example config file as default configuration # read example config file as default configuration
try: try:

View File

@@ -216,7 +216,7 @@ class InfosG3P(Infos):
self.set_db_def_value(Register.MANUFACTURER, 'TSUN') self.set_db_def_value(Register.MANUFACTURER, 'TSUN')
self.set_db_def_value(Register.EQUIPMENT_MODEL, 'TSOL-MSxx00') self.set_db_def_value(Register.EQUIPMENT_MODEL, 'TSOL-MSxx00')
self.set_db_def_value(Register.CHIP_TYPE, 'IGEN TECH') self.set_db_def_value(Register.CHIP_TYPE, 'IGEN TECH')
self.set_db_def_value(Register.NO_INPUTS, 2) self.set_db_def_value(Register.NO_INPUTS, 4)
def __hide_topic(self, row: dict) -> bool: def __hide_topic(self, row: dict) -> bool:
if 'dep' in row: if 'dep' in row:

View File

@@ -562,17 +562,12 @@ class SolarmanV5(SolarmanBase):
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 max_pow == 2000:
db.set_db_def_value(Register.NO_INPUTS, 4)
if rated == 800 or rated == 600: if rated == 800 or rated == 600:
model = f'TSOL-MS{max_pow}({rated})' model = f'TSOL-MS{max_pow}({rated})'
else: else:
model = f'TSOL-MS{max_pow}' model = f'TSOL-MS{max_pow}'
elif max_pow == 1800 or max_pow == 1600: elif max_pow == 1800 or max_pow == 1600:
db.set_db_def_value(Register.NO_INPUTS, 4)
model = f'TSOL-MS{max_pow}' model = f'TSOL-MS{max_pow}'
elif max_pow <= 800:
model = f'TSOL-MS{max_pow}'
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)

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

@@ -127,8 +127,7 @@ class Server():
def build_config(self): def build_config(self):
# read config file # read config file
Config.init(ConfigReadToml(self.src_dir + "cnf/default_config.toml"), Config.init(ConfigReadToml(self.src_dir + "cnf/default_config.toml"),
log_path=self.log_path, log_path=self.log_path)
cnf_path=self.config_path)
ConfigReadEnv() ConfigReadEnv()
ConfigReadJson(self.config_path + "config.json") ConfigReadJson(self.config_path + "config.json")
ConfigReadToml(self.config_path + "config.toml") ConfigReadToml(self.config_path + "config.toml")

View File

@@ -109,7 +109,7 @@ def test_default_db():
i = InfosG3P(client_mode=False) i = InfosG3P(client_mode=False)
assert json.dumps(i.db) == json.dumps({ assert json.dumps(i.db) == json.dumps({
"inverter": {"Manufacturer": "TSUN", "Equipment_Model": "TSOL-MSxx00", "No_Inputs": 2}, "inverter": {"Manufacturer": "TSUN", "Equipment_Model": "TSOL-MSxx00", "No_Inputs": 4},
"collector": {"Chip_Type": "IGEN TECH"}, "collector": {"Chip_Type": "IGEN TECH"},
}) })
@@ -271,7 +271,7 @@ def test_build_ha_conf1():
elif id == 'inv_count_456': elif id == 'inv_count_456':
assert False assert False
assert tests==5 assert tests==7
def test_build_ha_conf2(): def test_build_ha_conf2():
i = InfosG3P(client_mode=False) i = InfosG3P(client_mode=False)
@@ -346,7 +346,7 @@ def test_build_ha_conf3():
elif id == 'inv_count_456': elif id == 'inv_count_456':
assert False assert False
assert tests==5 assert tests==7
def test_build_ha_conf4(): def test_build_ha_conf4():
i = InfosG3P(client_mode=True) i = InfosG3P(client_mode=True)

View File

@@ -3,8 +3,7 @@ import pytest
import asyncio import asyncio
import aiomqtt import aiomqtt
import logging import logging
from aiomqtt import MqttError, MessagesIterator from aiomqtt import MqttError
from aiomqtt import Message as AiomqttMessage
from mock import patch, Mock from mock import patch, Mock
from async_stream import AsyncIfcImpl from async_stream import AsyncIfcImpl
@@ -35,26 +34,6 @@ def test_hostname():
# else: # else:
return 'test.mosquitto.org' return 'test.mosquitto.org'
@pytest.fixture(scope="function")
def aiomqtt_mock(monkeypatch):
recv_que = asyncio.Queue()
async def my_aenter(self):
return self
async def my_subscribe(self, *arg):
return
async def my_anext(self):
return await recv_que.get()
async def my_receive(self, topic: str, payload: bytes):
msg = AiomqttMessage(topic, payload,qos=0, retain=False, mid=0, properties=None)
await recv_que.put(msg)
await asyncio.sleep(0) # dispath the msg
monkeypatch.setattr(aiomqtt.Client, "__aenter__", my_aenter)
monkeypatch.setattr(aiomqtt.Client, "subscribe", my_subscribe)
monkeypatch.setattr(MessagesIterator, "__anext__", my_anext)
monkeypatch.setattr(Mqtt, "receive", my_receive, False)
@pytest.fixture @pytest.fixture
def config_mqtt_conn(test_hostname, test_port): def config_mqtt_conn(test_hostname, test_port):
Config.act_config = {'mqtt':{'host': test_hostname, 'port': test_port, 'user': '', 'passwd': ''}, Config.act_config = {'mqtt':{'host': test_hostname, 'port': test_port, 'user': '', 'passwd': ''},
@@ -182,17 +161,13 @@ async def test_ha_reconnect(config_mqtt_conn):
await m.close() await m.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mqtt_no_config(config_no_conn, monkeypatch): async def test_mqtt_no_config(config_no_conn):
_ = config_no_conn _ = config_no_conn
assert asyncio.get_running_loop() assert asyncio.get_running_loop()
on_connect = asyncio.Event() on_connect = asyncio.Event()
async def cb(): async def cb():
on_connect.set() on_connect.set()
async def my_publish(*args):
return
monkeypatch.setattr(aiomqtt.Client, "publish", my_publish)
try: try:
m = Mqtt(cb) m = Mqtt(cb)
@@ -201,9 +176,9 @@ async def test_mqtt_no_config(config_no_conn, monkeypatch):
assert not on_connect.is_set() assert not on_connect.is_set()
try: try:
await m.publish('homeassistant/status', 'online') await m.publish('homeassistant/status', 'online')
assert m.published == 1
except Exception:
assert False assert False
except Exception:
pass
except TimeoutError: except TimeoutError:
assert False assert False
finally: finally:
@@ -275,119 +250,92 @@ async def test_mqtt_except_def_config(config_def_conn, monkeypatch, caplog):
assert 'MQTT is unconfigured; Check your config.toml!' in caplog.text assert 'MQTT is unconfigured; Check your config.toml!' in caplog.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mqtt_dispatch(config_mqtt_conn, aiomqtt_mock, spy_modbus_cmd): async def test_msg_dispatch(config_mqtt_conn, spy_modbus_cmd):
_ = config_mqtt_conn _ = config_mqtt_conn
_ = aiomqtt_mock
spy = spy_modbus_cmd spy = spy_modbus_cmd
try: try:
m = Mqtt(None) m = Mqtt(None)
assert m.ha_restarts == 0 msg = aiomqtt.Message(topic= 'homeassistant/status', payload= b'online', qos= 0, retain = False, mid= 0, properties= None)
await m.receive('homeassistant/status', b'online') # send the message await m.dispatch_msg(msg)
assert m.ha_restarts == 1 assert m.ha_restarts == 1
await m.receive(topic= 'tsun/inv_1/rated_load', payload= b'2') 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.assert_awaited_once_with(Modbus.WRITE_SINGLE_REG, 0x2008, 2, logging.INFO)
spy.reset_mock() spy.reset_mock()
await m.receive(topic= 'tsun/inv_1/out_coeff', payload= b'100') 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.assert_awaited_once_with(Modbus.WRITE_SINGLE_REG, 0x202c, 1024, logging.INFO)
spy.reset_mock() spy.reset_mock()
await m.receive(topic= 'tsun/inv_1/out_coeff', payload= b'50') 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.assert_awaited_once_with(Modbus.WRITE_SINGLE_REG, 0x202c, 512, logging.INFO)
spy.reset_mock() spy.reset_mock()
await m.receive(topic= 'tsun/inv_1/modbus_read_regs', payload= b'0x3000, 10') 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.assert_awaited_once_with(Modbus.READ_REGS, 0x3000, 10, logging.INFO)
spy.reset_mock() spy.reset_mock()
await m.receive(topic= 'tsun/inv_1/modbus_read_inputs', payload= b'0x3000, 10') 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) spy.assert_awaited_once_with(Modbus.READ_INPUTS, 0x3000, 10, logging.INFO)
# test dispatching with empty mapping table # test dispatching with empty mapping table
m.topic_defs.clear() m.topic_defs.clear()
spy.reset_mock() spy.reset_mock()
await m.receive(topic= 'tsun/inv_1/modbus_read_inputs', payload= b'0x3000, 10') 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_not_called() spy.assert_not_called()
# test dispatching with incomplete mapping table - invalid fnc defined # test dispatching with incomplete mapping table - invalid fnc defined
m.topic_defs.append( m.topic_defs.append(
{'prefix': 'entity_prefix', 'topic': '/+/modbus_read_inputs', {'prefix': 'entity_prefix', 'topic': '/+/modbus_read_inputs',
'full_topic': 'tsun/+/modbus_read_inputs', 'fnc': 'addr'} 'full_topic': 'tsun/+/modbus_read_inputs', 'fnc': 'invalid'}
) )
spy.reset_mock() spy.reset_mock()
await m.receive(topic= 'tsun/inv_1/modbus_read_inputs', payload= b'0x3000, 10') 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_not_called() spy.assert_not_called()
except MqttError:
assert False
except Exception:
assert False
finally: finally:
await m.close() await m.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mqtt_dispatch_cb(config_mqtt_conn, aiomqtt_mock): async def test_msg_dispatch_err(config_mqtt_conn, spy_modbus_cmd):
_ = config_mqtt_conn _ = config_mqtt_conn
_ = aiomqtt_mock
on_connect = asyncio.Event()
async def cb():
on_connect.set()
try:
m = Mqtt(cb)
assert m.ha_restarts == 0
await m.receive('homeassistant/status', b'online') # send the message
assert on_connect.is_set()
assert m.ha_restarts == 1
except MqttError:
assert False
except Exception:
assert False
finally:
await m.close()
@pytest.mark.asyncio
async def test_mqtt_dispatch_err(config_mqtt_conn, aiomqtt_mock, spy_modbus_cmd, caplog):
_ = config_mqtt_conn
_ = aiomqtt_mock
spy = spy_modbus_cmd spy = spy_modbus_cmd
LOGGER = logging.getLogger("mqtt")
LOGGER.propagate = True
LOGGER.setLevel(logging.INFO)
try: try:
m = Mqtt(None) m = Mqtt(None)
# test out of range param # test out of range param
await m.receive(topic= 'tsun/inv_1/out_coeff', payload= b'-1') 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() spy.assert_not_called()
# test unknown node_id # test unknown node_id
await m.receive(topic= 'tsun/inv_2/out_coeff', payload= b'2') 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() spy.assert_not_called()
# test invalid fload param # test invalid fload param
await m.receive(topic= 'tsun/inv_1/out_coeff', payload= b'2, 3') 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.assert_not_called()
await m.receive(topic= 'tsun/inv_1/modbus_read_regs', payload= b'0x3000, 10, 7') 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() spy.assert_not_called()
await m.receive(topic= 'tsun/inv_1/dcu_power', payload= b'100W') spy.reset_mock()
msg = aiomqtt.Message(topic= 'tsun/inv_1/dcu_power', payload= b'100W', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called() spy.assert_not_called()
with caplog.at_level(logging.INFO):
msg = aiomqtt.Message(topic= 'tsun/inv_1/out_coeff', payload= b'2', qos= 0, retain = False, mid= 0, properties= None)
for _ in m.each_inverter(msg, "addr"):
pass # do nothing here
assert 'Cmd not supported by: inv_1/' in caplog.text
except MqttError:
assert False
except Exception:
assert False
finally: finally:
await m.close() await m.close()

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

@@ -462,39 +462,6 @@ def inverter_ind_msg800(): # 0x4210 rated Power 800W
msg += b'\x15' msg += b'\x15'
return msg return msg
@pytest.fixture
def inverter_ind_msg900(): # 0x4210 rated Power 900W
msg = b'\xa5\x99\x01\x10\x42\xe6\x9e' +get_sn() +b'\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'\x59\x31\x37\x45\x37\x41\x30\x46\x30\x31\x30\x42\x30\x31\x33\x45'
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\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\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\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\x00\x00\x00\x00'
msg += b'\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x40\x10\x08\xc8\x00\x49\x13\x8d\x00\x36\x00\x00\x03\x84\x06\x7a'
msg += b'\x01\x61\x00\xa8\x02\x54\x01\x5a\x00\x8a\x01\xe4\x01\x5a\x00\xbd'
msg += b'\x02\x8f\x00\x11\x00\x01\x00\x00\x00\x0b\x00\x00\x27\x98\x00\x04'
msg += b'\x00\x00\x0c\x04\x00\x03\x00\x00\x0a\xe7\x00\x05\x00\x00\x0c\x75'
msg += b'\x00\x00\x00\x00\x06\x16\x02\x00\x00\x00\x55\xaa\x00\x01\x00\x00'
msg += b'\x00\x00\x00\x00\xff\xff\x03\x84\x00\x03\x04\x00\x04\x00\x04\x00'
msg += b'\x04\x00\x00\x01\xff\xff\x00\x01\x00\x06\x00\x68\x00\x68\x05\x00'
msg += b'\x09\xcd\x07\xb6\x13\x9c\x13\x24\x00\x01\x07\xae\x04\x0f\x00\x41'
msg += b'\x00\x0f\x0a\x64\x0a\x64\x00\x06\x00\x06\x09\xf6\x12\x8c\x12\x8c'
msg += b'\x00\x10\x00\x10\x14\x52\x14\x52\x00\x10\x00\x10\x01\x51\x00\x05'
msg += b'\x04\x00\x00\x01\x13\x9c\x0f\xa0\x00\x4e\x00\x66\x03\xe8\x04\x00'
msg += b'\x09\xce\x07\xa8\x13\x9c\x13\x26\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x00\x00\x00\x00\x04\x00\x04\x00\x00\x00\x00\x00\xff\xff\x00\x00'
msg += b'\x00\x00\x00\x00'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
@pytest.fixture @pytest.fixture
def inverter_ind_msg_81(): # 0x4210 fcode 0x81 def inverter_ind_msg_81(): # 0x4210 fcode 0x81
msg = b'\xa5\x99\x01\x10\x42\x02\x03' +get_sn() +b'\x81\xb0\x02\xbc\xc8' msg = b'\xa5\x99\x01\x10\x42\x02\x03' +get_sn() +b'\x81\xb0\x02\xbc\xc8'
@@ -709,19 +676,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'
@@ -1481,7 +1435,6 @@ async def test_build_modell_600(my_loop, config_tsun_allow_all, inverter_ind_msg
m.read() # read complete msg, and dispatch msg m.read() # read complete msg, and dispatch msg
assert 2000 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) assert 2000 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
assert 600 == m.db.get_db_value(Register.RATED_POWER, 0) assert 600 == m.db.get_db_value(Register.RATED_POWER, 0)
assert 4 == m.db.get_db_value(Register.NO_INPUTS, 0)
assert 'TSOL-MS2000(600)' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) assert 'TSOL-MS2000(600)' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
assert '02b0' == m.db.get_db_value(Register.SENSOR_LIST, None) assert '02b0' == m.db.get_db_value(Register.SENSOR_LIST, None)
assert 0 == m.sensor_list # must not been set by an inverter data ind assert 0 == m.sensor_list # must not been set by an inverter data ind
@@ -1501,7 +1454,6 @@ async def test_build_modell_1600(my_loop, config_tsun_allow_all, inverter_ind_ms
m.read() # read complete msg, and dispatch msg m.read() # read complete msg, and dispatch msg
assert 1600 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) assert 1600 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
assert 1600 == m.db.get_db_value(Register.RATED_POWER, 0) assert 1600 == m.db.get_db_value(Register.RATED_POWER, 0)
assert 4 == m.db.get_db_value(Register.NO_INPUTS, 0)
assert 'TSOL-MS1600' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) assert 'TSOL-MS1600' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close() m.close()
@@ -1515,7 +1467,6 @@ async def test_build_modell_1800(my_loop, config_tsun_allow_all, inverter_ind_ms
m.read() # read complete msg, and dispatch msg m.read() # read complete msg, and dispatch msg
assert 1800 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) assert 1800 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
assert 1800 == m.db.get_db_value(Register.RATED_POWER, 0) assert 1800 == m.db.get_db_value(Register.RATED_POWER, 0)
assert 4 == m.db.get_db_value(Register.NO_INPUTS, 0)
assert 'TSOL-MS1800' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) assert 'TSOL-MS1800' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close() m.close()
@@ -1529,7 +1480,6 @@ async def test_build_modell_2000(my_loop, config_tsun_allow_all, inverter_ind_ms
m.read() # read complete msg, and dispatch msg m.read() # read complete msg, and dispatch msg
assert 2000 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) assert 2000 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
assert 2000 == m.db.get_db_value(Register.RATED_POWER, 0) assert 2000 == m.db.get_db_value(Register.RATED_POWER, 0)
assert 4 == m.db.get_db_value(Register.NO_INPUTS, 0)
assert 'TSOL-MS2000' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) assert 'TSOL-MS2000' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close() m.close()
@@ -1543,21 +1493,6 @@ async def test_build_modell_800(my_loop, config_tsun_allow_all, inverter_ind_msg
m.read() # read complete msg, and dispatch msg m.read() # read complete msg, and dispatch msg
assert 800 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0) assert 800 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
assert 800 == m.db.get_db_value(Register.RATED_POWER, 0) assert 800 == m.db.get_db_value(Register.RATED_POWER, 0)
assert 2 == m.db.get_db_value(Register.NO_INPUTS, 0)
assert 'TSOL-MS800' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close()
@pytest.mark.asyncio
async def test_build_modell_900(my_loop, config_tsun_allow_all, inverter_ind_msg900):
_ = config_tsun_allow_all
m = MemoryStream(inverter_ind_msg900, (0,))
assert 0 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
assert None == m.db.get_db_value(Register.RATED_POWER, None)
assert None == m.db.get_db_value(Register.INVERTER_TEMP, None)
m.read() # read complete msg, and dispatch msg
assert 900 == m.db.get_db_value(Register.MAX_DESIGNED_POWER, 0)
assert 900 == m.db.get_db_value(Register.RATED_POWER, 0)
assert 2 == m.db.get_db_value(Register.NO_INPUTS, 0)
assert 'TSOL-MSxx00' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0) assert 'TSOL-MSxx00' == m.db.get_db_value(Register.EQUIPMENT_MODEL, 0)
m.close() m.close()
@@ -2254,61 +2189,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,7 +2542,6 @@ 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

@@ -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

@@ -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,