Compare commits
No commits in common. "6f304efb57d0ef31ee059b10806d9a4e988345e0" and "6cb17be451ceb6e59330fb901f165f73d293e9a1" have entirely different histories.
6f304efb57
...
6cb17be451
|
|
@ -1,5 +0,0 @@
|
||||||
# Gitignore settings for ESPHome
|
|
||||||
# This is an example and may include too much for your use-case.
|
|
||||||
# You can modify this file to suit your needs.
|
|
||||||
/.esphome/
|
|
||||||
/secrets.yaml
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
import os
|
|
||||||
import base64
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
|
|
||||||
def generate_password(length=32):
|
|
||||||
"""Generate a random password."""
|
|
||||||
alphabet = string.ascii_letters + string.digits
|
|
||||||
return ''.join(secrets.choice(alphabet) for i in range(length))
|
|
||||||
|
|
||||||
def generate_api_key():
|
|
||||||
"""Generate a random 32-byte key and base64 encode it."""
|
|
||||||
return base64.b64encode(secrets.token_bytes(32)).decode('utf-8')
|
|
||||||
|
|
||||||
SECRETS_FILE = 'secrets.yaml'
|
|
||||||
# In a real ESPHome project, secrets are often included from a central location
|
|
||||||
# but for this script, we'll assume it's in the current directory.
|
|
||||||
# You might need to adjust this path.
|
|
||||||
secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), SECRETS_FILE)
|
|
||||||
|
|
||||||
yaml = YAML()
|
|
||||||
yaml.preserve_quotes = True
|
|
||||||
# To prevent line wrapping
|
|
||||||
yaml.width = 4096
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(secrets_path, 'r') as f:
|
|
||||||
secrets_data = yaml.load(f)
|
|
||||||
if secrets_data is None:
|
|
||||||
secrets_data = {}
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Info: '{SECRETS_FILE}' not found. A new file will be created.")
|
|
||||||
secrets_data = {}
|
|
||||||
|
|
||||||
# Generate new random passwords
|
|
||||||
new_api_key = generate_api_key()
|
|
||||||
new_ota_password = generate_password()
|
|
||||||
|
|
||||||
# Update the dictionary with the new passwords
|
|
||||||
if 'api_password' in secrets_data:
|
|
||||||
del secrets_data['api_password']
|
|
||||||
secrets_data['api_key'] = new_api_key
|
|
||||||
secrets_data['ota_password'] = new_ota_password
|
|
||||||
|
|
||||||
# Write the updated dictionary back to the YAML file
|
|
||||||
with open(secrets_path, 'w') as f:
|
|
||||||
yaml.dump(secrets_data, f)
|
|
||||||
|
|
||||||
print(f"Successfully updated '{SECRETS_FILE}'.")
|
|
||||||
print("New values:")
|
|
||||||
print(f" api_key: {new_api_key}")
|
|
||||||
print(f" ota_password: {new_ota_password}")
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
esphome:
|
|
||||||
name: irrigation-system
|
|
||||||
friendly_name: Bewässerung
|
|
||||||
|
|
||||||
esp32:
|
|
||||||
board: esp32-c6-devkitm-1
|
|
||||||
framework:
|
|
||||||
type: esp-idf
|
|
||||||
|
|
||||||
wifi:
|
|
||||||
ssid: !secret wifi_ssid
|
|
||||||
password: !secret wifi_password
|
|
||||||
fast_connect: true
|
|
||||||
|
|
||||||
api:
|
|
||||||
encryption:
|
|
||||||
key: !secret api_key
|
|
||||||
|
|
||||||
ota:
|
|
||||||
platform: esphome
|
|
||||||
password: !secret ota_password
|
|
||||||
|
|
||||||
logger:
|
|
||||||
|
|
||||||
web_server:
|
|
||||||
|
|
||||||
# UART-Bus für Modbus
|
|
||||||
uart:
|
|
||||||
id: uart_bus
|
|
||||||
tx_pin: GPIO1
|
|
||||||
rx_pin: GPIO2
|
|
||||||
baud_rate: 9600
|
|
||||||
stop_bits: 1
|
|
||||||
parity: NONE
|
|
||||||
|
|
||||||
# Modbus-Komponente (der Hub)
|
|
||||||
modbus:
|
|
||||||
- id: modbus1
|
|
||||||
uart_id: uart_bus
|
|
||||||
|
|
||||||
modbus_controller:
|
|
||||||
- id: valve_device
|
|
||||||
address: 0x01
|
|
||||||
modbus_id: modbus1
|
|
||||||
|
|
||||||
number:
|
|
||||||
- platform: modbus_controller
|
|
||||||
modbus_controller_id: valve_device
|
|
||||||
id: valve_controller_command
|
|
||||||
name: "Valve Control"
|
|
||||||
address: 0x01
|
|
||||||
value_type: U_WORD
|
|
||||||
# min_value: 0
|
|
||||||
# max_value: 2
|
|
||||||
# step: 1
|
|
||||||
|
|
||||||
globals:
|
|
||||||
- id: my_valve_is_open
|
|
||||||
type: bool
|
|
||||||
restore_value: false
|
|
||||||
initial_value: 'true'
|
|
||||||
|
|
||||||
valve:
|
|
||||||
- platform: template
|
|
||||||
name: "Modbus Ventil"
|
|
||||||
id: my_modbus_valve
|
|
||||||
|
|
||||||
# Lambda, um den aktuellen Zustand zu bestimmen
|
|
||||||
# Liest den Zustand aus der globalen Variable
|
|
||||||
lambda: |-
|
|
||||||
return id(my_valve_is_open);
|
|
||||||
|
|
||||||
# Aktion beim Drücken auf "Öffnen"
|
|
||||||
open_action:
|
|
||||||
- number.set:
|
|
||||||
id: valve_controller_command
|
|
||||||
value: 1
|
|
||||||
- globals.set:
|
|
||||||
id: my_valve_is_open
|
|
||||||
value: 'true'
|
|
||||||
|
|
||||||
# Aktion beim Drücken auf "Schliessen"
|
|
||||||
close_action:
|
|
||||||
- number.set:
|
|
||||||
id: valve_controller_command
|
|
||||||
value: 2
|
|
||||||
- globals.set:
|
|
||||||
id: my_valve_is_open
|
|
||||||
value: 'false'
|
|
||||||
|
|
||||||
# (Optional) Aktion beim Drücken auf "Stopp"
|
|
||||||
stop_action:
|
|
||||||
- number.set:
|
|
||||||
id: valve_controller_command
|
|
||||||
value: 0
|
|
||||||
|
|
||||||
sensor:
|
|
||||||
- platform: modbus_controller
|
|
||||||
modbus_controller_id: valve_device
|
|
||||||
name: "Supply Voltage"
|
|
||||||
register_type: read
|
|
||||||
device_class: voltage
|
|
||||||
entity_category: diagnostic
|
|
||||||
accuracy_decimals: 2
|
|
||||||
filters:
|
|
||||||
- lambda: |-
|
|
||||||
return x / 1000.0;
|
|
||||||
address: 0x00F5
|
|
||||||
unit_of_measurement: "V"
|
|
||||||
value_type: U_WORD
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
ruamel.yaml
|
|
||||||
esphome
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
wifi_ssid: 'PUT YOUR WIFI SSID HERE'
|
|
||||||
wifi_password: 'PUT YOUR WIFI PASSWORD HERE'
|
|
||||||
api_key: 'PUT YOUR KEY HERE OR USE create_secrets.py'
|
|
||||||
ota_password: 'PUT YOUR KEY HERE OR USE create_secrets.py'
|
|
||||||
|
|
@ -4,78 +4,63 @@ import serial
|
||||||
import serial_asyncio
|
import serial_asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Globale Variablen für den seriellen Reader und Writer
|
# Globale Variable für den seriellen Writer, um Zugriff im TCP-Handler zu ermöglichen
|
||||||
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_reader, serial_writer
|
global 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 or not serial_reader:
|
if not serial_writer:
|
||||||
log_message("❌ Fehler: Serielle Verbindung ist nicht aktiv. Client wird getrennt.")
|
log_message("❌ Fehler: Serielle Verbindung ist nicht aktiv. Client wird getrennt.")
|
||||||
writer.close(); await writer.wait_closed()
|
writer.close()
|
||||||
|
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: break
|
if not tcp_request:
|
||||||
|
# Verbindung vom Client geschlossen
|
||||||
if verbose: log_verbose_request(tcp_request)
|
break
|
||||||
|
|
||||||
# *** 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:
|
||||||
serial_response = await asyncio.wait_for(serial_reader.read(256), timeout=2.0)
|
# Auf Antwort vom seriellen Gerät warten (mit Timeout)
|
||||||
if verbose: log_verbose_response(serial_response)
|
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)
|
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:
|
||||||
log_message("VERBOSE: <-- Timeout from DEV")
|
if verbose:
|
||||||
|
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.")
|
||||||
|
|
@ -83,34 +68,36 @@ 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(); await writer.wait_closed()
|
writer.close()
|
||||||
|
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_reader, serial_writer
|
global 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:
|
||||||
reader_obj, writer_obj = await serial_asyncio.open_serial_connection(
|
# Erneuter Verbindungsversuch
|
||||||
|
_, writer = await serial_asyncio.open_serial_connection(
|
||||||
url=comport, baudrate=baudrate, rtscts=False, dsrdtr=False
|
url=comport, baudrate=baudrate, rtscts=False, dsrdtr=False
|
||||||
)
|
)
|
||||||
serial_reader, serial_writer = reader_obj, writer_obj
|
serial_writer = writer # Globale Variable aktualisieren
|
||||||
log_message(f"✅ Serielle Verbindung zu {comport} wiederhergestellt.")
|
log_message(f"✅ Serielle Verbindung zu {comport} wiederhergestellt.")
|
||||||
return True
|
return writer # Erfolgreich, gib den neuen Writer zurück
|
||||||
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.")
|
||||||
try:
|
# Sauberes Beenden des gesamten Programms
|
||||||
asyncio.get_running_loop().stop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError: pass
|
loop.stop()
|
||||||
return False
|
return None
|
||||||
|
|
||||||
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_reader, serial_writer
|
global 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}")
|
||||||
|
|
@ -118,33 +105,35 @@ 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_reader, serial_writer = await serial_asyncio.open_serial_connection(
|
_, 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}")
|
||||||
if not await serial_reconnector(args.comport, args.baudrate): return
|
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)
|
||||||
|
|
||||||
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:
|
||||||
try:
|
if not serial_writer or not serial_writer.protocol.transport.is_open():
|
||||||
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...")
|
||||||
if not await serial_reconnector(args.comport, args.baudrate):
|
serial_writer = await serial_reconnector(args.comport, args.baudrate)
|
||||||
server.close(); break
|
if not serial_writer:
|
||||||
await asyncio.sleep(2)
|
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__":
|
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.")
|
||||||
|
|
@ -158,6 +147,6 @@ if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
log_message("\n👋 Programm wird durch Benutzer beendet.")
|
log_message("👋 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}")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue