FOX ESS Homeassistant Integration
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-mcfarlanebzw.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:
entity_descriptions.py– Sind die EPS-Entitaeten ueberhaupt definiert?inverter_profiles.py– Werden sie dem H3-Pro-Profil zugeordnet?inverter_adapters.py– Verwendet der H3-Pro das erwartete Profil?inverter_model_spec.py– Sind fuer den H3-Pro passende Registeradressen (ModbusAddressSpecbzw.ModbusAddressesSpec) hinterlegt?entity_factory.py– Werden die Entitaeten aufgrund von Modell- oder Registertyppruefungen verworfen?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
#!/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.