(fix) RTU gateway

fixed rtu gateway
Signed-off-by: Eduard Iten <eduard@iten.pro>
This commit is contained in:
Eduard Iten 2025-07-12 16:08:49 +02:00
parent 6cb17be451
commit e1ae96506d
1 changed files with 72 additions and 61 deletions

133
software/tools/rtu_gateway/rtu_gateway.py Normal file → Executable file
View File

@ -4,63 +4,78 @@ import serial
import serial_asyncio import serial_asyncio
from datetime import datetime from datetime import datetime
# Globale Variable für den seriellen Writer, um Zugriff im TCP-Handler zu ermöglichen # Globale Variablen für den seriellen Reader und Writer
serial_reader = None
serial_writer = None serial_writer = None
def log_message(message: str): def log_message(message: str):
"""Gibt eine formatierte Log-Nachricht mit Zeitstempel aus.""" """Gibt eine formatierte Log-Nachricht mit Zeitstempel aus."""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}") print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}")
def log_verbose_request(req: bytes):
"""Gibt eine detaillierte, formatierte Log-Nachricht für eine Anfrage aus."""
if len(req) < 6: return
log_message(f"VERBOSE: --> REQ from HA: {req.hex(' ')}")
slave_id = req[0]
func_code = req[1]
addr = int.from_bytes(req[2:4], 'big')
# Für Lese-/Schreibbefehle
if func_code in [1, 2, 3, 4, 5, 6, 15, 16] and len(req) >= 6:
count_or_data = int.from_bytes(req[4:6], 'big')
log_message(f"VERBOSE: Parsed: slave={slave_id}, func={func_code}, addr={addr}, count/val={count_or_data}")
else:
log_message(f"VERBOSE: Parsed: slave={slave_id}, func={func_code}")
def log_verbose_response(res: bytes):
"""Gibt eine detaillierte, formatierte Log-Nachricht für eine Antwort aus."""
if len(res) < 5: return
log_message(f"VERBOSE: <-- RES from DEV: {res.hex(' ')}")
slave_id = res[0]
func_code = res[1]
if func_code < 0x80: # Keine Fehler-Antwort
byte_count = res[2]
data = res[3:-2].hex(' ')
log_message(f"VERBOSE: Parsed: slave={slave_id}, func={func_code}, bytes={byte_count}, data=[{data}]")
else: # Fehler-Antwort
error_code = res[2]
log_message(f"VERBOSE: ERROR: slave={slave_id}, func={func_code}, err_code={error_code}")
async def handle_tcp_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, verbose: bool): async def handle_tcp_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, verbose: bool):
"""Bearbeitet eine einzelne TCP-Client-Verbindung.""" """Bearbeitet eine einzelne TCP-Client-Verbindung."""
global serial_writer global serial_reader, serial_writer
peername = writer.get_extra_info('peername') peername = writer.get_extra_info('peername')
log_message(f"✅ Client verbunden: {peername}") log_message(f"✅ Client verbunden: {peername}")
if not serial_writer: if not serial_writer or not serial_reader:
log_message("❌ Fehler: Serielle Verbindung ist nicht aktiv. Client wird getrennt.") log_message("❌ Fehler: Serielle Verbindung ist nicht aktiv. Client wird getrennt.")
writer.close() writer.close(); await writer.wait_closed()
await writer.wait_closed()
return return
try: try:
while True: while True:
# Daten vom TCP-Client (Home Assistant) lesen
tcp_request = await reader.read(256) tcp_request = await reader.read(256)
if not tcp_request: if not tcp_request: break
# Verbindung vom Client geschlossen
break if verbose: log_verbose_request(tcp_request)
# *** HINZUGEFÜGTE ÄNDERUNG: Eine kleine Pause vor dem Senden ***
# Dies gibt empfindlichen Geräten oder langsamen Bussen Zeit.
await asyncio.sleep(0.05) # 50ms Verzögerung
# Anfrage an das serielle Gerät weiterleiten
serial_writer.write(tcp_request) serial_writer.write(tcp_request)
await serial_writer.drain() await serial_writer.drain()
try: try:
# Auf Antwort vom seriellen Gerät warten (mit Timeout) serial_response = await asyncio.wait_for(serial_reader.read(256), timeout=2.0)
serial_response = await asyncio.wait_for(serial_writer.protocol.transport.serial.read_async(256), timeout=2.0) if verbose: log_verbose_response(serial_response)
# Antwort an den TCP-Client senden
writer.write(serial_response) writer.write(serial_response)
await writer.drain() await writer.drain()
if verbose:
# Zerlegen der Modbus-Antwort für das Logging
dev_id = tcp_request[0]
func_code = tcp_request[1]
# Einfache Register-Extraktion (variiert je nach Funktion)
# Dies ist eine Annahme für gängige Lesefunktionen
if func_code in [3, 4]:
reg_addr = int.from_bytes(tcp_request[2:4], 'big')
data_hex = serial_response[3:-2].hex() # Daten ohne ID, Func, Count und CRC
log_message(f"VERBOSE: id {dev_id:03d}, reg 0x{reg_addr:04x}, data 0x{data_hex}")
else:
log_message(f"VERBOSE: id {dev_id:03d}, data {serial_response.hex()}")
except asyncio.TimeoutError: except asyncio.TimeoutError:
if verbose: log_message("VERBOSE: <-- Timeout from DEV")
reg_addr = int.from_bytes(tcp_request[2:4], 'big')
log_message(f"VERBOSE: reg 0x{reg_addr:04x} <timeout>")
# Kein Timeout an den Client senden, das Protokoll selbst behandelt dies
except asyncio.CancelledError: except asyncio.CancelledError:
log_message("TCP-Handler wurde abgebrochen.") log_message("TCP-Handler wurde abgebrochen.")
@ -68,36 +83,34 @@ async def handle_tcp_client(reader: asyncio.StreamReader, writer: asyncio.Stream
log_message(f"TCP-Verbindungsfehler: {e}") log_message(f"TCP-Verbindungsfehler: {e}")
finally: finally:
log_message(f"🔌 Client getrennt: {peername}") log_message(f"🔌 Client getrennt: {peername}")
writer.close() writer.close(); await writer.wait_closed()
await writer.wait_closed()
async def serial_reconnector(comport, baudrate): async def serial_reconnector(comport, baudrate):
"""Versucht, die serielle Verbindung wiederherzustellen.""" """Versucht, die serielle Verbindung wiederherzustellen."""
global serial_writer global serial_reader, serial_writer
for attempt in range(1, 6): for attempt in range(1, 6):
log_message(f"🚨 Serielle Verbindung verloren! Versuch {attempt}/5 in 5 Sekunden...") log_message(f"🚨 Serielle Verbindung verloren! Versuch {attempt}/5 in 5 Sekunden...")
await asyncio.sleep(5) await asyncio.sleep(5)
try: try:
# Erneuter Verbindungsversuch reader_obj, writer_obj = await serial_asyncio.open_serial_connection(
_, writer = await serial_asyncio.open_serial_connection(
url=comport, baudrate=baudrate, rtscts=False, dsrdtr=False url=comport, baudrate=baudrate, rtscts=False, dsrdtr=False
) )
serial_writer = writer # Globale Variable aktualisieren serial_reader, serial_writer = reader_obj, writer_obj
log_message(f"✅ Serielle Verbindung zu {comport} wiederhergestellt.") log_message(f"✅ Serielle Verbindung zu {comport} wiederhergestellt.")
return writer # Erfolgreich, gib den neuen Writer zurück return True
except (serial.SerialException, FileNotFoundError) as e: except (serial.SerialException, FileNotFoundError) as e:
log_message(f"❌ Wiederverbindung fehlgeschlagen: {e}") log_message(f"❌ Wiederverbindung fehlgeschlagen: {e}")
log_message("💥 Konnte serielle Verbindung nach 5 Versuchen nicht wiederherstellen. Programm wird beendet.") log_message("💥 Konnte serielle Verbindung nach 5 Versuchen nicht wiederherstellen. Programm wird beendet.")
# Sauberes Beenden des gesamten Programms try:
loop = asyncio.get_running_loop() asyncio.get_running_loop().stop()
loop.stop() except RuntimeError: pass
return None return False
async def main(args): async def main(args):
"""Hauptfunktion zum Starten des Servers und der seriellen Verbindung.""" """Hauptfunktion zum Starten des Servers und der seriellen Verbindung."""
global serial_writer global serial_reader, serial_writer
log_message("--- Modbus RTU zu TCP Gateway ---") log_message("--- Modbus RTU zu TCP Gateway ---")
log_message(f"Serieller Port: {args.comport}") log_message(f"Serieller Port: {args.comport}")
log_message(f"Baudrate: {args.baudrate}") log_message(f"Baudrate: {args.baudrate}")
@ -105,35 +118,33 @@ async def main(args):
log_message(f"Verbose Modus: {'Aktiv' if args.verbose else 'Inaktiv'}") log_message(f"Verbose Modus: {'Aktiv' if args.verbose else 'Inaktiv'}")
log_message("---------------------------------") log_message("---------------------------------")
# Initiale serielle Verbindung herstellen
try: try:
_, serial_writer = await serial_asyncio.open_serial_connection( serial_reader, serial_writer = await serial_asyncio.open_serial_connection(
url=args.comport, baudrate=args.baudrate, rtscts=False, dsrdtr=False url=args.comport, baudrate=args.baudrate, rtscts=False, dsrdtr=False
) )
log_message(f"✅ Serielle Verbindung zu {args.comport} erfolgreich hergestellt.") log_message(f"✅ Serielle Verbindung zu {args.comport} erfolgreich hergestellt.")
except (serial.SerialException, FileNotFoundError) as e: except (serial.SerialException, FileNotFoundError) as e:
log_message(f"❌ Kritischer Fehler bei der initialen Verbindung: {e}") log_message(f"❌ Kritischer Fehler bei der initialen Verbindung: {e}")
serial_writer = await serial_reconnector(args.comport, args.baudrate) if not await serial_reconnector(args.comport, args.baudrate): return
if not serial_writer:
return # Beenden, wenn die Wiederverbindung fehlschlägt
# TCP-Server starten
server_handler = lambda r, w: handle_tcp_client(r, w, args.verbose)
server = await asyncio.start_server(server_handler, '0.0.0.0', args.tcpport)
server = await asyncio.start_server(
lambda r, w: handle_tcp_client(r, w, args.verbose), '0.0.0.0', args.tcpport
)
addr = server.sockets[0].getsockname() addr = server.sockets[0].getsockname()
log_message(f"👂 Server lauscht auf {addr}") log_message(f"👂 Server lauscht auf {addr}")
async with server: async with server:
# Überwache die serielle Verbindung im Hintergrund
while True: while True:
if not serial_writer or not serial_writer.protocol.transport.is_open(): try:
if serial_reader and hasattr(serial_reader, '_transport') and serial_reader._transport:
_ = serial_reader._transport.serial.cts
else:
raise serial.SerialException("Transport nicht verfügbar.")
except (serial.SerialException, AttributeError, BrokenPipeError, TypeError):
log_message("Serielle Verbindung unterbrochen. Starte Wiederverbindungs-Logik...") log_message("Serielle Verbindung unterbrochen. Starte Wiederverbindungs-Logik...")
serial_writer = await serial_reconnector(args.comport, args.baudrate) if not await serial_reconnector(args.comport, args.baudrate):
if not serial_writer: server.close(); break
server.close() # Server stoppen, wenn seriell nicht mehr geht await asyncio.sleep(2)
break # Schleife und Programm beenden
await asyncio.sleep(1) # Kurze Pause, um die CPU zu schonen
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Modbus RTU zu TCP Gateway für Home Assistant.") parser = argparse.ArgumentParser(description="Modbus RTU zu TCP Gateway für Home Assistant.")
@ -147,6 +158,6 @@ if __name__ == "__main__":
try: try:
asyncio.run(main(args)) asyncio.run(main(args))
except KeyboardInterrupt: except KeyboardInterrupt:
log_message("👋 Programm wird durch Benutzer beendet.") log_message("\n👋 Programm wird durch Benutzer beendet.")
except Exception as e: except Exception as e:
log_message(f"💥 Unerwarteter Fehler im Hauptprogramm: {e}") log_message(f"💥 Unerwarteter Fehler im Hauptprogramm: {e}")