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