add Modbus polling mode for DCU1000 (#305)

* add Modbus scanning mode

* fix modbus polling for DCU 1000

* add modbus register for DCU 1000

* calculate meta values from modbus regs

* update changelog

* reduce code duplication

* refactor modbus_scan

* add additional unit tests
This commit is contained in:
Stefan Allius
2025-03-11 19:47:37 +01:00
committed by GitHub
parent be60f9ea1e
commit 88cb01f613
9 changed files with 386 additions and 32 deletions

View File

@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased] ## [unreleased]
- add Modbus polling mode for DCU1000 [#292](https://github.com/s-allius/tsun-gen3-proxy/issues/292)
- add Modbus scanning mode
- allow `R47`serial numbers for GEN3 inverters - allow `R47`serial numbers for GEN3 inverters
- add watchdog for Add-ons - add watchdog for Add-ons
- Respect logging.ini file, if LOG_ENV isn't set well [#288](https://github.com/s-allius/tsun-gen3-proxy/issues/288) - Respect logging.ini file, if LOG_ENV isn't set well [#288](https://github.com/s-allius/tsun-gen3-proxy/issues/288)

View File

@@ -93,6 +93,11 @@ class Config():
Optional('forward', default=False): Use(bool), Optional('forward', default=False): Use(bool),
}, },
Optional('modbus_polling', default=True): Use(bool), Optional('modbus_polling', default=True): Use(bool),
Optional('modbus_scanning'): {
'start': Use(int),
Optional('step', default=0x400): Use(int),
Optional('bytes', default=0x10): Use(int),
},
Optional('suggested_area', default=""): Use(str), Optional('suggested_area', default=""): Use(str),
Optional('sensor_list', default=0): Use(int), Optional('sensor_list', default=0): Use(int),
Optional('pv1'): { Optional('pv1'): {
@@ -136,6 +141,11 @@ class Config():
Optional('forward', default=False): Use(bool), Optional('forward', default=False): Use(bool),
}, },
Optional('modbus_polling', default=True): Use(bool), Optional('modbus_polling', default=True): Use(bool),
Optional('modbus_scanning'): {
'start': Use(int),
Optional('step', default=0x400): Use(int),
Optional('bytes', default=0x10): Use(int),
},
Optional('suggested_area', default=""): Use(str), Optional('suggested_area', default=""): Use(str),
Optional('sensor_list', default=0): Use(int), Optional('sensor_list', default=0): Use(int),
Optional('pv1'): { Optional('pv1'): {

View File

@@ -98,13 +98,9 @@ class Talent(Message):
if serial_no in inverters: if serial_no in inverters:
inv = inverters[serial_no] inv = inverters[serial_no]
self.node_id = inv['node_id'] self._set_config_parms(inv)
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
self.db.set_pv_module_details(inv) self.db.set_pv_module_details(inv)
if self.mb: logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
self.mb.set_node_id(self.node_id)
else: else:
self.node_id = '' self.node_id = ''
self.sug_area = '' self.sug_area = ''
@@ -175,12 +171,17 @@ class Talent(Message):
def mb_timout_cb(self, exp_cnt): def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.mb_timeout) self.mb_timer.start(self.mb_timeout)
if self.mb_scan:
self._send_modbus_scan()
return
if 2 == (exp_cnt % 30): if 2 == (exp_cnt % 30):
# logging.info("Regular Modbus Status request") # logging.info("Regular Modbus Status request")
self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.DEBUG) self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, 0x2000,
96, logging.DEBUG)
else: else:
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG) self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, 0x3000,
48, logging.DEBUG)
def _init_new_client_conn(self) -> bool: def _init_new_client_conn(self) -> bool:
contact_name = self.contact_name contact_name = self.contact_name
@@ -554,6 +555,9 @@ class Talent(Message):
logger.warning('Unknown Message') logger.warning('Unknown Message')
self.inc_counter('Unknown_Msg') self.inc_counter('Unknown_Msg')
return return
if (self.mb_scan):
modbus_msg_len = self.data_len - hdr_len
self._dump_modbus_scan(data, hdr_len, modbus_msg_len)
for key, update, _ in self.mb.recv_resp(self.db, data[ for key, update, _ in self.mb.recv_resp(self.db, data[
hdr_len:]): hdr_len:]):

