FOX ESS Homeassistant Integration

Aus TippvomTibb
Zur Navigation springen Zur Suche springen

Allgemeines

Es gibt nicht die eine FOXESS-Integration fuer Home Assistant, sondern mehrere Projekte mit unterschiedlichen Autoren.

Fuer deine FOX ESS H3-Pro wuerde ich zwischen diesen Projekten unterscheiden:

Integration Hauptautor Bemerkung
FoxESS Modbus Nathan Marlor Aktuell die aktivste und am weitesten entwickelte lokale Modbus-Integration. Unterstuetzt H3 Pro, KH, H1 u. a. (GitHub)
HA-FoxESS-Modbus (Original) StealthChesnut (GitHub) Urspruengliches Projekt. Wird heute nicht mehr aktiv weiterentwickelt; der Autor empfiehlt selbst den Wechsel zu Nathan Marlors Fork. (GitHub)
foxess-ha (Cloud API) macxq (GitHub) Nutzt die FoxESS-Cloud-API statt Modbus. Einfach einzurichten, aber langsamer und von der Cloud abhaengig. (GitHub)
home-assistant-foxess-api SoftXperience Ebenfalls Cloud-basiert mit der offiziellen API. (GitHub)


Wer entwickelt die Modbus-Integration?

Die heute gebraeuchliche Integration wird hauptsaechlich von Nathan Marlor gepflegt. Sie entstand urspruenglich als Fork der Arbeit von StealthChesnut und wurde anschlieszend grundlegend erweitert. Das urspruengliche Repository verweist inzwischen ausdruecklich auf Nathan Marlors Version als die empfohlene Variante. (GitHub)


Weitere Mitwirkende

Wie bei vielen Open-Source-Projekten stammen Beitraege auszerdem von zahlreichen Community-Mitgliedern. Besonders haeufig tauchen in Issues, Pull Requests und Diskussionen Namen wie:

  • William Eccles (williamjeccles)
  • Calum McFarlane (calum-mcfarlane bzw. canton7)
  • TonyM1958
  • rsaemann

auf. Sie haben Registerdefinitionen, Dokumentation, Tests oder Unterstuetzung fuer neue Wechselrichtermodelle beigetragen. (GitHub)




Vorhaben

Da ich ueberlegt habe, eine eigene Home-Assistant-Integration zu entwickeln, wuerde ich mich an Nathan Marlors foxess_modbus Implementation als Referenz orientiern. Das Projekt gilt als vorbildlich umgesetzt und nutzt moderne Home-Assistant-Konzepte wie:

  • Config Flow
  • DataUpdateCoordinator
  • gemeinsame Entity-Basisklassen
  • Diagnostics
  • Device Registry
  • Entity Descriptions
  • vollstaendige Typannotationen
  • umfassende Uebersetzungen

Es gehoert zu den qualitativ besten Community-Integrationen fuer Home Assistant und ist eine ausgezeichnete Vorlage fuer eigene Geraeteintegrationen. Sie hat aber nach meiner Einschaetzung einen riesigen Nachteil. Durch dieses dauernde Ergaenzen von Registern von neuen Modellen und Versionen ist eine schlecht wartbare Zusammenstellung von vielen Dateien geworden.

Es ist schon irre, welcher Overhead erzeugt wird, nur um ein paar Byte vom MODBUS auszuwerten. Deshalb kaeme ich nie auf die Idee z. B. fuer ein SignatureDisplay die Entitaeten aus HA zu verwenden. Mit gefuehlt 10 Zeilen Code bekommt man alle notwendigen Werte per MODBUS aus dem FOX und augenfreundlich auf JEDEM Display angezeigt.

„Vorbildlich“ bezieht sich aber nur auf die HA-Architektur auszen herum: Config Flow, Coordinator, Entity-Basis, Diagnostics usw.

Die Register-Implementierung in entity_descriptions.py empfinde ich als Katastrophe. Vielleicht frage ich mal bei Nathan Marlor nach, wie man auf sowas kommt. Grrrrr! Wahrscheinlich hat er klein angefangen und dann wurde immer nur dazugepackt. Jetzt wird es immer schwieriger Monster zu beherrschen. Bei einigen Versuchen die Registerliste mit entity_descriptions.py abzugleichen ist es mir kaum gelungen eine Systematik zu erkennen, die eine Ueberarbeitung/Kontrolle der Register erleichtert.

Die Registermodellierung ist davon getrennt — und gerade bei Modbus-Integrationen ist das oft der schwaechste Teil.

Besser waere eine Architektur wie:

registers/
├── fox_h3_pro.py
├── fox_h1.py
├── fox_kh.py
├── common.py
└── schema.py

mit sauber typisierten Registerdefinitionen:

@dataclass(frozen=True)
class RegisterDef:
    key: str
    address: int
    count: int
    data_type: DataType
    scale: float
    unit: str | None
    device_class: str | None
    entity_category: str | None = None

