10 Commits

Author SHA1 Message Date
Nils Reiners
8eda3bc954 reading out registers corrected 2025-09-16 22:46:42 +02:00
Nils Reiners
b9cba11be7 cleaned up 2025-09-16 12:57:37 +02:00
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
a7e67cc8f1 daten übertragen 2025-04-18 12:46:15 +01:00
24 changed files with 1435 additions and 100822 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
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:
- 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.

View File

@@ -1,46 +0,0 @@
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 data
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 data 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)

View File

@@ -3,15 +3,17 @@ import pandas as pd
import time
class HeatPump:
def __init__(self, ip_address: str):
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 = 502
port = self.port
self.client = ModbusTcpClient(self.ip, port=port)
try:
if not self.client.connect():
@@ -25,7 +27,7 @@ class HeatPump:
def get_registers(self):
# Excel-Datei mit den Input-Registerinformationen
excel_path = "data/ModBus TCPIP 1.17(1).xlsx"
excel_path = "modbus_registers/heat_pump_registers.xlsx"
xls = pd.ExcelFile(excel_path)
df_input_registers = xls.parse('04 Input Register')
@@ -42,7 +44,7 @@ class HeatPump:
for _, row in df_clean.iterrows()
}
def get_data(self):
def get_state(self):
data = {}
data['Zeit'] = time.strftime('%Y-%m-%d %H:%M:%S')
for address, info in self.registers.items():

30
main.py
View File

@@ -1,17 +1,35 @@
import time
from datetime import datetime
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
interval = 10 # z.B. alle 10 Sekunden
# For dev-System run in terminal: ssh -N -L 127.0.0.1:8111:10.0.0.10:502 pi@192.168.1.146
# For productive-System change IP-adress in heatpump to '10.0.0.10' and port to 502
db = DataBaseCsv('modbus_log.csv')
hp = HeatPump(ip_address='10.0.0.10')
interval_seconds = 10
db = DataBaseInflux(
url="http://192.168.1.146:8086",
token="Cw_naEZyvJ3isiAh1P4Eq3TsjcHmzzDFS7SlbKDsS6ZWL04fMEYixWqtNxGThDdG27S9aW5g7FP9eiq5z1rsGA==",
org="allmende",
bucket="allmende_db"
)
hp = HeatPump(device_name='hp_master', ip_address='10.0.0.10', port=502)
shelly = ShellyPro3m(device_name='wohnung_2_6', ip_address='192.168.1.121')
wr = PvInverter(device_name='wr_master', ip_address='192.168.1.112')
#controller = SgReadyController(hp, wr)
while True:
now = datetime.now()
if now.second % interval == 0 and now.microsecond < 100_000:
db.store_data(hp.get_data())
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)

File diff suppressed because it is too large Load Diff

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()

117
pv_inverter.py Normal file
View File

