Die Standard-Energiekarte in Home Assistant sieht auf den ersten Blick gut aus. Bis man einen Verbraucher dort einhängen will, wo er elektrisch wirklich misst, und bis man merkt, dass die Zahlen darunter nicht stimmen.
Das Problem
Zwei Dinge störten mich an power-flow-card-plus:
- Verbraucher hängen zeichnerisch immer am Home-Kreis. Egal wo ein Gerät elektrisch wirklich misst: Die Karte zeichnet es als Abzweig vom Haus. Eine Kopplung eines Last-Knotens an das Grid gibt es nicht: das ist im README und im JS der Karte nachvollziehbar so verdrahtet. Die Alternative, die das könnte (
sunsynk-power-flow-card), wurde getestet und wieder verworfen: Sie ist wechselrichter- und batteriezentral und zeigt ohne Batterie-Leistungssensor Phantom-Knoten mit „0 W". - Die Karte verträgt keine gerätespezifischen Sonderfälle. Wenn ich einen Knoten auf einen Nettosensor umstelle oder einen Knoten zusätzlich ins Layout hebe, passiert entweder nichts oder etwas Falsches. Die Karte war ständig am Rand ihrer Konfigurierbarkeit.
Dazu kam ein zweites, größeres Problem: Die Karte zeigte pro Gerät Werte, die plausibel aussahen, aber nicht stimmten. Ein Knoten zeigte 0 W, obwohl die Heizung lief. Ein anderer Wert war von gestern.
Die Diagnose
Der Verdacht war schnell: Es liegt nicht an der Karte, es liegt an den Sensoren.
Also die Geräteleistungssensoren des Haus-Energie-Dashboards durchgesehen. Der Befund:
- Viele Werte sind veraltet. Tote Z-Wave- und Zigbee-Sender liefern seit Stunden oder Tagen denselben letzten Wert: ein Sender stand bei 42 Stunden, ein anderer bei 5,7 Tagen. Die Entität ist nicht
unavailable, sie behauptet einfach fröhlich einen alten Stand. - Einige Sensoren sind falsch benannt oder dupliziert. Ein Sensor, der wie „Küchenbeleuchtung" heißt, ist in Wahrheit etwas anderes.
- Schalter sind invertiert. Bei einem Gerät bedeutet
offin der Entität „Licht ist an"; der Schalter schaltet also genau das Gegenteil dessen, was der Name nahelegt.
Real messbar blieb am Ende wenig: der Kühlschrank mit rund 25 W und ein Nachtlicht mit rund 11 W. Von 168 W Gesamtlast waren die restlichen rund 132 W schlicht nicht zuordenbar.
Für die Karte heißt das: Eine „Was verbraucht gerade?"-Aufdröselung auf Basis dieser Sensoren ist nicht eine Messung. Es ist eine Ansammlung von Vermutungen, die alle gleich aussehen.
Die Wahrheit liegt eine Ebene tiefer: bei den Zählern. Die drei physischen Stromzähler (Heizung/Nachtstrom, Tagstrom, Solar) liefern belastbare Summen. Ein Gerätesensor kann lügen, ohne dass man es an der Entität sieht; ein Zähler zählt einfach elektrische Arbeit.
Die Ursache
Das Perfide: Die defekten Sensoren sahen nicht defekt aus. Kein unavailable, kein Fehler-Icon, keine Lücke im Verlauf. Nur ein Wert, der still auf dem letzten Stand stehen blieb. Verlässt man sich auf sie, baut man eine hübsche Karte, die falsche Zahlen zeigt, und merkt es nie.
Dasselbe Muster traf die Karte selbst. Die erste eigene SVG-Version war plötzlich winzig: ein kleiner Kasten in der Ecke statt einer flächigen Grafik. Kein Fehler, kein leerer Zustand, nur falsche Darstellung.
Ursache hier: Der SVG-Inhalt war völlig korrekt. Ein custom:button-card ohne aspect_ratio gibt dem SVG keinen definierten Rahmen: es kollabiert auf seine Mindestgröße. Der Fehler sah aus wie ein Zeichenproblem, war aber ein Layout-Problem.
Die Lösung
Weg von der fertigen Karte, hin zu einer eigenen: custom:button-card als Hülle, das SVG kommt aus einem JS-Template. Der Builder ist dabei idempotent und schreibt die Konfiguration per WebSocket in den Container; aufrufbar als:
sudo docker exec -e HA_TOKEN=<tok> -i homeassistant \
python - < scripts/dashboard/build_flow_card.py
Alle folgenden Code-Ausschnitte sind vereinfacht nachgebaut; die vollständige Logik steckt im Builder, der die Karte per WebSocket schreibt.
Grundgerüst
Drei Zeilen sind entscheidend: aspect_ratio gibt dem SVG einen festen Rahmen, das Grid bekommt position: relative als Bezug, und das Custom-Field wird absolut positioniert, damit es die Karte füllt statt sie aufzublähen.
type: custom:button-card
aspect_ratio: 380/402 # entspricht dem viewBox; ohne den Rahmen kollabiert das SVG
show_name: false
show_icon: false
custom_fields:
svg: |
[[[ /* JS-Template, siehe unten */ ]]]
styles:
grid:
- position: relative # Bezugsrahmen für das absolute Overlay
- grid-template-columns: minmax(0, 1fr)
- grid-template-areas: '"svg"'
custom_fields:
svg:
- position: absolute # absolutes Overlay, nicht im Grid-Fluss
- inset: 0
Werte lesen, runden, dimmen
Der Builder trennt drei Dinge: raw() liest den Rohzustand, ok() sagt, ob überhaupt eine Zahl vorliegt, und num() liefert für die Rechnung einen Wert, der bei fehlendem Signal auf 0 zurückfällt. Gerundet wird erst bei der Anzeige (R), nicht beim Einlesen.
const raw = (id) => (states[id] ? states[id].state : null);
const ok = (id) => { const v = parseFloat(raw(id)); return isFinite(v); };
const num = (id) => { const v = parseFloat(raw(id)); return isFinite(v) ? v : 0; };
const solar = num('sensor.solareinspeisung');
const grid = num('sensor.grid_netz_leistung'); // + Bezug / - Einspeisung
const home = num('sensor.haus_verbrauch');
const heat = num('sensor.nachtstrom_leistung_w');
const Sok = ok('sensor.solareinspeisung'); // false → Anzeige „—" und „kein Signal"
const Gok = ok('sensor.grid_netz_leistung');
const Hok = ok('sensor.haus_verbrauch');
const Kok = ok('sensor.nachtstrom_leistung_w');
const R = (v) => Math.round(v / 10) * 10; // 10-W-Rundung, erst fürs Anzeigen
Ein Knoten zeigt einen Wert nur, wenn ok() true ist; sonst stehen Em-Dash und der Untertitel „kein Signal", und der Knoten wird gedimmt. Die 10-W-Rundung ist dabei kein Kosmetik-Detail: Ohne sie flackert die Karte im Sekundentakt und suggeriert eine Messgenauigkeit, die ein Zähler-Derivat gar nicht hat.
Ringe proportional per dasharray
Der Home-Ring trägt zwei Farben: Der solar gedeckte Anteil liegt als farbiger Bogen über einem graublauen Grundkreis. Technisch ist das ein Kreis, dessen Strichlänge (stroke-dasharray) dem Bruchteil des Umfangs entspricht.
const ring = (cx, cy, r, anteil, cls) => {
const umfang = 2 * Math.PI * r;
const an = Math.max(0, Math.min(1, anteil)) * umfang;
return `
<circle cx="${cx}" cy="${cy}" r="${r}" class="track"/>
<circle cx="${cx}" cy="${cy}" r="${r}" class="${cls}"
stroke-dasharray="${an} ${umfang - an}"
transform="rotate(-90 ${cx} ${cy})"/>`;
};
- Home-Ring: der solar gedeckte Anteil als farbiger Bogen; der Rest bleibt im graublauen Grundton.
- Solar-Ring: zeigt den Eigenverbrauch (der Teil der Solarleistung, der nicht eingespeist, sondern selbst verbraucht wird).
- Grid-Knoten: kein Ring, sondern ein normaler Knoten; er zeigt den Betrag und als Untertitel „Bezug" oder „Einspeisung".
Die Autarkie in Prozent ist dann reine Arithmetik auf denselben Werten:
const autarkie = home && solar
? Math.round(100 * Math.min(solar, home) / home)
: null;
Und weil ein Vorzeichenwechsel am Netzanschluss das Vorzeichen der Aussage dreht, bekommt der Grid-Knoten ein Wort statt nur eine Zahl:
const gridLabel = Gok ? (grid >= 0 ? 'Bezug' : 'Einspeisung') : 'kein Signal';
Animierte Bubbles uhr-synchron starten
Der Clou sind kleine Bubbles, die entlang dünner Bézier-Kurven zwischen den Knoten wandern. Sie sind SMIL-animateMotion-Animationen; ihre Dauer hängt an der transportierten Leistung und liegt zwischen 1,3 und 3,6 Sekunden pro Umlauf. Pro Kante laufen zwei Bubbles mit halbem Phasenversatz. Naiv bei 0 gestartet, setzt jeder Rebuild die Animation auf Phase 0 zurück: die Bubbles springen alle 30 Sekunden, wenn das Template neu gerendert wird.
Der Trick: begin nicht bei 0, sondern bei -(now % T). Damit startet die Animation genau in der Phase, in der sie zum aktuellen Zeitpunkt sein müsste; der Rebuild setzt sie auf denselben Punkt, an dem sie ohne Rebuild gerade wäre. Das hält, solange die Dauer T gleich bleibt: Ändert sich die Leistung und damit T, springt NOW % T einmal auf den neuen Wert. Der Uhr-Sync verhindert also den Sprung im Ruhezustand, nicht bei jeder Laständerung.
// Dauer pro Umlauf: 3,6 s bei kleiner, 1,3 s bei großer Leistung
const dur = (v) => Math.max(1.3, Math.min(3.6, 3.6 - (R(v) / 500) * 2.3));
const NOW = Date.now() / 1000;
// zwei Bubbles je Kante, um eine halbe Periode versetzt
const dot = (pfad, v, farbe) => {
const T = dur(v); // Sekunden pro Umlauf
const b1 = -(NOW % T); // uhr-synchron
const b2 = -((NOW + T / 2) % T); // halber Versatz
return `
<circle r="5" fill="${farbe}">
<animateMotion dur="${T}s" repeatCount="indefinite"
begin="${b1.toFixed(2)}s" path="${pfad}"/>
</circle>
<circle r="5" fill="${farbe}">
<animateMotion dur="${T}s" repeatCount="indefinite"
begin="${b2.toFixed(2)}s" path="${pfad}"/>
</circle>`;
};
Ergebnis: Solange die Leistung konstant ist, läuft die Animation flüssig durch Rebuilds hindurch, ohne bei jedem Rendern neu zu starten. Aktive Knoten stehen in ihrer Farbe, inaktive werden auf Grau gedimmt und halbtransparent; ein unavailable-Knoten zeigt Em-Dash und den Untertitel „kein Signal".
Die zweite Karte: „Was verbraucht gerade?"
Die Aufdröselung nach Gerät ist nur so ehrlich wie ihre Guards. Zwei Regeln machen aus einer Sammlung unsicherer Sensoren eine verteidigbare Anzeige. Der Ausschnitt ist vereinfacht nachgebaut; im Betrieb läuft dieselbe Logik als Jinja2-Template in einer Markdown-Karte.
// geraet = Kurzname, entity = Leistungssensor, schalter = optionale Schalter-Entity
function zaehle(geraet, entity, schalter) {
// 1. Schalter-Guard: nur zählen, wenn der Schalter wirklich 'on' meldet.
// Achtung: Ein invertierter Schalter (off = an) fällt hier durch und wird
// verworfen, obwohl das Gerät läuft. Solche Ausnahmen erkennt der Guard
// nicht von selbst.
if (schalter && states[schalter]?.state !== 'on') return null;
// 2. Frische-Guard: Werte verwerfen, die älter als eine Stunde sind.
// Der Builder liest last_updated; das Feld ändert sich aber nur bei einer
// Wertänderung. Ein Sensor, der konstant denselben Wert sendet, wirkt damit
// schnell „veraltet". last_reported zählt jedes Update und wäre die
// ehrlichere Wahl.
const alter = Date.now() - new Date(states[entity].last_updated).getTime();
if (alter > 3600_000) return null;
const v = parseFloat(states[entity].state);
return isFinite(v) && v >= 0 ? v : null;
}
Der Rest ist Rest = haus_verbrauch − Summe(gezählte Geräte). So verschwindet kein Strom, und veraltete Sensoren tauchen als Em-Dash auf statt als Lüge.
Wer einzelne Geräte wirklich sauber messen will, nimmt echte Messhardware statt Behelfssensoren. Eine schaltbare Zwischensteckdose mit eigenem Leistungsmesser liefert einen belastbaren Messwert: Shelly Wave 1PM.
Nach dem ersten Wurf blieb noch Feintuning: Icons auf 16,6 px (scale(0.69)), Werte 14 px, Labels 12 px, Sub-Labels 9,5 px, und das „% solar" wanderte von cy+37 auf cy+60, also unter den Ring statt darüber. Das alles steckt im eingebetteten SVG-Style und der node()-Funktion des Builders, nicht in separater Lovelace-Konfiguration.
Lessons Learned
aspect_ratioist Pflicht, sobald ein Custom-Field ein SVG rendert. Ohne einen definierten Rahmen kollabiert das SVG. Ein winziges Bild sieht nach Zeichenfehler aus, ist aber Layout.- SMIL-Bubbles uhr-synchron starten.
begin=-(NOW % T)stattbegin=0; sonst springt jede Animation bei jedem Rebuild auf 0. - Ringe über
stroke-dasharraysind proportionale Anzeigen für lau. Kein zusätzliches Chart, keine zweite Skala: der Strich ist der Anteil. unavailableexplizit behandeln. Kein erfundener Wert, sondern gedimmt und Em-Dash. Eine ehrliche Leerstelle ist besser als eine glatte Falschaussage.- 10-W-Rundung verhindert Flackern und übertreibt die Genauigkeit nicht.
- Geräteleistungssensoren können lügen, ohne kaputt auszusehen. Veraltete Werte haben kein Fehler-Icon. Zähler sind die Summen-Wahrheit; baue die Karte nach Zählern, nicht nach Geräten.
- Guards gehören in die Karte. Schalter- und Frische-Guard machen aus unsicheren Einzelwerten eine Anzeige, die man verantworten kann.
- Wenn eine fertige Karte an ihre Grenzen stößt, ist eine eigene SVG-Karte kein Hexenwerk.
custom:button-cardplus ein JS-Template reichen.
Checkliste
-
aspect_ratioauf derbutton-cardgesetzt und Custom-Fieldposition: absolute? -
raw()/ok()/num()getrennt:ok()entscheidet die Anzeige,num()fällt auf 0 zurück,R()rundet erst fürs Anzeigen? - Rundung auf 10 W aktiv?
-
animateMotionmitbegin=-(NOW % T)stattbegin=0? - Ringe über
stroke-dasharrayproportional zum Anteil? - Grid-Knoten mit Wort „Bezug"/„Einspeisung" statt nur Vorzeichen?
- Autarkie aus Eigenverbrauch/Hausverbrauch berechnet?
- „Was verbraucht gerade?": Schalter-Guard und Frische-Guard (> 1 h verwerfen)?
- Backup-Karte nach Zählern statt nach Gerätesensoren aufgebaut?