und nicht eine riesige entity_descriptions.py, in der Register, Entitaet, Skalierung, Geraetevariante und Logik vermischt werden.

Fuer robuste Modbus-Geraeteintegrationen wuerde ich die Trennung so machen:

api.py              # liest/schreibt Register
register_map.py     # reine Registerdefinitionen
models.py           # Datentypen, Skalierung, Bitfelder
coordinator.py      # zyklische Aktualisierung
entity_descriptions.py # nur HA-Entity-Metadaten
sensor.py           # HA-Sensoren
number.py           # HA-Schreibwerte
select.py           # Betriebsmodi
Der Kernfehler bei vielen Integrationen ist: Das Register ist nicht die Entitaet. Ein Register kann mehrere Werte enthalten, ein Wert kann aus mehreren Registern bestehen, und eine Entitaet ist nur die Home-Assistant-Darstellung eines daraus berechneten Werts.

Besser waere:

In der Regel bildet ein Register direkt die Grundlage fuer eine Entitaet. Es kann aber erforderlich oder sinnvoll sein, den Wert einer Entitaet aus mehreren Registern, aus Bitfeldern oder aus berechneten Zwischenwerten abzuleiten.

Noch sauberer:

Eine Entitaet ist die Home-Assistant-Darstellung eines fachlichen Geraetewertes. Dieser Geraetewert kann direkt aus einem Register stammen, aus mehreren Registern zusammengesetzt sein oder aus Registerwerten berechnet werden.

Beispiele:

1 Register → 1 Entitaet
Register 31000 = PV1-Spannung → sensor.pv1_voltage

2 Register → 1 Entitaet
Register 31010 + 31011 = 32-Bit-Energiezaehler → sensor.total_yield

1 Register → mehrere Entitaeten
Register 32000 Bit 0 = Netz vorhanden
Register 32000 Bit 1 = Batterie aktiv
Register 32000 Bit 2 = Fehler aktiv

mehrere Register → berechnete Entitaet
PV1-Spannung × PV1-Strom = PV1-Leistung

Fuer eine saubere Integration wuerde ich daher unterscheiden zwischen:

RegisterDefinition     Rohadresse, Datentyp, Laenge, Skalierung
ValueDefinition        fachlicher Geraetewert
EntityDescription      Darstellung in Home Assistant

Die eigentliche Regel waere also:

Nicht die Registerliste sollte die HA-Entitaeten treiben, sondern die fachlichen Geraetewerte. Register sind nur die Quelle dieser Werte.

Da ich jetzt aber keine Lust habe, die ganze Integration neu zu schreiben, baue ich mir nur ein Tool, welches die Register, die in der entity_descriptions.py versteckt sind, extrahiert.

Integrationsdateien Struktur

Die Verzeichnisstruktur der FOXESS-Integration ist insgesamt sehr sauber aufgebaut und folgt weitgehend dem Architekturmodell von Home Assistant.

Ich wuerde die Dateien in fuenf Ebenen unterteilen:



1. Einstiegsebene (Home Assistant)

Diese Dateien kennt Home Assistant direkt.

Datei Zweck
__init__.py Initialisiert die komplette Integration. Baut Modbus-Controller auf, laedt Profile und erzeugt alle Plattformen.
manifest.json Metadaten der Integration (Name, Version, Abhaengigkeiten).
config_flow.py Startet den Einrichtungsdialog.
sensor.py Registriert alle Sensor-Entitaeten.
binary_sensor.py Registriert BinarySensoren.
number.py Registriert Number-Entitaeten.
select.py Registriert Select-Entitaeten.
const.py Alle Konstanten der Integration.
icons.json Eigene Material Design Icons.



2. Herzstueck der Integration

Das sind die wirklich wichtigen Dateien.

entity_descriptions.py

Mit Abstand die wichtigste Datei.

Sie enthaelt praktisch saemtliche Entitaeten:

  • PV
  • Batterie
  • Grid
  • EPS
  • Temperatur
  • Spannungen
  • Leistungen
  • Energie
  • Fehler
  • Firmware
  • Remote Control

Diese Datei beschreibt was existieren kann, aber erzeugt noch keine Entitaeten.

110 kB grosz.

→ Das eigentliche Datenmodell der gesamten Integration.



entity_factory.py

Diese Datei baut aus einer Beschreibung

ModbusSensorDescription(...)

die eigentliche Home-Assistant-Entity.

Sie ist die Fabrik.



inverter_profiles.py

Enthaelt saemtliche Wechselrichterprofile.

Zum Beispiel

H1

KH

H3

H3 Pro

AIO

US

...

Hier wird festgelegt

  • welche Entitaeten existieren
  • welche Register benutzt werden
  • welche Adapter verwendet werden

Das ist die zweitwichtigste Datei.



inverter_adapters.py

Adapter zwischen

FOXESS-Modell

Registerlayout

Entity-Liste

Hier wird entschieden, welches Profil benutzt wird.



