Compare commits

...

7 Commits

Author SHA1 Message Date
Stefan Allius
3de23184aa fix the paths to copy the config.example.toml file 2025-05-22 21:25:03 +02:00
renovate[bot]
bb2250bca1 Update dependency coverage to v7.8.1 (#419)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-21 20:51:16 +02:00
Stefan Allius
e25aa5f922 S allius/issue397 (#418)
* change icon for notes
2025-05-20 23:40:26 +02:00
Stefan Allius
46945d55e1 add dcu_power MQTT topic (#416)
* add dcu_power MQTT topic

* add DCU_COMMAND counter

* test invalid dcu_power values

* handle and test DCU Command responses

* test dcu commands from the TSUN cloud

* cleanup MQTT topic handling

* update changelog

* test MQTT error and exception handling

* increase test coverage

* test dispatcher exceptions

* fix full_topic definition in dispatch test
2025-05-20 19:54:24 +02:00
Stefan Allius
c1bdec0844 S allius/issue396 (#413)
* improve translation of delete modal
2025-05-13 22:53:37 +02:00
Stefan Allius
4371f3dadb S allius/issue396 (#412)
* add title to table icons

* optimize datetime formatting

* change icons

* translate n/a
2025-05-13 21:38:33 +02:00
Stefan Allius
907dcb1623 S allius/issue409 (#411)
* scan log files for timestamp as creating timestamp

* increase test coverage

* add an empty file for unit tests

- the empty file is needed for unit tests to force
  an exception on the try to scan the first line
  for an timestamp

* set timezone of scanned creation time
2025-05-13 00:38:06 +02:00
20 changed files with 522 additions and 106 deletions

View File

@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- fix the paths to copy the config.example.toml file during proxy start
- 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

@@ -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.0 coverage==7.8.1
jinja2-cli==0.8.2 jinja2-cli==0.8.2

View File

@@ -162,7 +162,8 @@ class Config():
) )
@classmethod @classmethod
def init(cls, def_reader: ConfigIfc, log_path: str = '') -> None | str: def init(cls, def_reader: ConfigIfc, log_path: 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
@@ -173,12 +174,13 @@ 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.debug('Copy Default Config to config.example.toml') logging.info(
f'Copy Default Config to {cnf_path}config.example.toml')
shutil.copy2("default_config.toml", shutil.copy2("cnf/default_config.toml",
"config/config.example.toml") cnf_path + "config.example.toml")
except Exception: except Exception as e:
pass logging.error(e)
# read example config file as default configuration # read example config file as default configuration
try: try:

39
app/src/gen3plus/solarman_v5.py Normal file → Executable file
View File

@@ -247,6 +247,7 @@ class SolarmanBase(Message):
class SolarmanV5(SolarmanBase): class SolarmanV5(SolarmanBase):
AT_CMD = 1 AT_CMD = 1
MB_RTU_CMD = 2 MB_RTU_CMD = 2
DCU_CMD = 5
AT_CMD_RSP = 8 AT_CMD_RSP = 8
MB_CLIENT_DATA_UP = 30 MB_CLIENT_DATA_UP = 30
'''Data up time in client mode''' '''Data up time in client mode'''
@@ -532,6 +533,26 @@ class SolarmanV5(SolarmanBase):
except Exception: except Exception:
self.ifc.tx_clear() self.ifc.tx_clear()
def send_dcu_cmd(self, pdu: bytearray):
if self.sensor_list != 0x3026:
logger.debug(f'[{self.node_id}] DCU CMD not allowed,'
f' for sensor: {self.sensor_list:#04x}')
return
if self.state != State.up:
logger.warning(f'[{self.node_id}] ignore DCU CMD,'
' cause the state is not UP anymore')
return
self.inverter.forward_dcu_cmd_resp = False
self._build_header(0x4510)
self.ifc.tx_add(struct.pack('<BHLLL', self.DCU_CMD,
self.sensor_list, 0, 0, 0))
self.ifc.tx_add(pdu)
self._finish_send_msg()
self.ifc.tx_log(logging.INFO, f'Send DCU CMD :{self.addr}:')
self.ifc.tx_flush()
def __forward_msg(self): def __forward_msg(self):
self.forward(self.ifc.rx_peek(), self.header_len+self.data_len+2) self.forward(self.ifc.rx_peek(), self.header_len+self.data_len+2)
@@ -647,6 +668,10 @@ class SolarmanV5(SolarmanBase):
self.inc_counter('AT_Command') self.inc_counter('AT_Command')
self.inverter.forward_at_cmd_resp = True self.inverter.forward_at_cmd_resp = True
if ftype == self.DCU_CMD:
self.inc_counter('DCU_Command')
self.inverter.forward_dcu_cmd_resp = True
elif ftype == self.MB_RTU_CMD: elif ftype == self.MB_RTU_CMD:
rstream = self.ifc.remote.stream rstream = self.ifc.remote.stream
if rstream.mb.recv_req(data[15:], if rstream.mb.recv_req(data[15:],
@@ -670,6 +695,10 @@ class SolarmanV5(SolarmanBase):
if self.inverter.forward_at_cmd_resp: if self.inverter.forward_at_cmd_resp:
return logging.INFO return logging.INFO
return logging.DEBUG return logging.DEBUG
elif ftype == self.DCU_CMD:
if self.inverter.forward_dcu_cmd_resp:
return logging.INFO
return logging.DEBUG
elif ftype == self.MB_RTU_CMD \ elif ftype == self.MB_RTU_CMD \
and self.server_side: and self.server_side:
return self.mb.last_log_lvl return self.mb.last_log_lvl
@@ -689,6 +718,16 @@ class SolarmanV5(SolarmanBase):
logger.info(f'{key}: {data_json}') logger.info(f'{key}: {data_json}')
self.publish_mqtt(f'{Proxy.entity_prfx}{node_id}{key}', data_json) # noqa: E501 self.publish_mqtt(f'{Proxy.entity_prfx}{node_id}{key}', data_json) # noqa: E501
return return
elif ftype == self.DCU_CMD:
if not self.inverter.forward_dcu_cmd_resp:
data_json = '+ok'
node_id = self.node_id
key = 'dcu_resp'
logger.info(f'{key}: {data_json}')
self.publish_mqtt(f'{Proxy.entity_prfx}{node_id}{key}', data_json) # noqa: E501
return
elif ftype == self.MB_RTU_CMD: elif ftype == self.MB_RTU_CMD:
self.__modbus_command_rsp(data) self.__modbus_command_rsp(data)
return return

View File

@@ -44,6 +44,7 @@ class Register(Enum):
MODBUS_COMMAND = 60 MODBUS_COMMAND = 60
AT_COMMAND_BLOCKED = 61 AT_COMMAND_BLOCKED = 61
CLOUD_CONN_CNT = 62 CLOUD_CONN_CNT = 62
DCU_COMMAND = 63
OUTPUT_POWER = 83 OUTPUT_POWER = 83
RATED_POWER = 84 RATED_POWER = 84
INVERTER_TEMP = 85 INVERTER_TEMP = 85
@@ -625,6 +626,7 @@ class Infos:
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': FMT_INT, 'name': 'Invalid Message Format', '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': 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': FMT_INT, 'name': 'AT Command', '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': 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': FMT_INT, 'name': 'AT Command Blocked', 'icon': COUNTER, 'ent_cat': 'diagnostic'}}, # noqa: E501
Register.DCU_COMMAND: {'name': ['proxy', 'DCU_Command'], 'singleton': True, 'ha': {'dev': 'proxy', 'comp': 'sensor', 'dev_cla': None, 'stat_cla': None, 'id': 'dcu_cmd_', 'fmt': FMT_INT, 'name': 'DCU 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': 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': FMT_INT, 'name': 'Modbus Command', 'icon': 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':FMT_FLOAT,'name': 'Grid Voltage'}}, # noqa: E501

136
app/src/mqtt.py Normal file → Executable file
View File

@@ -2,6 +2,8 @@ import asyncio
import logging import logging
import aiomqtt import aiomqtt
import traceback import traceback
import struct
import inspect
from modbus import Modbus from modbus import Modbus
from messages import Message from messages import Message
@@ -27,14 +29,27 @@ class Mqtt(metaclass=Singleton):
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
self.topic_defs = [
{'prefix': 'auto_conf_prefix', 'topic': '/status',
'fnc': self._ha_status, 'args': []},
{'prefix': 'entity_prefix', 'topic': '/+/rated_load',
'fnc': self._modbus_cmd,
'args': [Modbus.WRITE_SINGLE_REG, 1, 0x2008]},
{'prefix': 'entity_prefix', 'topic': '/+/out_coeff',
'fnc': self._out_coeff, 'args': []},
{'prefix': 'entity_prefix', 'topic': '/+/dcu_power',
'fnc': self._dcu_cmd, 'args': []},
{'prefix': 'entity_prefix', 'topic': '/+/modbus_read_regs',
'fnc': self._modbus_cmd, 'args': [Modbus.READ_REGS, 2]},
{'prefix': 'entity_prefix', 'topic': '/+/modbus_read_inputs',
'fnc': self._modbus_cmd, 'args': [Modbus.READ_INPUTS, 2]},
{'prefix': 'entity_prefix', 'topic': '/+/at_cmd',
'fnc': self._at_cmd, 'args': []},
]
ha = Config.get('ha') ha = Config.get('ha')
self.ha_status_topic = f"{ha['auto_conf_prefix']}/status" for entry in self.topic_defs:
self.mb_rated_topic = f"{ha['entity_prefix']}/+/rated_load" entry['full_topic'] = f"{ha[entry['prefix']]}{entry['topic']}"
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):
@@ -75,19 +90,7 @@ class Mqtt(metaclass=Singleton):
try: try:
async with self.__client: async with self.__client:
logger_mqtt.info('MQTT broker connection established') logger_mqtt.info('MQTT broker connection established')
self.ctime = datetime.now() await self._init_new_conn()
self.published = 0
self.received = 0
if self.__cb_mqtt_is_up:
await self.__cb_mqtt_is_up()
await self.__client.subscribe(self.ha_status_topic)
await self.__client.subscribe(self.mb_rated_topic)
await self.__client.subscribe(self.mb_out_coeff_topic)
await self.__client.subscribe(self.mb_reads_topic)
await self.__client.subscribe(self.mb_inputs_topic)
await self.__client.subscribe(self.mb_at_cmd_topic)
async for message in self.__client.messages: async for message in self.__client.messages:
await self.dispatch_msg(message) await self.dispatch_msg(message)
@@ -117,47 +120,51 @@ class Mqtt(metaclass=Singleton):
f"Exception:\n" f"Exception:\n"
f"{traceback.format_exc()}") f"{traceback.format_exc()}")
async def _init_new_conn(self):
self.ctime = datetime.now()
self.published = 0
self.received = 0
if self.__cb_mqtt_is_up:
await self.__cb_mqtt_is_up()
for entry in self.topic_defs:
await self.__client.subscribe(entry['full_topic'])
async def dispatch_msg(self, message): async def dispatch_msg(self, message):
self.received += 1 self.received += 1
if message.topic.matches(self.ha_status_topic): for entry in self.topic_defs:
status = message.payload.decode("UTF-8") if message.topic.matches(entry['full_topic']) \
logger_mqtt.info('Home-Assistant Status:' and 'fnc' in entry:
f' {status}') fnc = entry['fnc']
if status == 'online':
self.ha_restarts += 1 if inspect.iscoroutinefunction(fnc):
await entry['fnc'](message, *entry['args'])
elif callable(fnc):
entry['fnc'](message, *entry['args'])
async def _ha_status(self, message):
status = message.payload.decode("UTF-8")
logger_mqtt.info('Home-Assistant Status:'
f' {status}')
if status == 'online':
self.ha_restarts += 1
if self.__cb_mqtt_is_up:
await self.__cb_mqtt_is_up() await self.__cb_mqtt_is_up()
if message.topic.matches(self.mb_rated_topic): async def _out_coeff(self, message):
await self.modbus_cmd(message, payload = message.payload.decode("UTF-8")
Modbus.WRITE_SINGLE_REG, try:
1, 0x2008) val = round(float(payload) * 1024/100)
if val < 0 or val > 1024:
if message.topic.matches(self.mb_out_coeff_topic): logger_mqtt.error('out_coeff: value must be in'
payload = message.payload.decode("UTF-8") 'the range 0..100,'
try: f' got: {payload}')
val = round(float(payload) * 1024/100) else:
if val < 0 or val > 1024: await self._modbus_cmd(message,
logger_mqtt.error('out_coeff: value must be in' Modbus.WRITE_SINGLE_REG,
'the range 0..100,' 0, 0x202c, val)
f' got: {payload}') except Exception:
else: pass
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)
@@ -175,7 +182,7 @@ class Mqtt(metaclass=Singleton):
else: else:
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):
payload = message.payload.decode("UTF-8") payload = message.payload.decode("UTF-8")
for fnc in self.each_inverter(message, "send_modbus_cmd"): for fnc in self.each_inverter(message, "send_modbus_cmd"):
res = payload.split(',') res = payload.split(',')
@@ -190,7 +197,22 @@ class Mqtt(metaclass=Singleton):
val = int(res[1]) # lenght val = int(res[1]) # lenght
await fnc(func, addr, val, logging.INFO) 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")
for fnc in self.each_inverter(message, "send_at_cmd"): for fnc in self.each_inverter(message, "send_at_cmd"):
await fnc(payload) await fnc(payload)
def _dcu_cmd(self, message):
payload = message.payload.decode("UTF-8")
try:
val = round(float(payload) * 10)
if val < 1000 or val > 8000:
logger_mqtt.error('dcu_power: value must be in'
'the range 100..800,'
f' got: {payload}')
else:
pdu = struct.pack('>BBBBBBH', 1, 1, 6, 1, 0, 1, val)
for fnc in self.each_inverter(message, "send_dcu_cmd"):
fnc(pdu)
except Exception:
pass

View File

@@ -127,7 +127,8 @@ 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

@@ -10,39 +10,40 @@ from .log_handler import LogHandler
def _get_device_icon(client_mode: bool): def _get_device_icon(client_mode: bool):
'''returns the icon for the device conntection''' '''returns the icon for the device conntection'''
if client_mode: if client_mode:
return 'fa-download fa-rotate-180' return 'fa-download fa-rotate-180', 'Server Mode'
return 'fa-upload fa-rotate-180' return 'fa-upload fa-rotate-180', 'Client Mode'
def _get_cloud_icon(emu_mode: bool): def _get_cloud_icon(emu_mode: bool):
'''returns the icon for the cloud conntection''' '''returns the icon for the cloud conntection'''
if emu_mode: if emu_mode:
return 'fa-cloud-arrow-up-alt' return 'fa-cloud-arrow-up-alt', 'Emu Mode'
return 'fa-cloud' return 'fa-cloud', 'Proxy Mode'
def _get_row(inv: InverterBase): def _get_row(inv: InverterBase):
'''build one row for the connection table''' '''build one row for the connection table'''
client_mode = inv.client_mode client_mode = inv.client_mode
inv_serial = inv.local.stream.inv_serial inv_serial = inv.local.stream.inv_serial
icon1 = _get_device_icon(client_mode) icon1, descr1 = _get_device_icon(client_mode)
ip1, port1 = inv.addr ip1, port1 = inv.addr
icon2 = '' icon2 = ''
descr2 = ''
ip2 = '--' ip2 = '--'
port2 = '--' port2 = '--'
if inv.remote.ifc: if inv.remote.ifc:
ip2, port2 = inv.remote.ifc.r_addr ip2, port2 = inv.remote.ifc.r_addr
icon2 = _get_cloud_icon(client_mode) icon2, descr2 = _get_cloud_icon(client_mode)
row = [] row = []
row.append(f'<i class="fa {icon1}"></i> {ip1}:{port1}') row.append(f'<i class="fa {icon1}" title="{_(descr1)}"></i> {ip1}:{port1}')
row.append(f'<i class="fa {icon1}"></i> {ip1}') row.append(f'<i class="fa {icon1}" title="{_(descr1)}"></i> {ip1}')
row.append(inv_serial) row.append(inv_serial)
row.append(f'<i class="fa {icon2}"></i> {ip2}:{port2}') row.append(f'<i class="fa {icon2}" title="{_(descr2)}"></i> {ip2}:{port2}')
row.append(f'<i class="fa {icon2}"></i> {ip2}') row.append(f'<i class="fa {icon2}" title="{_(descr2)}"></i> {ip2}')
return row return row

View File

@@ -1,26 +1,58 @@
from quart import render_template from quart import render_template
from quart_babel import format_datetime, format_decimal from quart_babel import format_datetime, format_decimal, _
from quart.helpers import send_from_directory from quart.helpers import send_from_directory
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from cnf.config import Config from cnf.config import Config
from datetime import datetime
from os import DirEntry
import os import os
from dateutil import tz
from . import web from . import web
def _get_file(file): def _get_birth_from_log(path: str) -> None | datetime:
'''read timestamp from the first line of a log file'''
dt = None
try:
with open(path) as f:
first_line = f.readline()
first_line = first_line.lstrip("'")
fmt = "%Y-%m-%d %H:%M:%S" if first_line[4] == '-' \
else "%d-%m-%Y %H:%M:%S"
dt = datetime.strptime(first_line[0:19], fmt). \
replace(tzinfo=tz.tzlocal())
except Exception:
pass
return dt
def _get_file(file: DirEntry) -> dict:
'''build one row for the connection table''' '''build one row for the connection table'''
entry = {} entry = {}
entry['name'] = file.name entry['name'] = file.name
stat = file.stat() stat = file.stat()
entry['size'] = format_decimal(stat.st_size) entry['size'] = format_decimal(stat.st_size)
entry['date'] = stat.st_mtime try:
entry['created'] = format_datetime(stat.st_ctime, format="short") dt = stat.st_birthtime
except Exception:
dt = _get_birth_from_log(file.path)
if dt:
entry['created'] = format_datetime(dt, format="short")
# sort by creating date, if available
entry['date'] = dt if isinstance(dt, float) else dt.timestamp()
else:
entry['created'] = _('n/a')
entry['date'] = stat.st_mtime
entry['modified'] = format_datetime(stat.st_mtime, format="short") entry['modified'] = format_datetime(stat.st_mtime, format="short")
return entry return entry
def get_list_data(): def get_list_data() -> list:
'''build the connection table''' '''build the connection table'''
file_list = [] file_list = []
with os.scandir(Config.get_log_path()) as it: with os.scandir(Config.get_log_path()) as it:

View File

@@ -46,10 +46,13 @@ def get_table_data():
@web.route('/mqtt-fetch') @web.route('/mqtt-fetch')
async def mqtt_fetch(): async def mqtt_fetch():
mqtt = Mqtt(None) mqtt = Mqtt(None)
ctime = format_datetime(dt=mqtt.ctime, format='short') cdatetime = format_datetime(dt=mqtt.ctime, format='d.MM. HH:mm')
data = { data = {
"update-time": format_datetime(format="medium"), "update-time": format_datetime(format="medium"),
"mqtt-ctime": f"<h3>{ctime}</h3>", "mqtt-ctime": f"""
<h3 class="w3-hide-small w3-hide-medium">{cdatetime}</h3>
<h4 class="w3-hide-large">{cdatetime}</h4>
""",
"mqtt-tx": f"<h3>{mqtt.published}</h3>", "mqtt-tx": f"<h3>{mqtt.published}</h3>",
"mqtt-rx": f"<h3>{mqtt.received}</h3>", "mqtt-rx": f"<h3>{mqtt.received}</h3>",
} }

View File

@@ -57,7 +57,7 @@
<button href="#" class="w3-bar-item w3-button w3-padding-16 w3-hide-large w3-dark-grey w3-hover-black" onclick="w3_close()" title="close menu"><i class="fa fa-remove fa-fw"></i>  Close Menu</button> <button href="#" class="w3-bar-item w3-button w3-padding-16 w3-hide-large w3-dark-grey w3-hover-black" onclick="w3_close()" title="close menu"><i class="fa fa-remove fa-fw"></i>  Close Menu</button>
<a href="{{ url_for('.index')}}" class="w3-bar-item w3-button w3-padding {% block menu1_class %}{% endblock %}"><i class="fa fa-network-wired fa-fw"></i>  {{_('Connections')}}</a> <a href="{{ url_for('.index')}}" class="w3-bar-item w3-button w3-padding {% block menu1_class %}{% endblock %}"><i class="fa fa-network-wired fa-fw"></i>  {{_('Connections')}}</a>
<a href="{{ url_for('.mqtt')}}" class="w3-bar-item w3-button w3-padding {% block menu2_class %}{% endblock %}"><i class="fa fa-database fa-fw"></i>  MQTT</a> <a href="{{ url_for('.mqtt')}}" class="w3-bar-item w3-button w3-padding {% block menu2_class %}{% endblock %}"><i class="fa fa-database fa-fw"></i>  MQTT</a>
<a href="{{ url_for('.notes')}}" class="w3-bar-item w3-button w3-padding {% block menu3_class %}{% endblock %}"><i class="fa fa-exclamation-triangle fa-fw"></i>  {{_('Important Messages')}}</a> <a href="{{ url_for('.notes')}}" class="w3-bar-item w3-button w3-padding {% block menu3_class %}{% endblock %}"><i class="fa fa-info fa-fw"></i>  {{_('Important Messages')}}</a>
<a href="{{ url_for('.logging')}}" class="w3-bar-item w3-button w3-padding {% block menu4_class %}{% endblock %}"><i class="fa fa-file-export fa-fw"></i>  {{_('Log Files')}}</a> <a href="{{ url_for('.logging')}}" class="w3-bar-item w3-button w3-padding {% block menu4_class %}{% endblock %}"><i class="fa fa-file-export fa-fw"></i>  {{_('Log Files')}}</a>
</div> </div>
</nav> </nav>

View File

@@ -7,9 +7,9 @@
<div id="id01" class="w3-modal"> <div id="id01" class="w3-modal">
<div class="w3-modal-content" style="width:600px"> <div class="w3-modal-content" style="width:600px">
<div class="w3-container w3-padding-24"> <div class="w3-container w3-padding-24">
<h2>{{_("Do you really want to delete the log file")}}:<br><b><span id="id03"></span></b> ?</h2> <h2>{{_('Do you really want to delete the log file: <br>%(file)s ?', file='<b><span id="id03"></span></b>')}}</h2>
<div class="w3-bar"> <div class="w3-bar">
<button id="id02" class="w3-button w3-red" onclick="deleteFile(); document.getElementById('id01').style.display='none'">{{_('Delete File</button')}}> <button id="id02" class="w3-button w3-red" onclick="deleteFile(); document.getElementById('id01').style.display='none'">{{_('Delete File')}}</button>
<button class="w3-button w3-grey w3-right" onclick="document.getElementById('id01').style.display='none'">{{_('Abort')}}</button> <button class="w3-button w3-grey w3-right" onclick="document.getElementById('id01').style.display='none'">{{_('Abort')}}</button>
</div> </div>
</div> </div>

View File

@@ -8,7 +8,7 @@
<div class="w3-third"> <div class="w3-third">
<div class="w3-card-4"> <div class="w3-card-4">
<div class="w3-container w3-indigo w3-padding-16"> <div class="w3-container w3-indigo w3-padding-16">
<div class="w3-left"><i class="fa fa-link w3-xxxlarge"></i></div> <div class="w3-left"><i class="fa fa-business-time w3-xxxlarge"></i></div>
<div id = "mqtt-ctime" class="w3-right"> <div id = "mqtt-ctime" class="w3-right">
<h3>-</h3> <h3>-</h3>
</div> </div>
@@ -21,7 +21,7 @@
<div class="w3-third"> <div class="w3-third">
<div class="w3-card-4"> <div class="w3-card-4">
<div class="w3-container w3-purple w3-padding-16"> <div class="w3-container w3-purple w3-padding-16">
<div class="w3-left"><i class="fa fa-server w3-xxxlarge"></i></div> <div class="w3-left"><i class="fa fa-angle-double-right w3-xxxlarge"></i></div>
<div id = "mqtt-tx" class="w3-right"> <div id = "mqtt-tx" class="w3-right">
<h3>-</h3> <h3>-</h3>
</div> </div>
@@ -34,7 +34,7 @@
<div class="w3-third"> <div class="w3-third">
<div class="w3-card-4"> <div class="w3-card-4">
<div class="w3-container w3-orange w3-text-white w3-padding-16"> <div class="w3-container w3-orange w3-text-white w3-padding-16">
<div class="w3-left"><i class="fa fa-user w3-xxxlarge"></i></div> <div class="w3-left"><i class="fa fa-angle-double-left w3-xxxlarge"></i></div>
<div id = "mqtt-rx" class="w3-right"> <div id = "mqtt-rx" class="w3-right">
<h3>-</h3> <h3>-</h3>
</div> </div>

View File

@@ -2,7 +2,7 @@
{% block title %}{{_("TSUN Proxy - Important Messages")}}{% endblock title %} {% block title %}{{_("TSUN Proxy - Important Messages")}}{% endblock title %}
{% block menu3_class %}w3-blue{% endblock %} {% block menu3_class %}w3-blue{% endblock %}
{% block headline %}<i class="fa fa-exclamation-triangle fa-fw"></i>  {{_('Important Messages')}}{% endblock headline %} {% block headline %}<i class="fa fa-info fa-fw"></i>  {{_('Important Messages')}}{% endblock headline %}
{% block content %} {% block content %}
<div id="notes-list"></div> <div id="notes-list"></div>
{% endblock content%} {% endblock content%}

0
app/tests/log/empty.txt Normal file
View File

View File

@@ -17,13 +17,13 @@ def test_statistic_counter():
assert val == None or val == 0 assert val == None or val == 0
i.static_init() # initialize counter i.static_init() # initialize counter
assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}}) assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 0, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "DCU_Command": 0, "Modbus_Command": 0}})
val = i.dev_value(Register.INVERTER_CNT) # valid and initiliazed addr val = i.dev_value(Register.INVERTER_CNT) # valid and initiliazed addr
assert val == 0 assert val == 0
i.inc_counter('Inverter_Cnt') i.inc_counter('Inverter_Cnt')
assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "Modbus_Command": 0}}) assert json.dumps(i.stat) == json.dumps({"proxy": {"Inverter_Cnt": 1, "Cloud_Conn_Cnt": 0, "Unknown_SNR": 0, "Unknown_Msg": 0, "Invalid_Data_Type": 0, "Internal_Error": 0,"Unknown_Ctrl": 0, "OTA_Start_Msg": 0, "SW_Exception": 0, "Invalid_Msg_Format": 0, "AT_Command": 0, "AT_Command_Blocked": 0, "DCU_Command": 0, "Modbus_Command": 0}})
val = i.dev_value(Register.INVERTER_CNT) val = i.dev_value(Register.INVERTER_CNT)
assert val == 1 assert val == 1