View File

@@ -257,21 +257,31 @@ class InfosG3P(Infos):
continue continue
info_id = row['reg'] info_id = row['reg']
result = Fmt.get_value(buf, addr, row) result = Fmt.get_value(buf, addr, row)
yield from self.__update_val(node_id, info_id, result) yield from self.__update_val(node_id, "GEN3PLUS", info_id, result)
yield from self.calc(sensor, node_id)
def calc(self, sensor: int = 0, node_id: str = '') \
-> Generator[tuple[str, bool], None, None]:
'''calculate meta values from the
stored values in Infos.db
sensor: sensor_list number
node_id: id-string for the node'''
reg_map = RegisterSel.get(sensor)
if 'calc' in reg_map: if 'calc' in reg_map:
for row in reg_map['calc'].values(): for row in reg_map['calc'].values():
info_id = row['reg'] info_id = row['reg']
result = row['func'](self, row['params']) result = row['func'](self, row['params'])
yield from self.__update_val(node_id, info_id, result) yield from self.__update_val(node_id, "CALC", info_id, result)
def __update_val(self, node_id, info_id, result): def __update_val(self, node_id, source: str, info_id, result):
keys, level, unit, must_incr = self._key_obj(info_id) keys, level, unit, must_incr = self._key_obj(info_id)
if keys: if keys:
name, update = self.update_db(keys, must_incr, result) name, update = self.update_db(keys, must_incr, result)
yield keys[0], update yield keys[0], update
if update: if update:
self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}' self.tracer.log(level, f'[{node_id}] {source}: {name}'
f' : {result}{unit}') f' : {result}{unit}')
def build(self, len, msg_type: int, rcv_ftype: int, sensor: int = 0): def build(self, len, msg_type: int, rcv_ftype: int, sensor: int = 0):

View File

