From 0bcf8a2d8c608f0c8c54dd61e2836a4b7bb3c846 Mon Sep 17 00:00:00 2001 From: Nils Reiners Date: Thu, 18 Sep 2025 14:14:53 +0200 Subject: [PATCH] inverter and meter seems to run --- __pycache__/pv_inverter.cpython-312.pyc | Bin 6960 -> 7147 bytes __pycache__/solaredge_meter.cpython-312.pyc | Bin 0 -> 7140 bytes main.py | 5 +- pv_inverter.py | 72 ++++++--- solaredge_meter.py | 134 +++++++++++++++++ test_meter.py | 153 ++++++++++++++------ 6 files changed, 296 insertions(+), 68 deletions(-) create mode 100644 __pycache__/solaredge_meter.cpython-312.pyc create mode 100644 solaredge_meter.py diff --git a/__pycache__/pv_inverter.cpython-312.pyc b/__pycache__/pv_inverter.cpython-312.pyc index 25174a8e73d8adfadee6ccfe8c13f9f21ebb1c6f..4adc3cb24d4145ae9c75d8858bd8198e7dd19443 100644 GIT binary patch delta 3341 zcmZ7&ZA?_z^}f#;=9`&;89ppe6zhl}3yLmZSr*)_bwwA^wNot}<~?L!m@)S~KDch~8w* zIrp4%&%N(_&fUg;9rQeOyB!49{68NKWP?{dVeY$EzpfnRlj5)xm4@Z0JZy{FhV4=N za6zVNKkS z6==Q^;P=aF$t5uw0PUbAXhr?J#pTsf;1-XUMZU%=_%*K+R82l{fx3a99=2WR@*$79 z5Nt4~gg(yw{y&5_IL^)XNtb+f6c%o3_}P7_vM>aD9Ue~<8b(o&eI;$@!YnLzbERxZ z{(h4W*b$!k0hv-fnbtMB1L&=Xs$f6iJ284BHL6iVqqUY=1>0e(1tZ9Ro8~ia6k=d<+>I%9WZlMtbdjLduQ&crQ zZi>eE1uf!a-*JYfWR<2bq++HV(~)ZmiIidbkF}raZSU;t?mc{_uWt7ihwaua)P;B zVH-wS{|jN&t{uGF%YNwE9hQm5H^vLzPr_yM9Sfdp z`M%|F-Q#d$Hr%)zKJF?Xrml&NNn z(A4`$_g=1~6)vZ30N|67u~7{&a-GF~{%k!&g;4eGV~t}vp@NEa^GP8_BBHZR2SZMy$Pax(XiD*g*(nT|6UN*`u98D z1>rT4;c18+FI3p9-^HhjE%G=1o|=;&rKb?|0D!ySLi!AXZvim5zO5Ix2I}kZgpQA! zCV#JQo_+1g(aT5YrTNi?(MPV@-xvR(?4z;=r=H=wr8%bt}v6${Bvu8JMG&+vZO% z(CcRx-@3=&K6fwqpfB5YVkHn<_4=sg5O+*fsiy0_+Qqn*j0;m>Ex%+S1o*ABS^eMG z=u*(9xO`WDZbSxA=Y^HwH#Niz^u!5}tK0;%3ImhKT0>{SJ={2-5mjLbx&&DJCCbm&=1i?YA2zOGR@uO&5IOFcVlB}f;=!AtRp5j(9%x*tIUf)fBt zA*rP(IE>IfauDEYAS3d6nVv_sl~*E0)08lobb1`vI>yj?I2P@tqRPX4$o(0BY4W9m z_yTjS%iirX;)*Z$*cZwAB8yeazPcGHCli-%uIh5j{PwJ~>`}!_A3I-K4TM&mzPa{y zn%HlP6mFIMt>{Q4W>I`IGhnlsHHwurDhGvL!3K)mydkllh)t#W2zHWl@M+f2;;lS# zLR9uWHNj~@c~0K6g5U;BmH{;>$)`tVHlC{b)ZGeKWjfm3L`h0wqu>@701(_WWl}_!28?9(h2uh7Yt6>|l!yHTuYo;CA zM!!YLIvCW{^|mg-yc^cHPat=}$x+%WrzV2Sy_40DKY029>BYv`N$3o{Xx^8tn-gzjy^U(6nrsZug zED8$BFCUwEeXeIE7=9eA$p&i{)w_+ilgq*8nZv8z;`yS>QwxPzZ{?$1hd%ZmTJeQf z{KeO5uGTEr7K4lZx67CPjWZo9tR-Ak831FsAn^rfoK_F&itvfAAXhe8Wlto;eh{vj zIsmU+F9nPw4I;oHO>t^doaIzPfCFp2MqIiPGZJy|#XwrMD#R4~(&;3W45(PeV^tM8 zkz&)L*o){Gf(`_j%j^FP90Ym+#eX)Kae6J=SJH5>P;x8=)(D{79+wnc2;>N$McqR8 z8Z7kTp$DByZ}$B0NVenj8j5q}>_*8qr+6uX`hXVW7P_Ze=z;bWnQH=({W%^e2n$lO zRJhQQBY+k=o+8@N04nDTNp%Zg1<>6c7P`0l!vi1eTSHE+-X{g;_b&(wCl~D3+rA{Q zrKQ-`t=Sw&fSkZ_+!Nw_LflVC;4{+jSJLn)sa|zA YO*^3V6qhX#|9`|Ojtf2|2(8fkA4+}ymjD0& delta 3276 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