modbus_controller.py

Das technische Herz.

Diese Datei

  • liest Register
  • schreibt Register
  • verwaltet Cache
  • Polling
  • Aktualisierung

Alle Entitaeten lesen letztlich hierueber.



3. entities/

Dieser Ordner enthaelt saemtliche Entity-Klassen.

Er ist sauber getrennt.



modbus_sensor.py

Standard-Sensor

liest

Register
↓

Skalierung

↓

Sensor

modbus_binary_sensor.py

Boolean

Bit

↓

True/False

modbus_number.py

Schreibbare Register.



modbus_select.py

Enum-Auswahl.



modbus_fault_sensor.py

Dekodiert Fehlerregister.



modbus_inverter_state_sensor.py

Dekodiert Betriebszustaende.

Den hatten wir eben analysiert.



modbus_battery_sensor.py

Batteriespezifische Logik.



modbus_lambda_sensor.py

Berechnet Werte aus mehreren Registern.

Also keine direkte Registerabbildung.



modbus_integration_sensor.py

Summiert oder integriert Messwerte.

Typischerweise Energiezaehler.



modbus_version_sensor.py

Firmware-Version.



modbus_charge_period_*

Ladesteuerung.



modbus_remote_control_*

Remote-Control.



modbus_work_mode_select.py

Arbeitsmodus des Wechselrichters.



modbus_entity_mixin.py

Eine der wichtigsten Basisklassen.

Alle Modbus-Entities erben hiervon.

Sie enthaelt gemeinsame Funktionen:

  • Entity-ID
  • DeviceInfo
  • Availability
  • Controllerzugriff
  • Diagnose



inverter_model_spec.py

Sehr wichtig.

Hier werden

ModbusAddressSpec

und

ModbusAddressesSpec

definiert.

Das sind die eigentlichen Registerdefinitionen.



4. common/

Hilfsklassen.

entity_controller.py

Schnittstelle

Entity

Controller



types.py

Sehr wichtig.

Hier sind saemtliche Typdefinitionen.

Unter anderem

Inv

(Registermodelle)

RegisterType

(Holding/Input)

ConnectionType

usw.



exceptions.py

Eigene Exceptions.



5. client/

Kommunikation.

modbus_client.py

Eigentliche Modbus-Kommunikation.



custom_modbus_tcp_client.py

FOXESS-spezifische Erweiterungen.



protocol_pollserial.py

Serielle Polling-Unterstuetzung.



6. flow/

Kompletter ConfigFlow.

Dialoge beim Einrichten.



7. services/

Home Assistant Services.

Beispielsweise

foxess.read_registers
foxess.write_registers
foxess.update_charge_period

8. vendor/

Mitgelieferte Bibliotheken.

Hier wurde die komplette Bibliothek PyModbus 3.6.9 eingebettet.

Dadurch ist die Integration unabhaengig von einer extern installierten Version.



Aus meiner Sicht die wichtigsten Dateien (Ranking)

Rang Datei Bedeutung
⭐⭐⭐⭐⭐ entity_descriptions.py Beschreibt alle moeglichen Entitaeten der Integration.
⭐⭐⭐⭐⭐ inverter_profiles.py Legt fest, welche Entitaeten welches Wechselrichtermodell erhaelt.
⭐⭐⭐⭐⭐ modbus_controller.py Liest und schreibt saemtliche Modbus-Register.
⭐⭐⭐⭐☆ entity_factory.py Erzeugt aus Beschreibungen die tatsaechlichen Home-Assistant-Entitaeten.
⭐⭐⭐⭐☆ inverter_model_spec.py Definiert die Zuordnung von Registeradressen zu Modellen und Registertypen.
⭐⭐⭐⭐☆ inverter_adapters.py Verknuepft Wechselrichtermodelle mit den passenden Profilen und Registerlayouts.
⭐⭐⭐⭐☆ modbus_entity_mixin.py Gemeinsame Basisklasse aller Modbus-Entitaeten.
⭐⭐⭐☆☆ common/types.py Enthaelt zentrale Typdefinitionen wie Inv und RegisterType.

Fuer dein aktuelles Ziel (H3-Pro-Unterstuetzung und fehlende EPS-Entitaeten)

Aus unseren bisherigen Gespraechen wuerde ich mich auf genau diese Dateien konzentrieren:

  1. entity_descriptions.py – Sind die EPS-Entitaeten ueberhaupt definiert?
  2. inverter_profiles.py – Werden sie dem H3-Pro-Profil zugeordnet?
  3. inverter_adapters.py – Verwendet der H3-Pro das erwartete Profil?
  4. inverter_model_spec.py – Sind fuer den H3-Pro passende Registeradressen (ModbusAddressSpec bzw. ModbusAddressesSpec) hinterlegt?
  5. entity_factory.py – Werden die Entitaeten aufgrund von Modell- oder Registertyppruefungen verworfen?
  6. modbus_controller.py – Werden die EPS-Register tatsaechlich gelesen?

