13 Commits

Author SHA1 Message Date
Nils Reiners
5319a299be inverter was included 2025-09-16 12:52:27 +02:00
Nils Reiners
2186c4d7db wechselrichter zum tesent eingebunden 2025-09-14 10:52:50 +02:00
Nils Reiners
7df61fd6c1 shelly upgedatet 2025-05-26 21:31:28 +02:00
Nils Reiners
0734f7a810 shelly hinzugefügt 2025-05-26 21:08:16 +02:00
Nils Reiners
65a75e061b läuft 2025-04-26 22:31:14 +01:00
Nils Reiners
974ec43f10 influx data base added 2025-04-26 23:13:22 +02:00
Nils Reiners
f0d390cd59 Merge branch 'feature_wp_klasse' 2025-04-18 18:59:48 +02:00
Nils Reiners
7181d4702c uplead measurements 2025-04-18 17:04:14 +01:00
Nils Reiners
6ce6db31cf update data 2025-04-18 13:13:08 +01:00
Nils Reiners
61f7813737 data update 2025-04-18 13:08:09 +01:00
Nils Reiners
11931869be test 2025-04-18 12:57:05 +01:00
Nils Reiners
a78b9ca3e0 new concept with classes seems to run well 2025-04-18 12:54:17 +01:00
Nils Reiners
907d24d759 new branch with heatpump and database classes 2025-04-18 13:47:10 +02:00
26 changed files with 728 additions and 52641 deletions

38
README
View File

@@ -11,10 +11,42 @@ Was needs to be done on the Raspberry pi before the tool can run.
- pip install -r requirements.txt - pip install -r requirements.txt
How to run the script: 3) How to run the script for testing:
- nohup python main.py > terminal_log 2>&1 & nohup python main.py > terminal_log 2>&1 &
For reading out the terminal_log while script is runing: For reading out the terminal_log while script is runing:
- tail -f terminal_log tail -f terminal_log
4) Implement and run the ems as systemd service:
create:
/etc/systemd/system/allmende_ems.service
insert:
[Unit]
Description=Allmende EMS Python Script
After=network.target
[Service]
WorkingDirectory=/home/pi/projects/allmende_ems
ExecStart=/home/pi/allmende_ems/bin/python3.11 /home/pi/projects/allmende_ems/main.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
manage the service with the following commands:
Once:
sudo systemctl daemon-reload
sudo systemctl start allmende_ems.service
sudo systemctl enable allmende_ems.service
While running:
sudo systemctl status allmende_ems.service
sudo systemctl restart allmende_ems.service
sudo systemctl stop allmende_ems.service
journalctl -u allmende_ems.service

31
SG_ready_schalten.py Normal file
View File

@@ -0,0 +1,31 @@
from pymodbus.client import ModbusTcpClient
def write_coils(ip):
# IP und Port der Wärmepumpe
port = 502
client = ModbusTcpClient(ip, port=port)
if not client.connect():
print("Verbindung zur Wärmepumpe fehlgeschlagen.")
return
try:
# Coil 300 = Kommunikation über Bus aktivieren (1)
response_300 = client.write_coil(300, True)
# Coil 301 = SG Ready Stufe 1 aktivieren (1)
response_301 = client.write_coil(301, False)
# Coil 302 = SG Ready Stufe 2 deaktivieren (0)
response_302 = client.write_coil(302, False)
# Optional: Rückmeldungen prüfen
for addr, resp in zip([300, 301, 302], [response_300, response_301, response_302]):
if resp.isError():
print(f"Fehler beim Schreiben von Coil {addr}: {resp}")
else:
print(f"Coil {addr} erfolgreich geschrieben.")
finally:
client.close()
# Testaufruf mit IP-Adresse deiner Wärmepumpe
write_coils("10.0.0.10") # <-- IP-Adresse hier anpassen

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
,nils,nils-ThinkPad-P52,17.04.2025 14:59,file:///home/nils/.config/libreoffice/4;

46
data_base_csv.py Normal file
View File

@@ -0,0 +1,46 @@
import csv
import os
import tempfile
import shutil
class DataBaseCsv:
def __init__(self, filename: str):
self.filename = filename
def store_data(self, data: dict):
new_fields = list(data.keys())
# If file does not exist or is empty → create new file with header
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
with open(self.filename, mode='w', newline='') as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=new_fields)
writer.writeheader()
writer.writerow(data)
return
# If file exists → read existing header and modbus_registers
with open(self.filename, mode='r', newline='') as csv_file:
reader = csv.DictReader(csv_file)
existing_fields = reader.fieldnames
existing_data = list(reader)
# Merge old and new fields (keep original order, add new ones)
all_fields = existing_fields.copy()
for field in new_fields:
if field not in all_fields:
all_fields.append(field)
# Write to a temporary file with updated header
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='', encoding='utf-8') as tmp_file:
writer = csv.DictWriter(tmp_file, fieldnames=all_fields)
writer.writeheader()
# Write old rows with updated field list
for row in existing_data:
writer.writerow({field: row.get(field, '') for field in all_fields})
# Write new modbus_registers row
writer.writerow({field: data.get(field, '') for field in all_fields})
# Replace original file with updated temporary file
shutil.move(tmp_file.name, self.filename)

28
data_base_influx.py Normal file
View File

@@ -0,0 +1,28 @@
from influxdb_client import InfluxDBClient, Point, WritePrecision
from datetime import datetime
class DataBaseInflux:
def __init__(self, url: str, token: str, org: str, bucket: str):
self.url = url
self.token = token
self.org = org
self.bucket = bucket
self.client = InfluxDBClient(url=self.url, token=self.token, org=self.org)
self.write_api = self.client.write_api()
def store_data(self, device_name: str, data: dict):
measurement = device_name # Fest auf "messungen" gesetzt
point = Point(measurement)
# Alle Key/Value-Paare als Fields speichern
for key, value in data.items():
point = point.field(key, value)
# Zeitstempel automatisch auf jetzt setzen
point = point.time(datetime.utcnow(), WritePrecision.NS)
# Punkt in InfluxDB schreiben
self.write_api.write(bucket=self.bucket, org=self.org, record=point)

64
heat_pump.py Normal file
View File

@@ -0,0 +1,64 @@
from pymodbus.client import ModbusTcpClient
import pandas as pd
import time
class HeatPump:
def __init__(self, device_name: str, ip_address: str, port: int=502):
self.device_name = device_name
self.ip = ip_address
self.port = port
self.client = None
self.connect_to_modbus()
self.registers = None
self.get_registers()
def connect_to_modbus(self):
port = self.port
self.client = ModbusTcpClient(self.ip, port=port)
try:
if not self.client.connect():
print("Verbindung zur Wärmepumpe fehlgeschlagen.")
exit(1)
print("Verbindung zur Wärmepumpe erfolgreich.")
except KeyboardInterrupt:
print("Beendet durch Benutzer (Ctrl+C).")
finally:
self.client.close()
def get_registers(self):
# Excel-Datei mit den Input-Registerinformationen
excel_path = "modbus_registers/heat_pump_registers.xlsx"
xls = pd.ExcelFile(excel_path)
df_input_registers = xls.parse('04 Input Register')
# Relevante Spalten bereinigen
df_clean = df_input_registers[['MB Adresse', 'Variable', 'Beschreibung', 'Variabel Typ']].dropna()
df_clean['MB Adresse'] = df_clean['MB Adresse'].astype(int)
# Dictionary aus Excel erzeugen
self.registers = {
row['MB Adresse']: {
'desc': row['Beschreibung'],
'type': 'REAL' if row['Variabel Typ'] == 'REAL' else 'INT'
}
for _, row in df_clean.iterrows()
}
def get_state(self):
data = {}
data['Zeit'] = time.strftime('%Y-%m-%d %H:%M:%S')
for address, info in self.registers.items():
reg_type = info['type']
result = self.client.read_input_registers(address, count=2 if reg_type == 'REAL' else 1)
if result.isError():
print(f"Fehler beim Lesen von Adresse {address}: {result}")
continue
if reg_type == 'REAL':
value = result.registers[0] / 10.0
else:
value = result.registers[0]
print(f"Adresse {address} - {info['desc']}: {value}")
data[f"{address} - {info['desc']}"] = value
return data

94
main.py
View File

@@ -1,75 +1,35 @@
from pymodbus.client import ModbusTcpClient
import pandas as pd
import struct
import time import time
import csv from datetime import datetime
import os from data_base_csv import DataBaseCsv
from data_base_influx import DataBaseInflux
from heat_pump import HeatPump
from pv_inverter import PvInverter
from shelly_pro_3m import ShellyPro3m
# Excel-Datei mit den Input-Registerinformationen # For dev-System run in terminal: ssh -N -L 127.0.0.1:8111:10.0.0.10:502 pi@192.168.1.146
excel_path = "data/ModBus TCPIP 1.17(1).xlsx" # For productive-System change port in heatpump to 502
xls = pd.ExcelFile(excel_path)
df_input_registers = xls.parse('04 Input Register')
# Relevante Spalten bereinigen interval_seconds = 10
df_clean = df_input_registers[['MB Adresse', 'Variable', 'Beschreibung', 'Variabel Typ']].dropna()
df_clean['MB Adresse'] = df_clean['MB Adresse'].astype(int)
# Dictionary aus Excel erzeugen db = DataBaseInflux(
registers = { url="http://192.168.1.146:8086",
row['MB Adresse']: { token="Cw_naEZyvJ3isiAh1P4Eq3TsjcHmzzDFS7SlbKDsS6ZWL04fMEYixWqtNxGThDdG27S9aW5g7FP9eiq5z1rsGA==",
'desc': row['Beschreibung'], org="allmende",
'type': 'REAL' if row['Variabel Typ'] == 'REAL' else 'INT' bucket="allmende_db"
} )
for _, row in df_clean.iterrows()
}
# CSV-Datei vorbereiten hp = HeatPump(device_name='hp_master', ip_address='localhost', port=8111)
csv_filename = "modbus_log.csv" shelly = ShellyPro3m(device_name='wohnung_2_6', ip_address='192.168.1.121')
csv_exists = os.path.isfile(csv_filename) wr = PvInverter(device_name='wr_master', ip_address='192.168.1.112')
# Verbindung zur Wärmepumpe #controller = SgReadyController(hp, wr)
ip = '10.0.0.10'
port = 502
unit_id = 1
client = ModbusTcpClient(ip, port=port) while True:
now = datetime.now()
if now.second % interval_seconds == 0 and now.microsecond < 100_000:
db.store_data(hp.device_name, hp.get_state())
db.store_data(shelly.device_name, shelly.get_state())
db.store_data(wr.device_name, wr.get_state())
#controller.perform_action()
time.sleep(0.1)
try:
if not client.connect():
print("Verbindung zur Wärmepumpe fehlgeschlagen.")
exit(1)
print("Verbindung zur Wärmepumpe erfolgreich.")
with open(csv_filename, mode='a', newline='') as file:
writer = csv.writer(file)
if not csv_exists:
header = ['Zeit'] + [f"{address} - {info['desc']}" for address, info in registers.items()]
writer.writerow(header)
while True:
print(f"\n--- Neue Abfrage --- {time.strftime('%Y-%m-%d %H:%M:%S')} ---")
row = [time.strftime('%Y-%m-%d %H:%M:%S')]
for address, info in registers.items():
reg_type = info['type']
result = client.read_input_registers(address, count=2 if reg_type == 'REAL' else 1)
if result.isError():
print(f"Fehler beim Lesen von Adresse {address}: {result}")
row.append('Fehler')
continue
if reg_type == 'REAL':
value = result.registers[0] / 10.0
else:
value = result.registers[0]
print(f"Adresse {address} - {info['desc']}: {value}")
row.append(value)
writer.writerow(row)
file.flush()
time.sleep(10)
except KeyboardInterrupt:
print("Beendet durch Benutzer (Ctrl+C).")
finally:
client.close()