@@ -322,6 +322,8 @@ class SolarmanV5(SolarmanBase):
self.at_acl = g3p_cnf['at_acl'] self.at_acl = g3p_cnf['at_acl']
self.sensor_list = 0 self.sensor_list = 0
self.mb_regs = [{'addr': 0x3000, 'len': 48},
{'addr': 0x2000, 'len': 96}]
''' '''
Our puplic methods Our puplic methods
@@ -357,7 +359,16 @@ class SolarmanV5(SolarmanBase):
self.new_data['controller'] = True self.new_data['controller'] = True
self.state = State.up self.state = State.up
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.DEBUG)
if self.mb_scan:
self._send_modbus_cmd(self.mb_inv_no, Modbus.READ_REGS,
self.mb_start_reg, self.mb_bytes,
logging.INFO)
else:
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS,
self.mb_regs[0]['addr'],
self.mb_regs[0]['len'], logging.DEBUG)
self.mb_timer.start(self.mb_timeout) self.mb_timer.start(self.mb_timeout)
def new_state_up(self): def new_state_up(self):
@@ -377,16 +388,16 @@ class SolarmanV5(SolarmanBase):
self.ifc.fwd_add(build_msg) self.ifc.fwd_add(build_msg)
self.ifc.fwd_add(struct.pack('<BB', 0, 0x15)) # crc & stop self.ifc.fwd_add(struct.pack('<BB', 0, 0x15)) # crc & stop
def __set_config_parms(self, inv: dict, serial_no: str): def _set_config_parms(self, inv: dict, serial_no: str = ""):
'''init connection with params from the configuration''' '''init connection with params from the configuration'''
self.node_id = inv['node_id'] super()._set_config_parms(inv)
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
self.sensor_list = inv['sensor_list'] self.sensor_list = inv['sensor_list']
if 0 == self.sensor_list: if 0 == self.sensor_list:
snr = serial_no[:3] snr = serial_no[:3]
if '410' == snr: if '410' == snr:
self.sensor_list = 0x3026 self.sensor_list = 0x3026
self.mb_regs = [{'addr': 0x0000, 'len': 45}]
else: else:
self.sensor_list = 0x02b0 self.sensor_list = 0x02b0
self.db.set_db_def_value(Register.SENSOR_LIST, self.db.set_db_def_value(Register.SENSOR_LIST,
@@ -394,9 +405,6 @@ class SolarmanV5(SolarmanBase):
logging.debug(f"Use sensor-list: {self.sensor_list:#04x}" logging.debug(f"Use sensor-list: {self.sensor_list:#04x}"
f" for '{serial_no}'") f" for '{serial_no}'")
if self.mb:
self.mb.set_node_id(self.node_id)
def _set_serial_no(self, snr: int): def _set_serial_no(self, snr: int):
'''check the serial number and configure the inverter connection''' '''check the serial number and configure the inverter connection'''
serial_no = str(snr) serial_no = str(snr)
@@ -411,7 +419,7 @@ class SolarmanV5(SolarmanBase):
# logger.debug(f'key: {key} -> {inv}') # logger.debug(f'key: {key} -> {inv}')
if (type(inv) is dict and 'monitor_sn' in inv if (type(inv) is dict and 'monitor_sn' in inv
and inv['monitor_sn'] == snr): and inv['monitor_sn'] == snr):
self.__set_config_parms(inv, key) self._set_config_parms(inv, key)
self.db.set_pv_module_details(inv) self.db.set_pv_module_details(inv)
logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501
@@ -474,12 +482,18 @@ class SolarmanV5(SolarmanBase):
def mb_timout_cb(self, exp_cnt): def mb_timout_cb(self, exp_cnt):
self.mb_timer.start(self.mb_timeout) self.mb_timer.start(self.mb_timeout)
if self.mb_scan:
self._send_modbus_scan()
else:
self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS,
self.mb_regs[0]['addr'],
self.mb_regs[0]['len'], logging.INFO)
self._send_modbus_cmd(Modbus.READ_REGS, 0x3000, 48, logging.INFO) if 1 == (exp_cnt % 30) and len(self.mb_regs) > 1:
# logging.info("Regular Modbus Status request")
if 1 == (exp_cnt % 30): self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS,
# logging.info("Regular Modbus Status request") self.mb_regs[1]['addr'],
self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.INFO) self.mb_regs[1]['len'], logging.INFO)
def at_cmd_forbidden(self, cmd: str, connection: str) -> bool: def at_cmd_forbidden(self, cmd: str, connection: str) -> bool:
return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \ return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \
@@ -672,16 +686,25 @@ class SolarmanV5(SolarmanBase):
return return
self.__forward_msg() self.__forward_msg()
def __parse_modbus_rsp(self, data): def __parse_modbus_rsp(self, data, modbus_msg_len):
inv_update = False inv_update = False
self.modbus_elms = 0 self.modbus_elms = 0
if (self.mb_scan):
self._dump_modbus_scan(data, 14, modbus_msg_len)
ts = self._timestamp()
for key, update, _ in self.mb.recv_resp(self.db, data[14:]): for key, update, _ in self.mb.recv_resp(self.db, data[14:]):
self.modbus_elms += 1 self.modbus_elms += 1
if update: if update:
if key == 'inverter': if key == 'inverter':
inv_update = True inv_update = True
self._set_mqtt_timestamp(key, self._timestamp()) self._set_mqtt_timestamp(key, ts)
self.new_data[key] = True self.new_data[key] = True
for key, update in self.db.calc(self.sensor_list, self.node_id):
if update:
self._set_mqtt_timestamp(key, ts)
self.new_data[key] = True
return inv_update return inv_update
def __modbus_command_rsp(self, data): def __modbus_command_rsp(self, data):
@@ -691,7 +714,7 @@ class SolarmanV5(SolarmanBase):
# logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}') # logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}')
if valid == 1 and modbus_msg_len > 4: if valid == 1 and modbus_msg_len > 4:
# logger.info(f'first byte modbus:{data[14]}') # logger.info(f'first byte modbus:{data[14]}')
inv_update = self.__parse_modbus_rsp(data) inv_update = self.__parse_modbus_rsp(data, modbus_msg_len)
if inv_update: if inv_update:
self.__build_model_name() self.__build_model_name()