Diese sechs Dateien bilden den vollstaendigen Pfad von der Registerdefinition bis zur erzeugten Home-Assistant-Entitaet und sind daher die entscheidenden Stellen, um zu verstehen, warum bestimmte Entitaeten – wie EPS – beim H3-Pro nicht erscheinen.

Analysetool entity_descriptions.py

analyseentitydescriptions.py
#!/usr/bin/env python3
"""
Analysiert eine Python-Datei mit FoxESS/Home-Assistant Modbus-Entity-Definitionen.

Ausgabeformat:
Zeile|Funktion|Unterfunktion|Entity-Typ|Entity-Key|Modell|Registeradresse|Registertyp

Eigenschaften:
- nutzt den Python-AST, keine fragile Regex-Blockerkennung
- erkennt ModbusAddressSpec(input=..., models=...) und ModbusAddressesSpec(holding=[...], models=...)
- erzeugt pro Modell und pro Registeradresse eine eigene Ausgabezeile
- ergänzt Funktionsname, Unterfunktion/Helper, Entity-Typ und Entity-Key soweit statisch ableitbar
"""

from __future__ import annotations

import argparse
import ast
import csv
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable

REGISTER_TYPES = ("input", "holding", "coil", "discrete")
ENTITY_CALL_NAMES = {
    "ModbusSensorDescription",
    "ModbusBatterySensorDescription",
    "ModbusFaultSensorDescription",
    "ModbusIntegrationSensorDescription",
    "ModbusInverterStateSensorDescription",
    "ModbusG2InverterStateSensorDescription",
    "ModbusLambdaSensorDescription",
    "ModbusNumberDescription",
    "ModbusVersionSensorDescription",
    "ModbusWorkModeSelectDescription",
}
SPEC_CALL_NAMES = {"ModbusAddressSpec", "ModbusAddressesSpec"}


@dataclass(frozen=True)
class EntityDescriptor:
    entity_type: str
    key_expr: ast.AST | None


@dataclass(frozen=True)
class Row:
    line: int
    function: str
    subfunction: str
    entity_type: str
    entity_key: str
    model: str
    register: int
    register_type: str


def call_name(node: ast.AST) -> str | None:
    if isinstance(node, ast.Call):
        func = node.func
        if isinstance(func, ast.Name):
            return func.id
        if isinstance(func, ast.Attribute):
            return func.attr
    return None


def keyword(call: ast.Call, name: str) -> ast.AST | None:
    for kw in call.keywords:
        if kw.arg == name:
            return kw.value
    return None


def literal_value(node: ast.AST, env: dict[str, Any] | None = None) -> Any:
    env = env or {}
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.Name):
        return env.get(node.id, "")
    if isinstance(node, ast.JoinedStr):
        out = []
        for part in node.values:
            if isinstance(part, ast.Constant):
                out.append(str(part.value))
            elif isinstance(part, ast.FormattedValue):
                out.append(str(literal_value(part.value, env)))
        return "".join(out)
    if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
        return str(literal_value(node.left, env)) + str(literal_value(node.right, env))
    if isinstance(node, ast.IfExp):
        test = literal_value(node.test, env)
        return literal_value(node.body if test else node.orelse, env)
    if isinstance(node, ast.Compare):
        # minimal: x is not None / x is None / x == None / x != None
        if len(node.ops) == 1 and len(node.comparators) == 1:
            left = literal_value(node.left, env)
            right = literal_value(node.comparators[0], env)
            op = node.ops[0]
            if isinstance(op, (ast.Is, ast.Eq)):
                return left is right if isinstance(op, ast.Is) else left == right
            if isinstance(op, (ast.IsNot, ast.NotEq)):
                return left is not right if isinstance(op, ast.IsNot) else left != right
    return ""


def eval_registers(node: ast.AST) -> list[int]:
    if isinstance(node, ast.Constant) and isinstance(node.value, int):
        return [node.value]
    if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
        values: list[int] = []
        for elt in node.elts:
            if isinstance(elt, ast.Constant) and isinstance(elt.value, int):
                values.append(elt.value)
        return values
    return []


def collect_inv_models(node: ast.AST) -> list[str]:
    """Sammelt Inv.MODELL-Namen in Quelltext-Reihenfolge, distinct."""
    result: list[str] = []

    class V(ast.NodeVisitor):
        def visit_Attribute(self, n: ast.Attribute) -> None:
            if isinstance(n.value, ast.Name) and n.value.id == "Inv":
                if n.attr not in result:
                    result.append(n.attr)
            self.generic_visit(n)

    V().visit(node)
    return result