22
make_tunnel.py Normal file
View File

@@ -0,0 +1,22 @@
from sshtunnel import SSHTunnelForwarder
# ---- KONFIG ----
SSH_HOST = "192.168.1.146" # Raspberry Pi im 192.168.1.x Netz
SSH_PORT = 22
SSH_USER = "pi"
PASSWORD = 'raspberry' # oder Passwort als String
REMOTE_IP = "10.0.0.10" # Wärmepumpe im 10.0.0.x Netz
REMOTE_PORT = 502 # Modbus/TCP Port
def make_tunnel(port):
tunnel = SSHTunnelForwarder(
(SSH_HOST, SSH_PORT),
ssh_username=SSH_USER,
ssh_password=PASSWORD,
remote_bind_address=(REMOTE_IP, REMOTE_PORT),
local_bind_address=("127.0.0.1", port),
)
tunnel.start()
return tunnel

Binary file not shown.

Binary file not shown.

41
plot_data.py Normal file
View File

@@ -0,0 +1,41 @@
# Neu laden nach Code-Reset
import pandas as pd
import matplotlib.pyplot as plt
# Pfad zur neu hochgeladenen Datei
file_path = "modbus_log.csv"
df_new = pd.read_csv(file_path)
# Zeitstempel in datetime konvertieren
df_new['Zeit'] = pd.to_datetime(df_new['Zeit'])
# Spaltenbezeichnungen für den Plot
registers = [
'10 - Gebäudeseite Wärmepumpe Vorlauf/Austritt (Warm)',
'11 - Gebäudeseite Wärmepumpe Rücklauf/Eintritt (Kalt)',
'12 - Umweltseite/Quelle Wärmepumpe Eintritt (Warm)',
'13 - Umweltseite/Quelle Wärmepumpe Austritt (Kalt)',
'50 - Rücklauftemperatur Direkterheizkreis oder Puffertemperatur',
'70 - Vorlauftemperatur Mischerkreis 1',
'150 - Trinkwarmwasserspiecher oben (Ein)',
'153 - Trinkwarmwasserspiecher unten (Aus)'
]
all_registers = ['300 - Aussentemperatur'] + registers
# Plot erzeugen
plt.figure(figsize=(14, 8))
for reg in all_registers:
plt.plot(df_new['Zeit'], df_new[reg], label=reg)
plt.title("Temperaturverläufe inkl. Außentemperatur (neue Daten)")
plt.xlabel("Zeit")
plt.ylabel("Temperatur (°C)")
plt.grid(True)
plt.tight_layout()
# Legende außerhalb des Plots platzieren
plt.legend(loc='center left', bbox_to_anchor=(1.0, 0.5))
plt.subplots_adjust(right=0.75)
plt.show()

68
pv_inverter.py Normal file
View File