View File

@@ -117,6 +117,11 @@ class Message(ProtocolIfc):
self.mb_first_timeout = self.MB_START_TIMEOUT self.mb_first_timeout = self.MB_START_TIMEOUT
'''timer value for next Modbus polling request''' '''timer value for next Modbus polling request'''
self.modbus_polling = False self.modbus_polling = False
self.mb_start_reg = 0
self.mb_step = 0
self.mb_bytes = 0
self.mb_inv_no = 1
self.mb_scan = False
@property @property
def node_id(self): def node_id(self):
@@ -135,6 +140,25 @@ class Message(ProtocolIfc):
# to our _recv_buffer # to our _recv_buffer
return # pragma: no cover return # pragma: no cover
def _set_config_parms(self, inv: dict):
'''init connection with params from the configuration'''
self.node_id = inv['node_id']
self.sug_area = inv['suggested_area']
self.modbus_polling = inv['modbus_polling']
if 'modbus_scanning' in inv:
scan = inv['modbus_scanning']
self.mb_scan = True
self.mb_start_reg = scan['start']
self.mb_step = scan['step']
self.mb_bytes = scan['bytes']
# if 'client_mode' in self.db and \
# self.db.client_mode:
self.mb_start_reg = scan['start']
# else:
# self.mb_start_reg = scan['start'] - scan['step']
if self.mb:
self.mb.set_node_id(self.node_id)
def _set_mqtt_timestamp(self, key, ts: float | None): def _set_mqtt_timestamp(self, key, ts: float | None):
if key not in self.new_data or \ if key not in self.new_data or \
not self.new_data[key]: not self.new_data[key]:
@@ -160,15 +184,39 @@ class Message(ProtocolIfc):
to = self.MAX_DEF_IDLE_TIME to = self.MAX_DEF_IDLE_TIME
return to return to
def _send_modbus_cmd(self, func, addr, val, log_lvl) -> None: def _send_modbus_cmd(self, dev_id, func, addr, val, log_lvl) -> None:
if self.state != State.up: if self.state != State.up:
logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,' logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,'
' as the state is not UP') ' as the state is not UP')
return return
self.mb.build_msg(Modbus.INV_ADDR, func, addr, val, log_lvl) self.mb.build_msg(dev_id, func, addr, val, log_lvl)
async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None: async def send_modbus_cmd(self, func, addr, val, log_lvl) -> None:
self._send_modbus_cmd(func, addr, val, log_lvl) self._send_modbus_cmd(Modbus.INV_ADDR, func, addr, val, log_lvl)
def _send_modbus_scan(self):
self.mb_start_reg += self.mb_step
if self.mb_start_reg > 0xffff:
self.mb_start_reg = self.mb_start_reg & 0xffff
self.mb_inv_no += 1
logging.info(f"Next Round: inv:{self.mb_inv_no}"
f" reg:{self.mb_start_reg:04x}")
if (self.mb_start_reg & 0xfffc) % 0x80 == 0:
logging.info(f"[{self.node_id}] Scan info: "
f"inv:{self.mb_inv_no}"
f" reg:{self.mb_start_reg:04x}")
self._send_modbus_cmd(self.mb_inv_no, Modbus.READ_REGS,
self.mb_start_reg, self.mb_bytes,
logging.INFO)
def _dump_modbus_scan(self, data, hdr_len, modbus_msg_len):
if (data[hdr_len] == self.mb_inv_no and
data[hdr_len+1] == Modbus.READ_REGS):
logging.info(f'[{self.node_id}] Valid MODBUS data '
f'(reg: 0x{self.mb.last_reg:04x}):')
hex_dump_memory(logging.INFO, 'Valid MODBUS data '
f'(reg: 0x{self.mb.last_reg:04x}):',
data[hdr_len:], modbus_msg_len)
''' '''
Our puplic methods Our puplic methods

