164 lines
6.9 KiB
Python
Executable File
164 lines
6.9 KiB
Python
Executable File
import asyncio
|
|
import argparse
|
|
import serial
|
|
import serial_asyncio
|
|
from datetime import datetime
|
|
|
|
# Globale Variablen für den seriellen Reader und Writer
|
|
serial_reader = None
|
|
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}")
|
|
|
|
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):
|
|
"""Bearbeitet eine einzelne TCP-Client-Verbindung."""
|
|
global serial_reader, serial_writer
|
|
peername = writer.get_extra_info('peername')
|
|
log_message(f"✅ Client verbunden: {peername}")
|
|
|
|
if not serial_writer or not serial_reader:
|
|
log_message("❌ Fehler: Serielle Verbindung ist nicht aktiv. Client wird getrennt.")
|
|
writer.close(); await writer.wait_closed()
|
|
return
|
|
|
|
try:
|
|
while True:
|
|
tcp_request = await reader.read(256)
|
|
if not tcp_request: 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
|
|
|
|
serial_writer.write(tcp_request)
|
|
await serial_writer.drain()
|
|
|
|
try:
|
|
serial_response = await asyncio.wait_for(serial_reader.read(256), timeout=2.0)
|
|
if verbose: log_verbose_response(serial_response)
|
|
writer.write(serial_response)
|
|
await writer.drain()
|
|
except asyncio.TimeoutError:
|
|
log_message("VERBOSE: <-- Timeout from DEV")
|
|
|
|
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_reader, 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:
|
|
reader_obj, writer_obj = await serial_asyncio.open_serial_connection(
|
|
url=comport, baudrate=baudrate, rtscts=False, dsrdtr=False
|
|
)
|
|
serial_reader, serial_writer = reader_obj, writer_obj
|
|
log_message(f"✅ Serielle Verbindung zu {comport} wiederhergestellt.")
|
|
return True
|
|
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.")
|
|
try:
|
|
asyncio.get_running_loop().stop()
|
|
except RuntimeError: pass
|
|
return False
|
|
|
|
async def main(args):
|
|
"""Hauptfunktion zum Starten des Servers und der seriellen Verbindung."""
|
|
global serial_reader, 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("---------------------------------")
|
|
|
|
try:
|
|
serial_reader, 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}")
|
|
if not await serial_reconnector(args.comport, args.baudrate): return
|
|
|
|
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()
|
|
log_message(f"👂 Server lauscht auf {addr}")
|
|
|
|
async with server:
|
|
while True:
|
|
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...")
|
|
if not await serial_reconnector(args.comport, args.baudrate):
|
|
server.close(); break
|
|
await asyncio.sleep(2)
|
|
|
|
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("\n👋 Programm wird durch Benutzer beendet.")
|
|
except Exception as e:
|
|
log_message(f"💥 Unerwarteter Fehler im Hauptprogramm: {e}")
|