@@ -0,0 +1,68 @@
import time
import pandas as pd
from pymodbus.client import ModbusTcpClient
class PvInverter:
def __init__(self, device_name: str, ip_address: str, port: int = 502, unit: int = 1):
self.device_name = device_name
self.ip = ip_address
self.port = port
self.unit = unit
self.client = None
self.registers = None
self.connect_to_modbus()
self.get_registers()
def connect_to_modbus(self):
# Timeout & retries optional, aber hilfreich:
self.client = ModbusTcpClient(self.ip, port=self.port, timeout=3.0, retries=3)
if not self.client.connect():
print("Verbindung zu Wechselrichter fehlgeschlagen.")
raise SystemExit(1)
print("Verbindung zu Wechselrichter erfolgreich.")
# WICHTIG: NICHT hier schließen!
# finally: self.client.close() <-- entfernen
def close(self):
if self.client:
self.client.close()
self.client = None
def get_registers(self):
excel_path = "modbus_registers/pv_inverter_registers.xlsx"
xls = pd.ExcelFile(excel_path)
df_input_registers = xls.parse()
df_clean = df_input_registers[['MB Adresse', 'Beschreibung', 'Variabel Typ']].dropna()
df_clean['MB Adresse'] = df_clean['MB Adresse'].astype(int)
self.registers = {
row['MB Adresse']: {
'desc': row['Beschreibung'],
'type': 'REAL' if str(row['Variabel Typ']).upper() == 'REAL' else 'INT'
}
for _, row in df_clean.iterrows()
}
def get_state(self):
data = {'Zeit': time.strftime('%Y-%m-%d %H:%M:%S')}
for address, info in self.registers.items():
reg_type = info['type']
# Unit-ID mitgeben (wichtig bei pymodbus>=3)
result = self.client.read_holding_registers(
address=address,
count=2 if reg_type == 'REAL' else 1,
slave=self.unit # pymodbus 2.x -> 'slave', nicht 'unit'
)
if result.isError():
print(f"Fehler beim Lesen von Adresse {address}: {result}")
continue
# Minimal invasiv: wie bei dir erstes Register verwenden
value = result.registers[0]
print(f"Adresse {address} - {info['desc']}: {value}")
data[f"{address} - {info['desc']}"] = value
return data

View File

@@ -1,3 +1,4 @@
pymodbus~=3.8.6 pymodbus~=3.8.6
pandas pandas
openpyxl openpyxl
sshtunnel

64
shelly_pro_3m.py Normal file
View File

@@ -0,0 +1,64 @@
import struct
from pymodbus.client import ModbusTcpClient
import pandas as pd
import time
class ShellyPro3m:
def __init__(self, device_name: str, ip_address: str, port: int=502):
self.device_name = device_name
self.ip = ip_address
self.port = port
self.client = None
self.connect_to_modbus()
self.registers = None
self.get_registers()
def connect_to_modbus(self):
port = self.port
self.client = ModbusTcpClient(self.ip, port=port)
try:
if not self.client.connect():
print("Verbindung zum Shelly-Logger fehlgeschlagen.")
exit(1)
print("Verbindung zum Shelly-Logger erfolgreich.")
except KeyboardInterrupt:
print("Beendet durch Benutzer (Ctrl+C).")
finally:
self.client.close()
def get_registers(self):
# Excel-Datei mit den Input-Registerinformationen
excel_path = "modbus_registers/shelly_pro_3m_registers.xlsx"
xls = pd.ExcelFile(excel_path)
df_input_registers = xls.parse()
# Relevante Spalten bereinigen
df_clean = df_input_registers[['MB Adresse', 'Beschreibung', 'Variabel Typ']].dropna()
df_clean['MB Adresse'] = df_clean['MB Adresse'].astype(int)
# Dictionary aus Excel erzeugen
self.registers = {
row['MB Adresse']: {
'desc': row['Beschreibung'],
'type': 'REAL' if row['Variabel Typ'] == 'REAL' else 'INT'
}
for _, row in df_clean.iterrows()
}
def get_state(self):
data = {}
data['Zeit'] = time.strftime('%Y-%m-%d %H:%M:%S')
for address, info in self.registers.items():
reg_type = info['type']
result = self.client.read_input_registers(address, count=2 if reg_type == 'REAL' else 1)
if result.isError():
print(f"Fehler beim Lesen von Adresse {address}: {result}")
continue
packed = struct.pack(">HH", result.registers[1], result.registers[0])
value = round(struct.unpack(">f", packed)[0], 2)
print(f"Adresse {address} - {info['desc']}: {value}")
data[f"{address} - {info['desc']}"] = value
return data

52759
terminal_log

File diff suppressed because it is too large Load Diff

110
test_wr.py Normal file
View File