View File

@@ -37,6 +37,34 @@ class Modbus():
__crc_tab = [] __crc_tab = []
mb_reg_mapping = { mb_reg_mapping = {
0x0000: {'reg': Register.SERIAL_NUMBER, 'fmt': '!16s'}, # noqa: E501
0x0008: {'reg': Register.BATT_PV1_VOLT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501, PV1 voltage
0x0009: {'reg': Register.BATT_PV1_CUR, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501, PV1 current
0x000a: {'reg': Register.BATT_PV2_VOLT, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501, PV2 voltage
0x000b: {'reg': Register.BATT_PV2_CUR, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501, PV2 current
0x000c: {'reg': Register.BATT_38, 'fmt': '!h'}, # noqa: E501
0x000d: {'reg': Register.BATT_3a, 'fmt': '!h', 'ratio': 0.01}, # noqa: E501
0x000e: {'reg': Register.BATT_STATUS_1, 'fmt': '!h'}, # noqa: E501
0x000f: {'reg': Register.BATT_STATUS_2, 'fmt': '!h'}, # noqa: E501
0x0010: {'reg': Register.BATT_VOLT, 'fmt': '!h', 'ratio': 0.01}, # noqa: E501
0x0011: {'reg': Register.BATT_CUR, 'fmt': '!h', 'ratio': 0.01}, # noqa: E501
0x0012: {'reg': Register.BATT_SOC, 'fmt': '!H', 'ratio': 0.01}, # noqa: E501, state of charge (SOC) in percent
# 0x0013: {'reg': Register.BATT_46, 'fmt': '!h'}, # noqa: E501
# 0x0014: {'reg': Register.BATT_48, 'fmt': '!h'}, # noqa: E501
# 0x0015: {'reg': Register.BATT_4a, 'fmt': '!h'}, # noqa: E501
# 0x0016: {'reg': Register.BATT_4c, 'fmt': '!h'}, # noqa: E501
# 0x0017: {'reg': Register.BATT_4e, 'fmt': '!h'}, # noqa: E501
# 0x0023: {'reg': Register.BATT_66, 'fmt': '!h'}, # noqa: E501
# 0x0024: {'reg': Register.BATT_68, 'fmt': '!h'}, # noqa: E501
# 0x0025: {'reg': Register.BATT_6a, 'fmt': '!h'}, # noqa: E501
0x0026: {'reg': Register.BATT_OUT_VOLT, 'fmt': '!h', 'ratio': 0.01}, # noqa: E501
0x0027: {'reg': Register.BATT_OUT_CUR, 'fmt': '!h', 'ratio': 0.01}, # noqa: E501
# 0x0028: {'reg': Register.BATT_70, 'fmt': '!h'}, # noqa: E501
# 0x0029: {'reg': Register.BATT_72, 'fmt': '!h'}, # noqa: E501
# 0x002a: {'reg': Register.BATT_74, 'fmt': '!h'}, # noqa: E501
# 0x002b: {'reg': Register.BATT_76, 'fmt': '!h'}, # noqa: E501
# 0x002c: {'reg': Register.BATT_78, 'fmt': '!h'}, # noqa: E501
0x2000: {'reg': Register.BOOT_STATUS, 'fmt': '!H'}, # noqa: E501 0x2000: {'reg': Register.BOOT_STATUS, 'fmt': '!H'}, # noqa: E501
0x2001: {'reg': Register.DSP_STATUS, 'fmt': '!H'}, # noqa: E501 0x2001: {'reg': Register.DSP_STATUS, 'fmt': '!H'}, # noqa: E501
0x2003: {'reg': Register.WORK_MODE, 'fmt': '!H'}, 0x2003: {'reg': Register.WORK_MODE, 'fmt': '!H'},

View File

@@ -643,6 +643,19 @@ def msg_modbus_rsp(): # 0x1510
msg += b'\x15' msg += b'\x15'
return msg return msg
@pytest.fixture
def msg_modbus_rsp_inv_id2(): # 0x1510
msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x01'
msg += total()
msg += hb()
msg += b'\x0a\xe2\xfa\x33\x02\x03\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\x2a\xaa'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
@pytest.fixture @pytest.fixture
def msg_modbus_invalid(): # 0x1510 def msg_modbus_invalid(): # 0x1510
msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x00' msg = b'\xa5\x3b\x00\x10\x15\x03\x03' +get_sn() +b'\x02\x00'
@@ -678,6 +691,22 @@ def msg_unknown_cmd_rsp(): # 0x1510
msg += b'\x15' msg += b'\x15'
return msg return msg
@pytest.fixture
def dcu_modbus_rsp(): # 0x1510
msg = b'\xa5\x6d\x00\x10\x15\x03\x03' +get_dcu_sn() +b'\x02\x01'
msg += total()
msg += hb()
msg += b'\x4d\x0d\x84\x34\x01\x03\x5a\x34\x31\x30\x31'
msg += b'\x32\x34\x30\x37\x30\x31\x34\x39\x30\x33\x31\x34\x00\x32\x00\x00'
msg += b'\x00\x32\x00\x00\x00\x00\x10\x7b\x00\x02\x00\x02\x14\x9b\xfe\xfd'
msg += b'\x25\x28\x0c\xe1\x0c\xde\x0c\xe1\x0c\xe1\x0c\xe0\x0c\xe1\x0c\xe3'
msg += b'\x0c\xdf\x0c\xe0\x0c\xe2\x0c\xe1\x0c\xe1\x0c\xe2\x0c\xe2\x0c\xe3'
msg += b'\x0c\xdf\x00\x14\x00\x14\x00\x13\x0f\x94\x01\x4a\x00\x01\x00\x15'
msg += b'\x00\x00\x02\x05\x02\x01\x14\xab'
msg += correct_checksum(msg)
msg += b'\x15'
return msg
@pytest.fixture @pytest.fixture
def dcu_dev_ind_msg(): # 0x4110 def dcu_dev_ind_msg(): # 0x4110
msg = b'\xa5\x3a\x01\x10\x41\x91\x01' +get_dcu_sn() +b'\x02\xc6\xde\x2d\x32' msg = b'\xa5\x3a\x01\x10\x41\x91\x01' +get_dcu_sn() +b'\x02\xc6\xde\x2d\x32'
@@ -749,6 +778,14 @@ def config_no_tsun_inv1():
def config_tsun_inv1(): def config_tsun_inv1():
Config.act_config = {'solarman':{'enabled': True},'inverters':{'Y170000000000001':{'monitor_sn': 2070233889, 'node_id':'inv1', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}} Config.act_config = {'solarman':{'enabled': True},'inverters':{'Y170000000000001':{'monitor_sn': 2070233889, 'node_id':'inv1', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}}
@pytest.fixture
def config_tsun_scan():
Config.act_config = {'solarman':{'enabled': True},'inverters':{'Y170000000000001':{'monitor_sn': 2070233889, 'node_id':'inv1', 'modbus_polling': True, 'modbus_scanning': {'start': 0xff80, 'step': 0x40, 'bytes':20}, 'suggested_area':'roof', 'sensor_list': 0}}}
@pytest.fixture
def config_tsun_scan_dcu():
Config.act_config = {'solarman':{'enabled': True},'inverters':{'4100000000000001':{'monitor_sn': 2070233888, 'node_id':'inv1', 'modbus_polling': True, 'modbus_scanning': {'start': 0x0000, 'step': 0x100, 'bytes':0x2d}, 'suggested_area':'roof', 'sensor_list': 0}}}
@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 = {'solarman':{'enabled': True},'batteries':{'4100000000000001':{'monitor_sn': 2070233888, 'node_id':'inv1', 'modbus_polling': True, 'suggested_area':'roof', 'sensor_list': 0}}}
@@ -1859,6 +1896,79 @@ async def test_modbus_polling(config_tsun_inv1, heartbeat_ind_msg, heartbeat_rsp
assert next(m.mb_timer.exp_count) == 4 assert next(m.mb_timer.exp_count) == 4
m.close() m.close()
@pytest.mark.asyncio
async def test_modbus_scaning(config_tsun_scan, heartbeat_ind_msg, heartbeat_rsp_msg, msg_modbus_rsp, msg_modbus_rsp_inv_id2):
_ = config_tsun_scan
assert asyncio.get_running_loop()
m = MemoryStream(heartbeat_ind_msg, (0x15,0x56,0))
m.append_msg(msg_modbus_rsp)
m.append_msg(msg_modbus_rsp_inv_id2)
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 == 0
await asyncio.sleep(0.5)
assert m.sent_pdu==b'\xa5\x17\x00\x10E\x04\x03!Ce{\x02\xb0\x02\x00\x00\x00\x00\x00\x00' \
b'\x00\x00\x00\x00\x00\x00\x02\x03\x00\x00\x00\x14\x45\xf6\xbf\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 == 3
assert m.msg_recvd[2]['control']==0x1510
assert m.msg_recvd[2]['seq']=='03:03'
assert m.msg_recvd[2]['data_len']==0x3b
assert m.mb.last_addr == 2
assert m.mb.last_fcode == 3
assert m.mb.last_reg == 0x0000 # mb_start_reg + mb_step
assert m.mb.last_len == 20
assert m.mb.err == 0
assert next(m.mb_timer.exp_count) == 3
m.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_client_mode(config_tsun_inv1, str_test_ip): async def test_start_client_mode(config_tsun_inv1, str_test_ip):
_ = config_tsun_inv1 _ = config_tsun_inv1
@@ -1891,6 +2001,75 @@ async def test_start_client_mode(config_tsun_inv1, str_test_ip):
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_start_client_mode_scan(config_tsun_scan_dcu, str_test_ip, dcu_modbus_rsp):
_ = config_tsun_scan_dcu
assert asyncio.get_running_loop()
m = MemoryStream(dcu_modbus_rsp, (131,0,))
m.append_msg(dcu_modbus_rsp)
assert m.state == State.init
assert m.no_forwarding == False
assert m.mb_timer.tim == None
assert asyncio.get_running_loop() == m.mb_timer.loop
await m.send_start_cmd(get_dcu_sn_int(), str_test_ip, False, m.mb_first_timeout)
assert m.sent_pdu==bytearray(b'\xa5\x17\x00\x10E\x01\x00 Ce{\x02&0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x03\x00\x00\x00-\x85\xd7\x95\x15')
assert m.mb_scan == True
m.mb_step = 0
assert m.db.get_db_value(Register.IP_ADDRESS) == str_test_ip
assert isclose(m.db.get_db_value(Register.POLLING_INTERVAL), 0.5)
assert m.db.get_db_value(Register.HEARTBEAT_INTERVAL) == 120
assert m.state == State.up
assert m.no_forwarding == True
assert m.ifc.tx_fifo.get()==b''
assert isclose(m.mb_timeout, 0.5)
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 == 1
assert m.msg_recvd[0]['control']==0x1510
assert m.msg_recvd[0]['seq']=='03:03'
assert m.msg_recvd[0]['data_len']==109
assert m.mb.last_addr == 1
assert m.mb.last_fcode == 3
assert m.mb.last_reg == 0x0000 # mb_start_reg + mb_step
assert m.mb.last_len == 45
assert m.mb.err == 0
assert isclose(m.db.get_db_value(Register.BATT_PWR, None), -136.6225)
assert isclose(m.db.get_db_value(Register.BATT_OUT_PWR, None), 131.604)
assert isclose(m.db.get_db_value(Register.BATT_PV_PWR, None), 0.0)
assert m.new_data['batterie'] == True
m.new_data['batterie'] = False
await asyncio.sleep(0.5)
assert m.sent_pdu==bytearray(b'\xa5\x17\x00\x10E\x04\x03 Ce{\x02&0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x03\x00\x00\x00-\x85\xd7\x9b\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']==109
assert m.mb.last_addr == 1
assert m.mb.last_fcode == 3
assert m.mb.last_reg == 0x0000 # mb_start_reg + mb_step
assert m.mb.last_len == 45
assert m.mb.err == 0
assert isclose(m.db.get_db_value(Register.BATT_PWR, None), -136.6225)
assert isclose(m.db.get_db_value(Register.BATT_OUT_PWR, None), 131.604)
assert isclose(m.db.get_db_value(Register.BATT_PV_PWR, None), 0.0)
assert m.new_data['batterie'] == False
assert next(m.mb_timer.exp_count) == 1
m.close()
def test_timeout(config_tsun_inv1): def test_timeout(config_tsun_inv1):
_ = config_tsun_inv1 _ = config_tsun_inv1
m = MemoryStream(b'') m = MemoryStream(b'')

View File

@@ -2184,6 +2184,56 @@ async def test_modbus_polling(config_tsun_inv1, msg_inverter_ind):
assert next(m.mb_timer.exp_count) == 4 assert next(m.mb_timer.exp_count) == 4
m.close() m.close()
@pytest.mark.asyncio
async def test_modbus_scaning(config_tsun_inv1, msg_inverter_ind, msg_modbus_rsp21):
_ = config_tsun_inv1
assert asyncio.get_running_loop()
m = MemoryStream(msg_inverter_ind, (0x8f,0))
m.append_msg(msg_modbus_rsp21)
m.mb_scan = True
m.mb_start_reg = 0x4560
m.mb_bytes = 0x14
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 not m.header_valid # must be invalid, since msg was handled and buffer flushed
assert m.msg_count == 1
assert m.id_str == b"R170000000000001"
assert m.unique_id == 'R170000000000001'
assert m.msg_recvd[0]['ctrl']==145
assert m.msg_recvd[0]['msg_id']==4
assert m.msg_recvd[0]['header_len']==23
assert m.msg_recvd[0]['data_len']==120
assert m.ifc.fwd_fifo.get()==msg_inverter_ind
assert m.ifc.tx_fifo.get()==b'\x00\x00\x00\x14\x10R170000000000001\x99\x04\x01'
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'\x00\x00\x00 \x10R170000000000001pw\x00\x01\xa3(\x08\x01\x03\x45\x60\x00\x14\x50\xd7'
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]['ctrl']==145
assert m.msg_recvd[1]['msg_id']==119
assert m.msg_recvd[1]['header_len']==23
assert m.msg_recvd[1]['data_len']==50
assert m.mb.last_addr == 1
assert m.mb.last_fcode == 3
assert m.mb.last_reg == 0x4560
assert m.mb.last_len == 20
assert m.mb.err == 0
assert next(m.mb_timer.exp_count) == 2
m.close()
def test_broken_recv_buf(config_tsun_allow_all, broken_recv_buf): def test_broken_recv_buf(config_tsun_allow_all, broken_recv_buf):
_ = config_tsun_allow_all _ = config_tsun_allow_all
m = MemoryStream(broken_recv_buf, (0,)) m = MemoryStream(broken_recv_buf, (0,))