def function_stack_for(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> list[ast.FunctionDef]:
    stack: list[ast.FunctionDef] = []
    cur: ast.AST | None = node
    while cur is not None:
        if isinstance(cur, ast.FunctionDef):
            stack.append(cur)
        cur = parents.get(cur)
    return list(reversed(stack))


def nearest_call_ancestor(node: ast.AST, parents: dict[ast.AST, ast.AST], skip_names: set[str] | None = None) -> ast.Call | None:
    skip_names = skip_names or set()
    cur = parents.get(node)
    while cur is not None:
        if isinstance(cur, ast.Call):
            name = call_name(cur)
            if name not in skip_names:
                return cur
        cur = parents.get(cur)
    return None


def first_entity_call_ancestor(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> ast.Call | None:
    cur = parents.get(node)
    while cur is not None:
        if isinstance(cur, ast.Call) and (call_name(cur) in ENTITY_CALL_NAMES):
            return cur
        cur = parents.get(cur)
    return None


def make_parents(tree: ast.AST) -> dict[ast.AST, ast.AST]:
    parents: dict[ast.AST, ast.AST] = {}
    for parent in ast.walk(tree):
        for child in ast.iter_child_nodes(parent):
            parents[child] = parent
    return parents


def collect_nested_function_defs(tree: ast.AST, parents: dict[ast.AST, ast.AST]) -> dict[tuple[str, str], ast.FunctionDef]:
    """Mappt (äußere Funktion, Unterfunktion) auf FunctionDef."""
    defs: dict[tuple[str, str], ast.FunctionDef] = {}
    for n in ast.walk(tree):
        if isinstance(n, ast.FunctionDef):
            stack = function_stack_for(n, parents)
            if len(stack) >= 2:
                defs[(stack[0].name, n.name)] = n
    return defs


def collect_entity_descriptors(func: ast.FunctionDef) -> list[EntityDescriptor]:
    descriptors: list[EntityDescriptor] = []
    for n in ast.walk(func):
        if isinstance(n, ast.Call):
            name = call_name(n)
            if name in ENTITY_CALL_NAMES:
                descriptors.append(EntityDescriptor(name, keyword(n, "key")))
    return descriptors


def bind_helper_arguments(helper_def: ast.FunctionDef, helper_call: ast.Call) -> dict[str, Any]:
    env: dict[str, Any] = {}
    params = [a.arg for a in helper_def.args.args]
    for param, arg in zip(params, helper_call.args):
        env[param] = literal_value(arg, env)
    for kw in helper_call.keywords:
        if kw.arg:
            env[kw.arg] = literal_value(kw.value, env)
    return env


def entity_info_for_spec(
    spec: ast.Call,
    parents: dict[ast.AST, ast.AST],
    helper_defs: dict[tuple[str, str], ast.FunctionDef],
) -> tuple[str, str, str]:
    """Liefert (Unterfunktion, Entity-Typ, Entity-Key)."""
    stack = function_stack_for(spec, parents)
    outer = stack[0].name if stack else ""

    # Fall 1: Spec steht direkt in einem Entity-Description-Aufruf.
    ent_call = first_entity_call_ancestor(spec, parents)
    if ent_call is not None:
        ent_type = call_name(ent_call) or ""
        key_node = keyword(ent_call, "key")
        return "", ent_type, str(literal_value(key_node)) if key_node else ""

    # Fall 2: Spec steht in den Argumenten eines lokalen Helper-Aufrufs, z.B. _pv_voltage(... addresses=[Spec...]).
    helper_call = nearest_call_ancestor(spec, parents, skip_names=SPEC_CALL_NAMES)
    helper_name = call_name(helper_call) if helper_call else None
    if helper_name and (outer, helper_name) in helper_defs:
        helper_def = helper_defs[(outer, helper_name)]
        env = bind_helper_arguments(helper_def, helper_call) if helper_call else {}
        descs = collect_entity_descriptors(helper_def)
        if descs:
            # Hauptfall: ein Helper erzeugt genau eine Entity. Bei mehreren nehmen wir später den ersten;
            # komplexe Multi-Yield-Helper werden unten beim Caller separat nicht eindeutig adressierbar.
            d = descs[0]
            key = str(literal_value(d.key_expr, env)) if d.key_expr else ""
            return helper_name, d.entity_type, key
        return helper_name, "", ""

    # Fall 3: Spec steht innerhalb einer Unterfunktionsdefinition selbst.
    if len(stack) >= 2:
        return stack[-1].name, "", ""

    return "", "", ""


def rows_from_file(path: Path) -> list[Row]:
    source = path.read_text(encoding="utf-8")
    tree = ast.parse(source, filename=str(path))
    parents = make_parents(tree)
    helper_defs = collect_nested_function_defs(tree, parents)
    rows: list[Row] = []

    for n in ast.walk(tree):
        if not isinstance(n, ast.Call):
            continue
        if call_name(n) not in SPEC_CALL_NAMES:
            continue

        reg_type = ""
        regs: list[int] = []
        for rt in REGISTER_TYPES:
            val = keyword(n, rt)
            if val is not None:
                reg_type = rt
                regs = eval_registers(val)
                break
        if not reg_type or not regs:
            continue

        models_node = keyword(n, "models")
        models = collect_inv_models(models_node) if models_node else []
        if not models:
            models = [""]

        stack = function_stack_for(n, parents)
        outer_func = stack[0].name if stack else ""
        subfunc, entity_type, entity_key = entity_info_for_spec(n, parents, helper_defs)

        for model in models:
            for reg in regs:
                rows.append(
                    Row(
                        line=getattr(n, "lineno", 0),
                        function=outer_func,
                        subfunction=subfunc,
                        entity_type=entity_type,
                        entity_key=entity_key,
                        model=model,
                        register=reg,
                        register_type=reg_type,
                    )
                )

    rows.sort(key=lambda r: (r.line, r.function, r.subfunction, r.register, r.register_type))
    return rows


def write_pipe(rows: Iterable[Row], out) -> None:
    print("Zeile|Funktion|Unterfunktion|Entity-Typ|Entity-Key|Modell|Registeradresse|Registertyp", file=out)
    for r in rows:
        print(
            f"{r.line}|{r.function}|{r.subfunction}|{r.entity_type}|{r.entity_key}|{r.model}|{r.register}|{r.register_type}",
            file=out,
        )


def write_csv(rows: Iterable[Row], out) -> None:
    w = csv.writer(out, delimiter=";")
    w.writerow(["Zeile", "Funktion", "Unterfunktion", "Entity-Typ", "Entity-Key", "Modell", "Registeradresse", "Registertyp"])
    for r in rows:
        w.writerow([r.line, r.function, r.subfunction, r.entity_type, r.entity_key, r.model, r.register, r.register_type])


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description="Extrahiert Modbus-Register aus Python-Entity-Definitionen.")
    p.add_argument("file", type=Path, help="Python-Datei, z.B. entity_descriptions.py")
    p.add_argument("--csv", action="store_true", help="CSV mit Semikolon statt Pipe-Ausgabe")
    p.add_argument("--no-header", action="store_true", help="Kopfzeile unterdrücken")
    args = p.parse_args(argv)

    rows = rows_from_file(args.file)
    if args.no_header:
        for r in rows:
            sep = ";" if args.csv else "|"
            print(sep.join(map(str, [r.line, r.function, r.subfunction, r.entity_type, r.entity_key, r.model, r.register, r.register_type])))
    elif args.csv:
        write_csv(rows, sys.stdout)
    else:
        write_pipe(rows, sys.stdout)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Ausgabe/Auswertung

python3.12 modbus_register_analyzer_V0.9l.py entity_descriptions.py --csv > ausgabe.csv


File:ausgabe.xls NB: csv ist in diesem Wiki (noch) nicht erlaubt, daher xls

Beispiel

Zeile|Funktion|Unterfunktion|Entity-Typ|Entity-Key|Modell|Registeradresse|Registertyp
66|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H1_G1|10016|input
66|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|KH_PRE119|10016|input
67|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H1_G1|30016|holding
67|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H1_LAN|30016|holding
67|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H3_SET|30016|holding
68|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H3_PRO_PRE122|36001|holding
74|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|KH_PRE133|30016|holding
75|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H1_G2_SET|36001|holding
75|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|KH_133|36001|holding
75|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H3_PRO_122|36001|holding
75|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|H3_SMART|36001|holding
75|_version_entities|_master_version|ModbusVersionSensorDescription|master_version|EVO|36001|holding
93|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H1_G1|10017|input
93|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|KH_PRE119|10017|input
94|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H1_G1|30017|holding
94|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H1_LAN|30017|holding
94|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H3_SET|30017|holding
95|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H3_PRO_PRE122|36002|holding
101|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|KH_PRE133|30017|holding
102|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H1_G2_SET|36002|holding
102|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|KH_133|36002|holding
102|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H3_PRO_122|36002|holding
102|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|H3_SMART|36002|holding
102|_version_entities|_slave_version|ModbusVersionSensorDescription|slave_version|EVO|36002|holding
120|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|H1_G1|10018|input
120|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|KH_PRE119|10018|input
121|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|H1_G1|30018|holding
121|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|H1_LAN|30018|holding
127|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|KH_PRE133|30018|holding
127|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|H3_SET|30018|holding
128|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|H1_G2_SET|36003|holding
128|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|KH_133|36003|holding
128|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|H3_PRO_SET|36003|holding
128|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|H3_SMART|36003|holding
128|_version_entities|_manager_version|ModbusVersionSensorDescription|manager_version|EVO|36003|holding


Kritik

Warum hinterlegt man die Register der verschiedenen Modelle nicht in yaml oder xml Dateien ?

Ich halte das sogar fuer eine der groeszten architektonischen Schwaechen der FOXESS-Integration. Die Entwickler haben sich fuer einen codezentrierten Ansatz entschieden, obwohl ein groszer Teil der Informationen reine Konfigurationsdaten sind.

Man muss allerdings unterscheiden, welche Informationen ausgelagert werden koennen und welche nicht.

Was problemlos in YAML/XML/JSON ausgelagert werden koennte

Alles, was reine Metadaten sind:

- key: pv1_voltage
  name: PV1 Voltage
  type: sensor
  register: 31001
  datatype: uint16
  scale: 0.1
  unit: V
  device_class: voltage
  state_class: measurement

oder fuer verschiedene Modelle:

models:
  H1:
    register: 31001

  H3_PRO:
    register: 31081

  KH:
    register: 33001

Das sind reine Daten.



Ebenso Statuslisten:

states:
  - Waiting
  - Checking
  - On Grid
  - Off Grid
  - Fault

statt

H1_INVERTER_STATES = [...]

Auch Faulttabellen:

faults:
  0x0001: Grid Lost
  0x0002: Over Voltage

Registerdefinitionen:

register:
    address: 41001
    type: holding
    signed: false
    scale: 0.1

Was besser im Python-Code bleibt

Logik.

Beispielsweise

power = voltage * current

oder

if status & 0x40:
    return "Fault"

Das gehoert in Python.



Ebenso komplizierte Berechnungen:

SOC = f(register1, register2, register3)

Warum wurde trotzdem Python verwendet?

Ich vermute mehrere Gruende.

1. Der Entwickler ist Python-Programmierer

Viele Home-Assistant-Integrationen entstehen aus privaten Projekten.

Python ist schneller geschrieben als ein eigener Parser.



2. Typpruefung

Durch

@dataclass

erhaelt man

  • Type Hints
  • Autocomplete
  • MyPy
  • IDE-Unterstuetzung

Das faellt bei YAML weg.



3. Bedingungen

Im Code kann man schreiben

if inverter == Inv.H3_PRO:
    ...

In YAML muesste man dafuer eine kleine Sprache entwickeln.



4. Weniger Parser

Fuer YAML braucht man einen Loader.

Fuer XML einen Parser.

Fuer JSON ebenfalls.

Python ist bereits vorhanden.



Ich wuerde die Architektur anders aufbauen

Fuer eine Integration mit inzwischen ueber 100.000 Zeilen Registerdefinitionen wuerde ich ungefaehr so strukturieren:

foxess/

    models/

        h1.yaml
        h3.yaml
        h3_pro.yaml
        kh.yaml
        aio.yaml

    entities/

        sensors.yaml
        numbers.yaml
        selects.yaml
        faults.yaml

    logic/

        battery.py
        inverter_state.py
        lambda.py
        remote_control.py

Dann wuerde der Python-Code nur noch

yaml.safe_load(...)

aufrufen.



Vorteile

Neue Register ergaenzen:

Heute:

ModbusSensorDescription(
...
)

Morgen:

- key: battery_power

Neue Modelle ergaenzen:

Heute:

Python aendern

Integration neu starten

Syntaxfehler moeglich



Morgen:

Neue YAML

fertig



Noch besser waere JSON

Persoenlich wuerde ich sogar JSON statt YAML bevorzugen.

Warum?

Weil Registerdefinitionen streng strukturiert sind.

Beispiel:

{
  "entity": "pv_voltage",
  "register": 31015,
  "datatype": "uint16",
  "scale": 0.1,
  "unit": "V"
}

Das kann man automatisch validieren.



Noch eleganter waere eine Datenbank

Fuer eine Integration dieser Groesze wuerde ich sogar SQLite verwenden.

Dann koennte man

SELECT *
FROM register
WHERE model='H3_PRO'

machen.

Oder

SELECT *
FROM register
WHERE entity='eps_voltage'

Oder

SELECT *
FROM register
WHERE register=41015

Das waere fuer Wartung, Analyse und Debugging extrem komfortabel.



Im konkreten Fall

Auffalled ist wie schwer sich die Informationen der Register derzeit extrahieren lassen. Waeren die Registerdefinitionen in YAML, JSON oder einer Datenbank abgelegt, koennte dein Analyzer sie direkt auswerten – ohne den Python-Code parsen zu muessen.

Deshalb wuerde ich fuer dieses Projekt sogar einen Schritt weiter gehen als YAML: eine zentrale JSON- oder SQLite-basierte Registerdatenbank, aus der die Home-Assistant-Entitaeten zur Laufzeit generiert werden. Das wuerde nicht nur die Wartbarkeit verbessern, sondern auch den Vergleich verschiedener Wechselrichtermodelle (H1, KH, H3, H3 Pro usw.) erheblich vereinfachen.

Gibt es Beispiele, die eine HA Geraeteintegration mit einer zentralen json Datei oder einer SQLite DB als Konfigurationsbasis benutzen?

Ja, aber sie sind ueberraschend selten.

Die ueberwiegende Mehrheit der Home-Assistant-Integrationen verwendet Python-Objekte (EntityDescription-Dataclasses) anstelle externer JSON- oder YAML-Dateien. Das wird auch in den offiziellen Entwicklerdokumenten und Beispielen so gezeigt. (Home Assistant Developer Docs)

Der Grund ist einfach: Die meisten Integrationen haben vielleicht 5–50 Entitaeten. Bei einer Integration wie FOXESS mit mehreren hundert Registerdefinitionen stoeszt dieser Ansatz jedoch an Grenzen.

Beispiele mit JSON

Mir ist keine bekannte Core-Integration bekannt, die saemtliche Registerdefinitionen in einer zentralen JSON-Datei verwaltet.

Es gibt allerdings Integrationen, die JSON fuer Teilbereiche verwenden, z. B.:

  • Uebersetzungen (strings.json)
  • Geraetekataloge
  • Herstellerdatenbanken
  • Icons
  • Mappingtabellen

JSON wird also durchaus genutzt – aber selten als komplette Konfigurationsbasis fuer Entitaeten. (Home Assistant Developer Docs)



Beispiele mit SQLite

Noch seltener.

Mir ist keine Home-Assistant-Core-Integration bekannt, deren Entitaetsdefinitionen aus einer SQLite-Datenbank gelesen werden.

SQLite wird eher verwendet fuer:

  • Cache
  • Historie
  • lokale API-Daten
  • Geraetezustaende

Nicht jedoch als Registerdatenbank.



Es gibt aber sehr aehnliche Konzepte

ESPHome

ESPHome ist wahrscheinlich das beste Beispiel.

Dort beschreibt YAML praktisch komplette Geraete:

sensor:
  - platform: modbus_controller
    address: 31015
    register_type: holding
    value_type: U_WORD
    name: PV Voltage

Die eigentliche Logik bleibt Python/C++, die Geraetekonfiguration ist deklarativ.



Zigbee2MQTT

Zigbee2MQTT trennt ebenfalls Daten und Logik weitgehend.

Es gibt:

  • Geraetebeschreibungen
  • Konverter
  • Metadaten

Viele Eigenschaften sind reine Datenobjekte.



Modbus-Integration von Home Assistant

Auch die eingebaute Modbus-Integration arbeitet deklarativ:

modbus:
  - sensors:
      - name: Battery Voltage
        address: 31015
        scale: 0.1

Hier wird fast alles durch Konfiguration beschrieben.



Fuer FOXESS wuerde ich eine andere Architektur waehlen

Aus dem, was wir in den letzten Tagen analysiert haben, besteht die FOXESS-Integration im Wesentlichen aus drei Dingen:

Register
↓

Skalierung

↓

Home Assistant Entity

Das sind ueberwiegend Daten, keine Logik.

Ich wuerde sie daher vollstaendig trennen.

1. registers.json

{
  "pv_voltage": {
    "device_class": "voltage",
    "unit": "V",
    "scale": 0.1,
    "datatype": "uint16"
  }
}

2. h3_pro.json

{
  "pv_voltage": 31015,
  "grid_voltage": 31023,
  "eps_voltage": 39215
}

3. entities.json

{
  "pv_voltage": {
    "platform": "sensor",
    "translation_key": "pv_voltage"
  }
}

Dann wuerde die Integration zur Laufzeit lediglich Folgendes tun:

JSON lesen
↓

EntityDescription erzeugen
↓

Entity erzeugen

Der Python-Code waere dadurch erheblich kleiner und klarer.

Noch einen Schritt weiter

Da du bereits einen Register-Analyzer entwickelt hast und haeufig Modelle wie H1, KH und H3 Pro vergleichst, wuerde ich persoenlich sogar keine JSON-Dateien mehr verwenden, sondern eine SQLite-Datenbank mit einer Struktur wie:

Models
------
id
name

Registers
---------
id
address
register_type
datatype
scale

Entities
--------
id
key
device_class
unit

Mappings
--------
model_id
entity_id
register_id

Das haette mehrere Vorteile:

  • Abfragen wie „Welche Register unterscheiden H3 und H3 Pro?“
  • „Welche Modelle besitzen eine EPS-Spannung?“
  • „Welche Entitaeten nutzen Register 31015?“
  • „Welche Register sind fuer Modell X noch nicht zugeordnet?“

Das waeren einfache SQL-Abfragen statt Python-Codeanalyse.

Meine Einschaetzung

Fuer normale Home-Assistant-Integrationen mit wenigen Entitaeten ist der Python-Ansatz vollkommen ausreichend und entspricht auch den Empfehlungen der Entwicklerdokumentation. (Home Assistant Developer Docs)

Fuer eine grosze Modbus-Integration wie FOXESS – mit Hunderten von Registern, vielen Wechselrichtermodellen und staendig neuen Firmwarestaenden – halte ich dagegen eine datengetriebene Architektur (JSON oder besser SQLite) fuer langfristig wartungsfreundlicher. Sie wuerde nicht nur die Pflege vereinfachen, sondern auch Werkzeuge wie deinen Register-Analyzer erheblich unterstuetzen.