From 6cb17be451ceb6e59330fb901f165f73d293e9a1 Mon Sep 17 00:00:00 2001 From: Eduard Iten Date: Sat, 12 Jul 2025 14:13:09 +0200 Subject: [PATCH] (feat) added RTU gateway Signed-off-by: Eduard Iten --- software/tools/rtu_gateway/requirements.txt | 1 + software/tools/rtu_gateway/rtu_gateway.py | 152 ++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 software/tools/rtu_gateway/requirements.txt create mode 100644 software/tools/rtu_gateway/rtu_gateway.py diff --git a/software/tools/rtu_gateway/requirements.txt b/software/tools/rtu_gateway/requirements.txt new file mode 100644 index 0000000..9fed3d0 --- /dev/null +++ b/software/tools/rtu_gateway/requirements.txt @@ -0,0 +1 @@ +pyserial-asyncio>=0.6 diff --git a/software/tools/rtu_gateway/rtu_gateway.py b/software/tools/rtu_gateway/rtu_gateway.py new file mode 100644 index 0000000..ed16da1 --- /dev/null +++ b/software/tools/rtu_gateway/rtu_gateway.py @@ -0,0 +1,152 @@ +import asyncio +import argparse +import serial +import serial_asyncio +from datetime import datetime + +# Globale Variable für den seriellen Writer, um Zugriff im TCP-Handler zu ermöglichen +serial_writer = None + +def log_message(message: str): + """Gibt eine formatierte Log-Nachricht mit Zeitstempel aus.""" + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}") + +async def handle_tcp_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, verbose: bool): + """Bearbeitet eine einzelne TCP-Client-Verbindung.""" + global serial_writer + peername = writer.get_extra_info('peername') + log_message(f"✅ Client verbunden: {peername}") + + if not serial_writer: + log_message("❌ Fehler: Serielle Verbindung ist nicht aktiv. Client wird getrennt.") + writer.close() + await writer.wait_closed() + return + + try: + while True: + # Daten vom TCP-Client (Home Assistant) lesen + tcp_request = await reader.read(256) + if not tcp_request: + # Verbindung vom Client geschlossen + break + + # Anfrage an das serielle Gerät weiterleiten + serial_writer.write(tcp_request) + await serial_writer.drain() + + try: + # Auf Antwort vom seriellen Gerät warten (mit Timeout) + serial_response = await asyncio.wait_for(serial_writer.protocol.transport.serial.read_async(256), timeout=2.0) + + # Antwort an den TCP-Client senden + writer.write(serial_response) + 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: + if verbose: + reg_addr = int.from_bytes(tcp_request[2:4], 'big') + log_message(f"VERBOSE: reg 0x{reg_addr:04x} ") + # Kein Timeout an den Client senden, das Protokoll selbst behandelt dies + + except asyncio.CancelledError: + log_message("TCP-Handler wurde abgebrochen.") + except Exception as e: + log_message(f"TCP-Verbindungsfehler: {e}") + finally: + log_message(f"🔌 Client getrennt: {peername}") + writer.close() + await writer.wait_closed() + + +async def serial_reconnector(comport, baudrate): + """Versucht, die serielle Verbindung wiederherzustellen.""" + global serial_writer + for attempt in range(1, 6): + log_message(f"🚨 Serielle Verbindung verloren! Versuch {attempt}/5 in 5 Sekunden...") + await asyncio.sleep(5) + try: + # Erneuter Verbindungsversuch + _, writer = await serial_asyncio.open_serial_connection( + url=comport, baudrate=baudrate, rtscts=False, dsrdtr=False + ) + serial_writer = writer # Globale Variable aktualisieren + log_message(f"✅ Serielle Verbindung zu {comport} wiederhergestellt.") + return writer # Erfolgreich, gib den neuen Writer zurück + except (serial.SerialException, FileNotFoundError) as e: + log_message(f"❌ Wiederverbindung fehlgeschlagen: {e}") + + log_message("💥 Konnte serielle Verbindung nach 5 Versuchen nicht wiederherstellen. Programm wird beendet.") + # Sauberes Beenden des gesamten Programms + loop = asyncio.get_running_loop() + loop.stop() + return None + +async def main(args): + """Hauptfunktion zum Starten des Servers und der seriellen Verbindung.""" + global serial_writer + log_message("--- Modbus RTU zu TCP Gateway ---") + log_message(f"Serieller Port: {args.comport}") + log_message(f"Baudrate: {args.baudrate}") + log_message(f"TCP Port: {args.tcpport}") + log_message(f"Verbose Modus: {'Aktiv' if args.verbose else 'Inaktiv'}") + log_message("---------------------------------") + + # Initiale serielle Verbindung herstellen + try: + _, serial_writer = await serial_asyncio.open_serial_connection( + url=args.comport, baudrate=args.baudrate, rtscts=False, dsrdtr=False + ) + log_message(f"✅ Serielle Verbindung zu {args.comport} erfolgreich hergestellt.") + except (serial.SerialException, FileNotFoundError) as e: + log_message(f"❌ Kritischer Fehler bei der initialen Verbindung: {e}") + serial_writer = await serial_reconnector(args.comport, args.baudrate) + 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) + + addr = server.sockets[0].getsockname() + log_message(f"👂 Server lauscht auf {addr}") + + async with server: + # Überwache die serielle Verbindung im Hintergrund + while True: + if not serial_writer or not serial_writer.protocol.transport.is_open(): + log_message("Serielle Verbindung unterbrochen. Starte Wiederverbindungs-Logik...") + serial_writer = await serial_reconnector(args.comport, args.baudrate) + if not serial_writer: + server.close() # Server stoppen, wenn seriell nicht mehr geht + break # Schleife und Programm beenden + await asyncio.sleep(1) # Kurze Pause, um die CPU zu schonen + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Modbus RTU zu TCP Gateway für Home Assistant.") + parser.add_argument("comport", help="Der serielle Port (z.B. /dev/ttyUSB0 oder COM3)") + parser.add_argument("-b", "--baudrate", type=int, default=19200, help="Baudrate der seriellen Verbindung (Standard: 19200)") + parser.add_argument("-p", "--tcpport", type=int, default=502, help="TCP-Port, auf dem der Server lauscht (Standard: 502)") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose-Modus aktivieren für detaillierte Logs") + + args = parser.parse_args() + + try: + asyncio.run(main(args)) + except KeyboardInterrupt: + log_message("👋 Programm wird durch Benutzer beendet.") + except Exception as e: + log_message(f"💥 Unerwarteter Fehler im Hauptprogramm: {e}")