u`;?RIag%&@~SXkSz?ty+r6*=gfAv|63}b zu&fZkMIl>^7}=6&WM4^7v9Z7pCBO{MXb~##;~-n?d5T$q14?V2O5tD{W`XS9UL#v- zx_{)0)^%)IYjO#`g_qJ?dN6If(e*8XYF?Bmz<`HR6eh-gx@9~Z11qI1Mg`&#h-x-y z!q6vrJ43y?dweqgPe4ol?}GeuZ8i2|a6(TaF*LPVd(Ujg@|fzPe*r9@Jfa=}0{1qC y`G%B#L!93b@4u3^uSwg#kOPmL9W!Mhw5r-=QknBq&N% str: - s = str(x).strip().upper() - return "REAL" if s == "REAL" else "INT" + + # 1) Vorab-Filter: nur Adressen < 40206 übernehmen + df = df[df["MB Adresse"] < MAX_ADDR_EXCLUSIVE] + self.registers = { int(row["MB Adresse"]): { "desc": str(row["Beschreibung"]).strip(), - "type": norm_type(row["Variabel Typ"]) + "type": str(row["Variabel Typ"]).strip() } 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), - ): + dict(address=address, count=count)): try: res = fn(**kwargs) if res is None or (hasattr(res, "isError") and res.isError()): @@ -83,33 +82,58 @@ class PvInverter: @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) + 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? + @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 + 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": + """ + Liest einen Wert nach Typ ('INT' oder 'REAL' etc.). + Es werden ausschließlich Register < 40206 gelesen. + """ + addr = int(address_excel) + words = self._word_count_for_type(rtype) + + # 2) Harte Grenze prüfen: höchstes angefasstes Register muss < 40206 sein + 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: # INT + else: 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.""" + """ + Liest ALLE Register aus self.registers und gibt dict zurück. + Achtet darauf, dass keine Adresse (inkl. Mehrwort) >= 40206 gelesen wird. + """ data = {"Zeit": time.strftime("%Y-%m-%d %H:%M:%S")} - for address, meta in self.registers.items(): + 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"]) if val is None: continue key = f"{address} - {meta['desc']}" data[key] = val - return data \ No newline at end of file + return data diff --git a/solaredge_meter.py b/solaredge_meter.py new file mode 100644 index 0000000..1214189 --- /dev/null +++ b/solaredge_meter.py @@ -0,0 +1,134 @@ +import time +import struct +import pandas as pd +from typing import Dict, Any, List, Tuple, Optional +from pymodbus.client import ModbusTcpClient + +EXCEL_PATH = "modbus_registers/pv_inverter_registers.xlsx" + +# Obergrenze: bis EXKLUSIVE 40206 (d.h. max. 40205) +MIN_ADDR_INCLUSIVE = 40121 +ADDRESS_SHIFT = 50 + +class SolaredgeMeter: + 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 Zähler fehlgeschlagen.") + raise SystemExit(1) + print("✅ Verbindung zu Zähler 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() + # Passe Spaltennamen hier an, falls nötig: + 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 + df = df[df["MB Adresse"] >= MIN_ADDR_INCLUSIVE] + + self.registers = { + int(row["MB Adresse"]): { + "desc": str(row["Beschreibung"]).strip(), + "type": str(row["Variabel Typ"]).strip() + } + for _, row in df.iterrows() + } + + + # ---------- 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 + shifted_addr = address + ADDRESS_SHIFT + for kwargs in (dict(address=shifted_addr, count=count, slave=self.unit), + dict(address=shifted_addr, 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: + 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? + @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 + + 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. + """ + addr = int(address_excel) + words = self._word_count_for_type(rtype) + + 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) + 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. + Achtet darauf, dass keine Adresse (inkl. Mehrwort) >= 40206 gelesen wird. + """ + 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"]) + + val = self.read_one(address, meta["type"]) + if val is None: + continue + key = f"{address} - {meta['desc']}" + data[key] = val + return data diff --git a/test_meter.py b/test_meter.py index bb90c24..c1c2a2b 100644 --- a/test_meter.py +++ b/test_meter.py @@ -1,61 +1,128 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import time +import struct +import pandas as pd +from typing import Dict, Any, List, Optional from pymodbus.client import ModbusTcpClient -import struct, sys -MODBUS_IP = "192.168.1.112" -MODBUS_PORT = 502 -UNIT_ID = 1 +# ==== Nutzer-Parameter ==== +IP = "192.168.1.112" +PORT = 502 # ggf. 1502 +UNIT = 1 +EXCEL_PATH = "modbus_registers/pv_inverter_registers.xlsx" -METER_START = 40240 # Startadresse Model 203-Felder +# Spaltennamen in deiner Excel +EXCEL_COLS = ["MB Adresse", "Beschreibung", "Variabel Typ"] -def to_i16(u16): # unsigned 16 → signed 16 +# Adressfilter +MIN_ADDR = 40121 # nur ab hier +ADDRESS_SHIFT = 50 # +50 für Synergy 2-Unit + + +# =================== Modbus-Helfer =================== + +def to_i16(u16: int) -> int: 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 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_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 word_count_for_type(rtype: str) -> int: + rt = (rtype or "").strip().lower() + if "uint32" in rt or "real" in rt or "float" in rt or "string(32)" in rt: + return 2 + return 1 # default: 16-bit -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" +class ModbusReader: + def __init__(self, ip: str, port: int, unit: int): + self.client = ModbusTcpClient(ip, port=port, timeout=3.0, retries=3) + if not self.client.connect(): + print("❌ Verbindung zu Wechselrichter fehlgeschlagen.") + raise SystemExit(1) + self.unit = unit + print("✅ Verbindung zu Wechselrichter hergestellt.") + + def close(self): + try: + self.client.close() + except Exception: + pass + + def _try_read(self, fn_name: str, address: int, count: int) -> Optional[List[int]]: + fn = getattr(self.client, fn_name) + 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 getattr(res, "registers", None) + 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 + + +# =================== Hauptlogik =================== + +def load_register_map(excel_path: str) -> Dict[int, Dict[str, Any]]: + xls = pd.ExcelFile(excel_path) + df = xls.parse() + df = df[EXCEL_COLS].dropna() + df["MB Adresse"] = df["MB Adresse"].astype(int) + + regmap: Dict[int, Dict[str, Any]] = {} + for _, row in df.iterrows(): + addr_excel = int(row["MB Adresse"]) + if addr_excel < MIN_ADDR: # nur ab 40121 + continue + desc = str(row["Beschreibung"]).strip() + rtype = str(row["Variabel Typ"]).strip() + regmap[addr_excel] = {"desc": desc, "type": rtype} + print(f"ℹ️ {len(regmap)} Register aus Excel geladen (>= {MIN_ADDR}).") + return regmap + +def read_value(reader: ModbusReader, start_addr: int, rtype: str) -> Optional[float]: + words = word_count_for_type(rtype) + if words == 2: + regs = reader.read_any(start_addr, 2) + if not regs or len(regs) < 2: + return None + return f32_from_two(regs[0], regs[1]) + else: + regs = reader.read_any(start_addr, 1) + if not regs: + return None + return float(to_i16(regs[0])) def main(): - client = ModbusTcpClient(MODBUS_IP, port=MODBUS_PORT) - if not client.connect(): - print("❌ Verbindung fehlgeschlagen."); sys.exit(1) + regs = load_register_map(EXCEL_PATH) + reader = ModbusReader(IP, PORT, UNIT) + 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.") + print(f"\n📋 Einmalige Auslesung ab {MIN_ADDR} (+{ADDRESS_SHIFT} Adressversatz) – {time.strftime('%Y-%m-%d %H:%M:%S')}\n") + + for addr_excel, meta in sorted(regs.items()): + shifted_addr = addr_excel + ADDRESS_SHIFT + val = read_value(reader, shifted_addr, meta["type"]) + if val is None: + continue + print(f"{addr_excel:5d}+{ADDRESS_SHIFT:2d} → {shifted_addr:5d} | " + f"{meta['desc']:<40} | Wert: {val}") + finally: - client.close() + reader.close() if __name__ == "__main__": main()