This commit is contained in:
Stefan Allius
2024-05-20 00:48:23 +02:00
parent 177706c3e6
commit 6ef6f4cd34
2 changed files with 133 additions and 79 deletions

View File

@@ -1,7 +1,7 @@
import struct import struct
import logging import logging
import asyncio import asyncio
from typing import Generator from typing import Generator, Callable
if __name__ == "app.src.modbus": if __name__ == "app.src.modbus":
from app.src.infos import Register from app.src.infos import Register
@@ -25,10 +25,13 @@ CRC_INIT = 0xFFFF
class Modbus(): class Modbus():
INV_ADDR = 1 INV_ADDR = 1
'''MODBUS slave address of the TSUN inverter'''
READ_REGS = 3 READ_REGS = 3
'''MODBUS function code: Read Holding Register'''
READ_INPUTS = 4 READ_INPUTS = 4
'''MODBUS function code: Read Input Register'''
WRITE_SINGLE_REG = 6 WRITE_SINGLE_REG = 6
'''Modbus function codes''' '''Modbus function code: Write Single Register'''
__crc_tab = [] __crc_tab = []
map = { map = {
@@ -66,21 +69,26 @@ class Modbus():
0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501 0x3029: {'reg': Register.PV4_TOTAL_GENERATION, 'fmt': '!L', 'ratio': 0.01}, # noqa: E501
} }
def __init__(self, snd_handler, timeout: int = 1): def __init__(self, snd_handler: Callable[[bool], None], timeout: int = 1):
if not len(self.__crc_tab): if not len(self.__crc_tab):
self.__build_crc_tab(CRC_POLY) self.__build_crc_tab(CRC_POLY)
self.que = asyncio.Queue(100) self.que = asyncio.Queue(100)
self.snd_handler = snd_handler self.snd_handler = snd_handler
'''Send handler to transmit a MODBUS request'''
self.rsp_handler = None self.rsp_handler = None
'''Response handler to forward the response'''
self.timeout = timeout self.timeout = timeout
'''MODBUS response timeout in seconds'''
self.max_retries = 3 self.max_retries = 3
'''Max retransmit for MODBUS requests'''
self.retry_cnt = 0 self.retry_cnt = 0
self.last_req = b'' self.last_req = b''
self.counter = {} self.counter = {}
'''Dictenary with statistic counter'''
self.counter['timeouts'] = 0 self.counter['timeouts'] = 0
self.counter['retries'] = {} self.counter['retries'] = {}
for i in range(0, self.max_retries): for i in range(0, self.max_retries+1):
self.counter['retries'][i] = 0 self.counter['retries'][f'{i}'] = 0
self.last_addr = 0 self.last_addr = 0
self.last_fcode = 0 self.last_fcode = 0
self.last_len = 0 self.last_len = 0
@@ -94,80 +102,67 @@ class Modbus():
if type(self.counter) is not None: if type(self.counter) is not None:
logging.info(f'Modbus __del__:\n {self.counter}') logging.info(f'Modbus __del__:\n {self.counter}')
def start_timer(self): def build_msg(self, addr: int, func: int, reg: int, val: int) -> None:
if self.req_pend: """Build MODBUS RTU message frame and add it to the tx queue
return
self.req_pend = True
self.tim = self.loop.call_later(self.timeout, self.timeout_cb)
# logging.debug(f'Modbus start timer {self}')
def stop_timer(self): Keyword arguments:
self.req_pend = False addr: RTU slave address
# logging.debug(f'Modbus stop timer {self}') func: MODBUS function code
if self.tim: reg: 16-bit register number
self.tim.cancel() val: 16 bit value
"""
def timeout_cb(self):
self.req_pend = False
if self.retry_cnt < self.max_retries:
logging.debug(f'Modbus retrans {self}')
self.retry_cnt += 1
self.start_timer()
self.snd_handler(self.last_req, retrans=True)
else:
logging.info(f'Modbus timeout {self}')
self.counter['timeouts'] += 1
self.get_next_req()
def get_next_req(self) -> None:
if self.req_pend:
return
try:
item = self.que.get_nowait()
req = item['req']
self.last_req = req
self.rsp_handler = item['rsp_hdl']
self.last_addr = req[0]
self.last_fcode = req[1]
res = struct.unpack_from('>HH', req, 2)
self.last_reg = res[0]
self.last_len = res[1]
self.retry_cnt = 0
self.start_timer()
self.snd_handler(self.last_req, retrans=False)
except asyncio.QueueEmpty:
pass
def build_msg(self, addr, func, reg, val) -> None:
msg = struct.pack('>BBHH', addr, func, reg, val) msg = struct.pack('>BBHH', addr, func, reg, val)
msg += struct.pack('<H', self.__calc_crc(msg)) msg += struct.pack('<H', self.__calc_crc(msg))
self.que.put_nowait({'req': msg, self.que.put_nowait({'req': msg,
'rsp_hdl': None}) 'rsp_hdl': None})
if self.que.qsize() == 1: if self.que.qsize() == 1:
self.get_next_req() self.__send_next_from_que()
def recv_req(self, buf: bytearray, rsp_handler=None) -> bool: def recv_req(self, buf: bytearray,
rsp_handler: Callable[[None], None] = None) -> bool:
"""Add the received Modbus request to the tx queue
Keyword arguments:
buf: Modbus RTU pdu incl ADDR byte and trailing CRC
rsp_handler: Callback, if the received pdu is valid
Returns:
True: PDU was added to the queue
False: PDU was ignored, due to an error
"""
# logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}') # logging.info(f'recv_req: first byte modbus:{buf[0]} len:{len(buf)}')
if not self.check_crc(buf): if not self.__check_crc(buf):
self.err = 1 self.err = 1
logging.error('Modbus recv: CRC error') logging.error('Modbus recv: CRC error')
return False return False
self.que.put_nowait({'req': buf, self.que.put_nowait({'req': buf,
'rsp_hdl': rsp_handler}) 'rsp_hdl': rsp_handler})
if self.que.qsize() == 1: if self.que.qsize() == 1:
self.get_next_req() self.__send_next_from_que()
return True return True
def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \ def recv_resp(self, info_db, buf: bytearray, node_id: str) -> \
Generator[tuple[str, bool, any], None, None]: Generator[tuple[str, bool, int | float | str], None, None]:
"""Generator which check and parse a received MODBUS response.
Keyword arguments:
info_db: database for info lockups
buf: received Modbus RTU response frame
node_id: string for logging which identifies the slave
Returns on error and set Self.err to:
1: CRC error
2: Wrong server address
3: Unexpected function code
4: Unexpected data length
5: No MODBUS request pending
"""
# logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}') # logging.info(f'recv_resp: first byte modbus:{buf[0]} len:{len(buf)}')
if not self.req_pend: if not self.req_pend:
self.err = 5 self.err = 5
return return
if not self.check_crc(buf): if not self.__check_crc(buf):
logging.error('Modbus resp: CRC error') logging.error('Modbus resp: CRC error')
self.err = 1 self.err = 1
return return
@@ -188,7 +183,7 @@ class Modbus():
self.err = 4 self.err = 4
return return
first_reg = self.last_reg # save last_reg before sending next pdu first_reg = self.last_reg # save last_reg before sending next pdu
self.stop_timer() # stop timer and send next pdu self.__stop_timer() # stop timer and send next pdu
for i in range(0, elmlen): for i in range(0, elmlen):
addr = first_reg+i addr = first_reg+i
@@ -215,23 +210,81 @@ class Modbus():
f'[\'{node_id}\']MODBUS: {name}' f'[\'{node_id}\']MODBUS: {name}'
f' : {result}{unit}') f' : {result}{unit}')
else: else:
self.stop_timer() self.__stop_timer()
self.counter['retries'][self.retry_cnt] += 1
self.counter['retries'][f'{self.retry_cnt}'] += 1
if self.rsp_handler: if self.rsp_handler:
self.rsp_handler() self.rsp_handler()
self.get_next_req() self.__send_next_from_que()
def check_crc(self, msg) -> bool: '''
MODBUS response timer
'''
def __start_timer(self) -> None:
'''Start response timer and set `req_pend` to True'''
self.req_pend = True
self.tim = self.loop.call_later(self.timeout, self.__timeout_cb)
# logging.debug(f'Modbus start timer {self}')
def __stop_timer(self) -> None:
'''Stop response timer and set `req_pend` to False'''
self.req_pend = False
# logging.debug(f'Modbus stop timer {self}')
if self.tim:
self.tim.cancel()
def __timeout_cb(self) -> None:
'''Rsponse timeout handler retransmit pdu or send next pdu'''
self.req_pend = False
if self.retry_cnt < self.max_retries:
logging.debug(f'Modbus retrans {self}')
self.retry_cnt += 1
self.__start_timer()
self.snd_handler(self.last_req, retrans=True)
else:
logging.info(f'Modbus timeout {self}')
self.counter['timeouts'] += 1
self.__send_next_from_que()
def __send_next_from_que(self) -> None:
'''Get next MODBUS pdu from queue and transmit it'''
if self.req_pend:
return
try:
item = self.que.get_nowait()
req = item['req']
self.last_req = req
self.rsp_handler = item['rsp_hdl']
self.last_addr = req[0]
self.last_fcode = req[1]
res = struct.unpack_from('>HH', req, 2)
self.last_reg = res[0]
self.last_len = res[1]
self.retry_cnt = 0
self.__start_timer()
self.snd_handler(self.last_req, retrans=False)
except asyncio.QueueEmpty:
pass
'''
Helper function for CRC-16 handling
'''
def __check_crc(self, msg: bytearray) -> bool:
'''Check CRC-16 and returns True if valid'''
return 0 == self.__calc_crc(msg) return 0 == self.__calc_crc(msg)
def __calc_crc(self, buffer: bytes) -> int: def __calc_crc(self, buffer: bytearray) -> int:
'''Build CRC-16 for buffer and returns it'''
crc = CRC_INIT crc = CRC_INIT
for cur in buffer: for cur in buffer:
crc = (crc >> 8) ^ self.__crc_tab[(crc ^ cur) & 0xFF] crc = (crc >> 8) ^ self.__crc_tab[(crc ^ cur) & 0xFF]
return crc return crc
def __build_crc_tab(self, poly) -> None: def __build_crc_tab(self, poly: int) -> None:
'''Build CRC-16 helper table, must be called exactly one time'''
for index in range(256): for index in range(256):
data = index << 1 data = index << 1
crc = 0 crc = 0

