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>
309 lines
12 KiB
Python
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
|