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