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}")