DCU: Possibility for setting the out power

Fixes #415
This commit is contained in:
Stefan Allius
2025-06-12 22:27:47 +02:00
7 changed files with 297 additions and 89 deletions

View File

@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- 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

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

@@ -543,6 +543,8 @@ class SolarmanV5(SolarmanBase):
logger.warning(f'[{self.node_id}] ignore DCU CMD,' logger.warning(f'[{self.node_id}] ignore DCU CMD,'
' cause the state is not UP anymore') ' cause the state is not UP anymore')
return return
self.inverter.forward_dcu_cmd_resp = False
self._build_header(0x4510) self._build_header(0x4510)
self.ifc.tx_add(struct.pack('<BHLLL', self.DCU_CMD, self.ifc.tx_add(struct.pack('<BHLLL', self.DCU_CMD,
self.sensor_list, 0, 0, 0)) self.sensor_list, 0, 0, 0))
@@ -666,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:],
@@ -689,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
@@ -708,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

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

@@ -3,6 +3,7 @@ import logging
import aiomqtt import aiomqtt
import traceback import traceback
import struct import struct
import inspect
from modbus import Modbus from modbus import Modbus
from messages import Message from messages import Message
@@ -28,15 +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.dcu_power_topic = f"{ha['entity_prefix']}/+/dcu_power"
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):
@@ -77,20 +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.dcu_power_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)
@@ -120,62 +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.dcu_power_topic):
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
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)
@@ -193,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(',')
@@ -208,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

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

121
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():
@@ -175,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)
@@ -205,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()
@@ -235,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()
@@ -287,3 +388,19 @@ async def test_dcu_dispatch(config_mqtt_conn, spy_dcu_cmd):
spy.assert_called_once_with(b'\x01\x01\x06\x01\x00\x01\x03\xe8') spy.assert_called_once_with(b'\x01\x01\x06\x01\x00\x01\x03\xe8')
finally: finally:
await m.close() 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()

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

@@ -821,6 +821,17 @@ def dcu_command_ind_msg(): # 0x4510
msg += b'\x15' msg += b'\x15'
return msg 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 = {
@@ -863,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):
@@ -2413,7 +2434,8 @@ async def test_proxy_at_blocked(my_loop, config_tsun_inv1, patch_open_connection
assert Proxy.mqtt.data == "+ok" assert Proxy.mqtt.data == "+ok"
@pytest.mark.asyncio @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, at_command_rsp_msg): 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 _ = config_tsun_allow_all
m = MemoryStream(dcu_dev_ind_msg, (0,), True) m = MemoryStream(dcu_dev_ind_msg, (0,), True)
m.read() # read device ind m.read() # read device ind
@@ -2447,19 +2469,20 @@ async def test_dcu_cmd(my_loop, config_tsun_allow_all, dcu_dev_ind_msg, dcu_dev_
assert Proxy.mqtt.key == '' assert Proxy.mqtt.key == ''
assert Proxy.mqtt.data == "" assert Proxy.mqtt.data == ""
# m.append_msg(at_command_rsp_msg) m.append_msg(dcu_command_rsp_msg)
# m.read() # read at resp m.read() # read at resp
# assert m.control == 0x1510 assert m.control == 0x1510
# assert str(m.seq) == '03:03' assert str(m.seq) == '03:94'
# assert m.ifc.rx_get()==b'' assert m.ifc.rx_get()==b''
# assert m.ifc.tx_fifo.get()==b'' assert m.ifc.tx_fifo.get()==b''
# assert m.ifc.fwd_fifo.get()==b'' assert m.ifc.fwd_fifo.get()==b''
# assert Proxy.mqtt.key == 'tsun/at_resp' assert Proxy.mqtt.key == 'tsun/dcu_resp'
# assert Proxy.mqtt.data == "+ok" assert Proxy.mqtt.data == "+ok"
Proxy.mqtt.clear() # clear last test result Proxy.mqtt.clear() # clear last test result
@pytest.mark.asyncio @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): 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 _ = config_tsun_allow_all
m = MemoryStream(device_ind_msg, (0,), True) m = MemoryStream(device_ind_msg, (0,), True)
m.read() # read device ind m.read() # read device ind
@@ -2488,3 +2511,44 @@ async def test_dcu_cmd_not_supported(my_loop, config_tsun_allow_all, device_ind_
assert m.ifc.tx_fifo.get()== b'' assert m.ifc.tx_fifo.get()== b''
assert m.sent_pdu == b'' assert m.sent_pdu == b''
Proxy.mqtt.clear() # clear last test result 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 == ""