141
app/tests/test_mqtt.py Normal file → Executable file
View File

@@ -3,8 +3,9 @@ import pytest
import asyncio import asyncio
import aiomqtt import aiomqtt
import logging import logging
from aiomqtt import MqttError
from mock import patch, Mock from mock import patch, Mock
from async_stream import AsyncIfcImpl from async_stream import AsyncIfcImpl
from singleton import Singleton from singleton import Singleton
from mqtt import Mqtt from mqtt import Mqtt
@@ -17,7 +18,7 @@ NO_MOSQUITTO_TEST = False
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ('pytest_asyncio',)
@pytest.fixture(scope="module", autouse=True) @pytest.fixture(scope="function", autouse=True)
def module_init(): def module_init():
Singleton._instances.clear() Singleton._instances.clear()
yield yield
@@ -44,6 +45,14 @@ def config_no_conn(test_port):
Config.act_config = {'mqtt':{'host': "", 'port': test_port, 'user': '', 'passwd': ''}, Config.act_config = {'mqtt':{'host': "", 'port': test_port, 'user': '', 'passwd': ''},
'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'} 'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'}
} }
Config.def_config = {}
@pytest.fixture
def config_def_conn(test_port):
Config.act_config = {'mqtt':{'host': "unknown_url", 'port': test_port, 'user': '', 'passwd': ''},
'ha':{'auto_conf_prefix': 'homeassistant','discovery_prefix': 'homeassistant', 'entity_prefix': 'tsun'}
}
Config.def_config = Config.act_config
@pytest.fixture @pytest.fixture
def spy_at_cmd(): def spy_at_cmd():
@@ -69,6 +78,14 @@ def spy_modbus_cmd_client():
yield wrapped_conn yield wrapped_conn
conn.close() conn.close()
@pytest.fixture
def spy_dcu_cmd():
conn = SolarmanV5(None, ('test.local', 1234), server_side=True, client_mode= False, ifc=AsyncIfcImpl())
conn.node_id = 'inv_3/'
with patch.object(conn, 'send_dcu_cmd', wraps=conn.send_dcu_cmd) as wrapped_conn:
yield wrapped_conn
conn.close()
def test_native_client(test_hostname, test_port): def test_native_client(test_hostname, test_port):
"""Sanity check: Make sure the paho-mqtt client can connect to the test """Sanity check: Make sure the paho-mqtt client can connect to the test
MQTT server. Otherwise the test set NO_MOSQUITTO_TEST to True and disable MQTT server. Otherwise the test set NO_MOSQUITTO_TEST to True and disable
@@ -167,12 +184,81 @@ async def test_mqtt_no_config(config_no_conn):
finally: finally:
await m.close() await m.close()
@pytest.mark.asyncio
async def test_mqtt_except_no_config(config_no_conn, monkeypatch, caplog):
_ = config_no_conn
assert asyncio.get_running_loop()
async def my_aenter(self):
raise MqttError('TestException') from None
monkeypatch.setattr(aiomqtt.Client, "__aenter__", my_aenter)
LOGGER = logging.getLogger("mqtt")
LOGGER.propagate = True
LOGGER.setLevel(logging.INFO)
with caplog.at_level(logging.INFO):
m = Mqtt(None)
assert m.task
await asyncio.sleep(0)
try:
await m.publish('homeassistant/status', 'online')
assert False
except MqttError:
pass
except Exception:
assert False
finally:
await m.close()
assert 'Connection lost; Reconnecting in 5 seconds' in caplog.text
@pytest.mark.asyncio
async def test_mqtt_except_def_config(config_def_conn, monkeypatch, caplog):
_ = config_def_conn
assert asyncio.get_running_loop()
on_connect = asyncio.Event()
async def cb():
on_connect.set()
async def my_aenter(self):
raise MqttError('TestException') from None
monkeypatch.setattr(aiomqtt.Client, "__aenter__", my_aenter)
LOGGER = logging.getLogger("mqtt")
LOGGER.propagate = True
LOGGER.setLevel(logging.INFO)
with caplog.at_level(logging.INFO):
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 MqttError:
pass
except Exception:
assert False
finally:
await m.close()
assert 'MQTT is unconfigured; Check your config.toml!' in caplog.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_msg_dispatch(config_mqtt_conn, spy_modbus_cmd): async def test_msg_dispatch(config_mqtt_conn, spy_modbus_cmd):
_ = config_mqtt_conn _ = config_mqtt_conn
spy = spy_modbus_cmd spy = spy_modbus_cmd
try: try:
m = Mqtt(None) m = Mqtt(None)
msg = aiomqtt.Message(topic= 'homeassistant/status', payload= b'online', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
assert m.ha_restarts == 1
msg = aiomqtt.Message(topic= 'tsun/inv_1/rated_load', payload= b'2', qos= 0, retain = False, mid= 0, properties= 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) 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)
@@ -197,6 +283,23 @@ async def test_msg_dispatch(config_mqtt_conn, spy_modbus_cmd):
await m.dispatch_msg(msg) 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
m.topic_defs.clear()
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_not_called()
# test dispatching with incomplete mapping table - invalid fnc defined
m.topic_defs.append(
{'prefix': 'entity_prefix', 'topic': '/+/modbus_read_inputs',
'full_topic': 'tsun/+/modbus_read_inputs', 'fnc': 'invalid'}
)
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_not_called()
finally: finally:
await m.close() await m.close()
@@ -227,6 +330,12 @@ async def test_msg_dispatch_err(config_mqtt_conn, spy_modbus_cmd):
msg = aiomqtt.Message(topic= 'tsun/inv_1/modbus_read_regs', payload= b'0x3000, 10, 7', qos= 0, retain = False, mid= 0, properties= None) 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) await m.dispatch_msg(msg)
spy.assert_not_called() spy.assert_not_called()
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()
finally: finally:
await m.close() await m.close()
@@ -267,3 +376,31 @@ async def test_at_cmd_dispatch(config_mqtt_conn, spy_at_cmd):
finally: finally:
await m.close() await m.close()
@pytest.mark.asyncio
async def test_dcu_dispatch(config_mqtt_conn, spy_dcu_cmd):
_ = config_mqtt_conn
spy = spy_dcu_cmd
try:
m = Mqtt(None)
msg = aiomqtt.Message(topic= 'tsun/inv_3/dcu_power', payload= b'100.0', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_called_once_with(b'\x01\x01\x06\x01\x00\x01\x03\xe8')
finally:
await m.close()
@pytest.mark.asyncio
async def test_dcu_inv_value(config_mqtt_conn, spy_dcu_cmd):
_ = config_mqtt_conn
spy = spy_dcu_cmd
try:
m = Mqtt(None)
msg = aiomqtt.Message(topic= 'tsun/inv_3/dcu_power', payload= b'99.9', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called()
msg = aiomqtt.Message(topic= 'tsun/inv_3/dcu_power', payload= b'800.1', qos= 0, retain = False, mid= 0, properties= None)
await m.dispatch_msg(msg)
spy.assert_not_called()
finally:
await m.close()

152
app/tests/test_solarman.py Normal file → Executable file
View File

@@ -812,6 +812,26 @@ def dcu_data_rsp_msg(): # 0x1210
msg += b'\x15' msg += b'\x15'
return msg return msg
@pytest.fixture
def dcu_command_ind_msg(): # 0x4510
msg = b'\xa5\x17\x00\x10\x45\x94\x02' +get_dcu_sn() +b'\x05\x26\x30'
msg += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
msg += b'\x01\x01\x06\x01\x00\x01\x03\xe8'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
@pytest.fixture
def dcu_command_rsp_msg(): # 0x1510
msg = b'\xa5\x11\x00\x10\x15\x94\x03' +get_dcu_sn() +b'\x05\x01'
msg += total()
msg += hb()
msg += b'\x00\x00\x00\x00'
msg += b'\x01\x01\x01'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
@pytest.fixture @pytest.fixture
def config_tsun_allow_all(): def config_tsun_allow_all():
Config.act_config = { Config.act_config = {
@@ -854,7 +874,17 @@ def config_tsun_scan_dcu():
@pytest.fixture @pytest.fixture
def config_tsun_dcu1(): def config_tsun_dcu1():
Config.act_config = {'solarman':{'enabled': True},'batteries':{'4100000000000001':{'monitor_sn': 2070233888, 'node_id':'inv1/', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}} Config.act_config = {
'ha':{
'auto_conf_prefix': 'homeassistant',
'discovery_prefix': 'homeassistant',
'entity_prefix': 'tsun',
'proxy_node_id': 'test_1',
'proxy_unique_id': ''
},
'solarman':{'enabled': True, 'host': 'test_cloud.local', 'port': 1234},'batteries':{'4100000000000001':{'monitor_sn': 2070233888, 'node_id':'inv1/', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}}
Proxy.class_init()
Proxy.mqtt = Mqtt()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_read_message(device_ind_msg): async def test_read_message(device_ind_msg):
@@ -2402,3 +2432,123 @@ async def test_proxy_at_blocked(my_loop, config_tsun_inv1, patch_open_connection
assert Proxy.mqtt.key == 'tsun/inv1/at_resp' assert Proxy.mqtt.key == 'tsun/inv1/at_resp'
assert Proxy.mqtt.data == "+ok" assert Proxy.mqtt.data == "+ok"
@pytest.mark.asyncio
async def test_dcu_cmd(my_loop, config_tsun_allow_all, dcu_dev_ind_msg, dcu_dev_rsp_msg, dcu_data_ind_msg, dcu_data_rsp_msg, dcu_command_ind_msg, dcu_command_rsp_msg):
'''test dcu_power command fpr a DCU device with sensor 0x3026'''
_ = config_tsun_allow_all
m = MemoryStream(dcu_dev_ind_msg, (0,), True)
m.read() # read device ind
assert m.control == 0x4110
assert str(m.seq) == '01:92'
assert m.ifc.tx_fifo.get()==dcu_dev_rsp_msg
assert m.ifc.fwd_fifo.get()==dcu_dev_ind_msg
m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8')
assert m.ifc.tx_fifo.get()==b''
assert m.ifc.fwd_fifo.get()==b''
assert m.sent_pdu == b''
assert str(m.seq) == '01:92'
assert Proxy.mqtt.key == ''
assert Proxy.mqtt.data == ""
m.append_msg(dcu_data_ind_msg)
m.read() # read inverter ind
assert m.control == 0x4210
assert str(m.seq) == '02:93'
assert m.ifc.tx_fifo.get()==dcu_data_rsp_msg
assert m.ifc.fwd_fifo.get()==dcu_data_ind_msg
m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8')
assert m.ifc.fwd_fifo.get() == b''
assert m.ifc.tx_fifo.get()== b''
assert m.sent_pdu == dcu_command_ind_msg
m.sent_pdu = bytearray()
assert str(m.seq) == '02:94'
assert Proxy.mqtt.key == ''
assert Proxy.mqtt.data == ""
m.append_msg(dcu_command_rsp_msg)
m.read() # read at resp
assert m.control == 0x1510
assert str(m.seq) == '03:94'
assert m.ifc.rx_get()==b''
assert m.ifc.tx_fifo.get()==b''
assert m.ifc.fwd_fifo.get()==b''
assert Proxy.mqtt.key == 'tsun/dcu_resp'
assert Proxy.mqtt.data == "+ok"
Proxy.mqtt.clear() # clear last test result
@pytest.mark.asyncio
async def test_dcu_cmd_not_supported(my_loop, config_tsun_allow_all, device_ind_msg, device_rsp_msg, inverter_ind_msg, inverter_rsp_msg):
'''test that an inverter don't accept the dcu_power command'''
_ = config_tsun_allow_all
m = MemoryStream(device_ind_msg, (0,), True)
m.read() # read device ind
assert m.control == 0x4110
assert str(m.seq) == '01:01'
assert m.ifc.tx_fifo.get()==device_rsp_msg
assert m.ifc.fwd_fifo.get()==device_ind_msg
m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8')
assert m.ifc.tx_fifo.get()==b''
assert m.ifc.fwd_fifo.get()==b''
assert m.sent_pdu == b''
assert str(m.seq) == '01:01'
assert Proxy.mqtt.key == ''
assert Proxy.mqtt.data == ""
m.append_msg(inverter_ind_msg)
m.read() # read inverter ind
assert m.control == 0x4210
assert str(m.seq) == '02:02'
assert m.ifc.tx_fifo.get()==inverter_rsp_msg
assert m.ifc.fwd_fifo.get()==inverter_ind_msg
m.send_dcu_cmd(b'\x01\x01\x06\x01\x00\x01\x03\xe8')
assert m.ifc.fwd_fifo.get() == b''
assert m.ifc.tx_fifo.get()== b''
assert m.sent_pdu == b''
Proxy.mqtt.clear() # clear last test result
@pytest.mark.asyncio
async def test_proxy_dcu_cmd(my_loop, config_tsun_dcu1, patch_open_connection, dcu_command_ind_msg, dcu_command_rsp_msg):
_ = config_tsun_inv1
_ = patch_open_connection
assert asyncio.get_running_loop()
with InverterTest(FakeReader(), FakeWriter(), client_mode=False) as inverter:
await inverter.create_remote()
await asyncio.sleep(0)
r = inverter.remote.stream
l = inverter.local.stream
l.db.stat['proxy']['DCU_Command'] = 0
l.db.stat['proxy']['AT_Command'] = 0
l.db.stat['proxy']['Unknown_Ctrl'] = 0
l.db.stat['proxy']['AT_Command_Blocked'] = 0
l.db.stat['proxy']['Modbus_Command'] = 0
inverter.forward_dcu_cmd_resp = False
r.append_msg(dcu_command_ind_msg)
r.read() # read complete msg, and dispatch msg
assert inverter.forward_dcu_cmd_resp
inverter.forward(r,l)
assert l.ifc.tx_fifo.get()==dcu_command_ind_msg
assert l.db.stat['proxy']['Invalid_Msg_Format'] == 0
assert l.db.stat['proxy']['DCU_Command'] == 1
assert l.db.stat['proxy']['AT_Command'] == 0
assert l.db.stat['proxy']['AT_Command_Blocked'] == 0
assert l.db.stat['proxy']['Modbus_Command'] == 0
l.append_msg(dcu_command_rsp_msg)
l.read() # read at resp
assert l.ifc.fwd_fifo.peek()==dcu_command_rsp_msg
inverter.forward(l,r)
assert r.ifc.tx_fifo.get()==dcu_command_rsp_msg
assert Proxy.mqtt.key == ''
assert Proxy.mqtt.data == ""

View File

@@ -9,6 +9,8 @@ from cnf.config import Config
from mock import patch from mock import patch
from proxy import Proxy from proxy import Proxy
import os, errno import os, errno
from os import DirEntry, stat_result
import datetime
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ('pytest_asyncio',)
@@ -201,14 +203,33 @@ async def test_notes_fetch(client, config_conn):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_file_fetch(client, config_conn): async def test_file_fetch(client, config_conn, monkeypatch):
"""Test the data-fetch route.""" """Test the data-fetch route."""
_ = config_conn _ = config_conn
assert Config.log_path == 'app/tests/log/' assert Config.log_path == 'app/tests/log/'
def my_stat1(*arg):
stat = stat_result
stat.st_size = 20
stat.st_birthtime = datetime.datetime(2024, 1, 31, 10, 30, 15)
stat.st_mtime = datetime.datetime(2024, 1, 1, 1, 30, 15).timestamp()
return stat
monkeypatch.setattr(DirEntry, "stat", my_stat1)
response = await client.get('/file-fetch') response = await client.get('/file-fetch')
assert response.status_code == 200 assert response.status_code == 200
def my_stat2(*arg):
stat = stat_result
stat.st_size = 20
stat.st_mtime = datetime.datetime(2024, 1, 1, 1, 30, 15).timestamp()
return stat
monkeypatch.setattr(DirEntry, "stat", my_stat2)
monkeypatch.delattr(stat_result, "st_birthtime")
response = await client.get('/file-fetch')
assert response.status_code == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_file(client, config_conn): async def test_send_file(client, config_conn):
"""Test the send-file route.""" """Test the send-file route."""

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-04 18:16+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"
@@ -19,30 +19,34 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
#: src/web/conn_table.py:52 src/web/templates/base.html.j2:58 #: src/web/conn_table.py:53 src/web/templates/base.html.j2:58
msgid "Connections" msgid "Connections"
msgstr "Verbindungen" msgstr "Verbindungen"
#: src/web/conn_table.py:59 #: src/web/conn_table.py:60
msgid "Device-IP:Port" msgid "Device-IP:Port"
msgstr "Geräte-IP:Port" msgstr "Geräte-IP:Port"
#: src/web/conn_table.py:59 #: src/web/conn_table.py:60
msgid "Device-IP" msgid "Device-IP"
msgstr "Geräte-IP" msgstr "Geräte-IP"
#: src/web/conn_table.py:60 src/web/mqtt_table.py:34 #: src/web/conn_table.py:61 src/web/mqtt_table.py:34
msgid "Serial-No" msgid "Serial-No"
msgstr "Seriennummer" msgstr "Seriennummer"
#: src/web/conn_table.py:61 #: src/web/conn_table.py:62
msgid "Cloud-IP:Port" msgid "Cloud-IP:Port"
msgstr "Cloud-IP:Port" msgstr "Cloud-IP:Port"
#: src/web/conn_table.py:61 #: src/web/conn_table.py:62
msgid "Cloud-IP" msgid "Cloud-IP"
msgstr "Cloud-IP" msgstr "Cloud-IP"
#: src/web/log_files.py:48
msgid "n/a"
msgstr "keine Angabe"
#: src/web/mqtt_table.py:27 #: src/web/mqtt_table.py:27
msgid "MQTT devices" msgid "MQTT devices"
msgstr "MQTT Geräte" msgstr "MQTT Geräte"
@@ -116,12 +120,12 @@ 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
msgid "Do you really want to delete the log file" msgid "Do you really want to delete the log file: <br>%(file)s ?"
msgstr "Soll die Datei wirklich gelöscht werden" msgstr "Soll die Datei: <br>%(file)s<br>wirklich gelöscht werden?"
#: src/web/templates/page_logging.html.j2:12 #: src/web/templates/page_logging.html.j2:12
msgid "Delete File</button" msgid "Delete File"
msgstr "File löschen" msgstr "Datei löschen"
#: src/web/templates/page_logging.html.j2:13 #: src/web/templates/page_logging.html.j2:13
msgid "Abort" msgid "Abort"