@@ -0,0 +1,117 @@
import time
import struct
import pandas as pd
import matplotlib.pyplot as plt
from collections import deque
from typing import Dict, Any, List, Tuple, Optional
from pymodbus.client import ModbusTcpClient
EXCEL_PATH = "modbus_registers/pv_inverter_registers.xlsx"
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: Optional[ModbusTcpClient] = None
self.registers: Dict[int, Dict[str, Any]] = {} # addr -> {"desc":..., "type":...}
self.connect_to_modbus()
self.load_registers(EXCEL_PATH)
# ---------- Verbindung ----------
def connect_to_modbus(self):
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 hergestellt.")
def close(self):
if self.client:
self.client.close()
self.client = None
# ---------- Register-Liste ----------
def load_registers(self, excel_path: str):
xls = pd.ExcelFile(excel_path)
df = xls.parse()
# Passen die Spaltennamen bei dir anders, bitte hier anpassen:
cols = ["MB Adresse", "Beschreibung", "Variabel Typ"]
for c in cols:
if c not in df.columns:
raise ValueError(f"Spalte '{c}' fehlt in {excel_path}")
df = df[cols].dropna()
df["MB Adresse"] = df["MB Adresse"].astype(int)
# NORMALISIERE TYP
def norm_type(x: Any) -> str:
s = str(x).strip().upper()
return "REAL" if s == "REAL" else "INT"
self.registers = {
int(row["MB Adresse"]): {
"desc": str(row["Beschreibung"]).strip(),
"type": norm_type(row["Variabel Typ"])
}
for _, row in df.iterrows()
}
print(f" {len(self.registers)} Register aus Excel geladen.")
# ---------- Low-Level Lesen ----------
def _try_read(self, fn_name: str, address: int, count: int) -> Optional[List[int]]:
fn = getattr(self.client, fn_name)
# pymodbus 3.8.x hat 'slave='; Fallbacks schaden nicht
for kwargs in (dict(address=address, count=count, slave=self.unit),
dict(address=address, count=count),
):
try:
res = fn(**kwargs)
if res is None or (hasattr(res, "isError") and res.isError()):
continue
return res.registers
except TypeError:
continue
return None
def _read_any(self, address: int, count: int) -> Optional[List[int]]:
regs = self._try_read("read_holding_registers", address, count)
if regs is None:
regs = self._try_read("read_input_registers", address, count)
return regs
# ---------- Decoding ----------
@staticmethod
def _to_i16(u16: int) -> int:
return struct.unpack(">h", struct.pack(">H", u16))[0]
@staticmethod
def _to_f32_from_two(u16_hi: int, u16_lo: int, msw_first: bool = True) -> float:
if msw_first:
b = struct.pack(">HH", u16_hi, u16_lo)
else:
b = struct.pack(">HH", u16_lo, u16_hi)
return struct.unpack(">f", b)[0]
def read_one(self, address_excel: int, rtype: str) -> Optional[float]:
"""Liest einen Wert nach Typ ('INT' oder 'REAL') unter Berücksichtigung Base-1."""
addr = address_excel
if rtype == "REAL":
regs = self._read_any(addr, 2)
if not regs or len(regs) < 2:
return None
return self._to_f32_from_two(regs[0], regs[1])
else: # INT
regs = self._read_any(addr, 1)
if not regs:
return None
return float(self._to_i16(regs[0]))
def get_state(self) -> Dict[str, Any]:
"""Liest ALLE Register aus self.registers und gibt dict zurück."""
data = {"Zeit": time.strftime("%Y-%m-%d %H:%M:%S")}
for address, meta in self.registers.items():
val = self.read_one(address, meta["type"])
if val is None:
continue
key = f"{address} - {meta['desc']}"
data[key] = val
return data

View File

@@ -1,3 +1,4 @@
pymodbus~=3.8.6
pandas
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

99599
terminal_log

File diff suppressed because it is too large Load Diff

61
test_meter.py Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pymodbus.client import ModbusTcpClient
import struct, sys
MODBUS_IP = "192.168.1.112"
MODBUS_PORT = 502
UNIT_ID = 1
METER_START = 40240 # Startadresse Model 203-Felder
def to_i16(u16): # unsigned 16 → signed 16
return struct.unpack(">h", struct.pack(">H", u16))[0]
def read_regs(client, addr, count):
rr = client.read_holding_registers(address=addr, count=count, slave=UNIT_ID)
if rr.isError():
return None
return rr.registers
def read_meter_power(client):
base = METER_START
p = read_regs(client, base + 16, 1) # M_AC_Power
pa = read_regs(client, base + 17, 1) # Phase A
pb = read_regs(client, base + 18, 1) # Phase B
pc = read_regs(client, base + 19, 1) # Phase C
sf = read_regs(client, base + 20, 1) # Scale Factor
if not p or not sf:
return None
sff = to_i16(sf[0])
return {
"total": to_i16(p[0]) * (10 ** sff),
"A": to_i16(pa[0]) * (10 ** sff) if pa else None,
"B": to_i16(pb[0]) * (10 ** sff) if pb else None,
"C": to_i16(pc[0]) * (10 ** sff) if pc else None,
"sf": sff
}
def fmt_w(v):
if v is None: return "-"
neg = v < 0
v = abs(v)
return f"{'-' if neg else ''}{v/1000:.2f} kW" if v >= 1000 else f"{'-' if neg else ''}{v:.0f} W"
def main():
client = ModbusTcpClient(MODBUS_IP, port=MODBUS_PORT)
if not client.connect():
print("❌ Verbindung fehlgeschlagen."); sys.exit(1)
try:
m = read_meter_power(client)
if m:
print(f"Meter-Leistung: {fmt_w(m['total'])} "
f"(A {fmt_w(m['A'])}, B {fmt_w(m['B'])}, C {fmt_w(m['C'])}) [SF={m['sf']}]")
else:
print("Meter-Leistung konnte nicht gelesen werden.")
finally:
client.close()
if __name__ == "__main__":
main()

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()