diff --git a/main.py b/main.py index e77d000..923f25d 100644 --- a/main.py +++ b/main.py @@ -26,57 +26,57 @@ db = DataBaseInflux( bucket="allmende_db" ) -hp_master = HeatPump(device_name='hp_master', ip_address='10.0.0.10', port=502) -hp_slave = HeatPump(device_name='hp_slave', ip_address='10.0.0.11', port=502) -shelly = ShellyPro3m(device_name='wohnung_2_6', ip_address='192.168.1.121') +# hp_master = HeatPump(device_name='hp_master', ip_address='10.0.0.10', port=502) +# hp_slave = HeatPump(device_name='hp_slave', ip_address='10.0.0.11', port=502) +# shelly = ShellyPro3m(device_name='wohnung_2_6', ip_address='192.168.1.121') wr_master = PvInverter(device_name='solaredge_master', ip_address='192.168.1.112', unit=1) wr_slave = PvInverter(device_name='solaredge_slave', ip_address='192.168.1.112', unit=3) meter = SolaredgeMeter(device_name='solaredge_meter', ip_address='192.168.1.112') -es.add_components(hp_master, hp_slave, shelly, wr_master, wr_slave, meter) -controller = SgReadyController(es) - -# FORECASTING -latitude = 48.041 -longitude = 7.862 -TZ = "Europe/Berlin" -HORIZON_DAYS = 2 -weather_forecaster = WeatherForecaster(latitude=latitude, longitude=longitude) -site = Location(latitude=latitude, longitude=longitude, altitude=35, tz=TZ, name="Gundelfingen") - -p_module = 435 -upper_roof_north = PvWattsSubarrayConfig(name="north", pdc0_w=(29+29+21)*p_module, tilt_deg=10, azimuth_deg=20, dc_loss=0.02, ac_loss=0.01) -upper_roof_south = PvWattsSubarrayConfig(name="south", pdc0_w=(29+21+20)*p_module, tilt_deg=10, azimuth_deg=200, dc_loss=0.02, ac_loss=0.01) -upper_roof_east = PvWattsSubarrayConfig(name="east", pdc0_w=7*p_module, tilt_deg=10, azimuth_deg=110, dc_loss=0.02, ac_loss=0.01) -upper_roof_west = PvWattsSubarrayConfig(name="west", pdc0_w=7*p_module, tilt_deg=10, azimuth_deg=290, dc_loss=0.02, ac_loss=0.01) -cfgs = [upper_roof_north, upper_roof_south, upper_roof_east, upper_roof_west] -pv_plant = PvWattsPlant(site, cfgs) - -now = datetime.now() -next_forecast_at = (now + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) +es.add_components(wr_master, wr_slave)#hp_master, hp_slave, shelly, wr_master, wr_slave, meter) +# controller = SgReadyController(es) +# +# # FORECASTING +# latitude = 48.041 +# longitude = 7.862 +# TZ = "Europe/Berlin" +# HORIZON_DAYS = 2 +# weather_forecaster = WeatherForecaster(latitude=latitude, longitude=longitude) +# site = Location(latitude=latitude, longitude=longitude, altitude=35, tz=TZ, name="Gundelfingen") +# +# p_module = 435 +# upper_roof_north = PvWattsSubarrayConfig(name="north", pdc0_w=(29+29+21)*p_module, tilt_deg=10, azimuth_deg=20, dc_loss=0.02, ac_loss=0.01) +# upper_roof_south = PvWattsSubarrayConfig(name="south", pdc0_w=(29+21+20)*p_module, tilt_deg=10, azimuth_deg=200, dc_loss=0.02, ac_loss=0.01) +# upper_roof_east = PvWattsSubarrayConfig(name="east", pdc0_w=7*p_module, tilt_deg=10, azimuth_deg=110, dc_loss=0.02, ac_loss=0.01) +# upper_roof_west = PvWattsSubarrayConfig(name="west", pdc0_w=7*p_module, tilt_deg=10, azimuth_deg=290, dc_loss=0.02, ac_loss=0.01) +# cfgs = [upper_roof_north, upper_roof_south, upper_roof_east, upper_roof_west] +# pv_plant = PvWattsPlant(site, cfgs) +# +# now = datetime.now() +# next_forecast_at = (now + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) while True: now = datetime.now() if now.second % interval_seconds == 0 and now.microsecond < 100_000: state = es.get_state_and_store_to_database(db) - mode = controller.perform_action(heat_pump_name='hp_master', meter_name='solaredge_meter', state=state) + # mode = controller.perform_action(heat_pump_name='hp_master', meter_name='solaredge_meter', state=state) + # + # if mode == 'mode1': + # mode_as_binary = 0 + # else: + # mode_as_binary = 1 + # db.store_data('sg_ready', {'mode': mode_as_binary}) - if mode == 'mode1': - mode_as_binary = 0 - else: - mode_as_binary = 1 - db.store_data('sg_ready', {'mode': mode_as_binary}) - - if now >= next_forecast_at: - # Start der Prognose: ab der kommenden vollen Stunde - start_hour_local = (now + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) - weather = weather_forecaster.get_hourly_forecast(start_hour_local, HORIZON_DAYS) - total = pv_plant.get_power(weather) - db.store_forecasts('pv_forecast', total) - - # Nächste geplante Ausführung definieren (immer volle Stunde) - # Falls wir durch Delay mehrere Stunden verpasst haben, hole auf: - while next_forecast_at <= now: - next_forecast_at = (next_forecast_at + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + # if now >= next_forecast_at: + # # Start der Prognose: ab der kommenden vollen Stunde + # start_hour_local = (now + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) + # weather = weather_forecaster.get_hourly_forecast(start_hour_local, HORIZON_DAYS) + # total = pv_plant.get_power(weather) + # db.store_forecasts('pv_forecast', total) + # + # # Nächste geplante Ausführung definieren (immer volle Stunde) + # # Falls wir durch Delay mehrere Stunden verpasst haben, hole auf: + # while next_forecast_at <= now: + # next_forecast_at = (next_forecast_at + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0) time.sleep(0.1) diff --git a/pv_inverter.py b/pv_inverter.py index 0caba94..9f7dcfa 100644 --- a/pv_inverter.py +++ b/pv_inverter.py @@ -1,32 +1,40 @@ import time import struct import pandas as pd -from typing import Dict, Any, List, Tuple, Optional +from typing import Dict, Any, List, Optional from pymodbus.client import ModbusTcpClient EXCEL_PATH = "modbus_registers/pv_inverter_registers.xlsx" -# Obergrenze: bis EXKLUSIVE 40206 (d.h. max. 40205) -MAX_ADDR_EXCLUSIVE = 40121 +# Bis EXKLUSIVE 40206 (also max. 40205) +MAX_ADDR_EXCLUSIVE = 40206 + class PvInverter: def __init__(self, device_name: str, ip_address: str, port: int = 502, unit: int = 1): + """ + device_name : Anzeigename (z.B. 'master' oder 'slave') + ip_address : IP des Wechselrichters oder Modbus-Gateways + port : TCP-Port (Standard 502) + unit : Modbus Unit-ID (1 = Master, 3 = Slave) + """ 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.registers: Dict[int, Dict[str, Any]] = {} + 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) + self.client = ModbusTcpClient(self.ip, port=self.port, timeout=3.0) if not self.client.connect(): - print("❌ Verbindung zu Wechselrichter fehlgeschlagen.") + print(f"❌ Verbindung zu {self.device_name} ({self.ip}:{self.port}) fehlgeschlagen.") raise SystemExit(1) - print("✅ Verbindung zu Wechselrichter hergestellt.") + print(f"✅ Verbindung hergestellt zu {self.device_name} ({self.ip}:{self.port}, unit={self.unit})") def close(self): if self.client: @@ -37,12 +45,13 @@ class PvInverter: def load_registers(self, excel_path: str): xls = pd.ExcelFile(excel_path) df = xls.parse() - # Passe Spaltennamen hier an, falls nötig: + + # Passe Spaltennamen an deine Excel an cols = ["MB Adresse", "Beschreibung", "Variabel Typ"] df = df[cols].dropna() df["MB Adresse"] = df["MB Adresse"].astype(int) - # 1) Vorab-Filter: nur Adressen < 40206 übernehmen + # Nur Register unterhalb der Grenze übernehmen df = df[df["MB Adresse"] < MAX_ADDR_EXCLUSIVE] self.registers = { @@ -53,21 +62,16 @@ class PvInverter: for _, row in df.iterrows() } - # ---------- Low-Level Lesen ---------- def _try_read(self, fn_name: str, address: int, count: int) -> Optional[List[int]]: + """ + Ruft die pymodbus-Funktion mit fester unit-ID auf (kein Fallback). + """ 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 + res = fn(address=address, count=count, slave=self.unit) + if res is None or (hasattr(res, "isError") and res.isError()): + return None + return getattr(res, "registers", None) def _read_any(self, address: int, count: int) -> Optional[List[int]]: regs = self._try_read("read_holding_registers", address, count) @@ -85,34 +89,30 @@ class PvInverter: b = struct.pack(">HH", u16_hi, u16_lo) if msw_first else struct.pack(">HH", u16_lo, u16_hi) return struct.unpack(">f", b)[0] - # Hilfsfunktion: wie viele 16-Bit-Register braucht dieser Typ? + # Wie viele Register braucht der Typ? @staticmethod def _word_count_for_type(rtype: str) -> int: rt = (rtype or "").lower() - # Passe hier an deine Excel-Typen an: if "uint32" in rt or "real" in rt or "float" in rt or "string(32)" in rt: return 2 - # Default: 1 Wort (z.B. int16/uint16) return 1 + # ---------- Lesen ---------- def read_one(self, address_excel: int, rtype: str) -> Optional[float]: """ - Liest einen Wert nach Typ ('INT' oder 'REAL' etc.). - Es werden ausschließlich Register < 40206 gelesen. + Liest einen Wert nach Typ ('INT', 'REAL' etc.) mit fixer Unit-ID. """ addr = int(address_excel) words = self._word_count_for_type(rtype) - # 2) Harte Grenze prüfen: höchstes angefasstes Register muss < 40206 sein + # Grenze prüfen if addr + words - 1 >= MAX_ADDR_EXCLUSIVE: - # Überspringen, da der Lesevorgang die Grenze >= 40206 berühren würde return None if words == 2: regs = self._read_any(addr, 2) if not regs or len(regs) < 2: return None - # Deine bisherige Logik interpretiert 2 Worte als Float32: return self._to_f32_from_two(regs[0], regs[1]) else: regs = self._read_any(addr, 1) @@ -122,13 +122,11 @@ class PvInverter: def get_state(self) -> Dict[str, Any]: """ - Liest ALLE Register aus self.registers und gibt dict zurück. - Achtet darauf, dass keine Adresse (inkl. Mehrwort) >= 40206 gelesen wird. + Liest alle gültigen Register und gibt ein dict zurück. """ data = {"Zeit": time.strftime("%Y-%m-%d %H:%M:%S")} for address, meta in sorted(self.registers.items()): words = self._word_count_for_type(meta["type"]) - # 3) Nochmals Schutz auf Ebene der Iteration: if address + words - 1 >= MAX_ADDR_EXCLUSIVE: continue val = self.read_one(address, meta["type"])