@@ -0,0 +1,110 @@
from pymodbus.client import ModbusTcpClient
import struct
import sys
from typing import Optional
# === Verbindungseinstellungen ===
MODBUS_IP = "192.168.1.112"
MODBUS_PORT = 502 # SetApp: 1502; LCD-Menü: 502 -> ggf. anpassen
UNIT_ID = 1 # Default laut Doku: 1
client = ModbusTcpClient(MODBUS_IP, port=MODBUS_PORT)
if not client.connect():
print("Verbindung fehlgeschlagen.")
sys.exit(1)
def read_regs(addr: int, count: int):
"""Hilfsfunktion: liest 'count' Holding-Register ab base-0 'addr'."""
rr = client.read_holding_registers(address=addr, count=count)
if rr.isError():
return None
return rr.registers
def read_string(addr: int, words: int) -> Optional[str]:
"""
SunSpec-Strings: ASCII, Big-Endian, 2 Bytes pro Register, 0x00 gepadded.
"""
regs = read_regs(addr, words)
if regs is None:
return None
b = b"".join(struct.pack(">H", r) for r in regs)
# SunSpec Strings sind meist mit \x00 und Spaces gepadded:
s = b.decode("ascii", errors="ignore").rstrip("\x00 ").strip()
return s or None
def to_int16(u16: int) -> int:
"""unsigned 16 -> signed 16"""
return struct.unpack(">h", struct.pack(">H", u16))[0]
def apply_sf(raw: int, sf: int) -> float:
return raw * (10 ** sf)
def read_scaled(value_addr: int, sf_addr: int) -> Optional[float]:
regs = read_regs(value_addr, 1)
sf = read_regs(sf_addr, 1)
if regs is None or sf is None:
return None
raw = to_int16(regs[0])
sff = to_int16(sf[0])
return apply_sf(raw, sff)
def read_u32_with_sf(value_addr: int, sf_addr: int) -> Optional[float]:
"""
Liest 32-bit Zähler (acc32, Big-Endian, 2 Register) + SF.
"""
regs = read_regs(value_addr, 2)
sf = read_regs(sf_addr, 1)
if regs is None or sf is None:
return None
# Big-Endian zusammenbauen:
u32 = (regs[0] << 16) | regs[1]
sff = to_int16(sf[0])
return apply_sf(u32, sff)
# ==== Common Block (base-0) ====
manufacturer = read_string(40004, 16) # C_Manufacturer
model = read_string(40020, 16) # C_Model
version = read_string(40044, 8) # C_Version
serial = read_string(40052, 16) # C_SerialNumber
print(f"Hersteller: {manufacturer}")
print(f"Modell: {model}")
print(f"Version: {version}")
print(f"Seriennummer: {serial}")
# ==== Inverter Block (base-0) ====
# AC Power + Scale Factor
ac_power = read_scaled(40083, 40084) # I_AC_Power, I_AC_Power_SF
if ac_power is not None:
print(f"AC Power: {ac_power} W")
else:
print("Fehler beim Lesen von AC Power")
# AC Spannung L-N Durchschnitt (falls 1ph/3ph mit N verfügbar) + SF
ac_voltage = read_scaled(40079, 40082) # I_AC_VoltageAN, I_AC_Voltage_SF
if ac_voltage is not None:
print(f"AC Spannung: {ac_voltage} V")
# AC Frequenz + SF
ac_freq = read_scaled(40085, 40086) # I_AC_Frequency, _SF
if ac_freq is not None:
print(f"Frequenz: {ac_freq} Hz")
# DC Power + SF
dc_power = read_scaled(40100, 40101) # I_DC_Power, _SF
if dc_power is not None:
print(f"DC Power: {dc_power} W")
# Lifetime Energy (AC_Energy_WH, acc32) + SF
lifetime_wh = read_u32_with_sf(40093, 40095) # I_AC_Energy_WH, _SF
if lifetime_wh is not None:
print(f"Lifetime Energy: {lifetime_wh} Wh")
# Status
status_regs = read_regs(40107, 2) # I_Status, I_Status_Vendor
if status_regs:
i_status = status_regs[0]
i_status_vendor = status_regs[1]
print(f"Status: {i_status} (Vendor: {i_status_vendor})")
client.close()