Files
SolarManager/gatherBYDData.py
adminandClaude Opus 5 0a37d8d763 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 <noreply@anthropic.com>
2026-09-15 16:16:07 +02:00

309 lines
12 KiB
Python

"""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