View File

@@ -21,24 +21,25 @@ def test_modbus_crc():
mb = Modbus(None) mb = Modbus(None)
assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04') assert 0x0b02 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04')
assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b')
assert mb.check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b') assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x04\x02\x0b')
assert 0xc803 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00') assert 0xc803 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00')
assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') assert 0 == mb._Modbus__calc_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8')
assert mb.check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8') assert mb._Modbus__check_crc(b'\x01\x06\x20\x08\x00\x00\x03\xc8')
assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46') assert 0x5c75 == mb._Modbus__calc_crc(b'\x01\x03\x08\x01\x2c\x00\x2c\x02\x2c\x2c\x46')
def test_build_modbus_pdu(): def test_build_modbus_pdu():
mb = TestHelper() mb = TestHelper()
mb.build_msg(1,6,0x2000,0x12) mb.build_msg(1,6,0x2000,0x12)
mb.get_next_req() mb._Modbus__send_next_from_que()
assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07' assert mb.pdu == b'\x01\x06\x20\x00\x00\x12\x02\x07'
assert mb.check_crc(mb.pdu) assert mb._Modbus__check_crc(mb.pdu)
def test_recv_req_crc(): def test_recv_req_crc():
mb = TestHelper() mb = TestHelper()
mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08') mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x08')
mb.get_next_req() mb._Modbus__send_next_from_que()
assert mb.last_fcode == 0 assert mb.last_fcode == 0
assert mb.last_reg == 0 assert mb.last_reg == 0
assert mb.last_len == 0 assert mb.last_len == 0
@@ -47,7 +48,7 @@ def test_recv_req_crc():
def test_recv_req_addr(): def test_recv_req_addr():
mb = TestHelper() mb = TestHelper()
mb.recv_req(b'\x02\x06\x20\x00\x00\x12\x02\x34') mb.recv_req(b'\x02\x06\x20\x00\x00\x12\x02\x34')
mb.get_next_req() mb._Modbus__send_next_from_que()
assert mb.last_addr == 2 assert mb.last_addr == 2
assert mb.last_fcode == 6 assert mb.last_fcode == 6
assert mb.last_reg == 0x2000 assert mb.last_reg == 0x2000
@@ -56,7 +57,7 @@ def test_recv_req_addr():
def test_recv_req(): def test_recv_req():
mb = TestHelper() mb = TestHelper()
mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07') mb.recv_req(b'\x01\x06\x20\x00\x00\x12\x02\x07')
mb.get_next_req() mb._Modbus__send_next_from_que()
assert mb.last_fcode == 6 assert mb.last_fcode == 6
assert mb.last_reg == 0x2000 assert mb.last_reg == 0x2000
assert mb.last_len == 0x12 assert mb.last_len == 0x12
@@ -88,7 +89,7 @@ def test_recv_recv_addr():
assert mb.err == 2 assert mb.err == 2
assert 0 == call assert 0 == call
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
mb.stop_timer() mb._Modbus__stop_timer()
assert not mb.req_pend assert not mb.req_pend
def test_recv_recv_fcode(): def test_recv_recv_fcode():
@@ -104,7 +105,7 @@ def test_recv_recv_fcode():
assert mb.err == 3 assert mb.err == 3
assert 0 == call assert 0 == call
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
mb.stop_timer() mb._Modbus__stop_timer()
assert not mb.req_pend assert not mb.req_pend
def test_recv_recv_len(): def test_recv_recv_len():
@@ -120,7 +121,7 @@ def test_recv_recv_len():
assert mb.err == 4 assert mb.err == 4
assert 0 == call assert 0 == call
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
mb.stop_timer() mb._Modbus__stop_timer()
assert not mb.req_pend assert not mb.req_pend
def test_build_recv(): def test_build_recv():
@@ -162,7 +163,7 @@ def test_build_recv():
assert 0 == mb.err assert 0 == mb.err
assert 5 == call assert 5 == call
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
mb.stop_timer() mb._Modbus__stop_timer()
assert not mb.req_pend assert not mb.req_pend
def test_build_long(): def test_build_long():
@@ -186,7 +187,7 @@ def test_build_long():
assert 0 == mb.err assert 0 == mb.err
assert 3 == call assert 3 == call
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
mb.stop_timer() mb._Modbus__stop_timer()
assert not mb.req_pend assert not mb.req_pend
def test_queue(): def test_queue():
@@ -198,12 +199,12 @@ def test_queue():
assert mb.send_calls == 1 assert mb.send_calls == 1
assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03' assert mb.pdu == b'\x01\x030"\x00\x04\xeb\x03'
mb.pdu = None mb.pdu = None
mb.get_next_req() mb._Modbus__send_next_from_que()
assert mb.send_calls == 1 assert mb.send_calls == 1
assert mb.pdu == None assert mb.pdu == None
assert mb.que.qsize() == 0 assert mb.que.qsize() == 0
mb.stop_timer() mb._Modbus__stop_timer()
assert not mb.req_pend assert not mb.req_pend
def test_queue2(): def test_queue2():
@@ -215,7 +216,7 @@ def test_queue2():
assert mb.send_calls == 1 assert mb.send_calls == 1
assert mb.pdu == b'\x01\x030\x07\x00\x06{\t' assert mb.pdu == b'\x01\x030\x07\x00\x06{\t'
mb.get_next_req() mb._Modbus__send_next_from_que()
assert mb.send_calls == 1 assert mb.send_calls == 1
call = 0 call = 0
exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30] exp_result = ['V0.0.212', 4.4, 0.7, 0.7, 30]