From 88cb01f6131afcde60b117c8afa71ec6e52fed98 Mon Sep 17 00:00:00 2001 From: Stefan Allius <122395479+s-allius@users.noreply.github.com> Date: Tue, 11 Mar 2025 19:47:37 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + app/src/cnf/config.py | 10 ++ app/src/gen3/talent.py | 20 ++-- app/src/gen3plus/infos_g3p.py | 18 +++- app/src/gen3plus/solarman_v5.py | 57 +++++++--- app/src/messages.py | 54 +++++++++- app/src/modbus.py | 28 +++++ app/tests/test_solarman.py | 179 ++++++++++++++++++++++++++++++++ app/tests/test_talent.py | 50 +++++++++ 9 files changed, 386 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd0faa..9c5f8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 - 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) diff --git a/app/src/cnf/config.py b/app/src/cnf/config.py index 1bc59a9..88a0ab0 100644 --- a/app/src/cnf/config.py +++ b/app/src/cnf/config.py @@ -93,6 +93,11 @@ class Config(): Optional('forward', default=False): 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('sensor_list', default=0): Use(int), Optional('pv1'): { @@ -136,6 +141,11 @@ class Config(): Optional('forward', default=False): 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('sensor_list', default=0): Use(int), Optional('pv1'): { diff --git a/app/src/gen3/talent.py b/app/src/gen3/talent.py index 73fdae7..29df94c 100644 --- a/app/src/gen3/talent.py +++ b/app/src/gen3/talent.py @@ -98,13 +98,9 @@ class Talent(Message): if serial_no in inverters: inv = inverters[serial_no] - self.node_id = inv['node_id'] - 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._set_config_parms(inv) self.db.set_pv_module_details(inv) - if self.mb: - self.mb.set_node_id(self.node_id) + logger.debug(f'SerialNo {serial_no} allowed! area:{self.sug_area}') # noqa: E501 else: self.node_id = '' self.sug_area = '' @@ -175,12 +171,17 @@ class Talent(Message): def mb_timout_cb(self, exp_cnt): self.mb_timer.start(self.mb_timeout) + if self.mb_scan: + self._send_modbus_scan() + return if 2 == (exp_cnt % 30): # 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: - 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: contact_name = self.contact_name @@ -554,6 +555,9 @@ class Talent(Message): logger.warning('Unknown Message') self.inc_counter('Unknown_Msg') 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[ hdr_len:]): diff --git a/app/src/gen3plus/infos_g3p.py b/app/src/gen3plus/infos_g3p.py index 55fdb6a..cd6d040 100644 --- a/app/src/gen3plus/infos_g3p.py +++ b/app/src/gen3plus/infos_g3p.py @@ -257,21 +257,31 @@ class InfosG3P(Infos): continue info_id = row['reg'] 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: for row in reg_map['calc'].values(): info_id = row['reg'] 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) if keys: name, update = self.update_db(keys, must_incr, result) yield keys[0], update if update: - self.tracer.log(level, f'[{node_id}] GEN3PLUS: {name}' + self.tracer.log(level, f'[{node_id}] {source}: {name}' f' : {result}{unit}') def build(self, len, msg_type: int, rcv_ftype: int, sensor: int = 0): diff --git a/app/src/gen3plus/solarman_v5.py b/app/src/gen3plus/solarman_v5.py index 4fcb71b..d279cae 100644 --- a/app/src/gen3plus/solarman_v5.py +++ b/app/src/gen3plus/solarman_v5.py @@ -322,6 +322,8 @@ class SolarmanV5(SolarmanBase): self.at_acl = g3p_cnf['at_acl'] self.sensor_list = 0 + self.mb_regs = [{'addr': 0x3000, 'len': 48}, + {'addr': 0x2000, 'len': 96}] ''' Our puplic methods @@ -357,7 +359,16 @@ class SolarmanV5(SolarmanBase): self.new_data['controller'] = True 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) def new_state_up(self): @@ -377,16 +388,16 @@ class SolarmanV5(SolarmanBase): self.ifc.fwd_add(build_msg) self.ifc.fwd_add(struct.pack(' {inv}') if (type(inv) is dict and 'monitor_sn' in inv and inv['monitor_sn'] == snr): - self.__set_config_parms(inv, key) + self._set_config_parms(inv, key) self.db.set_pv_module_details(inv) 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): 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): - # logging.info("Regular Modbus Status request") - self._send_modbus_cmd(Modbus.READ_REGS, 0x2000, 96, logging.INFO) + if 1 == (exp_cnt % 30) and len(self.mb_regs) > 1: + # logging.info("Regular Modbus Status request") + self._send_modbus_cmd(Modbus.INV_ADDR, Modbus.READ_REGS, + self.mb_regs[1]['addr'], + self.mb_regs[1]['len'], logging.INFO) def at_cmd_forbidden(self, cmd: str, connection: str) -> bool: return not cmd.startswith(tuple(self.at_acl[connection]['allow'])) or \ @@ -672,16 +686,25 @@ class SolarmanV5(SolarmanBase): return self.__forward_msg() - def __parse_modbus_rsp(self, data): + def __parse_modbus_rsp(self, data, modbus_msg_len): inv_update = False 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:]): self.modbus_elms += 1 if update: if key == 'inverter': inv_update = True - self._set_mqtt_timestamp(key, self._timestamp()) + self._set_mqtt_timestamp(key, ts) 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 def __modbus_command_rsp(self, data): @@ -691,7 +714,7 @@ class SolarmanV5(SolarmanBase): # logger.debug(f'modbus_len:{modbus_msg_len} accepted:{valid}') if valid == 1 and modbus_msg_len > 4: # 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: self.__build_model_name() diff --git a/app/src/messages.py b/app/src/messages.py index eecfc80..7efaa86 100644 --- a/app/src/messages.py +++ b/app/src/messages.py @@ -117,6 +117,11 @@ class Message(ProtocolIfc): self.mb_first_timeout = self.MB_START_TIMEOUT '''timer value for next Modbus polling request''' 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 def node_id(self): @@ -135,6 +140,25 @@ class Message(ProtocolIfc): # to our _recv_buffer 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): if key not in self.new_data or \ not self.new_data[key]: @@ -160,15 +184,39 @@ class Message(ProtocolIfc): to = self.MAX_DEF_IDLE_TIME 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: logger.log(log_lvl, f'[{self.node_id}] ignore MODBUS cmd,' ' as the state is not UP') 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: - 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 diff --git a/app/src/modbus.py b/app/src/modbus.py index 5c64086..b8244b5 100644 --- a/app/src/modbus.py +++ b/app/src/modbus.py @@ -37,6 +37,34 @@ class Modbus(): __crc_tab = [] 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 0x2001: {'reg': Register.DSP_STATUS, 'fmt': '!H'}, # noqa: E501 0x2003: {'reg': Register.WORK_MODE, 'fmt': '!H'}, diff --git a/app/tests/test_solarman.py b/app/tests/test_solarman.py index f379489..a4bbbfc 100644 --- a/app/tests/test_solarman.py +++ b/app/tests/test_solarman.py @@ -643,6 +643,19 @@ def msg_modbus_rsp(): # 0x1510 msg += b'\x15' 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 def msg_modbus_invalid(): # 0x1510 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' 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 def dcu_dev_ind_msg(): # 0x4110 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(): 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 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}}} @@ -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 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 async def test_start_client_mode(config_tsun_inv1, str_test_ip): _ = 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 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): _ = config_tsun_inv1 m = MemoryStream(b'') diff --git a/app/tests/test_talent.py b/app/tests/test_talent.py index b5d6e36..2d6029c 100644 --- a/app/tests/test_talent.py +++ b/app/tests/test_talent.py @@ -2184,6 +2184,56 @@ async def test_modbus_polling(config_tsun_inv1, msg_inverter_ind): assert next(m.mb_timer.exp_count) == 4 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): _ = config_tsun_allow_all m = MemoryStream(broken_recv_buf, (0,))