From 0a37d8d7633da84ff272163a1b716f262d6693cd Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 15 Sep 2026 16:16:07 +0200 Subject: [PATCH] BYD-Speicher direkt aus der BMU auslesen gatherBYDData.py fragt die BMU der HVM unter 192.168.16.254:8080 ab (BE-Connect-Protokoll, nach ioBroker.bydhvs): Ladestand und SOH laut BMU, 128 Zellspannungen, 64 Temperaturen, Spreizung, Ausgleich, Fehlerbits, Gesamtzaehler. Das Netzwerkmodul startet alle ~102 s neu und bedient nur die ersten Verbindungen danach - eigene Klopf-Schleife, ein Satz etwa alle 100 s. Werte unter solarManager/byd/#, Historie in byd und byd_zellen. tbatt kommt jetzt aus der BMU statt fest 0. mqttClient.publish legt ein einzelnes Dataclass-Objekt in rtData als Untertopics ab. Co-Authored-By: Claude Opus 5 --- README.md | 20 ++- config.ini.example | 7 ++ gatherBYDData.py | 308 +++++++++++++++++++++++++++++++++++++++++++++ mqttClient.py | 6 +- solarManager.py | 10 +- 5 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 gatherBYDData.py diff --git a/README.md b/README.md index dcd5402..f18ca74 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ flowchart LR WR["Wechselrichter
GoodWe · OpenDTU · DTU-BI"] EM3["Stromzähler
2× Shelly EM3"] HEIZ["Heizung"] + BYD["BYD-Speicher
BMU"] GOE["go-eCharger"] WPILOT["Wattpilot"] SKODA["MySkoda-API"] @@ -25,7 +26,7 @@ flowchart LR VENTILE["Ventilsteuerungen
2× ESP32"] HAUSGER["Thermostate · Shellys · Schalter
melden sich per
Home-Assistant-Discovery
"] - MGR["solarManager.py
gatherModbusData · gatherOpenDTUData
gatherDTUBIData · gatherShellyEM3Data EG/UG
gatherHeaterData · gatherSkodaData
gatherWaterData · charger_goE
"] + MGR["solarManager.py
gatherModbusData · gatherOpenDTUData
gatherDTUBIData · gatherShellyEM3Data EG/UG
gatherHeaterData · gatherSkodaData
gatherBYDData · gatherWaterData
charger_goE
"] WPB["wattpilot_bruecke.py"] WSB["wsMQTTbridge.py"] RAIN["gatherRainData.py"] @@ -36,6 +37,7 @@ flowchart LR WR -->|Modbus · HTTP| MGR EM3 -->|HTTP| MGR HEIZ -->|HTTP| MGR + BYD -->|Modbus-RTU in TCP| MGR GOE -->|HTTP| MGR WPILOT -->|WebSocket| MGR SKODA -->|HTTPS| MGR @@ -44,7 +46,7 @@ flowchart LR METEO -->|HTTPS| RAIN SOLARLOG -->|zisterne| MGR - MGR -->|"EnergyFlow · skoda
skoda_ladepunkte"| SOLARLOG + MGR -->|"EnergyFlow · skoda
skoda_ladepunkte · byd
byd_zellen"| SOLARLOG MGR -->|"solarManager/#"| BROKER WPB -->|"wattpilot/#"| BROKER @@ -57,7 +59,7 @@ flowchart LR classDef quelle fill:#eef4fb,stroke:#7f9dc0 classDef skript fill:#fff6e5,stroke:#d0a548 classDef speicher fill:#eaf5ee,stroke:#6fa981 - class WR,EM3,HEIZ,GOE,WPILOT,SKODA,STATION,METEO,VENTILE,HAUSGER quelle + class WR,EM3,HEIZ,BYD,GOE,WPILOT,SKODA,STATION,METEO,VENTILE,HAUSGER quelle class MGR,WPB,WSB,RAIN skript class BROKER,SOLARLOG speicher ``` @@ -68,11 +70,20 @@ Home-Assistant-Discovery spricht, schreiben ohne Umweg auf den Broker. Für deren Messwerte ist also **kein Skript** zuständig — wer sie sucht, sucht am Gerät, nicht im Quelltext. -`solarManager.py` ist der Sonderfall: ein Prozess, aber acht +`solarManager.py` ist der Sonderfall: ein Prozess, aber neun Sammler-Module, jedes für eine Anlage. Sie laufen nicht einzeln, sondern werden importiert; ihr gemeinsames Ergebnis geht als ein Baum nach `solarManager/#`. +`gatherBYDData` liest die BMU der Batterie direkt (192.168.16.254:8080) und +legt alles unter `solarManager/byd/…` ab: Ladestand und Gesundheit laut BMU, +Zellspannungen (`zellen`, 128 Werte in mV, 16 je Modul), Temperaturen +(`temperaturen`, 64 Werte, 8 je Modul), Spreizung, Ausgleich, Fehlerbits und +die Gesamtzähler. `ok` fällt auf 0, wenn zehn Minuten nichts kam. Das +Netzwerkmodul der BMU startet etwa alle 102 Sekunden neu und antwortet nur +kurz danach — der Sammler klopft deshalb jede Sekunde an und bekommt so rund +alle 100 Sekunden einen vollständigen Satz. Einzelheiten im Kopf des Moduls. + Die Zisterne fällt aus der Reihe — ihr Stand steht in `solarLog`, und `gatherWaterData` liest ihn von dort. Die Daten laufen also durch die Datenbank hindurch von einem Prozess zum nächsten. @@ -147,6 +158,7 @@ von Hand, wenn sich am Bestand etwas geändert hat. | `Raumtemp/#`, `Power_*/#`, `wasser/#` | die Geräte selbst | Browser, Runner | | `homeassistant/#` | die Geräte selbst | `device_discovery.py` | | **`solarLog`** | `solarManager.py` | `ajax/*.php`, `gatherWaterData` | +| `byd`, `byd_zellen` (in `solarLog`) | `gatherBYDData.py`, alle 5 bzw. 15 Minuten | Web-Repo (`solarLog_byd.sql`) | | **`homeMesh`** | `device_discovery.py`, Runner, Web-Editor | Runner, `ajax/*.php` | | `skoda.conf` | Einstellungen → Fahrzeug (`restricted/skodaKeys.php`) oder von Hand | `gatherSkodaData.py`, `ajax/skodaCmd.php` | diff --git a/config.ini.example b/config.ini.example index d7dcc3c..f719fe6 100644 --- a/config.ini.example +++ b/config.ini.example @@ -47,3 +47,10 @@ intervall_minuten = 30 ; Messwerte geleert, und es wird nicht mehr bewaessert - eine retained "0,0 mm" ; von gestern wuerde den Runner sonst giessen lassen. max_alter_stunden = 3 + +; =========================================================================== +; BYD-Speicher (gatherBYDData.py) +; =========================================================================== +; Adresse der BMU. Ab Werk 192.168.16.254; der Abschnitt darf fehlen. +[byd] +host = 192.168.16.254 diff --git a/gatherBYDData.py b/gatherBYDData.py new file mode 100644 index 0000000..e41e627 --- /dev/null +++ b/gatherBYDData.py @@ -0,0 +1,308 @@ +"""BYD Battery-Box HVM direkt an der BMU auslesen. + +Der Gen24 liefert ueber Modbus nur, was er selbst von der Batterie wissen +muss: Ladestand, Spannung, Strom. Alles darunter - 128 Zellspannungen, 64 +Temperaturfuehler, Gesundheit (SOH), Gesamtzaehler, Ausgleich - kennt nur die +BMU. Sie spricht auf Port 8080 dasselbe Protokoll wie die BE-Connect-App: +Modbus-RTU in TCP verpackt. Ablauf und Byte-Lagen stammen aus dem +ioBroker-Adapter bydhvs (MIT, github.com/christianh17/ioBroker.bydhvs). + +Drei Eigenheiten bestimmen den Aufbau: + + Alles in EINER Verbindung, Anfrage 0 zuerst. Eine neue Verbindung, die + gleich mit Anfrage 1 beginnt, schliesst die BMU wortlos. + + Das Netzwerkmodul (Hi-Link HLK-RM08K) startet etwa alle 102 Sekunden neu, + unabhaengig davon, ob jemand fragt. Waehrend des Neustarts laufen + Verbindungen rund 15 Sekunden ins Leere; danach bedient es nur die ersten + ein, zwei Verbindungen, alle spaeteren schliesst es sofort mit 0 Bytes. + Das Fenster ist also nur wenige Sekunden breit (beobachtet am 15.09.2026). + Der Sammler klopft deshalb in einer eigenen Schleife jede _KLOPFEN + Sekunde mit kurzem Verbindungs-Timeout an - der 3-Sekunden-Takt des + Managers waere dafuer zu grob. Geht eine Verbindung durch, laeuft der + ganze Zyklus in ihr; die haelt das Modul offen, auch ueber die Messpause. + Nach einem Erfolg ruht die Schleife _RUHE Sekunden bis kurz vor dem + naechsten Neustart. + + Die Lade-/Entladezaehler stehen bei dieser HVM wortvertauscht (low word + zuerst). Mit der Lesart des Adapters kaemen 31 Millionen kWh heraus. + +Wie die anderen langsamen Sammler blockiert gatherData() nie: die Schleife +laeuft als Hintergrund-Task, zurueck kommt immer der letzte Stand. + +Historie: alle 5 Minuten eine Zeile in byd, alle 15 Minuten die Zellen +in byd_zellen (siehe solarLog_byd.sql im Web-Repository). +""" + +import asyncio +import datetime +import json +import logging +import struct +import time +from dataclasses import dataclass, field +from typing import List + +import mysql.connector as mc + +import konfig + +_LOGGER = logging.getLogger(__name__) + +_HOST = konfig.wert("byd", "host", "192.168.16.254") +_PORT = 8080 +_KLOPFEN = 1 # s zwischen zwei Versuchen, solange das Fenster zu ist +_RUHE = 80 # s nach einem Erfolg - das naechste Fenster kommt erst nach ~102 s +_VERBINDEN = 1.5 # s Timeout fuers Verbinden; im Neustart antwortet niemand +_ZEITLIMIT = 6 # s je Antwort +_MESSPAUSE = 8 # s, die die BMU nach "Zellmessung starten" braucht +_VERALTET = 600 # s ohne Erfolg, danach gilt der Stand als nicht mehr aktuell +_DB_ZEILE = 300 # s zwischen zwei Zeilen in byd +_DB_ZELLEN = 900 # s zwischen zwei Zeilen in byd_zellen + +_ANFRAGE = { + "stamm": bytes.fromhex("010300000066c5e0"), + "echt": bytes.fromhex("01030500001984cc"), + "typ": bytes.fromhex("010300100003040e"), + "messen": bytes.fromhex("0110055000020400018100f853"), + "status": bytes.fromhex("010305510001d517"), + "zellen": bytes.fromhex("01030558004104e5"), +} + +# Bit j in Paket 1, Byte 29/30 - Wortlaut aus dem Adapter, uebersetzt. +_FEHLER = [ + "Zu warm beim Laden", "Zu kalt beim Laden", "Ueberstrom beim Entladen", + "Ueberstrom beim Laden", "Hauptstromkreis gestoert", "Kurzschlussalarm", + "Zellen ungleich", "Stromsensor gestoert", "Batteriespannung zu hoch", + "Batteriespannung zu niedrig", "Zellspannung zu hoch", + "Zellspannung zu niedrig", "Spannungssensor gestoert", + "Temperatursensor gestoert", "Zu warm beim Entladen", + "Zu kalt beim Entladen", +] + + +@dataclass +class BYDData: + ok: int = 0 # 1 = Stand juenger als _VERALTET + stand: int = 0 # Unix-Zeit des letzten vollstaendigen Zyklus + seriennummer: str = "" + firmware: str = "" + module: int = 0 + soc: float = 0.0 # % laut BMU + soh: float = 0.0 # % + spannung: float = 0.0 # V am Batterieausgang + strom: float = 0.0 # A, positiv = laden + leistung: float = 0.0 # W, positiv = laden + tMin: float = 0.0 # degC ueber alle Fuehler + tMax: float = 0.0 + zelleMin: int = 0 # mV + zelleMax: int = 0 + zelleMinNr: int = 0 # 1..128 + zelleMaxNr: int = 0 + spreizung: int = 0 # mV + ausgleich: int = 0 # Zahl der Zellen, die gerade ausgeglichen werden + ausgleichRoh: str = "" # "0x" + 32 Hex-Zeichen, Bitlage noch nicht bestaetigt; + # das Praefix haelt mqttClient davon ab, es als Zahl zu lesen + fehler: int = 0 + fehlertext: str = "" + geladen: float = 0.0 # kWh seit Inbetriebnahme + entladen: float = 0.0 + zellen: List[int] = field(default_factory=list) # mV, 16 je Modul + temperaturen: List[int] = field(default_factory=list) # degC, 8 je Modul + + +ret = BYDData() +_task = None +_letzteZeile = 0.0 +_letzteZellen = 0.0 + + +def _crc_ok(p): + crc = 0xFFFF + for b in p: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc == 0 + + +class _Abgewiesen(Exception): + """Die BMU hat die Verbindung ohne Antwort geschlossen - Fenster zu.""" + + +async def _frage(reader, writer, name, pause=0.2): + await asyncio.sleep(pause) + writer.write(_ANFRAGE[name]) + await writer.drain() + buf = b"" + while True: + teil = await asyncio.wait_for(reader.read(1024), _ZEITLIMIT) + if not teil: + raise _Abgewiesen("%s: geschlossen nach %d Bytes" % (name, len(buf))) + buf += teil + if len(buf) < 3: + continue + if buf[1] & 0x80: + n = 5 + elif buf[1] == 0x10: + n = 8 + else: + n = buf[2] + 5 + if len(buf) >= n: + p = buf[:n] + if not _crc_ok(p): + raise IOError("%s: Pruefsumme falsch" % name) + if p[1] & 0x80: + raise IOError("%s: BMU meldet Ausnahme %d" % (name, p[2])) + return p + + +def _i16(b, o): + return struct.unpack_from(">h", b, o)[0] + + +def _u16(b, o): + return struct.unpack_from(">H", b, o)[0] + + +def _u32_vertauscht(b, o): + """Zwei Worte, das niedrige zuerst.""" + return (_u16(b, o + 2) << 16) | _u16(b, o) + + +async def _zyklus(): + reader, writer = await asyncio.wait_for( + asyncio.open_connection(_HOST, _PORT), _VERBINDEN) + try: + p0 = await _frage(reader, writer, "stamm", 0) + p1 = await _frage(reader, writer, "echt") + p2 = await _frage(reader, writer, "typ") + module = p0[36] % 16 + hvm = p2[5] == 1 + nZellen = module * (16 if hvm else 32) + nTemps = module * (8 if hvm else 12) + + await _frage(reader, writer, "messen") + await asyncio.sleep(_MESSPAUSE) + await _frage(reader, writer, "status", 0) + p5 = await _frage(reader, writer, "zellen") + p6 = await _frage(reader, writer, "zellen") + p7 = await _frage(reader, writer, "zellen") + p8 = await _frage(reader, writer, "zellen") + finally: + writer.close() + + zellen = [_i16(p5, 101 + 2 * i) for i in range(16)] + zellen += [_i16(p6, 5 + 2 * i) for i in range(min(64, nZellen - 16))] + zellen += [_i16(p7, 5 + 2 * i) for i in range(min(48, nZellen - 80))] + temps = [p7[103 + i] for i in range(min(30, nTemps))] + temps += [p8[5 + i] for i in range(min(34, nTemps - 30))] + + d = BYDData() + d.seriennummer = p0[3:22].decode("ascii", "replace") + d.firmware = "BMS V%d.%d" % (p0[31], p0[32]) + d.module = module + d.soc = _i16(p1, 3) + d.soh = _i16(p1, 9) + d.strom = _i16(p1, 11) / 10 + d.spannung = _u16(p1, 35) / 100 + d.leistung = round(d.strom * d.spannung) + d.fehler = _u16(p1, 29) + d.fehlertext = "; ".join(t for j, t in enumerate(_FEHLER) if d.fehler & (1 << j)) + d.geladen = _u32_vertauscht(p1, 37) / 10 + d.entladen = _u32_vertauscht(p1, 41) / 10 + d.zellen = zellen + d.temperaturen = temps + gueltig = [z for z in zellen if 2000 < z < 4000] + if gueltig: + d.zelleMin, d.zelleMax = min(gueltig), max(gueltig) + d.zelleMinNr = zellen.index(d.zelleMin) + 1 + d.zelleMaxNr = zellen.index(d.zelleMax) + 1 + d.spreizung = d.zelleMax - d.zelleMin + if temps: + d.tMin, d.tMax = min(temps), max(temps) + else: + d.tMin, d.tMax = _i16(p1, 17), _i16(p1, 15) + d.ausgleichRoh = "0x" + p5[17:33].hex() + d.ausgleich = bin(int(d.ausgleichRoh, 16)).count("1") + d.stand = int(time.time()) + d.ok = 1 + return d + + +def _speichern(d): + """Historie schreiben, gedrosselt auf _DB_ZEILE / _DB_ZELLEN.""" + global _letzteZeile, _letzteZellen + jetzt = time.time() + zeile = jetzt - _letzteZeile >= _DB_ZEILE + zellen = jetzt - _letzteZellen >= _DB_ZELLEN + if not (zeile or zellen): + return + zeit = datetime.datetime.now().replace(microsecond=0) + try: + with mc.connect(**konfig.datenbank()) as verbindung: + with verbindung.cursor() as cursor: + if zeile: + cursor.execute( + "INSERT INTO byd (datetime, soc, soh, spannung, strom, leistung, " + "t_min, t_max, zelle_min, zelle_max, zelle_min_nr, zelle_max_nr, " + "spreizung, ausgleich, fehler, geladen_kwh, entladen_kwh) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s);", + (zeit, d.soc, d.soh, d.spannung, d.strom, d.leistung, + d.tMin, d.tMax, d.zelleMin, d.zelleMax, d.zelleMinNr, + d.zelleMaxNr, d.spreizung, d.ausgleich, d.fehler, + d.geladen, d.entladen)) + if zellen: + cursor.execute( + "INSERT INTO byd_zellen (datetime, zellen, temperaturen, ausgleich) " + "VALUES (%s,%s,%s,%s);", + (zeit, json.dumps(d.zellen), json.dumps(d.temperaturen), + d.ausgleichRoh[2:])) + verbindung.commit() + if zeile: + _letzteZeile = jetzt + if zellen: + _letzteZellen = jetzt + except Exception as e: + _LOGGER.error("BYD: Historie nicht geschrieben: %s", e) + + +async def _schleife(): + global ret + gewarnt = False + letzterErfolg = time.time() + while True: + try: + d = await _zyklus() + except (_Abgewiesen, ConnectionError, asyncio.TimeoutError, OSError) as e: + # Ein zugesperrtes Fenster ist der Normalfall und kein Log wert. + # Erst wenn gar nichts mehr durchkommt, soll es jemand sehen. + _LOGGER.debug("BYD: %s: %s", type(e).__name__, e) + if not gewarnt and time.time() - letzterErfolg > _VERALTET: + _LOGGER.warning("BYD: seit %d Minuten keine Daten von %s (%s)", + _VERALTET // 60, _HOST, e) + gewarnt = True + if ret.ok and time.time() - ret.stand > _VERALTET: + ret.ok = 0 + await asyncio.sleep(_KLOPFEN) + except Exception: + _LOGGER.exception("BYD: Antwort nicht verstanden") + await asyncio.sleep(_RUHE) + else: + ret = d + letzterErfolg = time.time() + if gewarnt: + _LOGGER.warning("BYD: Daten kommen wieder") + gewarnt = False + await asyncio.get_running_loop().run_in_executor(None, _speichern, d) + await asyncio.sleep(_RUHE) + + +async def gatherData() -> BYDData: + """Startet beim ersten Aufruf die Schleife und gibt den letzten Stand zurueck.""" + global _task + if _task is None or _task.done(): + if _task is not None and not _task.cancelled() and _task.exception(): + _LOGGER.error("BYD: Schleife beendet: %r - starte neu", _task.exception()) + _task = asyncio.ensure_future(_schleife()) + return ret diff --git a/mqttClient.py b/mqttClient.py index 6c3128d..ad8c5b0 100644 --- a/mqttClient.py +++ b/mqttClient.py @@ -54,7 +54,11 @@ def publish(dict,topic="solarManager"): mqttClient.publish(topic+"/"+key,value) else: for key, value in dict.items(): - if hasattr(value, '__len__') and (not isinstance(value, str)): + # Ein einzelnes Dataclass-Objekt (z.B. rtData["byd"]) hat keine + # Laenge und fiel frueher in den Zahlenzweig - "value is no number". + if dataclasses.is_dataclass(value) and not isinstance(value, type): + publish(value,topic+"/"+key) + elif hasattr(value, '__len__') and (not isinstance(value, str)): if dataclasses.is_dataclass(value): #make subtopics of dataclasses publish(value,topic+"/"+key) else: diff --git a/solarManager.py b/solarManager.py index 0db91b9..148a56d 100644 --- a/solarManager.py +++ b/solarManager.py @@ -16,6 +16,7 @@ import gatherWaterData import gatherHeaterData import gatherShellyEM3DataEG import gatherShellyEM3DataUG +import gatherBYDData import datetime from dateutil import tz from suntime import Sun, SunTimeException @@ -156,6 +157,7 @@ rtData = {"P_Load":0.0, "waterHeight":0, "waterTemp": 0.0, "inverters":[InvData(),InvData(),InvData(),InvData(),InvData(),InvData(),InvData(),InvData()], + "byd":gatherBYDData.BYDData(), # direkt aus der BMU, siehe gatherBYDData.py "P_AC": 0.0 } avgData = dict(rtData) @@ -268,7 +270,7 @@ async def repeat(interval): rtData["inverters"][0].p_PV.append(0) while True: - inv,dtubi,dtu,heat,chrg,pEG,pUG,skoda,slp = await asyncio.gather( + inv,dtubi,dtu,heat,chrg,pEG,pUG,skoda,byd,slp = await asyncio.gather( gatherModbusData.get_runtime_data(estProduction, sunset), gatherDTUBIData.gatherData(), gatherOpenDTUData.gatherData(), @@ -280,8 +282,10 @@ async def repeat(interval): rtData["evPowerOG"], rtData["evPlugOG"], rtData["P_PV"]/1000.0, rtData["P_Grid"]/1000.0, rtData["wbWh"], rtData["wbogWh"]), + gatherBYDData.gatherData(), asyncio.sleep(interval), ) + rtData["byd"] = byd rtData["evSOC"] = skoda.soc rtData["evRange"] = skoda.range_km rtData["evRemChrgTime"] = skoda.chgRemMin @@ -400,7 +404,9 @@ async def repeat(interval): rtData["P_WR"] = inv.p_wr rtData["ibatt"] = inv.ibatt rtData["ubatt"] = inv.ubatt - rtData["tbatt"] = 0 + # Der Gen24 kennt keine Batterietemperatur; die BMU schon. Die + # Spalte battTemp in EnergyFlow stand bisher immer auf 0. + rtData["tbatt"] = byd.tMax if byd.ok else 0 rtData["crgMaxPct"] = inv.crgMaxPct #rtData["pwrMaxPct"] = inv.pwrMaxPct if((rtData["P_PV"]+rtData["P_Akku"]) != 0):