PROJECT_CONTEXT — homeESS
Zweck dieser Datei: Einstieg für neue Agent-Sitzungen ohne erneute Vollanalyse. Hält Architektur, Konventionen und offene Punkte fest. Bei strukturellen Änderungen mitpflegen. Siehe auch README.md (Bedienung) und CHANGELOG.md (Verlauf).
Was ist homeESS?
Basis für ein Energy Storage System (ESS). Der Server abonniert MQTT-Topics (Quelle: der angebundene MQTT-Broker), hält die eingehenden Werte in einem Cache und soll daraus ableiten, wie Lasten geschaltet werden (Regel-Engine, deren Betriebslevel prognosegeführt werden; ein zentraler Betriebslevel-Handler (operating-level/handler.js) setzt registrierte Verbraucher nach Priorität durch — erste Verbraucher sind Filter- und Solarpumpe der Poolsteuerung). Bedienoberfläche ist ein Web-Dashboard mit vorgeschaltetem Login.
Aktueller Funktionsstand:
- Login (Passwort) mit „Passwort merken"-Checkbox.
- Dashboard mit frei konfigurierbaren Widgets und Gruppen (Titel +
Breite voll/halb/viertel). Die Widget-Typen (Add-Dialog mit Tabs, Spalte dashboard_widgets.type): value (Live-Kachel eines Werts aus dem Wert-Katalog output/internal-values.js), switch (Gerät/Schaltgruppe aus Messen + Schalten), info (System-Infos aus dashboard/system-info.js — Versionen, CPU/RAM als Fortschrittsbalken u. a.; gewählte Felder als JSON in dashboard_widgets.config, Default = alle), chart (Zeitreihen der Systemdatenbank) und weather (aktuelle Lage oder ein Prognosetag mit angehakten Messgrößen). Widgets und Gruppen per Drag & Drop anordbar, Widgets in Gruppen verschiebbar, Widgets/Gruppen bearbeit- und löschbar. Live-Refresh über GET /dashboard/data (Werte + system- und weather-Block).
- Energie (
/energie, Menü Position 2,energie/overview.js, View
views/energie.js): Übersicht und Einstiegspunkt der Energieseiten. Je Fachbereich eine Karte — Photovoltaik (Leistung, Ertrag heute/Woche/Jahr
- Vorjahr), Stromverbrauch (Eigenverbrauch/Netzbezug aktuell, heute,
Woche, Jahr), Batterie (SoC + Mindest-SoC, Leistung, nutzbare Energie und Kapazität, Spannung/Temperatur, SoC-Balken) und, bei aktivem Modul, Grid-Control (Schaltzustände) und zuletzt Prognose (Ampelbewertung als Chip, PV/Verbrauch/Netzbedarf heute noch, SoC Tagesende + Autarkie) — mit Titel und Schaltfläche als Sprung auf die jeweilige Unterseite. Stromverbrauch, Photovoltaik, Batterie, (falls aktiv) Grid-Control und Prognose sind im Menü Unterpunkte von Energie. Die Seite nutzt ausschließlich die schreibfreien Lesevarianten (readStromverbrauchValues, readPhotovoltaikValues, readBatterieData) sowie computePrognosis mit allowFetch: false (nur Prognose-Cache, nie ein Netzabruf) und schreibt keine Summen fort; Live-Aktualisierung über GET /energie/data (MQTT-Ereignis + 60-s-Takt). Die Ampeltexte der Prognose kommen aus prognosis/status.js und sind mit der Prognoseseite geteilt. Abschnitte, deren Seite für den Benutzer nicht freigeschaltet ist, entfallen.
- Stromverbrauch: MQTT-Topic-Felder für Eigenverbrauch L1–L3, Netzbezug
L1–L3 und Zählerstände; oben Eigenverbrauch/Netzbezug als Phasensummen, Woche/Jahr aus Tageswert plus Tagesstart-Abgleich über den Dialog „Wert abgleichen" (Woche/Jahr/Vorjahr sowie Minimum/Maximum je Kennzahl mit Datum); Jahreswechsel → Vorjahr. Ein Zähler-Topic-Wechsel verwirft den gemerkten Rohstand (resetCountersForChangedTopics), damit der erste Wert eines neuen/getauschten Zählers als Ist-Stand gilt und kein Sprung gezählt wird. Die Energiesummen des Eigenverbrauchs sind zentral um den Hausakku bereinigt: PV + Import − Export − Ladung + Entladung. Dazu liefert batterie/energy.js die integrierten Nettoflüsse für Tag, Woche, Jahr und Vorjahr; eine nächtliche Akkuentladung bleibt dadurch als Hausverbrauch erhalten. Der Akku-Zähler wird im selben Snapshot-Takt wie die PV-/Netzzähler fortgeschrieben (updateBatteryEnergy in buildStromverbrauchSnapshot), damit die bereinigte Bilanz pro Intervall konsistent bleibt. Ohne diese Synchronität sägt der sonst nur asynchron gepflegte Ladezähler den kumulierten Wert minütlich hoch/runter; die auf Positiv-Deltas beruhende Verbrauchslernung verwirft dann die Abwärtsspitzen und bläht die Ladestunden massiv auf. Die Momentanleistung stammt dagegen direkt aus den Eigenverbrauchs-Topics des Wechselrichters und wird ausschließlich um Leistung verbraucherseitig einspeisender PV-Anlagen ergänzt. Batterie und Glättung gehen nicht in diesen Leistungspfad ein. Optionaler echter Eigenverbrauchszähler (3 Phasen, Felder eigenverbrauch_zaehler_l1..3_topic, Zähler-Schlüssel self_l1..3): sind die Topics gesetzt und liefern Werte (counterUpdate.selfMeterPresent), gilt der Tageszuwachs dieser Zähler plus verbraucherseitige PV-Tagesenergie (getConsumerSidePvTodayTotal) als tatsächlicher Eigenverbrauch heute – statt der Bilanz. Der Snapshot liefert zusätzlich eigenverbrauchTodayMeter und eigenverbrauchTodayBalance (Transparenz/Guard).
- Photovoltaik: verwaltet mehrere PV-Anlagen (Stammdaten, Zelltyp,
Konverter-/Reglertyp, MQTT-Topics). Je Anlage aktuelle Leistung groß, Clear-Sky-Idealwert klein. Ideal = kWp × Einstrahlung/1000 × Wirkungsgrad × Zell-Temperaturfaktor × Konverter-Wirkungsgrad; der Wirkungsgrad wirkt als Kalibrierfaktor, die Zell-Temperaturkorrektur ist zelltyp-spezifisch bezogen auf 20 °C, der Konverter-Wirkungsgrad (MPPT-Regler, Wechselrichter, …) ist typ- und temperaturabhängig (Geräte auf Außentemperaturniveau, Referenz 25 °C, converters.js). Der Sonnenstand nutzt die echte Ortssonnenzeit: die per MQTT gelieferte Wanduhrzeit wird über Längengrad, Zeitzonen-UTC-Versatz (inkl. Sommerzeit) und Zeitgleichung umgerechnet (aggregation.js, buildSolarContext); ohne Längengrad/Zeitzone gilt die unkorrigierte Ortszeit. Direkte-Sonne-Erkennung je Anlage über Ist/Ideal ≥ zelltyp-Schwelle (☀️/☁️) und globales Himmelssymbol in der Titelzeile (☀️/☁️/🌙 je Sonnenstand, via /live/header). Bewertet wird nur, solange die Anlage als Sonnenreferenz taugt (siehe Sonnenintensität). Ertrag heute/Woche/Jahr inkl. Vorjahr. Das Ertrags-Topic ist ein kumulativer Rohzähler (wie alle Zählertopics): je Anlage schreibt pv_aggregation nur die Deltas in einen internen kWh-Zähler (counter_total_kwh) fort, „heute" ist der Fortschritt seit der Tagesbasis (day_start_kwh). Ein Rohwert wird nie als Tagesertrag übernommen; ein Topic-/Einheitenwechsel (bzw. Geräte-Reset) leert nur die Baseline (last_counter_raw) und basiert ohne Sprung neu. Die Einheit je Anlage (today_yield_unit, Wh/kWh) rechnet Wh beim Einlesen auf kWh um. Die Summen Σheute/Woche/Jahr laufen weiterhin über pv_summary_aggregation (Fortschreibung in buildPhotovoltaikSnapshot, 60-s-Job; readPhotovoltaikValues liest schreibfrei). PV-Prognose (photovoltaik/forecast.js): Prognosestreifen unter den KPI-Kacheln mit erwartetem Tagesertrag (kWh) für Heute + 6 Tage — derselbe Horizont wie die Wetterseite, damit dort jeder angezeigte Tag einen PV-Wert trägt. Als States werden weiterhin nur Heute/Morgen/+2/+3 veröffentlicht. Quelle ist die stündliche Strahlungsprognose von Open-Meteo (wetter/client.js, kostenlos, kein API-Key, 30-min-In-Memory-Cache, Startup-Prime + 30-min-Refresh in app.js). Die Prognose nutzt dieselbe Transposition + Skalierung wie der Live-Idealwert (gemeinsame Helfer solarGeometryAt, transposePlaneIrradiance, idealPowerFromIrradiance in aggregation.js) — nur mit prognostizierter statt modellierter Clear-Sky-Strahlung, daher konsistent mit dem Live-Modell. Read-only; clientseitig über /photovoltaik/forecast aktualisiert (15-min-Takt). Die Heute-Karte zeigt zusätzlich den bis jetzt erwarteten und den noch erwarteten Ertrag (Aufteilung des Tagesgesamtwerts an der lokalen Uhrzeit, laufende Stunde anteilig). Selbstkalibrierung (photovoltaik/calibration.js, je Anlage per Checkbox auto_calibrate): ein pro Tageszeit-Bucket (15 min, 0..95) hinterlegter Kalibrierfaktor (pv_calibration_buckets). Je abgeschlossenem 15-min-Fenster wird der gemessene Leistungs-Durchschnitt der letzten 15 Minuten gegen die von Open-Meteo gelieferte Strahlung desselben Fensters (minutely_15, in erwartete Leistung umgerechnet) verglichen und der Bucket sanft per EMA (α≈0,05) auf gemessen/erwartet nachgezogen (Faktor wirkt in beide Richtungen, geklammert 0,2–1,5). Weil die Wetter-Strahlung die Bewölkung bereits enthält, fällt das frühere Klarhimmel-Gate weg; verbleibende Gates: kein voller Akku (batterie.soc, Abregelung), Verhältnis plausibel (0,4–1,5) und ein anlagenspezifischer Sonnenstand-Cutoff — kalibriert wird nur, wenn die erwartete Leistung den morgens/abends konfigurierten Sonnenreferenz-Cutoff (sun_cutoff_morning/_evening, Default 10 % der kWp-Spitze) überschreitet. So kalibriert eine Westanlage nur nachmittags, eine Ostanlage nur vormittags. Der Messdurchschnitt wird über die 60-s-Ticks im Speicher akkumuliert. Ein neuer Bucket übernimmt den Faktor des vorangehenden Buckets als Startwert (statt 1,0); der frisch berechnete Faktor wird zudem auf den neuen (aktuellen) Bucket übernommen, sofern dort noch kein Wert (z. B. aus dem Vorjahr) liegt. Hat ein Bucket keinen eigenen Wert (Randzeiten außerhalb des Kalibrierfensters), liefert effectiveFactor den Faktor des rückwärts nächstgelegenen kalibrierten Buckets — die Mittagskalibrierung trägt so sanft in Morgen-/Abendstunden, ohne Sprung auf 1,0. Sobald ein Faktor wirkt, multipliziert er den Idealwert (idealEffektiv = idealBasis × factor) — auf Live-Ideal, Sonnenintensität und Prognose, bildet u. a. Verschattung ab. Der aktuelle Faktor wird zur Diagnose in der Anlagenzeile angezeigt; „Kalibrierung löschen“ (Bearbeiten-Dialog, mit Sicherheitsabfrage) verwirft alle Buckets einer Anlage. Tick im 60-s-Job (app.js). Bucket-Reset beim Löschen einer Anlage sowie bei Änderung von Ausrichtung oder Gesamtleistung (plants.js).
- Sonnenintensität (
photovoltaik/sun-intensity.js): Ist/Ideal in %,
gedeckelt auf 100 %, nur über Anlagen gebildet, die aktuell als Sonnenreferenz taugen — d. h. ihr Klarhimmel-Idealwert erreicht mindestens den anlagenweise konfigurierten größenrelativen Cutoff (isSunReference/sunCutoffWatt in aggregation.js: idealBasis ≥ kWp × 1000 × Cutoff%, Cutoff getrennt für morgens/abends, Default 10 %). So fließen off-axis-Anlagen (z. B. die große Südanlage morgens) nicht ein und ziehen das Verhältnis nicht künstlich hoch. Momentanwert plus 10-Minuten-/Tages-/Vortagsmittel aus periodischen Samples (sun_intensity_samples, Sampling im 60-s-Intervall in app.js).
- Batterie (
/batterie): voll implementiert. MQTT-Topics für SoC (%),
Leistung (W, positiv = laden), Spannung (V), Temperatur (°C) konfigurierbar; KPI-Kacheln nur wenn Topic gesetzt; SoC-Balken farbcodiert (grün/dunkelgelb/rot). Live-Updates via SSE. State-Definitionen integriert (kein Ad-hoc-System). Titelzeile: Batterie-Ladeanzeige als Icon mit Füllstand + Prozentzahl, erscheint automatisch sobald batterie.soc-Wert im Cache vorhanden ist; der Füllbalken ist farbcodiert (≥ 50 % grün, < 50 % gelb, < 20 % rot). Die Betriebslevel-Anzeige zeigt die fünf Levelbalken und daneben das aktive Level als Zahl in einem farbig umrandeten Kreis (Randfarbe je Level, weiße Ziffer). Zusätzlich: Mindest-SoC-Ziel-Topic mit 5-%-Regler und Batterieparameter (Typ, Zellzahl, Kapazität in Ah, untere/obere Gesamtspannung). Die Prognose leitet daraus über die zelltypspezifische Nennspannung die Energie in kWh ab. Der Wertekatalog stellt außerdem die abgeleiteten Batteriezustände Charge, Charged today, Discharging, Empty, Full, Good, HalfCharged, High, Overflow und Reserve bereit. Schwellen beziehen sich auf den dynamischen Mindest-SoC: Reserve endet bei 30 %, Good beginnt bei 25 % und HalfCharged bei 50 % des nutzbaren Bereichs bis 100 %; High gilt über 90 %, Full über 98 %. Charged today wird persistent bis zum lokalen Tageswechsel gehalten. Neben dem Mindest-SoC-Ziel-Topic (Steuer-Topic: wir schreiben den Wert dorthin und nutzen ihn als Live-Override für die abgeleiteten Zustände) gibt es ein optionales, separates Mindest-SoC-Remote-Topic (remote_topic, batterie/min-soc-sync.js, Vorbild: Schalt- + Remote-Topic der Messen-+-Schalten-Geräte). Es ist bidirektional mit der Mindest-SoC-Einstellung verknüpft (nicht mit dem Live-SoC): Speichern der Einstellung spiegelt den Wert an das Remote-Topic (routes/batterie.js); ändert ein externes System den Wert auf dem Remote-Topic, wird er (auf 5-%-Schritte gerundet) als neue Mindest-SoC-Einstellung übernommen ("mitgezogen"), persistiert und zusätzlich an das Steuer-Topic weitergegeben. Ein receivedAt-Vergleich plus noteLocalChange beim Speichern verhindern, dass ein noch im Cache liegender älterer Remote-Wert eine gerade gespeicherte Einstellung zurückdreht. Läuft reaktiv bei Wertänderung (entprellt) sowie einmalig beim Start.
- Messen + Schalten (
/messen-schalten, Kernseite, Menü unter Energie):
Gruppen als einklappbare Abschnitte über die volle Seitenbreite (Vorbild Output-Kategorien: Standard zugeklappt, Auf/Zu-Zustand je Gruppe in localStorage homeess.ms.openGroups), unter Geschwistern alphanumerisch nach Titel sortiert (listGroups). Gruppen sind jetzt mehrschichtig verschachtelbar: Der Gruppenkopf hat eine Drag-Fläche, mit der eine Gruppe wie ein Verzeichnis in eine andere geschoben wird (mess_schalt_groups.parent_id; Zyklen/Selbst-Verschachtelung werden in messen-schalten/groups.js abgewiesen, Route POST /messen-schalten/groups/:id/parent). Untergruppen stehen eingerückt im Body und klappen mit der Elterngruppe zu. Prioritäten werden nicht vererbt. Der Titel einer Gruppe mit Untergruppen zeigt verkürzt „Ebene/Gesamt W" (eigene Ebene / Gesamtleistung inkl. Untergruppen; readGroupPowerTree). Die Gruppenoption Zählergruppe (meter_group) fixiert den Zweig-Gesamtverbrauch aus den eigenen Zählern und weist als Fußzeile die „Sonstige Verbraucher dieser Gruppe" aus; mit gesetztem offset_total_consumption wirkt sie als Sperrschicht (voller Zweig-Beitrag, Untergruppen global nicht mehr zusätzlich verrechnet). Geräte (Aktoren) sind einzeilige Zeilen über die volle Breite, per Drag & Drop frei anordbar und zwischen Gruppen verschiebbar; Drop auf den Kopf einer zugeklappten Gruppe ordnet ans Gruppenende zu. Gruppenlose Geräte stehen im festen Abschnitt „Ohne Gruppe" am Seitenende unter den Gruppen. Je Gerät bis zu fünf MQTT-Topics: Schalten, Remote, Status, Leistung, Zähler — mindestens Schalten, Leistung oder Zähler ist Pflicht (messen-schalten/actors.js, Validierung). Ohne Status-Topic gilt das Schalt-Topic (sonst die Leistung) als Ist-Stand. Ist nur ein Zähler gesetzt, wird die Leistung aus dem Zählerfortschritt abgeleitet (Δkwh/Δt, messen-schalten/aggregation.js, 60-s-Job messSchaltAggregation) und fällt nach über 10 min ohne Fortschritt auf 0 W. Der angezeigte Zählerstand ist ein interner Zähler (counter_total_kwh in mess_schalt_actor_state), der wie der Stromverbrauchs-Zähler nur die Deltas des Roh-Topics fortschreibt: Neuanlage startet bei 0; Topic-/Einheitenwechsel setzt nur die Baseline (last_counter_raw) zurück; Rückwärtssprünge des Rohwerts (Geräte-Reset) basieren neu, ohne den Stand zu ändern. Altbestände ohne internen Zähler übernehmen beim ersten Snapshot einmalig den Rohwert (nahtlose Anzeige). Für Geräte mit Leistungs- UND Zähler-Topic wird zusätzlich die aus der Live-Leistung integrierte Tagesenergie geführt (power_energy_kwh/power_energy_day_start_kwh/last_power_ts, integratePowerEnergy); weicht der Zähler heute stark davon ab (counterPowerMismatch, Faktor ≥ 3 ab 0,05 kWh) — typisch bei vertauschter Einheit Wh↔kWh —, warnt die Zählerzelle mit rotem ⚠. Die Zählung/Gruppensummen bleiben unverändert (nur Hinweis). Der Zählerstand wird bewusst nie als „veraltet" markiert (interner, immer bekannter Wert); eine fehlende Verbindung zeigt sich stattdessen als Gerät „offline" (offline/DEVICE_OFFLINE_MS, roter Statuspunkt-Ring + Namensbadge), sobald die periodische Telemetrie (Leistung/Zähler) länger als 30 min schweigt – Schalt-/Status-Topics zählen dafür nicht (ereignisgetrieben). Das optionale remote_topic ist ebenfalls ein abonnierter Zustand (messschalt:<id>:remote) und wird in messen-schalten/automation.js bidirektional mit dem Gerät synchronisiert. Eine Wertänderung am Remote-Topic wird als Schaltwunsch behandelt; Schaltbefehle und neuere bestätigte Gerätezustände werden zurückgespiegelt. Einschalten bleibt durch effektive Priorität und Lastabwurf gegatet. Bei Ablehnung publiziert die Automation 0 auf Schalt- und Remote-Topic, damit beide Zustände konsistent bleiben. Zwei Betriebsarten je Gerät mit Schalt-Topic (Checkbox always_on):
- „Immer an":
messen-schalten/automation.jsregistriert das Gerät am zentralen
Betriebslevel-Handler mit seiner effektiven Priorität (eigene oder – per Checkbox – die der zugeordneten Gruppe; siehe LEVEL_HANDLING.md) und schaltet es automatisch EIN, sobald das Level die Priorität erreicht – und hält es an (auch bei externem Ausschalten wieder ein). Unter der Priorität wird es (auch extern eingeschaltet, readActualOn) abgeschaltet. Der Toggle der Geräte-Zeile ist hier ausgeblendet.
- Manuell (ohne „Immer an"): Unterhalb der effektiven Priorität greift ebenfalls
Zwangs-Aus und manuelles Einschalten wird abgewiesen. Nach erneuter Freigabe bleibt das Gerät aus, bis es über den Zeilen-Toggle wieder eingeschaltet wird. Die Steuerschleife reagiert zusätzlich zum 30-s-Tick entprellt auf MQTT-Änderungen der messschalt:-Topics (onValuesChanged/isRelevantEvent), sodass Geräte bei externem Schalten prompt nachgeregelt werden. Schaltbefehle werden pro Gerät als pendingSwitch/pendingRemote entprellt: Solange das jeweilige Readback den Befehl nicht bestätigt, wird derselbe Wert im 30-s-Tick nicht erneut publiziert. Nach Bestätigung kann eine spätere echte Abweichung wieder genau einen Regelbefehl auslösen. Zähler-Aggregation reagiert ebenfalls ereignisgesteuert auf neue messschalt:<id>:counter-Werte; der 60-s-Job bleibt als Fallback bestehen. /messen-schalten/data fordert ausschließlich lokale Adapter-Topics über das zentral auf 5 s gedrosselte mqttClient.requestStateValue aktiv an. Über den MQTT-Broker werden Funk-/Homematic-Topics dabei niemals per /get gepollt, da dies reale Funkabfragen und Duty-Cycle-Last auslösen kann. Der HM-RPC- Adapter bedient diesen Refresh dagegen CCU-seitig aus seinem Cache (getParamset auf VALUES) — kein Funk, kein Duty-Cycle: So werden CCU- Änderungen auch ohne Push-Event übernommen und Werte veralten nicht mehr fälschlich. Ergänzend hält der Adapter Werte über einen optionalen, gleichmäßig verteilten Hintergrund-Refresh (Round-Robin, ein Kanal nach dem anderen) sowie ein 5-s-Beobachtungsfenster nach jedem Steuerbefehl aktuell (adapter/hm-rpc). Die primäre Aktualisierung ist ereignisbasiert: HM-RPC 1.1.2 registriert eine vollständige XML-RPC-Logikschicht einschließlich listDevices(interface_id); die CCU liefert Änderungen unmittelbar über event bzw. system.multicall. Beim Stop erfolgt die spezifikationskonforme Abmeldung mit gleicher Callback-URL und leerer interface_id. Es gibt keinen HM-spezifischen Sekunden-Poll im Hauptsystem; Adapter und Kern bleiben über read()/publishState(s) entkoppelt. Ein Schreibvorgang mit unverändertem Wert löst dabei keinen erneuten setValue an die CCU aus (Vergleich gegen den zuletzt bekannten Wert; Ausnahme: ACTION-Parameter/Tastimpulse) — spart Funk und Duty-Cycle. Tasmota bündelt lokale Reads in einem maximal alle 3 s ausgelösten STATUS 0-Request. Die Oberfläche bewertet receivedAt der empfangenen Status-, Leistungs- und Zählerwerte: Nach 5 min ohne Aktualisierung wird ein Wert als veraltet markiert. Ein bestätigtes AUS setzt einen eventuell älteren/hängengebliebenen direkten Leistungswert des schaltbaren Geräts auf 0 W. Je Geräte-Zeile wird die Betriebsart angezeigt: „Immer an · Priorität N", „manuell" oder „nur Messen". Optional kann ein Gerät für den Lastabwurf des optionalen Grid-Control-Moduls markiert werden (load_shed_enabled, load_shed_phase = L1/L2/L3/Drehstrom). Die Lastabwurf-Logik arbeitet stufenweise je Phase: Grundlage ist die je Phase separat konfigurierte Lastabwurf-Maximallast. Bei mindestens 80 % davon wird zunächst die niedrigste Priorität dieser Phase abgeworfen; erst nach 10 s Stabilisierung wird bei weiter hoher Last die nächste Prioritätsstufe abgeschaltet. Die Freigabe erfolgt in umgekehrter Reihenfolge, erst unter 50 % der Maximallast und mit 60 s Pause bereits vor der ersten sowie zwischen allen weiteren Freigabestufen. Geräte ohne „Immer an" bleiben nach einem Lastabwurf aus; aktive Abwürfe erscheinen in der Geräte-Zeile als „Lastabwurf · Priorität N". Gruppen zeigen ihre Priorität in der Titelzeile. Die Werte der gesetzten Topics stehen im Wertekatalog in Kategorie Geräte (geraet.<id>.schalten/status/leistung/zaehler), die Leistungssummen der Gruppen in Kategorie Verbrauchssummen (verbrauchssumme.<id>.leistung); jede Gruppe zeigt ihre Summe zusätzlich in der Titelzeile. Die Gruppen-Checkbox „Verbrauchssumme mit Gesamtverbrauch verrechnen“ (offset_total_consumption, Default 1) steuert, ob die jeweilige Gruppensumme in output/internal-values.js vom Eigenverbrauch abgezogen wird, um „Sonstige Verbraucher“ zu bilden (der Beitrag rechnet Sperrschicht und Untergruppen bereits ein, readGroupPowerTree.contributionW). Der Gruppenwert selbst bleibt unabhängig davon im Wertekatalog sichtbar. Zusätzlich stehen je Gruppe die Verbrauchssummen aus dem internen Gerätezähler bereit: verbrauchssumme.<id>.verbrauchHeute, .verbrauchJahr, .verbrauchVorjahr (baum-konsistent, readGroupEnergyTree; Tages-/Jahres-Baselines je Gerät in mess_schalt_actor_state, im 60-s-Snapshot fortgeschrieben, Vorjahr beim Jahreswechsel abgeschlossen). Live-Refresh über GET /messen-schalten/data. Gruppen und Geräte besitzen zusätzlich ein Dropdown Funktion (Licht, Waschen, Warmwasser, Heizung / Klima, Kochen; Geräte ohne eigene Funktion erben die der Gruppe, messen-schalten/functions.js). Je zugeordneter Funktion entstehen zwei Wertekatalog-Einträge in Kategorie Funktionen (funktion.<key>.leistung, funktion.<key>.verbrauchHeute); die minütlich integrierten Stundenenergien (mess_schalt_function_hourly) liefern der Prognose Stundenprofile je Funktion und werden aus dem gelernten Haus-Grundverbrauch herausgerechnet. Unterseite „Energiefluss" (/messen-schalten/energiefluss, klappt im Menü unter Messen + Schalten aus): ein clientseitig gezeichnetes, vollständig animiertes SVG-Flussdiagramm (views/energiefluss.js; reine Aufbereitung in messen-schalten/energiefluss.js, assembleEnergiefluss). Eingang: PV gebündelt (Anlagen → PV gesamt), Netzbezug (bei Einspeisung negativ), Batterie als neutrale Stabstelle; zentraler Knoten Eigenverbrauch; Ausgang: die verschachtelten Gruppen plus ein „Sonstige Verbraucher"-Ast (global sowie hinter jeder Zählergruppe), sodass an jedem Knoten Gesamt = Σ(gezeichnete Kinder) + Sonstige gilt (nur angehakte Untergruppen werden als eigener Ast gezeichnet). Strichbreite/Fließtempo folgen der Leistung, die Richtung dem Vorzeichen; Live-Update per GET /messen-schalten/energiefluss/data ohne Neustart der Animation. Systemfarben für PV/Netz/Batterie/Eigenverbrauch, je Gruppe eine frei wählbare Farbe (mess_schalt_groups.color, Stift-Button → Mini-Colorpicker, Route POST /messen-schalten/groups/:id/color); Pfade zu Gruppen in Gruppenfarbe. Durch Priorität/Lastabwurf abgeschaltete Gruppen werden ausgegraut (levelHandler.isAllowed + loadShedOff). Gruppen, der „Sonstige"-Ast sowie PV/Netz/Eigenverbrauch weisen Verbrauch heute und dieses Jahr aus. Einzelne Geräte werden bewusst nicht gezeigt. Die Zeichen-Logik (Layout + Rendering) liegt gemeinsam in public/energiefluss-diagram.js (window.EFDiagram; zusätzlich module.exports für DOM-freie Geometrie-Tests), genutzt von der Seite (interaktiv, mit Farb-Stift) und den Exporten (viewport-füllend). Zwei Layout-Modi: horizontal (layoutH, Spalten = Ebenen) und vertikal (layoutV, schmaler Stamm mit eingerückten Zweigen, einheitlich oben→unten: Einzel-Anlagen → PV gesamt → Netz → Batterie ÜBER dem Eigenverbrauch (Batterie wie eine Quelle eingerückt angebunden), Verbraucher eingerückt darunter; ein Knoten je Zeile). Jede Kante läuft in einem eigenen senkrechten Kanal im Einrück-Spalt links der Knoten (orthogonal, polyD; nächstes Kind außen, ferne innen) – so liegen die Linien nebeneinander, queren nie einen Knoten, bündeln am Eigenverbrauch (dicker) und dünnen nach unten aus. Die Einrückung je Kinderreihe ist dynamisch (childIndent: mehr Kinder → breiterer Kanal-Spalt → tiefere Einrückung; über/unter getrennt, sodass die wenigen Quellen oben schmal bleiben). Die Seite wählt bei ≤ 760 px vertikal (füllt die Breite, wächst in die Höhe) und zeichnet bei Breitenwechsel neu; Desktop bleibt horizontal. Exporte (Tabelle energiefluss_exports, energiefluss-exports.js, views/energiefluss-export.js): unter dem Diagramm benannte, öffentlich abrufbare Live-Ansichten (Theme hell/dunkel) unter /energiefluss/export/<slug> (+ /data, beide ohne Auth). Die Export-Ansicht zeigt nur das Diagramm, skaliert den Baum via viewBox auf den Viewport und blendet bei Platzmangel zuerst die Zählersummen aus (kompakte Knoten), bevor die Schrift schrumpft; Legende unten links, Wasserzeichen unten rechts. Unterseite „Schaltgruppen" (/messen-schalten/schaltgruppen, klappt im Menü unter Messen + Schalten aus): zwei unabhängig scrollbare Spalten — links die Schaltgruppen (Name, optionales Remote-Topic, Checkbox „Gruppe schaltet als Einheit"; Tabelle mess_schalt_switch_groups), rechts schmaler alle Geräte ohne Schaltgruppe; Zuordnung per Drag & Drop (Desktop) oder klickbasiert über den „+ Gerät hinzufügen"-Dialog je Gruppe bzw. das „×" je Zeile (Touch/Handy; am Handy ist die Pool-Spalte ausgeblendet und dient nur als Quelle des Auswahldialogs) — beides schreibt POST …/schaltgruppen/assign (mess_schalt_actors.switch_group_id, nur über diese Seite gepflegt). Eine Gruppe gilt als an, sobald ein Gerät an ist, und erst als aus, wenn alle Geräte aus sind; nur „als Einheit" schaltet die Einschalt-Flanke eines Geräts die übrigen automatisch mit ein. Einschalten der Gruppe (UI-Toggle, Remote-Topic oder State-Write) schaltet alle Geräte ein, Ausschalten alle aus — je Gerät durch die effektive Priorität gegatet (commandManual; „Immer an"-Geräte bleiben levelgeführt). Das Remote-Topic (schaltgruppe:<id>:remote) wird bidirektional synchron gehalten: externe Wertänderung = Schaltwunsch, ein neuerer lokaler Zustand wird zurückgespiegelt, der erste Wert nach einem Neustart ist nur Baseline (kein Massenschalten aus retained Werten). Der Schaltzustand jeder Gruppe ist ein beschreibbarer State schaltgruppe://gruppen/<id> über eine virtuelle States-Instanz (adapterRouter.registerVirtualInstance, Provider-Hook registerStatesProvider in adapters/states.js): er erscheint unter der Kategorie Schaltgruppen auf der States-Seite, im State-Picker und im Wertekatalog (Kategorie „Schaltgruppen") und kann so in beliebigen Topic-Feldern weiterverarbeitet werden (messen-schalten/schaltgruppen.js + schaltgruppen-automation.js, 30-s-Tick + entprellte Reaktion auf messschalt:/schaltgruppe:-Änderungen).
- Wetterprognose (
/wetter,wetter/forecast.js, Viewviews/wetter.js):
eigenständige Open-Meteo-Abfrage (current + hourly + daily, sieben Tage, timezone=auto) mit eigenem 30-Minuten-Cache und eigenem Job (weatherForecast in app.js). Sie ist getrennt von der Strahlungsabfrage der PV-Prognose (wetter/client.js), damit deren Variablenumfang unverändert bleibt; der Tageshorizont beider Abrufe ist jedoch gleich (sieben Tage), damit die Seite für jeden Tag einen erwarteten PV-Ertrag zeigen kann. Die Seite steht als letzter Punkt des Hauptmenüs (NAV_MAIN_TRAILING in views/layout.js, also hinter den optionalen Modulen) und zeigt die ersten drei Tage ausführlich samt Stundenverlauf, die restlichen als Kurzübersicht. WMO-Wettercodes werden in wetter/codes.js in deutschen Klartext und Piktogramme übersetzt (dort auch Himmelsrichtung, Beaufort und UV-Stufe); ein fehlender Code ergibt „Unbekannt" und nicht „Klar". Die Seite zeigt die Messgrößen je Tag in thematischen Blöcken statt in einer langen Kachelreihe. Dieselbe Auszeichnung trägt beide Darstellungen: ab 769 px wird jeder Block eine Spalte mit schmucklosen Wertezeilen (wetter-metric-figure hält Wert und Einordnung rechtsbündig zusammen), der Stundenverlauf läuft als wetter-group--wide über alle Spalten; darunter bleibt das Kachelraster. Die Seite trägt den erwarteten PV-Tagesertrag aus photovoltaik/forecast.js (Zuordnung über dateKey) im Titel jedes Detailtages und stellt zwischen aktueller Lage und erstem Tag ein Verlaufsdiagramm über alle Tage (wetter/chart.js, Inline-SVG): Temperatur rot auf der linken Achse, Sonnenintensität ockergelb auf der rechten (zur Nulllinie hin mit 50 % Deckkraft gefüllt, Farbton aus --wetter-sun-fill für Diagramm und Legende zugleich), Niederschlag als blaue Balken dahinter mit eigenem Maßstab. Lücken in einer Reihe trennen Linie und Fläche, statt überbrückt zu werden. Das Diagramm hat zwei Bauformen (variant: 'wide' | 'compact'), beide liegen im Markup, sichtbar ist je Breite eine: die schmale trägt keinen Text im SVG — Tagesnamen und Wertebereiche stehen als HTML daneben, damit die Beschriftung beim Dehnen nicht gestaucht wird. Am Telefon wird zudem der Stundenverlauf per :nth-child(3n + 1) auf 3-Stunden-Schritte ausgedünnt; dadurch muss auf der ganzen Seite nichts seitlich gescrollt werden. Sonnenintensität meint hier die Globalstrahlung aus der Prognose (shortwave_radiation, W/m²) — nicht sun.intensity.*, das die reale PV-Leistung gegen den Klarhimmel-Idealwert misst und nur den Istzustand kennt. wetter/values.js bildet daraus 159 System-States mit dem Präfix wetter. in den Untergruppen Aktuell, Standort, Tag 1 – Heute, Tag 2 – Morgen, Tag 3 – Übermorgen und Weitere Tage; die Gruppennamen sind so gewählt, dass die alphanumerische Sortierung des States-Baums die zeitliche Reihenfolge ergibt. Die States werden in states/system-values.js ausschließlich aus dem Cache gefüllt (kein Netzabruf im State-Pfad) und existieren auch ohne hinterlegte Koordinaten. POST /wetter/aktualisieren folgt Post/Redirect/Get (303 auf /wetter?ok=1/?fehler=1), und dieselbe Adresse beantwortet auch GET mit einer Umleitung auf /wetter: die WebView der App merkt sich ihre zuletzt besuchte Adresse und ruft sie beim Start erneut per GET auf — ohne beides landete sie auf einer Adresse, die nur POST kennt.
- Systemprognose (
/prognose, Menü unter Energie,prognosis/forecast.js):
simuliert heute + drei Folgetage stündlich aus PV-Wetterprognose, Verbrauch und Batterie. Die nutzbare Batterie endet am Mindest-SoC; die unter den Batterieparametern gepflegten Lade- und Entladewirkungsgrade werden getrennt gerechnet. Ungelernte Wochentage übernehmen ausschließlich die Lernkurve des jüngsten abgeschlossenen Tages (Vortag) als Vorlage — Kurvenform und Tagesziel (previousDayKey/previousDayKwh, selectUnlearnedDailyTarget); danach folgen gleitender Mittelwert und Jahresmittel, eine Hochrechnung des laufenden Tages greift nur noch im echten Kaltstart ab 30 % Tagesanteil (die frühere heute/Tagesanteil-Erstwahl explodierte morgens bei ungelernter Profilform). Diese Basis enthält weder Wallbox, Netto-Akkuladung, Pool noch die funktionszugeordneten Messen-+-Schalten-Lasten. Das BDEW-Standardprofil dient nur noch als Kaltstart ohne einen einzigen abgeschlossenen Tag; der heutige Verlauf kalibriert die Restprognose begrenzt nach. 60-s-Sampling persistiert Tagesstände in prognosis_daily_consumption und Zählerdifferenzen stündlich in prognosis_hourly_consumption. Die Delta-Lernung ist vorzeichenbehaftet: kleine negative Deltas der kumulierten Bilanz (Sägezahn beim Akku-Laden, weil PV-/Netz-/Akkuzähler nicht exakt synchron fortschreiten) werden gegengerechnet (MAX_NEGATIVE_SAMPLE_DELTA_KWH, Stunden-/Tagessummen bei 0 begrenzt) — eine reine Positiv-Delta-Lernung wirkte als Gleichrichter und blähte die Bilanz-Stunden tagsüber massiv auf (real: > 2× Selbstzählung); große Rücksprünge gelten weiterhin als verspäteter Zähler-Reset (nur Neubasierung). Abgehärtete Datenbasis (prognosis/self-count.js): parallel zum zähler-/bilanzbasierten Stundenwert (primary_kwh) wird die Eigenverbrauch-Leistung (≥ 0, ohne Nulldurchgänge) stundenweise zur Selbstzählung integriert (self_kwh, integrateSelfCount). Nach Abschluss einer Stunde ersetzt reconcileCompletedHours die Bilanz durch die Selbstzählung, wenn beide relativ UND absolut zu weit auseinanderliegen. Beide Schwellen sind als Modellparameter auf der Prognoseseite konfigurierbar: self_count_guard_percent (Standard 25 %, 1–100 %) und self_count_guard_min_kwh (Standard 0,2 kWh, 0–5; 0 = allein die relative Schwelle entscheidet); GUARD_REL/GUARD_ABS_KWH sind die Fallback-Konstanten. Mit echtem Eigenverbrauchszähler greift der Guard nicht (Zähler ist maßgeblich). Bewusst kein Glätten – echte Verbrauchsspitzen bleiben; nur bei belegbarer Divergenz wird ersetzt (reconciled-Flag je Stunde, Tageswert wird aus der Stundensumme neu gebildet). Die Prognose-Seite zeigt dazu ein Transparenz-Diagramm (je Stunde Selbstzählung vs. Bilanz/Messung, laufende aktuelle Stunde, Marke des übernommenen Werts; todaySelfByHour/ todayPrimaryByHour/todayByHour). Die Selbstzählung läuft unabhängig von den Netzzählern (in app.js NICHT hinter deren Early-Return – ein fehlender Netzzähler darf die grid-unabhängige Selbstzählung nicht mit abschalten). Fehlererkennung (prognosis/sampling-health.js): markSampleHealthy merkt sich das letzte Sample MIT verbraucherseitigen Daten; checkSamplingHealth markiert vollständig verpasste Stunden als incomplete, setzt ihren Lernwert auf den Vortageswert (Rohwerte bleiben) und rechnet den Tageswert neu; die Datenbasis-Ansicht graut solche Stunden aus. Störungen landen in data/prognosis-sampling.log (prognosis/sampling-log.js). Zustand in prognosis_sampling_state, Flag in prognosis_hourly_consumption.incomplete. Wallbox-Zählerdelta und Haus-Sample laufen im selben Takt; E-Auto-Energie wird nur aus angeschlossenem Fahrzeug, Live-SoC und Ladestrategie geplant, nicht aus historischen Ladezeiten. Heizung / Klima wird separat als mittlere Leistung (W) je 1-°C-Außentemperaturfenster und Tagesstunde gelernt (Tabelle mess_schalt_temperature_power mit PK bucket, day_key, hour; je Fenster für jede der 24 Stunden bis zu 30 Messtage, Modellwert = deren Mittel) und anhand der prognostizierten Außentemperatur je Stunde zu erwartetem Verbrauch (kWh = W/1000 × Stunden) umgerechnet — dabei greift der Stundenwert des Fensters, noch ungelernte Stunden fallen auf das Fenstermittel zurück (hourWindowPower/nearestWindowPower). Das Temperatur-Balkendiagramm zeigt je Fenster das Mittel über alle 24 Stunden; ein Klick öffnet die 24-Stunden-Kurve (summarizeTemperatureDemand liefert dazu hourlyPowerW/hourlyDays/todayHourlyPowerW). Je Prognosetag zeigt die Seite ein 24-h-Stundenprofil-Balkendiagramm (Soll = Tagesziel × Wochentagskurve); bereits gelernte Stunden von heute erscheinen als Ist-Balken in abweichender Farbe mit Soll-Marke je Stunde (model.todayByHour). Der erwartete Heiz-/Kühlbedarf sitzt je Stunde als gestapelter Balken über der Grundlast (day.climateByHour aus climateLoadForHour; reine Anzeige, die Grundlast-/Lastsumme bleibt unberührt). Die Ergebnisse sind als prognose.* im Wertekatalog verfügbar. Zusätzlich zeigt die Seite den persistenten operating.autark-Tagesstatus. Beim lokalen Tageswechsel wird ein Jahreszähler nur dann erhöht, wenn der Tag weiterhin autark endete; ein optionales MQTT-Topic synchronisiert diesen Zähler bidirektional und bietet beim Einrichten die Übernahme des externen Startwerts an. Beim Jahreswechsel wird nach Wertung des 31. Dezember der vollständige Stand samt Jahreskennung in „Autarke Tage Vorjahr“ verschoben und der aktuelle Stand zurückgesetzt. Der Vorjahresstand hat ein separates optionales MQTT-Abgleich-Topic nach demselben Muster. Beim Beginn eines neuen lokalen Lerntags wird der erste kumulierte Verbrauchswert nur als Delta-Basis gespeichert; consumption_kwh startet bei
- Damit kann ein extern erst nach Mitternacht zurückspringender Tageszähler
den Vortageswert nicht in den neuen Lerntag übertragen. Die Batteriesimulation sucht ab dem Folgetag den ersten Zeitslot mit PV > Verbrauch und freier Akkukapazität. Bleibt der Folgetag ohne Ladebeginn, wird über alle weiteren sichtbaren Open-Meteo-Tage kumuliert. Heutiger Überschuss fließt in den Akkustand ein, beendet das Nachtfenster aber nicht. SoC, Tagesoffset und Uhrzeit dieses Ladebeginns sowie das erste erwartete Erreichen des Mindest-SoC stehen im Wertekatalog. Die Ampel bewertet primär Netzbedarf beziehungsweise Mindest-SoC bis zum Ladebeginn; Tagesend-SoC ist nachgeordnet. Der Katalog bildet außerdem die früheren externen Prognosegrößen ab: dynamischer Tagesdurchschnitt, 24-h-Hochrechnung aus der letzten Stunde, Verbrauch bis zum nächsten Sonnenaufgang aus Stundenprofil beziehungsweise letzter Stunde, Gesamtbedarf inklusive Akkufüllung, verfügbare, fehlende und freie Energie. Der Sonnenaufgang folgt der Standortgeometrie; ohne Koordinaten dient 06:00 Uhr als definierter Ersatz. Verbrauchssampling übernimmt den zentral bereits um Batterieenergie bereinigten Eigenverbrauch und entfernt zusätzlich Wallbox, Pool und zugeordnete Funktionslasten für den Haus-Grundverbrauch. Lässt sich ein Intervall nicht als plausibel einstufen (z. B. veralteter Zeitstempel nach einem Neustart oder ein Sprung im Quellzähler), wird das Intervall verworfen; auch plausible Minutensamples sind auf 2 kWh begrenzt. Damit kann kein einzelner Ausreißer als Tagesverbrauch stehen bleiben und die Prognose der Folgetage verzerren. Die Jahresbasis (annualAverage) nutzt dieselbe zentrale Batteriebereinigung (battery_energy_state, Tag/Woche/Monat/Jahr + Vorjahr, batterie/energy.js, 60-s-Job), ohne die Netto-Akkuladung nochmals abzuziehen; Wallbox-, Pool- und die per mess_schalt_function_hourly erfassten Funktionslasten werden weiterhin separat entfernt. Aus den bereinigten Stundenwerten entstehen sieben getrennte, weich gelernte Wochentagsprofile samt wochentagsabhängigem Tagesniveau; bei wenig Daten wird zur Vortageskurve zurückgeblendet. Die Funktions-Statistik (messen-schalten/functions.js) ersetzt das frühere Klimatisierungsmodell: Geräte/Gruppen mit Funktion (Licht, Waschen, Warmwasser, Heizung / Klima, Kochen) werden minütlich zu Stundenenergien integriert (plausible Intervalle ≤ 5 min, analog Lernmodell) und in der Simulation je Stunde aufgeschlagen — die übrigen Funktionen nach Wochentag. Heizung / Klima wird abweichend als mittlere Leistung (W) je 1-°C-Außentemperaturfenster und Tagesstunde gelernt (mess_schalt_temperature_power, PK bucket, day_key, hour): je Fenster für jede der 24 Stunden bis zu 30 Messtage (gezählt über verschiedene Tage), pro Tag/Stunde die zeitgewichtete mittlere Leistung; der Modellwert je Stunde ist deren Mittel (bewusst begrenzt statt dauerhaftem Mittelwert, damit die Anpassung nicht mit der Zeit abflacht). Die Stunde ist nötig, weil der Heiz-/Kühlbedarf je Tageszeit variiert. Ein Fenster wird nur an Tagen belegt, an denen diese Außentemperatur real auftrat — dadurch kann der Sommer die Winterkurve (und umgekehrt) nicht überschreiben. In der Prognose liefert das nächstgelegene gelernte Fenster (Prognosetemperatur aus Open-Meteo) je Stunde die erwartete Leistung — der Stundenwert des Fensters, sonst dessen Mittel über alle 24 Stunden —, aus der der Stundenverbrauch errechnet wird (kWh = W/1000 × Stunden). Eine gemessene 0 W ist dabei eine gültige Beobachtung des Fensters; das Leistungsdiagramm erscheint, sobald ein Messtag in ein Fenster einfließt oder eine Außentemperatur vorliegt. Der Balken zeigt das Mittel über alle 24 Stunden, eine Markierungslinie den Wert des aktuellen Tages; ein Klick öffnet die 24-Stunden-Kurve des Fensters. Alt-Datenbanken werden migriert, indem das bisherige Tagesmittel gleichmäßig auf alle 24 Stunden verteilt wird (die Balkenhöhe bleibt gleich, die Stundenkurve verfeinert sich beim Weiterlernen). Das Stunden-Energielog (mess_schalt_function_hourly) speist dieses Heizmodell nicht; es dient nur der Jahres-Grundlastkorrektur und der Tagesanzeige. Persistente Verhaltensmodelle (prognosis_config.behavior_model/_active): grid_parallel bewertet ausschließlich Reserve und Netzbedarf bis zum nächsten Ladebeginn; spätere Tage sind wegen des verfügbaren Netzes irrelevant und Level 1 ist bis zur tatsächlichen Mindest-SoC-Unterschreitung gesperrt. off_grid bewertet dagegen Mindest-SoC und Energiebilanz aller sichtbaren Tage und kann vorausschauend auch Level 1 setzen. prognosis/behavior.js läuft als eigenständige, serialisierte Regelung bei MQTT-Änderungen, spätestens alle 30 Sekunden sowie unmittelbar beim Aktivieren und besitzt exklusiv alle Level 1–5. Level 1 ist ausschließlich im erkannten Notstrombetrieb (emergencyMode) zulässig; mit Netz ist Level 2 die Untergrenze, auch wenn ein Modell oder die Mindest-SoC-Unterschreitung Level 1 ergäbe. Im Notstrombetrieb setzt es unter Mindest-SoC Level 1 auch bei deaktiviertem Verhaltensmodell; endet der Notstrombetrieb, wird Level 1 sofort verlassen. Im Autarkbetrieb erfordert Level 5 SoC > 98 % plus Überschuss; Im Netzparallelbetrieb bedeutet Level 4 sichere Deckung bis zum nächsten Ladebeginn. Die Prognoseampel ist direkt zugeordnet: Grün = Level 4, Gelb = Level 3, Rot = Level 2; Level 1 greift erst unter Mindest-SoC im Notstrombetrieb. Dort gilt die obere Grid-Control-SoC-Schwelle als voll, bei deaktiviertem Grid-Control ersatzweise 90 %. Grid-Control verwaltet nur noch das Ein- und Ausschalten des persistenten Notstromzustands.
- Grid-Control (
/grid-control, optional): Netz- und Einspeisungssteuerung
über getrennte untere/obere SoC- und Spannungs-Schaltfenster mit lokaler Hysterese sowie Wechselrichter-Temperaturwarnung. Die obere SoC-Grenze schaltet das Netz nur zu, wenn Überschusseinspeisung aktiviert ist. Veröffentlicht Warnungen und stellt fünf Grid-Zustände im Wert-Katalog bereit. Netzfrequenz 0 nach konfigurierbarer Wartezeit auf einer beliebigen Phase verriegelt einen persistenten Notstromzustand. Eine Frequenz über 51,5 Hz zählt ebenso als Netzausfall, aber nur solange der SoC unterhalb der oberen Grid-Control-SoC-Schwelle (samt Hysterese) liegt und bekannt ist — darüber hebt der Batteriewechselrichter die Frequenz zum Abregeln der AC-PV an. Erst L1/L2/L3 jeweils > 0 und ≤ 51,5 Hz entriegeln ihn. Überalterte Frequenzwerte (Frische-Prüfung) entriegeln nicht. Dreiphasige Lastschaltung auf Basis der bestehenden Eigenverbrauchsleistung L1–L3 mit separaten Ein-/Ausschaltschwellen und grid.byLoad. Die Verriegelung rastet bei Überlast immer ein und hält das Netz zugeschaltet, bis alle Phasen unter ihre Rückschaltschwelle fallen — auch wenn der ursprüngliche Schaltgrund wegfällt. Die kritische Warnung „Wechselrichterlast zu hoch" wird dagegen nur protokolliert, wenn die Last der alleinige Schaltgrund ist: War das Netz bereits aus einem anderen Grund geschaltet, existiert keine Wechselrichter-Obergrenze (das öffentliche Netz kompensiert) und die Meldung entfällt. Globaler Zustand in operating-state.js (operatingLevel 1–5, emergencyMode Boolean), visualisiert im Header. Dort liegt auch der persistente Tages-Latch autark; im Wert-Katalog als operating.autark. Eine untere SoC-Netzschaltung setzt ihn bis Tagesende false.
- Geschlossene Regelschleife (
grid-control/automation.js): jeder Tick (2 s - bei MQTT-Änderung) gleicht den Soll-Wert gegen die **tatsächliche
Broker-Rückmeldung der Befehls-Topics ab und schreibt bei Abweichung erneut (selbstheilend nach verlorenem Write/Reconnect; eine frisch erkannte Divergenz wird sofort nachgesetzt, danach gedrosselt alle 10 s). Bestätigt gilt nur, wenn verbunden und** der Broker den Soll-Wert (ack:true/Rohwert) zurückmeldet. Status je Befehl im UI als Badge.
- Warnung erst bei persistenter Divergenz (bewusst träge): bis 90 s
(COMMAND_GRACE_MS) ist die ausbleibende Bestätigung ein normaler Roundtrip, danach gilt sie intern als Störung. Gewarnt (Warntopic, systemweite Warnung, roter Protokolleintrag) wird erst nach 5 Minuten durchgehender Abweichung (COMMAND_WARN_AFTER_MS) und mindestens 10 erfolglosen Wiederholungen. Ohne Broker-Verbindung läuft diese Uhr nicht weiter — ein Verbindungsabriss ist kein Schaltfehler. Grund: Netzaussetzer und ein spät antwortendes Cerbo-GX sind normale „Huster" einer Automatik; das Warntopic ist ausschließlich für Fehler, die ein Eingreifen des Nutzers erfordern.
- Plausibilität der Einspeise-Warnung: Bei Soll ein warnt jede
anhaltende Abweichung. Bei Soll aus nur ein aktiver Widerspruch (Broker meldet weiter 1) — die Überschusseinspeisung ist erst oberhalb der oberen SoC-Offset-Schwelle gefordert; schaltet das Netz nur wegen der Wechselrichtergrenzen, gibt es gar keinen Überschuss zu bestätigen.
- Ist-Übernahme nach Neustart (Schützschonung): erst Ist-Werte kennen,
dann steuern. (1) Kein Aus-Befehl, solange die Broker-Rückmeldung des Ziel-Schützes unbekannt ist (Ein-Befehle bleiben erlaubt — sicherheitsgerichtet). (2) Meldet der Broker das Netz als EIN, werden die SoC-/Spannungs-Hysteresefenster einmalig als „ausgelöst" übernommen: Messwerte im Hystereseband halten das Netz wie vor dem Neustart, Werte außerhalb lösen regulär. (3) Solange nicht alle aktivierten Messgrößen (SoC, Spannung, Temperaturwarnung, Lasten L1–L3) bekannt sind, wird ein laut Broker eingeschaltetes Netz gehalten, nie ausgeschaltet; fehlende SoC-/Spannungswerte halten außerdem den letzten Fensterzustand statt ihn auf „aus" zu kippen. (4) Die Ausschaltverzögerung der Lastschaltung gilt auch über Neustarts (persistierte grid_control_runtime: load_active, load_off_since).
- Protokoll (
grid-control/log.js, Tabellegrid_control_log, max. 2000):
nur Schwellen-Übertritte mit Aktionen (gelb) und kritische Zustände (rot), einzeilig mit Zeitstempel + Werte-Schnappschuss; paginiert (100/Seite, /grid-control/log), Seite 1 live, ab Seite 2 statisch. Reine Wertänderungen werden bewusst nicht protokolliert. Das kritische „dauerhaft nicht bestätigt“ erscheint erst bei als persistent bestätigter Divergenz (dieselbe Bedingung wie die MQTT-Warnung, siehe oben) — nicht schon im Schalt-Tick, in dem der Broker den Soll-Wert unmöglich zurückmelden kann; der Live-Status-Badge im UI bleibt davon unberührt momentan. Die Auflösung („wird wieder bestätigt“) wird neutral protokolliert.
- Output (
/output): beliebige berechnete Werte (Wert-Katalog) an
Ziel-Topics des MQTT-Brokers zurückgeben. Die Engine (output/engine.js) arbeitet als geschlossene Regelschleife: Ziel-States werden abonniert und in einem 30-s-Fenster aktiv per /get gelesen — jedoch je Output zu einem zufälligen Zeitpunkt innerhalb des Fensters (verifyTick, Slot-Verteilung), damit nicht alle gleichzeitig den Broker treffen. Nur eine frische Broker- Rückmeldung gilt als Bestätigung; ein bereits bestätigter Wert wird erst wieder aktiv geprüft, wenn sein Ist-Wert älter als ein Prüffenster ist (readbackNeedsVerification). Bei fehlendem oder abweichendem Istwert wird rate-limitiert erneut publiziert; ack:false-Schreib-Echos zählen nicht. Die Seite zeigt je Output den Bestätigungsstatus. Command-Topics sind ausgeschlossen, weil sie keinen verifizierbaren Istwert bereitstellen. Angelegte Outputs erscheinen als dichte, nach Kategorie gruppierte und einklappbare Liste mit festen Spaltenbreiten (Statuswechsel verschiebt den Ist-Wert nicht); der Auf-/Zu-Zustand je Kategorie wird pro Browser (localStorage) gemerkt, das Nachladen bei MQTT-Bursts gebündelt (max. 1×/s). Die Wertauswahl im Dialog nutzt den zentralen Wertekatalog (views/value-catalog.js).
- Bedingungen (
/conditions,src/conditions/, Menü zwischen Messen +
Schalten und Adapter): frei anlegbare Automationen aus Trigger (Zeit, Wertänderung, exaktes Ereignis), Wenn (State-Vergleiche, gemeinsam geprüft, abschaltbar), Dann und optionalem Sonst. Verzeichnisse ordnen die Bedingungen; ein Ausführungsschutz begrenzt Selbstauslösungen (60 Läufe/Minute je Bedingung).
- Dann und Sonst sind Aktionsfolgen (dieselben Bausteine wie die
Aktionsfolgen der Module, gemeinsame Datenschicht in conditions/repository.js, Ausführung über automation/action-runner.js): Wert zuweisen (fester Wert oder Topic-Verweis, Rechenfunktion, Rundung), Pause (Sekunden) und Schleife (parent_id, beliebig verschachtelbar, repeats Durchläufe). Die Folge läuft von oben nach unten; die Reihenfolge ist deshalb bedeutsam und per Dragfläche änderbar — auch in eine Schleife hinein und wieder heraus (POST /conditions/:id/items/layout speichert den vollständigen Baum beider Zweige). Trigger und Wenns bleiben unsortiert, ihre Reihenfolge hat keine Bedeutung.
- Prüfung einer Schleife (optional): im eingestellten Abstand wird eine
Bedingung (ein „Wenn") erneut bewertet; trifft sie nicht zu, wird ausschließlich diese Schleife erneut abgespult, nicht der übrige Zweig. Der Abstand zählt ab der letzten Ausführung der Schleife bzw. ab dem Start von homeESS; ein unbekannter Wert gilt als nicht erfüllt. Geprüft wird nur der Zweig, der zuletzt gelaufen ist (Dann oder Sonst) — sonst würden sich beide dauerhaft überschreiben. Nach einem Neustart ruht die Prüfung, bis die Bedingung einmal ausgeführt wurde.
- Schleife ohne Bedingung (
createConditionmitmode: 'loop', Seite:
„Schleife hinzufügen" bzw. +S je Verzeichnis): eine Automation ohne Trigger, Wenn und Sonst, deren einzige Dann-Aktion eine Schleife mit zwingender zyklischer Prüfung ist. Die Prüfung ist dann die Ausführungsbedingung; sie greift ab dem Start (checkLoops behandelt den Dann-Zweig triggerloser Bedingungen als den maßgeblichen, statt auf einen vorherigen Lauf zu warten). Eine Bedingung braucht deshalb entweder einen Trigger oder eine sich selbst auslösende Schleife (isSelfTriggeringLoop); die letzte Auslösequelle lässt sich weder löschen noch ihre Prüfung abschalten.
- Solange eine Folge läuft (Pausen, Schleifen), löst dieselbe Bedingung nicht
erneut aus; der laufende Durchgang wird zu Ende geführt.
- Nachrichten (
/notifications,src/notifications/, Menü direkt hinter
den Bedingungen): benutzerdefinierte Regeln, die aus einer State-Änderung eine Push-Benachrichtigung an gekoppelte Geräte machen. Tabelle mit Name, State, Trigger, Nachricht, Priorität, Aktiv, letztem Trigger und den Aktionen Bearbeiten / Testen / Aktivieren-Deaktivieren / Löschen.
- NotificationService (
notifications/service.js) ist der einzige
Aufrufpunkt für alle homeESS-Funktionen: push({ title, body, type, severity }). Er validiert serverseitig (Titel 1–120, Nachricht 1–500, Ereignistyp 1–64 mit ^[a-z0-9_-]+$, Priorität normal|critical) und liefert ein strukturiertes Ergebnis (accepted/recipients bzw. reason: relay_unavailable, not_authenticated, send_failed, timeout). Kein anderes Modul kennt Relay, FCM oder Push-Protokolldetails.
- Versandweg: ausschließlich die bestehende, Ed25519-authentifizierte
Origin-WebSocket-Verbindung (remote-access/connection-service → relay-connection.pushNotification()). Gesendet wird genau { type, title, body, eventType, severity } — nie instanceId, deviceId, Empfängerlisten oder Push-Token. Die Empfänger bestimmt allein der Relay aus seinen aktiven Kopplungen. Keine Offline-Queue: ohne Verbindung scheitert nur der Push, die auslösende Funktion läuft weiter.
- State-Auflösung über
states/catalog.resolveStates()(gemeinsame Grenze
mit der Output-Engine). Nötig, weil berechnete Systemwerte im flachen Wertekatalog unter der Kurz-ID (operating.notstrom) stehen, adressiert aber über system://homeess/operating.notstrom werden — was der State-Picker einträgt und die Engine abonniert.
- NotificationRuleEngine (
notifications/engine.js): abonniert die States
der aktiven Regeln über mqttClient.subscribeAdHoc() unter je eigenem Cache-Schlüssel (notification:<id>) und hängt am values-Ereignis des state-bus — kein Polling. Den alten Wert führt sie selbst mit (der Bus überschreibt seinen Cache vor dem Ereignis); der eigene Schlüssel je Regel macht mehrere Regeln auf demselben State voneinander unabhängig.
- Trigger (
notifications/triggers.js, alle flankenbasiert):changed
(alt != neu), equals (alt != Wert und neu == Wert), not_equals (alt == Wert und neu != Wert), above (alt <= Wert und neu > Wert), below (alt >= Wert und neu < Wert). Ein unbekannter Vorwert löst nie aus — der erste Wert nach dem Start ist nur die Ausgangsbasis. above/below sind ausschließlich für numerische States zulässig (Boolean zählt dabei nicht als Zahl) und werden sonst in Oberfläche und Repository gesperrt.
- Cooldown je Regel (
cooldown_seconds, Standard 5,0erlaubt): trifft
sie währenddessen erneut zu, wird nichts gesendet und last_triggered_at bleibt unverändert (der Cooldown verlängert sich nicht); der mitgeführte Vorwert wird dagegen immer fortgeschrieben.
- Testen sendet die Nachricht sofort über den Dienst, ohne den State zu
verändern, ohne die Triggerbedingung zu simulieren und ohne last_triggered_at fortzuschreiben. Regeln auf gelöschte States lösen nicht aus, werden als ungültig markiert und nicht automatisch entfernt.
- Optionale Module (
src/modules/index.js): generische Registry +
In-Memory-Enabled-State; Seite /module zum Aktivieren/Deaktivieren. Aktivierte Module erscheinen automatisch in der Sidebar. Aktuell:
- Poolsteuerung (
/pool): Solarpumpe + Filterpumpe mit je Status-/
Steuerungs-Topic, Priorität 1–5 und eigener Lastabwurf-Phase (l1/l2/l3/three_phase). KPI-Kacheln (Temperatur, Pumpen, pH, Chlor) nur wenn konfiguriert. Drei Modus-Buttons (An/Aus/Automatik) je Pumpe, aktiver Button hervorgehoben.
- Solarautomatik: sonnenbasiert, 2-Min-Mindesthaltedauer, Maximaltemperatur
mit konfigurierbarer Probezyklus-Einschaltdauer (s) und Pause (min). Option „Filterpumpe für Probelauf verwenden" (wenn Filterpumpe konfiguriert). Probeläufe nur bei Sonneneinstrahlung (hasSun): Neue Proben starten nur wenn Sonne scheint. Eine bereits laufende Probe wird bei Beschattung vollständig zu Ende geführt. Der Pausenzähler (tempCycleStart) läuft bei Beschattung still weiter (kein Reset); kehrt die Sonne zurück und ist die Pausenzeit abgelaufen, startet sofort eine neue Probe.
- Filterautomatik: bis zu 3 Zeitfenster, Follow-Solar, Akku-Override
(liest batterie.soc aus dem zentralen Cache — kein eigenes Topic).
- Pool-Energiemodell (
pool/energy-model.js): lernt Solar-/Filterpumpenleistung
robust aus realen Schaltflanken (Median), integriert tatsächliche Laufzeiten persistent und entfernt sie beim 60-s-Sampling aus dem Grund-Hausverbrauch. Die Prognose setzt die Solarpumpe aus den erwarteten PV-Stunden und die Filterpumpe aus Zeitfenstern/Follow-Solar beziehungsweise simuliertem Akku-Override als eigene Last an. Temperaturabschaltung und Probeläufe werden prospektiv nicht angenommen, rückwirkend aber vollständig abgezogen.
- Polling
/pool/statusalle 5 s (Pool-Topics außerhalb der normalen
State-Definitionen, via Ad-hoc-Subscription-System in client.js).
getEffectivePriority(which, cfg)liefert während Filter-Probeläufen die
Solarpumpen-Priorität. Beide Pumpen sind als Verbraucher am zentralen Betriebslevel-Handler registriert (siehe „Betriebslevel / Lastmanagement" und LEVEL_HANDLING.md): Einschalten nur nach Freigabe, Zwangsabschaltung bei Levelabfall — im Automatik-Modus, nicht bei Hand An/Aus.
- Zusätzlich sind beide Pumpen am gemeinsamen Grid-Control-Lastabwurf
angemeldet (grid-control/load-shed.js): je Phase wird zuerst die niedrigste Priorität abgeworfen, nach 10 s ggf. weiter eskaliert und erst unter 50 % der je Phase konfigurierten Lastabwurf-Maximallast mit 60 s Abstand je Freigabestufe wieder freigegeben. Der Lastabwurf wirkt auf die Schaltentscheidung nur, solange er aktiv ist (loadShedActive), damit ein alter Cutoff nach beendetem Grid-Control-Betrieb die Pumpe nicht aussperrt — konsistent zu Messen+Schalten und Wallbox.
- Ist-Zustands-Abgleich: Beide Pumpen richten ihre Schaltentscheidung am
tatsächlichen Status-Topic aus, nicht nur am internen Soll-Glauben. Weicht das Gerät vom Zielzustand ab (verlorener Befehl, extern/an der CCU geschaltet, Neustart), wird der Befehl nachgesendet — gedrosselt über die 2-Min-Haltesperre; ein Moduswechsel (An/Aus) hebt die Drossel für sofortiges Schalten auf. Ohne Status-Topic gilt weiterhin der interne Soll-Zustand.
- Wallbox (
/wallbox): verwaltet mehrere PKW-Wallboxen, einzeln anlegbar wie die
PV-Anlagen (wallbox/boxes.js, Tabellen wallboxes/wallbox_counter_state/ wallbox_summary_state). Je Box ein Pflicht-Steuer-Topic (reiner Aktor: homeESS schaltet die Wallbox darüber, keine Bedienerkennung), optional ein bidirektionales Steuerung-Sync-Topic (an/aus: homeESS spiegelt den Schaltzustand darauf und erkennt externe Nutzerschaltungen – siehe unten), optional Status (sonst Sync- bzw. Steuer-Topic als Ist-Stand), Leistung (W/kW), fortlaufender Zähler (Wh/kWh), Soll-Leistung, „Fahrzeug angesteckt" (true/false), Fahrzeug-SoC (%) und ein bidirektionales Modus-Sync-Topic (nur der Ladeplan: 1 = Privat, 2 = Beruflich, 3 = Immer voll); dazu Maximalleistung, Fahrzeug-Akkugröße und Lastabwurf-Phase. Konfigurationsregel: Dieselbe physische Wallbox darf zwar zusätzlich unter Messen + Schalten zur Leistungserfassung auftauchen, dort aber nur mit Mess-/Zähler-Topics. Wallbox-command_topic und control_sync_topic nicht erneut als Schalt-/Remote-Topic verwenden, weil sonst die Messen-+-Schalten- Steuerung eigene Aus-Befehle auf die Wallbox-Automatik zurückspiegeln kann.
- Verbrauch je Box Tag/Woche/Monat/Jahr + Vorjahr (
wallbox/aggregation.js,
buildWallboxSnapshot im 60-s-Job, Vorbild stromverbrauch/aggregation.js); ohne Zähler-Topic aus der Leistung integriert. SoC-Schätzung aus der seit Einstecken geladenen Energie ÷ Akkugröße, wenn kein SoC-Topic gesetzt ist.
- Prognose-Lernen:
wallbox_daily_consumptionund
wallbox_hourly_consumption führen je Box getrennte Wochentags- und Stundenprofile. Die aktuelle Wallboxleistung wird vor dem Lernen aus dem Hausverbrauch entfernt; prognosis/wallbox-model.js fügt den erwarteten Ladebedarf je Box in der Batteriesimulation separat wieder hinzu. Der gemeinsame Vorausplan wertet aktiven Modus, Verbraucherpriorität, Live-/geschätzten Fahrzeug-SoC, Akkugröße, Mindestladung und Arbeitstage aus. Pflichtladungen (Mindest-SoC, Beruflich-Garantie, Immer voll) sind feste Lasten; flexible Ladungen werden nacheinander auf den verbleibenden PV-Überschuss verteilt. Die gelernte Historie bleibt Fallback für noch unbekannte künftige Ladevorgänge.
- Drei Lademodi mit je eigener Priorität (
wallbox/planner.js): Privat lädt bis
zum Mindest-Ladestand, darüber nur den prognostizierten Überschuss, der nach Hausverbrauch und Hausakku nicht mehr speicherbar ist. Hausakku-Entladung wird live gegengerechnet, nahe dessen Mindest-SoC bleibt die flexible Ladung aus. Ein live nachgewiesener Überlauf (Hausakku-SoC ≥ 95 % und laufende Netzeinspeisung über der Einschaltschwelle) übersteuert dabei eine zu vorsichtige Tagesprognose — die eingetretene Realität hat Vorrang, die Prognose bleibt nur für den vorausschauenden Start zuständig. Beruflich stellt an gewählten Arbeitstagen bis 06:00 Uhr den Mindest-Ladestand Beruflich bereit (spätester Start aus Restenergie und Ladeleistung berechnet); oberhalb davon gilt die Privatregel (nur Überschuss). Fällt der Ladestand AN einem Arbeitstag unter den Mindest-Ladestand, startet die Ladung sofort. Folgt ein freier Tag, gilt ab der einstellbaren Feierabend-Uhrzeit (business_end_hour) nur noch die Privatregel; freie Tage → Privatregel. Immer voll lässt das Ladegerät aktiviert. Mit Soll-Leistungs-Topic Feinmodulation, sonst An/Aus an einer Schwelle.
- Steuerschleife
wallbox/automation.js(30-s-Tick + serielle Kette, Init aus
routes/wallbox.js). Jede Box ist Verbraucher am Betriebslevel-Handler mit der Priorität des aktiven Modus (Einschalten nur nach Freigabe, Zwangsabschaltung, Mindesthaltedauer). Die vorausschauende Bewertung nutzt die System-Prognose (computePrognosis); die Mehrtagessicht wirkt zusätzlich über das prognosegeführte Betriebslevel auf die Modus-Priorität.
- Wallboxen nehmen zusätzlich am gemeinsamen Grid-Control-Lastabwurf
teil. Die Abwurfreihenfolge richtet sich nach der Priorität des aktiven Lademodus und der konfigurierten Phase; Eskalation und Freigabe laufen identisch zu Messen + Schalten beziehungsweise Pool über grid-control/load-shed.js.
- Sonderfälle in
decideWallboxAction(planner.js, testbar): Ladestart-Neustart
(hängt die Ist-Leistung trotz Befehl nach stall_timeout_seconds unter stall_power_w, 1 Minute aus/ein, gedeckelte Versuche — nur bei plugged === true, damit ohne eingestecktes Auto kein Aus/Ein-Takten entsteht); extern EIN am Steuerung-Sync-Topic → einmalige Volladung bis Leistungsabfall unter Leerlaufschwelle; extern AUS am Steuerung-Sync-Topic → aus bis Folgetag mit PV größer als Eigenverbrauch plus Wallboxleistung und ausreichender Hausakku-Reserve; das „angesteckt"-Signal dient ausschließlich der Ladeüberwachung (Stall-/Neustart-Schleife: angesteckt + Ladung aktiv + SoC unter Voll ⇒ Leistung muss fließen) und darf weder Ladefreigabe noch Volladung noch Prognose-Ladebedarf sperren — manche Fahrzeuge melden „angesteckt" erst, nachdem die Ladung freigegeben wurde (Henne-Ei), zudem ist das Mobilfunk-Signal möglich falsch-negativ. Ist laut Plan oder Anforderung eine Ladung erforderlich, wird immer eingeschaltet.
- An/Aus-Kanäle strikt getrennt (Regeln 1–3, keine Rückkopplung): Das
Steuer-Topic ist reiner Aktor (homeESS schreibt an/aus, kein Readback). Das Steuerung-Sync-Topic ist der bidirektionale An/Aus-Schalter: (1) Schaltet die Automatik, spiegelt homeESS den Zustand darauf und bleibt auf Automatik; jeder eigene Spiegel-Write wird als erwarteter Readback konsumiert und nie als Nutzerschaltung gewertet. (2)/(3) Nur eine extern ausgelöste Wertänderung dort (nicht von homeESS gespiegelt) wechselt auf Vollladen bzw. Aus. Der gewählte Stand liegt neustart-resistent in control_mode. Nach jedem MQTT-(Wieder-)Verbindungs- aufbau (Connect-Epoch aus dem MQTT-Client) öffnet die Steuerschleife je Box ein kurzes Re-Baseline-Fenster (45 s): der erneut eingespielte retained-Sync-Wert wird nur als Ausgangszustand übernommen, nie als Nutzerschaltung. Damit ändert ein Neustart, Adapter-Reconnect oder Topic-Refresh den Schaltmodus nicht — nur ein direkt beobachteter, nicht selbst ausgelöster Wechsel zählt. Das Status-Topic ist reiner Ist-Zustand.
- Nächster Ladebeginn: wird gerade nicht geladen, übernimmt die Automatik den
Ladebeginn aus dem gemeinsamen Mehr-Wallbox-Vorausplan. predictNextChargeStart bleibt Fallback und behandelt insbesondere die manuelle Sperre. Die Steuerschleife legt den absoluten Zeitpunkt je Box ab (getNextCharge); der Wertekatalog rechnet daraus zur Lesezeit die Restzeit in Sekunden (wallbox.<id>.naechsterLadebeginnSekunden) sowie die Uhrzeit (wallbox.<id>.naechsterLadebeginn); die Wallbox-Seite zeigt es an.
- Heimkino (
/heimkino,src/heimkino/): beliebig viele frei benannte Räume.
Jeder Raum hat einen beschreibbaren Kinomodus als virtuellen State (heimkino://raeume/<id>, Tabelle heimkino_rooms), der über registerStatesProvider unter „System / Heimkino" in States-Seite, State-Picker und Wertekatalog erscheint (Vorbild: Schaltgruppen) und zusätzlich als Dashboard-Schaltziel heimkino:<id> („Kinomodus Raum …") bereitsteht. Anders als bei der Wallbox öffnet ein Raum keinen Dialog, sondern eine eigene Seite (/heimkino/raum/<id>) in Liste und Design der Bedingungen.
- Optionales Sync-Topic (
remote_topic) je Raum: bidirektional
gekoppelt. Externe Wertänderung ⇒ Raum schalten (mit Aktionsfolge), lokaler Wechsel ⇒ Topic sofort nachziehen (Payload-Format aus der zuletzt empfangenen Darstellung, wie bei den Schaltgruppen). Der erste retained Wert nach Start und nach jedem MQTT-Connect ist nur Ausgangsbasis: er wird übernommen, ohne die Aktionsfolge zu durchlaufen (analog Regel 3 der Wallbox-Steuerung). Das eigene Echo eines Rückschreibens gilt nie als Schaltwunsch.
- Je Raum zwei Aktionsfolgen (
heimkino_actions.phase=on/off), die
bei jeder Zustandsänderung der Reihe nach abgearbeitet werden. Eine neue Änderung bricht eine noch laufende Folge desselben Raums ab.
- Aktionsarten: Wert zuweisen (identische Regeln und Validierung wie das
„Dann" der Bedingungen — fester Wert oder Topic-Verweis, Rechenfunktion, Rundung), Pause (Sekunden) und Schleife (parent_id, beliebig verschachtelbar, repeats Durchläufe). Alle Aktionen sind per Dragfläche frei verschiebbar (Layout wird als vollständige Momentaufnahme gespeichert).
- Prüfung einer Schleife (optional): im eingestellten Abstand wird eine
Bedingung (ein „Wenn" der Bedingungen) erneut bewertet; trifft sie nicht zu, wird ausschließlich diese Schleife erneut abgespult, nicht die übrige Folge. Der Abstand zählt ab der letzten Ausführung der Schleife bzw. ab dem Start von homeESS; ein unbekannter Wert gilt als nicht erfüllt. Geprüft wird nur die Folge, die zum aktuellen Kinomodus gehört — sonst würden „an" und „aus" einander dauerhaft überschreiben.
- Heizung & Klima (
/heizung,src/heizung/): beliebig viele Räume
(heizung_rooms) mit eigener Soll-Temperatur, Offsets, Hysterese und den optionalen Geräten. Ein Raum öffnet — wie beim Heimkino — keinen Dialog, sondern eine eigene Seite (/heizung/raum/<id>); die Zentralheizung hat ihre eigene Seite (/heizung/zentrale). Die Regelung läuft in heizung/runtime.js im 5-Sekunden-Takt.
- States = Systemwerte. Das Modul ist kein Adapter und bekommt
deshalb kein eigenes Schema: seine Werte sind homeESS-Systemwerte unter system://homeess/…. Je Raum raeume.<Raumname>.{temperatur, soll, boost, heizen, kuehlen, zentral, fenster} (beschreibbar sind soll und boost), für die Zentralheizung zentralheizung.{brenner, anforderungen, aussentemperatur, vorlauf, ruecklauf, laufzeit_heute, verbrauch_heute, kosten_heute, schornsteinfeger}. Auf der States-Seite ergibt die Kategorie Räume/<Raumname> bzw. Zentralheizung die Ordner (mehrstufige Kategorien werden in repository.systemCategories über categoryParts zum Baum). Adressiert wird über den Namen (rooms.addressFor: Leerraum, Punkt und Schrägstrich werden zu _, damit die id-Ebenen heil bleiben); ein Umbenennen ändert daher die States, ensureFreeAddress verhindert Kollisionen und reload() räumt die Topics umbenannter/entfernter Räume aus dem State-Bus.
- Boost je Raum:
heizung_rooms.boost_activehält den Zustand,
heizung_rooms.boost_topic ein optionales Fremd-Topic. syncBoost() in heizung/runtime.js hält beide Seiten synchron — nach denselben Regeln wie die Thermostatkopplung, inklusive Echo-Fenster gegen die eigene Schreibung. Ist Boost aktiv, entfallen Sollwertvergleich und Kühlentscheidung; der Raum fordert die für den aktuellen Außentemperaturbereich zuständige Heizquelle mit maximaler Leistung an.
- Temperaturdiagramm:
src/views/heizung-chart.jsrendert die Balken
über der Räume-Kachel. Der Soll-Strich schreibt über dieselbe Route wie das Formular der Raumzeile; die Anordnung liegt in heizung_rooms.position und wird beim Ziehen über die Ordnung des CSS-Rasters verschoben, nicht über den DOM-Baum — ein Umhängen während des Ziehens nähme der Griffleiste ihre Zeigererfassung.
- Anbindung an die Systemwerte:
states/system-values.registerValueProvider
nimmt den Provider des Moduls entgegen (Vorbild: registerStatesProvider der Adapter) — dadurch bleibt system-values.js frei von einem Import des Moduls. Eine vom Provider gesetzte category bleibt erhalten, sonst greift weiter categoryForId. Der Takt veröffentlicht zusätzlich direkt über systemRouter.publish, damit Schaltentscheidungen ohne Umweg im Bus stehen.
- Beschreibbare Systemwerte: berechnete Systemwerte sind reine
Lesequellen; ein Modul kann einzelne ausdrücklich freigeben. Dafür nimmt states/system-router.registerWriter(idPräfix, handler) ein Schreibziel entgegen, mqttClient.publish reicht system://-Topics dorthin weiter (ohne angemeldetes Ziel weiterhin false), und repository übernimmt writable/topicSelectable aus dem Eintrag. Freigegeben sind allein raeume.<Raum>.soll und zentralheizung.schornsteinfeger; der Schreibzugriff löst den Raum über den Namen auf (ohne Rücksicht auf Groß-/Kleinschreibung).
- Abos:
reload()gleicht die Ad-hoc-Abos differenziell ab
(Cache-Schlüssel → Topic). Unverändertes bleibt bestehen, denn unsubscribeAdHoc löscht den zuletzt empfangenen Wert — sonst stünde nach jedem Speichern kurzzeitig keine Temperatur bereit und die Geräte würden flackern.
- Temperaturquellen (
heizung_room_sensors): beliebig viele je Raum,
Ist-Temperatur ist ihr Durchschnitt; Werte außerhalb −60…120 °C gelten als Störung und fallen heraus. Ohne gültigen Wert wird nicht geschaltet.
- Thermostat (
thermostat_topic, optional): bidirektionale Kopplung der
Soll-Temperatur nach dem Muster des Heimkino-Sync-Topics — erster (retained) Wert nach Start bzw. nach jedem MQTT-Connect ist nur Ausgangsbasis, spätere externe Verstellung gewinnt, lokale Änderung wird zurückgeschrieben. Ein abweichender Wert innerhalb von THERMOSTAT_ECHO_MS (15 s) nach einem eigenen Schreiben gilt als verspätetes Echo des vorherigen Standes, nicht als Verstellung von Hand; andernfalls würde der Nachhall des Schornsteinfeger-Modus (28 °C) als neue Soll-Temperatur übernommen und die Wärmeanforderung des Raums bliebe nach dem Beenden stehen.
- Kontakte (
heizung_room_contacts, optional, je Kontaktinverted):
offener Kontakt sperrt Heizen und Kühlen nach contact_delay_seconds (0 = sofort); die Verzögerung zählt ab dem beobachteten Öffnen, das Schließen wirkt immer sofort.
- Geräte = Aktionsfolgen (
heizung_actions,heizung/actions.js): je
Raum vier Folgen heat_on/heat_off/cool_on/cool_off. Ein einzelnes An-/Aus-Topic reicht für echte Geräte nicht (Betriebsart, Solltemperatur, Wiederholung bei IR). Datenschicht und Validierung kommen aus automation/action-sequences.js (createActionRepository), die Ausführung aus automation/action-runner.js (createActionRunner), die Oberfläche aus views/action-sequences.js — alle drei mit dem Heimkino geteilt. Ein Wechsel des Soll-Zustands startet die passende Folge einmal (Schlüssel <raum>:heat bzw. <raum>:cool, eine neue Schaltung bricht die laufende ab); die zyklische Prüfung einer Schleife läuft nur in der Folge, die zum aktuellen Zustand des Gerätes gehört. hasDevice(tree, kind) entscheidet, ob der Raum ein Gerät hat: maßgeblich ist die „ein"-Folge. Bestandsdaten mit heat_topic/cool_topic wandern per migrateHeizungDeviceActions einmalig in Folgen mit je einer Wertzuweisung.
- Heizkörperlüfter (
fan_topic, optional): folgtcentralDemanddes
Raums — an, solange er Wärme von der Zentralheizung anfordert. Geschaltet wird direkt per Topic (runtime.commandFan: bei Änderung sowie bei dauerhaft abweichendem Readback), nicht über eine Aktionsfolge, und ohne Betriebslevel-Gate — er wird gerade dann gebraucht, wenn das lokale Heizgerät gesperrt ist.
- Betriebslevel (LEVEL_HANDLING.md): je Gerät eine Priorität
(heat_priority, cool_priority; Verbraucher-IDs heizung.<raum>.heat bzw. .cool). Registriert wird nur, was es gibt (vorhandene „ein"-Folge); onMustTurnOff spult sofort die „aus"-Folge ab. Der Bedarf (heatDemand/ coolDemand) ist vom Gate getrennt — sonst würde die Hysterese beim Sperren umklappen. Mit heat_central_fallback springt die Zentralheizung ein, solange das Level das Heizgerät sperrt; dann gilt für den Raum keine Außentemperaturgrenze (Bedingung: central_allowed).
- Schwellen: Wärmebedarf des Raums bei
Ist < Soll − heat_offset,
Kühlen bei Ist > max(Soll + cool_offset, cool_min_temp) (runtime.coolThreshold; cool_min_temp ist eine absolute Untergrenze gegen Kühlen bei Nachtabsenkung, leer = keine), Ausschaltpunkt jeweils um hysteresis versetzt. Wer den Bedarf deckt, entscheidet allein die Außentemperatur: central_temp ist eine Außentemperatur-Grenze (kein Raumwert). Liegt die Außentemperatur darunter (mit derselben Hysterese) und hat der Raum central_allowed, übernimmt die Zentralheizung anstelle des lokalen Heizgerätes; darüber heizt das lokale Gerät. Ohne bekannte Außentemperatur übernimmt die Zentralheizung nicht (das lokale Gerät bleibt zuständig). Eingestellte Werte werden nie zurechtgebogen: zwischen Grenz-Außentemperatur und Soll-Temperatur heizt allein ein lokales Gerät — ist keines hinterlegt, bleibt dieser Bereich bewusst ungeheizt.
- Zentralheizung (
heizung_central, Einzelzeile):outdoor_topicist
eine optionale eigene Außentemperatur-Quelle; ohne sie zählt die systemweite (mqtt_config.outdoor_temperature_topic über buildEnvironmentSnapshot). Bei enabled muss eine der beiden vorliegen, sonst könnte kein Raum die Zentralheizung anfordern (central.readOutdoorTemperature). mode = modbus (Anlage regelt selbst) oder relais (Schaltaktor).
- Drei Zustände, klar getrennt (
runtime.js): boiler— Schaltzustand der Anlage (Statezentralheizung.kessel). Ein
vom Schaltaktor zurückgemeldeter Zustand gilt als Wahrheit (Echo-Fenster READBACK_GRACE_MS).
burner— feuert der Brenner? (Statezentralheizung.brenner,
runtime.detectFiring).
pump— Umwälzpumpe mit Vor-/Nachlauf (Statezentralheizung.pumpe).- Brennererkennung (
detectFiring): erste Quelle ist
burner_feedback_topic. Fehlt sie, wird der Vorlauf ausgewertet — FIRING_SAMPLES (3) Messwerte hintereinander über flow_drop_delta hinaus nach oben ⇒ „an"; Werte innerhalb dieses Rauschbandes halten den Stand (die Halte-Phase zählt als Brennerlauf); FIRING_SAMPLES Messwerte in Folge nach unten ⇒ „aus". Ohne Rückmeldung und ohne Vorlauf bleibt nur der Kesselzustand (source = 'switch').
- Abschalten des Kessels (
mayStopBoiler): nur wenn keine Anforderung
mehr besteht und der Brenner als aus erkannt ist. Ohne Erkennungsmöglichkeit greift flow_window_seconds als Zeitfenster, max_hold_minutes (0 = keine) als Notabschaltung. Der Rücklauf wird überwacht und angezeigt, entscheidet aber nichts — er hinkt einen Kreislauf hinterher (im Betrieb beobachtet: als Bedingung hielt er den Kessel endlos fest).
- Umwälzpumpe (
pump_topic, optional, nur beirelais): sie läuft,
solange Wärme gebraucht wird oder der Kessel an ist. Der Kessel startet erst, wenn die Pumpe läuft — pump_lead_seconds Vorlauf und, sofern der State zurückmeldet, bestätigter Lauf (runtime.pumpReady). Nach dem Abschalten des Kessels beginnt der Nachlauf pump_lag_seconds.
- Laufzeiten/Kosten (
heizung_burner_runs): protokolliert wird die Zeit,
in der der Brenner feuert, nicht die Einschaltzeit des Kessels (trackFiring folgt der Brennererkennung). Die verwendete Quelle steht in centralSnapshot().firingSource (central.FIRING_SOURCES) und über der Auswertung. ended_at/duration_ms werden im Takt fortgeschrieben (Stromausfall kostet höchstens einen Takt), offene Läufe schließt der Start. Verbrauch = Laufzeit × consumption_per_hour, Kosten = Verbrauch × price_per_unit; ausgewertet für heute, 30 Tage, laufendes Jahr und gesamt.
- Zählwerk (
heizung_billing, Einzelzeile,src/heizung/billing.js):
laufender Abrechnungszeitraum ab started_at plus start_consumption (was vor dem Mitzählen anfiel). billingStatistics summiert die Brennerlaufzeit seit started_at, rechnet sie mit consumption_per_hour/price_per_unit um und weist den Monatsabschlag als Kosten ÷ 12 aus. closePeriod schiebt den Zeitraum in die previous_*-Spalten (mit optional abgelesenem previous_metered, das dann die Kosten bestimmt) und startet bei 0. Mit calibrate wird consumption_per_hour um den Faktor (abgelesen − Startwert) / gemessen nachgezogen; außerhalb von MIN/MAX_CALIBRATION_FACTOR (0,2–5) wird abgelehnt, weil dann Fremdverbraucher am Zähler hängen oder der Startwert nicht stimmt.
- Schornsteinfeger-Modus (
sweep_enabled, persistiert): ignoriert die
Außentemperatur und die Betriebslevel-Prioritäten, schreibt allen gekoppelten Thermostaten 28 °C, hält die dezentralen Geräte aus und lässt die Zentralheizung durchlaufen. Die gespeicherten Soll-Temperaturen bleiben unverändert und werden beim Beenden wieder auf die Thermostate geschrieben.
- Diagramm-Kachel (
dashboard/chart-config.js,dashboard/chart-svg.js):
Widget-Typ chart, zeichnet bis zu vier Messreihen der Systemdatenbank. Die Konfiguration (Linien mit Messreihe, Legendenname und Farbe, dazu Zeitraum, Verdichtung, Überschrift, Einheit) liegt im config-JSON des Widgets (series: [{ measurement, label, color }]; der Dialog schickt drei parallele Feldlisten, ältere Konfigurationen mit reiner measurements-Namensliste werden weiterhin gelesen); je Zeitraum ist eine Rasterweite hinterlegt, die ~150–300 Punkte je Linie ergibt. Das SVG wird serverseitig gezeichnet und über /dashboard/widgets/:id/chart als fertiges Markup geliefert (Legende getrennt); die Kachel rendert zunächst nur einen Platzhalter und lädt danach im Minutentakt nach — eine langsame oder fehlende Datenbank hält das Dashboard damit nie auf, sondern zeigt einen Hinweis in der Kachel. Farben: feste, geprüfte Reihenfolge (dashboard/chart-palette.js: Blau/Gold/Violett/Grün; benachbarte Paare ΔE ≥ 8 bei Deuteranopie, alle ≥ 3:1 Kontrast) als Vorbelegung neuer Linien; gespeichert wird die Farbe je Linie, damit das Entfernen einer Linie die übrigen nicht umfärbt. Die Legende trägt Name und aktuellen Wert, damit kein Wert nur über das Fadenkreuz erreichbar ist. Achsenzahlen werden ab 10.000 einheitlich auf k/M gekürzt, Messlücken (> 2,5 × Rasterweite) brechen die Linie.
- Wetter-Kachel (
dashboard/weather-widget.js): Widget-Typweather, zeigt
wahlweise die aktuelle Lage oder einen Prognosetag (day: 'current' bzw. '0'…'6') mit frei angehakten Messgrößen (fields, Katalog WEATHER_FIELDS); beides liegt im config-JSON des Widgets. Das Modul beschafft keine Daten: es bekommt die normalisierte Prognose aus wetter/forecast.js (nur Cache, nie ein Netzabruf beim Seitenaufbau) und formt daraus fertig formatierte Zeilen — Seite „Wetterprognose" und Dashboard teilen sich damit Quelle, Cache und Fachlogik. Der erwartete PV-Ertrag ist eine der wählbaren Größen; nur wenn sie angehakt ist, lädt die Route zusätzlich die PV-Prognose. Größen, die es in der jeweiligen Anzeigeart nicht gibt (Sonnenaufgang kennt nur ein Tag, Sichtweite nur der Istzustand), blendet der Dialog aus, statt sie leer anzuzeigen. Fehlt die Prognose, behält die Kachel ihre Form und trägt den Grund als Hinweis — so kann das periodische Nachladen (GET /dashboard/data, Block weather) sie später ohne Seitenneuaufbau füllen. Anordnung: Die Kachel belegt die volle Breite ihrer Gruppe und ist im Stylesheet ein container-type: inline-size-Container; alle Umbrüche darin hängen an der Kachelbreite (@container weather …), nicht an der Fensterbreite. Dieselbe Kachel steht deshalb in einer Viertel-Gruppe einspaltig und in einer vollen Gruppe mehrspaltig — ohne zweite Bauform im Markup.
- Systemweite Datenbank (
src/database/, Tabellesystem_database):
zentrale Zeitreihen-Datenbank (InfluxDB 1.x) für Diagramme und Auswertungen. homeESS selbst bleibt bei SQLite; hier geht es ausschließlich um Zeitreihen. Konfiguriert in Einstellungen → Allgemein → Datenbank (unterhalb der MQTT-Karten, eigenes Formular /settings/database, Verbindungstest über /settings/database/test). config.js hält die Konfiguration (Cache, Normalisierung; ohne Server bleibt die Anbindung aus), influx-reader.js ist ein rein lesender Client (/ping, /query) — bewusst eigenständig und nicht aus adapter/influxdb/ importiert, damit die Anbindung auch ohne installierten Adapter funktioniert (externe Datenbank). index.js ist die Dienstschicht: testConnection, listMeasurements, readSeries, readSeriesSet (Serien nacheinander, um eine einzelne Datenbank nicht mit parallelen Abfragen zu überfahren). Bezeichner und Literale werden für InfluxQL maskiert, Aggregate gegen eine feste Liste geprüft. JSON-Schnittstelle: /database/status (ohne Zugangsdaten), /database/measurements, /database/series (measurement kommagetrennt, from/to in ms, interval, aggregate). Der Browser spricht nie direkt mit der Datenbank. Datenbank-Adapter können ihre Verbindungsdaten über das Manifest-Feld systemDatabase anbieten (siehe ADAPTER.md); der Knopf auf der Instanzseite kopiert sie einmalig hierher (/adapter/instance/:id/system-database, Herkunft in source_label/ source_instance_id). Speichern von Hand löscht die Herkunft wieder.
- Systemweite Warnung (
system-warning.js, Tabellesystem_warning):
ein Warntext (operating.warnungText) und ein Aktiv-Flag (operating.warnungAktiv) unter System → Betrieb. Jede Automatik, die einen Warntext an ihr MQTT-Warntopic schreibt, meldet ihn zusätzlich hier an; das Flag geht dabei automatisch auf true. Solange es steht, rendert views/layout.js auf jeder Seite ein rotes Warnband (Anfangszustand serverseitig, Aktualisierung über /live/header). Der Nutzer quittiert über POST /live/warnung/quittieren (Bedienrecht genügt): Flag auf false, Warntext leer — und angemeldete Zuhörer (onAcknowledged) räumen ihre eigenen Warntopics auf; die Netzsteuerung leert dabei zusätzlich ihre Persistenzzählung, ein weiterhin bestehender Fehler muss also erst wieder die volle Persistenzzeit durchlaufen. Der Zustand überdauert Neustarts. Grundsatz: Hier landen nur Fehler, die ein Eingreifen des Nutzers erfordern.
- Wert-Katalog (
output/internal-values.js): berechnete und gemessene Werte
für Outputs und Dashboard-Widgets. Enthält PV, Stromverbrauch, Sonnenintensität, PV-Prognose (erwarteter Tagesertrag heute/morgen/+2/+3 sowie heute bisher / heute noch erwartet), Systemprognose (prognose.*) sowie Batterie-Werte (SoC, Leistung, Spannung, Temperatur) und Pool-Werte (Wassertemperatur, Pumpen-Status, pH, Chlor — nur wenn Modul aktiv) sowie Betrieb (operating.*, u. a. Autark, operating.notstrom = Notstrombetrieb sowie die systemweite Warnung operating.warnungText/operating.warnungAktiv, siehe unten). Die Kalibrierfaktoren sind bewusst nicht im Katalog (reine Diagnose). Zusätzlich statistische Jahreswerte je Kennzahl (PV, Netzbezug, Eigenverbrauch, E-Auto gesamt): gestern, Minimum/Maximum inkl. Datum, Jahres-/Vorjahressumme aus history/daily-metrics.js (Tabelle daily_metric_history, je Metrik ein abgeschlossener Tageswert pro Tag); der Durchschnitt wird als Jahressumme ÷ angebrochene Tage gerechnet. Fehlt ein Wert, zeigt der Katalog 0 (Datum: 1. Januar). Außerdem erscheinen automatisch alle Adapter-States (buildStatesTree, mehrstufige Kategorie „Adapter: <Instanz> / <Gerät> / <Kanal>", id = Scheme-Topic). Alle Einträge haben id, label, value, display und category (Herkunft, abgeleitet aus dem id-Präfix; siehe categoryForId/VALUE_CATEGORIES). Die Darstellung übernimmt die zentrale, wiederverwendbare Routine views/value-catalog.js (durchsuchbare, einklappbare Liste mit Ist-Werten) — eingebettet in Output- und Dashboard-Dialoge. Kategorien der Form „A / B / C" werden als eingerückter Verzeichnisbaum gerendert (wie der Adapter-State-Picker, buildValueCatalogTree). Der Auf-/Zuklapp-Zustand jeder Ebene wird clientseitig in localStorage (homeess.valuecatalog.expanded.v1) gemerkt und beim Öffnen des Dialogs wiederhergestellt; die Suche klappt Treffer (auch über den Kategorie-Pfad) auf und stellt beim Leeren den gemerkten Zustand wieder her.
- Einstellungen (Karten-Layout): Passwort ändern, Standort & Zeit
(Breiten-/Längengrad, Zeitzone, automatische Zeitumstellung — Eingangsgrößen fürs Clear-Sky-Modell), MQTT-Broker konfigurieren + Verbindung testen.
- MQTT-Verbindungs-Manager (Connect/Reconnect/Cache + publish); abonnierte
Topics ergeben sich aus den konfigurierten States (mqtt/state-definitions.js) plus Ad-hoc-Abonnements für Pool-Topics. Homematic-Duty-Cycle-Schutz: Funk-Topics (hm-rpc.*) werden nie aktiv per /get gepollt (auch nicht bei Connect/Reconnect, Konfig-Speichern oder Ad-hoc-Registrierung) und beim Schreiben nicht auf mehrere Kandidaten aufgefächert — genau ein Publish pro Schaltbefehl (mqttWriteCandidates/isRadioTopic in mqtt/topics.js). setStateDefinitions fragt nur noch neue/umkonfigurierte Topics aktiv an.
- Live-Updates per SSE (
/live/events); Header-Werte + Himmelssymbol +
Batterie-SoC über /live/header.
- systemd-Service
home-ess— startet automatisch beim Systemstart.
Mobile Ansicht
Parallel zur Desktop-Ansicht entsteht eine vollwertige Smartphone-Ansicht (ein Breakpoint ≤ 768px, Mobile-Layer am Ende von public/styles.css, Shell-Bausteine Tab-Bar + Menü-Sheet in views/layout.js). Konzept, Framework-Regeln und Arbeitsstand je Seite: MOBILE.md — bei jeder mobil umgesetzten Seite dort die Checkliste pflegen. Der Zoomfaktor ist per Viewport-Meta auf 100 % festgenagelt (initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no) — Layouts bleiben 1:1; horizontaler Überlauf darf entsprechend nie entstehen.
Leitprinzipien (vom Auftraggeber vorgegeben)
- Keine statischen Seiten. Jede Seite wird serverseitig dynamisch
gerendert (Template-Funktionen in src/views/). public/ enthält nur statische Assets (CSS).
- Eine Datei pro Funktion. Jede neue Funktion/Feature kommt in eine eigene
kleine .js-Datei, um Dateien überschaubar zu halten und den Ausbau zu vereinfachen.
- Modulgrenzen: Rendering (
views/), HTTP-Routen (routes/,auth/),
Fachlogik (mqtt/, auth/, Module-Unterverzeichnisse), Infrastruktur (db.js, config.js, app.js).
Verzeichnisstruktur
server.js Einstiegspunkt: App bauen + listen
src/
config.js Zentrale Konstanten (Port, Cookie, DB-Pfad, Timeouts)
db.js SQLite öffnen, Schema, Seed, Migrationen
app.js Express-App zusammenbauen + periodische Jobs
operating-state.js Globaler Zustand (operatingLevel 1–5, emergencyMode,
Tages-Latch autark), persistent in `operating_state`;
`onOperatingLevelChanged`-Abo bei Levelwechsel
operating-level/
handler.js Betriebslevel-Handler / Lastmanagement: register/
unregister, requestTurnOn/isAllowed, onMustTurnOff bei
Levelabfall (siehe LEVEL_HANDLING.md)
modules/
index.js Modul-Registry + In-Memory-Enabled-State
auth/
password.js scrypt-Hashing
session.js DB-gestützte Cookie-Sessions + requireAuth
routes.js /, POST /login, /logout
mqtt/
topics.js MQTT-Topic-Helfer (reine Funktionen, aus MQTT.md)
config.js MQTT-Config + Umgebungs-Snapshot (Temp/Zeit/Datum)
client.js Verbindungs-Manager + publish + testConnection
+ Ad-hoc-Subscription-API (subscribeAdHoc)
state-definitions.js Sammelt alle abonnierten Topics (mqtt/strom/pv/batterie)
automation/
action-sequences.js Datenschicht der Aktionsfolgen (Module); Aktionsarten
und Grenzen kommen aus conditions/repository.js
action-runner.js Ausfuehrung: Werte schreiben, Pausen, Schleifen,
zyklische Pruefung (Bedingungen + Module)
conditions/
repository.js Bedingungen, Verzeichnisse, Elemente; Aktionsarten
write/pause/loop inkl. Baum und Layout
engine.js Auswertung: Trigger, Wenn-Pruefung, Dann-/Sonst-Folge
und zyklische Schleifenpruefung
values.js Wert-/Topic-Erkennung, Rechenfunktionen, Rundung
notifications/
service.js Zentraler NotificationService: Validierung + Versand
ueber den bestehenden Relay-WebSocket
engine.js Rule Engine: State-Abos, Flankenerkennung, Cooldown
rules.js Regeln (notification_rules): CRUD + Validierung
triggers.js Trigger-Typen, Werttypen, Flankenlogik
log.js Strukturierte Logs (nur Metadaten, nie Nachrichtentext)
energie/
overview.js Eckdaten der Energieseiten (read-only) fuer /energie
stromverbrauch/
config.js Topics laden/speichern + buildStateDefinitions
aggregation.js Aggregation (schreibend) + readStromverbrauchValues
photovoltaik/
plants.js CRUD + MQTT-State-Definitionen + Zelltyp-Vorgabewerte
converters.js Konverter-/Reglertypen + temperaturabh. Wirkungsgrad
aggregation.js Clear-Sky/Ideal, direkte Sonne, Himmelszustand,
readPhotovoltaikValues (read-only); gemeinsame Helfer
solarGeometryAt/transposePlaneIrradiance/
idealPowerFromIrradiance (von Live + Prognose genutzt)
self-count.js Abgehärtete Datenbasis: Selbstzählung (Eigenverbrauch-
Leistung) + Guard (Ersatzwert bei Divergenz zur Bilanz)
forecast.js PV-Prognose: Open-Meteo-Strahlung → Tageserträge (kWh)
calibration.js Selbstkalibrierung: 15-min-Kalibrierfaktor je Anlage/Bucket
(gemessen vs. Open-Meteo-Strahlung, EMA, Gates SoC/Sonne)
sun-intensity.js Momentane Intensität + Sampling + Mittelwerte
wetter/
client.js Open-Meteo-Abruf (GHI/DNI/DHI/Temp, stündlich +
minutely_15) + In-Memory-Cache
batterie/
config.js Topics laden/speichern, buildBatterieStateDefinitions,
readBatterieData
min-soc-sync.js Bidirektionale Mindest-SoC-Synchronisierung über das
separate Remote-Topic (Einstellung <-> Remote, kein Aktor)
pool/
config.js Topics laden/speichern, rowToConfig, subscribePoolTopics,
readPoolValue
automation.js Pump-Automation (solar/filter), Modus-Buttons,
getEffectivePriority, getPumpMode/setPumpMode;
Registrierung + Level-Gate beim Betriebslevel-Handler
grid-control/
config.js Topics/Schwellen laden/speichern, State-Definitionen,
readGridControlBrokerValues
automation.js Schaltlogik + geschlossene Regelschleife (Verifikation
gegen Broker-Readback), Notstrom, Audit-Logging
log.js Audit-Log (`grid_control_log`): append/read, Pagination
wallbox/
boxes.js CRUD + Validierung + buildWallboxStateDefinitions (Vorbild plants.js)
aggregation.js Zähler/Summen Tag/Woche/Monat/Jahr+Vorjahr, Power-Integration,
SoC-Schätzung, readWallboxValues (read-only), buildWallboxSnapshot
planner.js Lademodus-Logik (Privat/Beruflich/Immer voll) — reine Funktion
automation.js Steuerschleife: Tick, Level-Handler-Registrierung, Modus-Sync,
Überschussberechnung, Schalten via mqttClient.publish
messen-schalten/
groups.js Gruppen-CRUD (Titel/Priorität/Funktion/Verrechnung/
Verschachtelung/Zählergruppe/Farbe): setGroupParent,
setGroupColor
actors.js Geräte-CRUD + Validierung (min. 1 Topic), effectivePriority,
buildMessSchaltStateDefinitions, cacheKey, setDesiredOn
aggregation.js Live-Werte je Gerät, Leistung aus Zählerfortschritt
(buildActorSnapshot, 60-s-Job), Gruppen-Verbrauchssummen
(readGroupPowerTree, readGroupEnergyTree Tag/Jahr/Vorjahr)
energiefluss.js Reine Aufbereitung des Energiefluss-Diagramms
(assembleEnergiefluss: Quellen, Gruppenbaum, Sonstige)
energiefluss-exports.js CRUD der Energiefluss-Exporte (Name→Slug, Theme)
automation.js Steuerschleife: Level-Handler-Gate je Gerät mit Schalt-Topic
schaltgruppen.js Schaltgruppen-CRUD (Name/Remote-Topic/„als Einheit"),
optionaler AUS-Timer, Geräte-Zuordnung (switch_group_id),
State-Definitionen,
States-Block (virtuelle Instanz schaltgruppe://gruppen)
schaltgruppen-automation.js Gruppenzustand (an, sobald ein Gerät an),
„als Einheit"-Mitschalten beider Schaltflanken,
ereignisbasierte Remote-/State-Synchronisierung
output/
internal-values.js Katalog inkl. gruppengesteuerter Berechnung „Sonstige Verbraucher“
outputs.js Output-CRUD
engine.js Publish-Engine (diff, debounced)
dashboard/
groups.js Gruppen-CRUD
widgets.js Widget-CRUD (Typen value/info, config-JSON)
system-info.js Info-Kachel: Feld-Katalog + Live-System-Werte
weather-widget.js Wetter-Kachel: Feld-Katalog, Tageswahl, Wertermittlung
aus der bestehenden Wetterprognose
remote-access/
relay-config.js Auflösung/Validierung der Relay-Basis-URL (SSRF),
Origin-WebSocket-URL + Instanzname
relay-client.js essrelay-Client: create/read/cancel Pairing-Session,
confirm (Instanz-Proof-Body), provision, capabilities,
HTTPS-Zwang, Timeouts, keine Redirects, Größenlimit,
strenge Antwortvalidierung, Fehler-Mapping
identity-crypto.js Reine Ed25519-Crypto: kanonische Proof-/Auth-Nutzlasten,
Signatur/Verifikation, Fingerprint (Hex/Anzeige/Präfix)
identity-store.js Dauerhafte Instanzidentität: Schlüssel erzeugen/laden/
validieren (atomar, 0600/0700), signieren, IDs persistieren
pairing-state.js In-Memory-Pairing-Zustand je Admin-Session, Orchestrierung
Confirm→Provisioning, Retry/Reconciliation, Promise-Lock,
Cleanup, Shutdown (paired persistiert im Identity Store)
relay-connection.js Origin-WebSocket-Client (State-Machine, Challenge-Response,
Reconnect-Backoff, Heartbeat, Tunnel-Dispatch, Shutdown)
origin-tunnel.js Origin-Ende des Relay-Tunnels: HTTP-artige Requests
lokal validieren/ausführen, Antworten streamen,
Timeouts/Backpressure/Cleanup
connection-service.js Singleton-Wrapper um genau eine Origin-Verbindung
errors.js Stabile interne Fehlercodes (RemoteAccessError)
redact.js Redaction + Logging der Fernzugriff-Ereignisse
routes/
dashboard.js GET /dashboard + Widget/Gruppen-CRUD + /layout + /data
energie.js GET /energie (Uebersicht) + GET /energie/data
stromverbrauch.js GET /stromverbrauch + Topic/Abgleich-POSTs + /data
photovoltaik.js GET /photovoltaik + CRUD + /data + /forecast
batterie.js GET /batterie + POST /batterie/topics + GET /batterie/data
output.js GET /output + Output-CRUD + /data
settings.js GET /settings, POST password/mqtt/mqtt-test
remote-access.js GET /remote-access + lokale API
POST/GET/DELETE /api/remote-access/pairing,
POST /api/remote-access/pairing/confirm|reject|provision,
GET/POST /api/remote-access/connection[/connect|/disconnect]
(Admin-only, no-store, CSRF-Header)
live.js SSE /live/events + /live/header
modules.js GET /module + POST /module/:key/enable|disable
pool.js GET /pool + POST /pool/config + GET /pool/status
+ POST /pool/pump/:which/:mode
grid-control.js GET /grid-control + POST /grid-control/config
+ GET /grid-control/status + GET /grid-control/log
wallbox.js GET /wallbox + Box-CRUD + POST /wallbox/box/:id/mode/:mode
+ GET /wallbox/data
messen-schalten.js GET /messen-schalten + Gruppen-/Geräte-CRUD + /layout
+ POST /messen-schalten/actor/:id/switch/:state + /data
+ Gruppen /:id/parent (Verschachtelung) + /:id/color
+ Unterseite /messen-schalten/energiefluss (+ /data)
+ Unterseite /messen-schalten/schaltgruppen (CRUD,
/assign, /:id/switch/:state, /data)
views/
components.js escapeHtml, statusText
value-catalog.js Zentrale Wertekatalog-Routine (Liste + Client-Script)
layout.js App-Hülle + Nav + Header-Live-Script (inkl. Batterie-Icon)
login.js Login-Seite
dashboard.js Dashboard: Widgets/Gruppen, Drag&Drop, Dialoge
energie.js Energie — Uebersicht der Energieseiten mit Sprungzielen
stromverbrauch.js Stromverbrauch — KPI-Kacheln + Config
photovoltaik.js Photovoltaik — Anlagenliste
batterie.js Batterie — KPI-Kacheln + SoC-Balken + Config
output.js Output — kategorisierte, einklappbare Zeilenliste
settings.js Einstellungen
remote-access.js Fernzugriff — Pairing-QR, Countdown, Status-Polling,
Claim-Bestätigung/-Ablehnung, Abbruch
(clientseitiger Controller inline)
modules.js Modul-Verwaltung
pool.js Pool — KPI-Kacheln + Pumpen-Buttons + Config
grid-control.js Grid-Control — Zustände, Config, Bestätigungs-Badges,
Protokoll-Panel (live Seite 1, paginiert)
wallbox.js Wallbox — Boxenliste (KPI je Box), Modus-Buttons, Config-Dialog
messen-schalten.js Messen + Schalten — verschachtelbare Gruppen/Geräte-
Kacheln, Drag&Drop, Dialoge (inkl. Zählergruppe)
energiefluss.js Energiefluss — Seite mit Diagramm (gemeinsames
EFDiagram), Gruppen-Colorpicker und Export-Verwaltung
energiefluss-export.js Eigenständige öffentliche Export-Ansicht (viewport-
füllend, Theme hell/dunkel, Legende + Wasserzeichen)
schaltgruppen.js Schaltgruppen — zwei unabhängig scrollbare Spalten
(Gruppen | nicht zugeordnete Geräte), Drag&Drop-Zuordnung
public/styles.css Statische Assets (CSS)
public/energiefluss-diagram.js Gemeinsame Zeichen-Logik des Energiefluss-
Diagramms (window.EFDiagram; Seite + Export)
data/app.db SQLite (gitignored)
MQTT.md Referenz: MQTT-Broker-RegelnFernzugriff / Pairing / Relay-Tunnel
Seite /remote-access („Fernzugriff", Footer-Navigation, nur angemeldete Admins). homeESS koppelt sich dauerhaft mit der Android-App und stellt danach den Fernzugriff über einen Relay-Tunnel bereit. Für den Nutzer sind weder eigenes VPN noch Portfreigabe, DynDNS oder Nutzeraccount erforderlich. Für die Nutzung über das Internet ist die homeESS Remote Lizenz in der App aus dem Google Play Store erforderlich (<https://play.google.com/store/apps/details?id=de.mykaefer.homeess>). App und Relay-Server sind ein eigenständiges Add-on und nicht Teil des AGPLv3- lizenzierten homeESS-Servers.
- Dauerhafte Ed25519-Instanzidentität und ein sicherer Identity Store
(HOME_ESS_IDENTITY_DIR, Default <data>/identity): privater Schlüssel als PKCS8-DER (0600), Metadaten/IDs in identity.json (0600), Verzeichnis 0700, atomar geschrieben, bei Neustart stabil, bei Beschädigung kontrollierter Fehler (keine Neuerzeugung). Privater Schlüssel bleibt lokal, nie an Browser/Relay/Log.
- Instanz-Proof of Possession beim Confirm (Ed25519-Signatur über die
kanonische Instanz-Proof-Nutzlast, gebunden an Pairing-ID, Origin-Token-Hash, Instanz- und Gerätefingerprint).
confirmedist nicht terminal. Der Origin-Token bleibt bispaired
erhalten. Nach Confirm folgt automatisch das Provisioning (idempotent, retriable, mit Reconciliation) → Status paired.
- Persistente Provisioning-Daten (
instanceId,deviceId, Fingerprints,
Gerätename/-plattform, pairedAt, Relay-URL, Protokollversion) im Identity Store; Fingerprints werden gegen den lokalen Schlüssel bzw. Claim abgeglichen.
- Authentifizierte Origin-WebSocket-Verbindung (
clientType: homeess):
hello → Challenge (streng validiert) → Signatur über die kanonische Auth-Nutzlast → authenticated. State-Machine mit Reconnect-Backoff (Jitter), Auth-/Idle-Timeout, Heartbeat und sauberem Shutdown.
- Relay-Tunnel: Bei
relayTunnel: trueverarbeitet homeESS
tunnel_request_start/body/end/cancel, führt Requests ausschließlich gegen den lokalen homeESS-HTTP-Server aus und streamt Status, Header und Body-Chunks zurück. Hop-by-Hop-/WebSocket-Header, externe Ziele, falsche Sequenzen und übergroße Chunks/Bodies werden abgelehnt; Timeouts, Backpressure, Disconnects und entfernte Links räumen offene Requests auf.
- Sichtbare lokale Zustände:
pending,awaiting_confirmation,confirming,
confirmed, provisioning, paired, rejected, cancelled, expired plus Verbindungsstatus (nicht verbunden / wird aufgebaut / am Relay authentifiziert / getrennt / fehlgeschlagen). Die UI zeigt vor der Bestätigung den Gerätefingerprint und die gekoppelten Geräte samt Laufzeitstatus.
- Konfiguration:
ESS_RELAY_WS_URL(sonst aus Basis-URL abgeleitet),
HOME_ESS_IDENTITY_DIR, ESS_RELAY_CONNECTION_DISABLED=1.
Details in ARCHITECTURE.md, SECURITY.md, THREAT_MODEL.md. Die App-/Relay-Schnittstelle ist Teil des eigenständigen proprietären Add-ons und wird nicht öffentlich dokumentiert.
Eckpunkte:
- Datenfluss
Browser → homeESS → essrelay. Der Browser kommuniziert nie
direkt mit dem essrelay; der homeESS-Server ist der Relay-Client.
- Neue lokale API-Endpunkte (Admin-only,
Cache-Control: no-store,
CSRF-Header): POST (Session erstellen), GET (Status lesen/abgleichen), DELETE (abbrechen) unter /api/remote-access/pairing plus POST /api/remote-access/pairing/confirm|reject.
- Serverseitiger Relay-Client (
src/remote-access/relay-client.js):
createPairingSession, readPairingSessionStatus, cancelPairingSession, confirmPairingSession, rejectPairingSession.
- Relay-Basis-URL serverseitig über
ESS_RELAY_BASE_URL(Default
https://essrelay.mykaefer.net), beim Start streng validiert (SSRF-Schutz); nie aus einem Browser-Request übernommen.
- Pairing-Zustand nur im Arbeitsspeicher (
src/remote-access/pairing-state.js),
je Admin-Session eine aktive Session, Promise-Lock gegen Races. Keine Persistenz (DB/Datei/Browser/Log) — nach einem Neustart ist die lokale Zuordnung verloren; eine Relay-Session läuft dort nach ihrer TTL selbst ab.
- QR-Code wird vom Relay geliefert (PNG), von homeESS validiert und nur als
Base64-PNG an den authentifizierten Browser gereicht. Der QR enthält den Claim-Token; der Origin-Token bleibt ausschließlich serverseitig — nie an den Browser, nie persistiert, nie geloggt (Redaction in src/remote-access/redact.js).
- Status-Polling nach
pollIntervalSeconds(client 2–30 s), nur bei
pending und awaiting_confirmation, ohne Überlappung; Abbruch idempotent; Countdown auf Basis expiresAt.
- Provisioning, WebSocket und Tunnel wie oben beschrieben: nach
confirmed
automatisch paired, danach authentifizierte Origin-WebSocket-Verbindung mit Relay-Tunnel für gekoppelte, lizenzierte App-Verbindungen.
Datenmodell (SQLite)
users(id, password)— Passwort als scrypt-Hash.- `mqtt_config(id=1, host, port, username, password, latitude, longitude, timezone,
dst_enabled, outdoor_temperature_topic, clock_time_topic, clock_date_topic)`
sessions(id, expires_at)- `stromverbrauch_config(id=1, eigenverbrauch_l1-3_topic, netzbezug_l1-3_topic,
netzbezug_zaehler_l1-3_topic, einspeisung_zaehler_l1-3_topic, eigenverbrauch_zaehler_l1-3_topic) — die drei eigenverbrauch_zaehler_*` sind der optionale echte Eigenverbrauchszähler (siehe Stromverbrauch-Abschnitt oben).
stromverbrauch_aggregation(id=1, week/year_import/export_offset, previous_year_*, ...)stromverbrauch_counter_state(counter_key, last_raw_value, day_total, last_day_key)- `pv_plants(id, name, kw_peak, efficiency, orientation, tilt, is_consumer_side,
cell_type, converter_type, power_topic, today_yield_topic, today_yield_unit, auto_calibrate, sun_cutoff_morning, sun_cutoff_evening) — die beiden Cutoff-Spalten (Prozent, Default 10) steuern den größenrelativen Sonnenreferenz-Cutoff morgens/abends; today_yield_unit` (Wh/kWh, Default kWh) ist die Einheit des Ertrags-Rohzählers.
- `pv_aggregation(plant_id, …, last_counter_raw, counter_total_kwh, day_key,
day_start_kwh) — je Anlage der interne Ertragszähler (Delta-Fortschreibung des Ertrags-Rohzählers + Tagesbasis) / pv_summary_aggregation(id=1, ...)` — Σ über alle Anlagen (Woche/Jahr/Vorjahr, manueller Abgleich).
- `pv_calibration_buckets(plant_id, bucket 0..95, factor, sample_count, updated_at,
window_minutes) — je Anlage und 15-Min-Tageszeit-Bucket ein langsam nachgeführter Kalibrierfaktor (window_minutes` dokumentiert die Fensterbreite und dient als Migrations-Marker; Altbestand wird einmalig verworfen).
sun_intensity_samples(id, recorded_at, day_key, intensity, day_average_eligible)outputs(id, source_id, target_topic)dashboard_groups(id, title, width, position)dashboard_widgets(id, source_id, group_id, position)- `batterie_config(id=1, soc/power/voltage/temperatur/min_soc_topic, remote_topic,
min_soc, capacity_ah, battery_type, cell_count, lower_voltage, upper_voltage, charge/discharge_efficiency) — remote_topic` ist bidirektional mit der Mindest-SoC-Einstellung synchronisiert (siehe Abschnitt Batterie oben).
battery_daily_state(id=1, day_key, charged_today)prognosis_config(id=1, history_days, behavior_model, behavior_active)- `prognosis_daily_consumption(day_key, consumption_kwh, raw_consumption_kwh,
max_temperature, completed, updated_at)`
- `prognosis_hourly_consumption(day_key, hour, consumption_kwh, primary_kwh,
self_kwh, reconciled) — consumption_kwh = in die Prognose eingeflossener Wert; primary_kwh = zähler-/bilanzbasiert, self_kwh = integrierte Selbstzählung, reconciled = Guard für diese Stunde gelaufen (siehe prognosis/self-count.js`).
modules(key TEXT PRIMARY KEY, enabled INTEGER)— aktivierte optionale Module.- `pool_config(id=1, temperature_topic, solar_pump_status_topic,
solar_pump_command_topic, solar_pump_priority, solar_pump_max_temp, solar_pump_temp_on_seconds, solar_pump_temp_pause_minutes, solar_pump_temp_use_filter, filter_pump_status_topic, filter_pump_command_topic, filter_pump_priority, filter_pump_follow_solar, filter_time_1_start/end, filter_time_2_start/end, filter_time_3_start/end, filter_battery_enabled, filter_battery_soc, ph_topic, chlor_topic)`
- `grid_control_config(id=1, grid/feed_in_command_topic, temperature_warning_*,
warning_text/active_topic, soc/voltage/temperature/load_enabled, feed_in_allowed, soc_lower/upper_offset, soc/voltage_hysteresis, grid_frequency_l1-3_topic, grid_detection_seconds, load_on/off_l1-3)`
- `operating_state(id=1, operating_level 1–5, emergency_mode, autark,
autark_day_key, autark_days_count/year/counted_day_key/topic, autark_days_previous_year_count/year/topic)`
- `grid_control_log(id, ts, category 'info'|'action'|'critical', message,
values_text)` — Audit-Log, automatisch auf 2000 Einträge beschnitten.
- `wallboxes(id, name, max_power_w, battery_capacity_kwh, command_topic,
control_sync_topic, status_topic, power_topic, power_unit 'W'|'kW', counter_topic, counter_unit 'Wh'|'kWh', setpoint_topic, plugged_topic, soc_topic, mode_sync_topic, mode 1|2|3, priority_private/business/full 1–5, min_charge_percent, business_days CSV Mo..So, stall_timeout_seconds, stall_power_w, control_mode 'auto'|'off'|'full') — je Wallbox eine Zeile (optionales Modul). command_topic ist reiner Aktor, control_sync_topic der optionale bidirektionale An/Aus-Schalter (Bedienerkennung); die beiden Stall-Spalten steuern den Ladestart-Neustart, control_mode` hält die manuelle Übersteuerung neustart-resistent.
- `wallbox_counter_state(wallbox_id, last_raw_value, day_total, last_day_key,
plugged_energy_start, last_power_ts)` — Zähler-/Power-Integrationsstand + SoC-Schätzbasis.
- `wallbox_summary_state(wallbox_id, week/month/year_offset, previous_year_total,
last_rollover_date, week/month/year_key)` — historische Summen inkl. Monat + Jahreswechsel.
wallbox_daily_consumption(wallbox_id, day_key, consumption_kwh, completed, updated_at)wallbox_hourly_consumption(wallbox_id, day_key, hour, consumption_kwh)—
getrennte Lernhistorie für Wochentagsbedarf und Ladezeit je Box.
- `mess_schalt_groups(id, title, priority 1–5, position, function_key,
offset_total_consumption, parent_id, meter_group, color) — Messen-+-Schalten-Gruppen; Priorität wird von Geräten mit use_group_priority übernommen (nicht an Untergruppen vererbt), function_key von Geräten ohne eigene Funktion geerbt; offset_total_consumption ist standardmäßig 1 und bestimmt, ob die Gruppensumme von „Sonstige Verbraucher“ abgezogen wird. parent_id = übergeordnete Gruppe (NULL = oberste Ebene, mehrschichtige Verschachtelung); meter_group = Zählergruppe (eigene Geräte messen den ganzen Zweig, wirkt mit gesetztem Offset als Sperrschicht); color` = frei wählbare Diagrammfarbe (Hex, leer = Standard).
- `mess_schalt_actors(id, name, group_id, position, switch_topic, status_topic,
power_topic, power_unit 'W'|'kW', counter_topic, counter_unit 'Wh'|'kWh', rated_power, rated_power_unit 'W'|'kW', priority 1–5, use_group_priority, always_on, desired_on, function_key, switch_group_id) — je Gerät eine Zeile; always_on = automatisch übers Betriebslevel (sonst manueller Toggle, direkt am Schalt-Topic). desired_on ist ungenutzter Altbestand. switch_group_id = Zuordnung zu einer Schaltgruppe (nur über die Schaltgruppen-Unterseite gepflegt). rated_power/rated_power_unit = Nennleistung für die virtuelle Zählung: ohne Leistungs- **und** Zähler-Topic werden Leistung/Energie daraus aus dem Schaltzustand abgeleitet (aggregation.updateVirtualState`, in denselben internen Zähler integriert).
energiefluss_exports(id, name, slug, theme 'light'|'dark')— benannte,
öffentlich abrufbare Live-Ansichten des Energiefluss-Diagramms. Der aus dem Namen abgeleitete, eindeutige slug bildet die Export-URL /energiefluss/export/<slug> (ohne Auth; CRUD über messen-schalten/energiefluss-exports.js).
mess_schalt_switch_groups(id, name, remote_topic, switch_as_unit, timer_minutes)—
Schaltgruppen der Unterseite /messen-schalten/schaltgruppen; Zustand wird nicht persistiert, sondern je Tick aus den Geräten abgeleitet und als virtueller State schaltgruppe://gruppen/<id> veröffentlicht. Ein Wert timer_minutes > 0 startet beim Wechsel auf AN einen Laufzeittimer, der alle Mitglieder anschließend ausschaltet.
- `mess_schalt_actor_state(actor_id, last_counter_raw, last_progress_ts,
derived_power_w, counter_total_kwh, day_key, day_start_kwh, year_key, year_start_kwh, prev_year_kwh, power_energy_kwh, power_energy_day_start_kwh, last_power_ts) — Ableitungszustand für „Leistung aus Zählerfortschritt" (0 W nach über 10 min ohne Fortschritt), interner Zähler sowie Tages-/Jahres-Baselines für die Gruppen-Verbrauchssummen (heute/Jahr/Vorjahr). Die drei power_energy*`- Felder halten die aus der Live-Leistung integrierte Tagesenergie für die Zähler-Gegenprobe (Warnung bei Wh↔kWh-Fehlkonfiguration).
- `mess_schalt_function_hourly(function_key, day_key, hour, consumption_kwh,
temperature) + mess_schalt_function_state(id=1, last_sample_ts)` — je Funktion und Stunde integrierte Energie samt energiegewichteter Außentemperatur der Stunde; Grundlage der Wochentags-Stundenprofile der übrigen Funktionen (400 Tage Aufbewahrung). Speist das Heizmodell nicht mehr — dient nur Jahres-Grundlast und Tagesanzeige.
- `mess_schalt_temperature_power(bucket, day_key, avg_power_w, weight_seconds,
PRIMARY KEY(bucket, day_key))` — Heizung / Klima nach Außentemperatur: je 1-°C-Fenster bis zu 30 Messtage, pro Tag die zeitgewichtete mittlere Leistung (W). Modellwert = Mittel der Messtage; ältere Tage je Fenster werden verworfen. Ein Fenster wird nur an Tagen mit real aufgetretener Temperatur belegt.
- `battery_energy_state(id=1, day_charge/discharge_kwh, week/month/year_charge/
discharge_offset, previous_year_charge/discharge_total, last_power_ts, last_rollover_date, week/month/year_key)` — per Leistungsintegration erfasste Netto-Akkuladung nach Tag/Woche/Monat/Jahr + Vorjahr.
- `notification_rules(id, name UNIQUE, enabled, state_id, trigger_type
'changed'|'equals'|'not_equals'|'above'|'below', trigger_value, title, body, event_type, severity 'normal'|'critical', cooldown_seconds, position, created_at, updated_at, last_triggered_at) — Regeln der Seite „Nachrichten". state_id ist die kanonische State-Adresse aus der bestehenden State-Verwaltung (keine zweite State-Datenbank). Bewusst **ohne** instance_id, device_id`, Push-Token oder Firebase-Daten: eine Regel kann damit weder eine fremde Instanz noch einen bestimmten Empfänger adressieren — die Empfänger bestimmt allein der Relay über seine aktiven Kopplungen.
daily_metric_history(metric, day_key, value, updated_at)— je Kennzahl
(pv, strom.netzbezug, strom.eigenverbrauch) ein abgeschlossener Tageswert pro Tag; Grundlage für die statistischen Jahreswerte (gestern, Minimum/Maximum inkl. Datum) im Wert-Katalog. 400 Tage Aufbewahrung.
Zentrale States-Quelle (
states/repository.js,states/system-values.js): Outputs, Dashboard-Widgets und die States-Seite beziehen ihre Werte aus demselben Repository. Interne Werte stehen im Baum unter System. Enthält PV (Leistungen, Erträge, Sonne), Stromverbrauch (Leistungen, Energien je Zeitraum, Zählersummen), Sonnenintensität, PV-Prognose (Tagesertrag heute/morgen/+2/+3 sowie heute bisher / noch erwartet), Systemprognose (38 Werte), Batterie (Messwerte, Energie/Restzeit und abgeleitete Zustände), Betriebszustand, Grid-Control, Geräte und Verbrauchssummen (Messen + Schalten) sowie Pool und Wallbox (wenn das jeweilige Modul aktiv ist). Jeder Eintrag hatid,label,value,display. Für Topic-Felder veröffentlicht die System-Runtime dieselben Werte zusätzlich untersystem://homeess/<id>und routet sie ohne MQTT-Umweg in den jeweils konfigurierten Cache-Key.
MQTT Ad-hoc-Subscriptions (Pool und Output-Readback)
Pool-Topics liegen außerhalb der normalen State-Definitionen (Pool ist optional, Topics ändern sich per Config). client.subscribeAdHoc(configuredTopic, cacheKey) registriert alle mqttReadCandidates als Routen und abonniert alle mqttSubscribeCandidates (inkl. Wildcard für Slash-States). /get-Anfragen werden beim Subscribe und beim Reconnect gesendet. Cache-Keys: pool:<topic>. Abgerufen über readPoolValue(cache, topic). Die Output-Regelschleife verwendet pro Ziel-State einen gemeinsamen output.readback:<topic>-Cache-Key und fordert den Istwert zusätzlich alle 30 Sekunden aktiv an. Die Rule Engine des Nachrichtensystems abonniert die States ihrer aktiven Regeln unter je einem eigenen Cache-Key notification:<regel-id>; ein eigener Key je Regel hält mehrere Regeln auf demselben State voneinander unabhängig und trägt den für die Flankenerkennung nötigen Vorwert.
Adapter-Schnittstelle (Geräte-Anbindung)
Austauschbare Adapter verbinden homeESS mit Geräten, ohne Eingriff in den Quellcode. Vollständiges Regelwerk in ADAPTER.md; Vorlage: /adapter/demo.
- Verzeichnis
config.ADAPTER_DIR(Default<repo>/adapter, override
HOME_ESS_ADAPTER_DIR). Je Adapter ein Unterordner mit adapter.json (Manifest: id, prefix, settings-Schema, main, optional copyright) + Einstiegsdatei. src/adapters/registry.js scannt und validiert. Da Adapter eigenständige Anwendungen sind, führt jedes Manifest einen eigenen copyright-Vermerk, der auf der Adapter-Seite angezeigt wird.
- Gemeinsamer Wert-Bus
src/state-bus.js: hält den zentralenvalueCache+
EventEmitter. Sowohl mqtt/client.js (Broker) als auch Adapter schreiben hier hinein; mqttClient.getCache()/onValuesChanged() sind Fassaden darauf — alle bestehenden Konsumenten (Output-Engine, /live, Dashboard) bleiben unverändert. ingest aktualisiert den Cache (inkl. receivedAt) immer, emittiert das values-Event aber nur bei tatsächlicher Wertänderung — das verhindert write→Echo-Rückkopplungen auf Adapter-Topics und hält die Event-Last niedrig. Reaktive Konsumenten (Output-Engine, Prognose-Verhalten) entprellen mit 1000 ms; browser-seitig fassen Dashboard/States/Output das Nachladen pro Event-Burst zusammen (max. 1×/s).
- Router
src/adapters/router.js+ Schema-HelferparseSchemeTopic/
buildSchemeTopic in mqtt/topics.js. Topics prefix://instanz/adresse werden vom Client an den Router delegiert (in publish, subscribeAdHoc, requestAdHocValue, buildTopicRoutes); Topics ohne Schema laufen unverändert über den Broker (abwärtskompatibel). Der Router wirkt wie ein kleiner interner Broker: registerRoute liefert dem neuen Abonnenten sofort den zuletzt bekannten Wert (retained delivery aus dem Bus, unabhängig von read()), ingestFromInstance verteilt jede Wertänderung automatisch an alle Abonnenten. Wichtig: normalizeMqttTopic ist schema-fest — es darf das :// von Schema-Topics nicht kollabieren (sonst würden sie als Broker-Topic fehlgeroutet und lieferten keinen Wert). Config-Speicherpfade normalisieren gefahrlos.
- Instanzen (
adapter_instances, CRUD insrc/adapters/instances.js): pro
Adapter mehrere benannte Instanzen mit eigenen JSON-Settings; Name = Autorität im Topic.
- Isolation:
src/adapters/host.js(Supervisor) forkt je aktiver Instanz
src/adapters/runtime.js als Kindprozess (Auto-Restart mit Backoff). Der Runtime-Shim lädt die Adapter-main und bildet die host-API transparent auf IPC ab — Adapter-Autoren kennen kein IPC. forkImpl ist für Tests injizierbar (host._setForkImpl). Init in app.js vor loadAllStateDefinitions.
- State-Editor (generisch, schema-getrieben): Adapter können im Manifest einen
stateEditor deklarieren (Spalten + presets-Flag, parse in registry.js). homeESS rendert daraus die Verwaltungs-Unterseite /adapter/instance/:id/states (src/views/adapter-states.js, Routen in src/routes/adapters.js); Zeilen- Normalisierung/Validierung in src/adapters/state-editor.js. Die Zeilen liegen in instance.settings[storageKey] und sind die Live-States. Presets (src/adapters/presets.js, Verzeichnis <adapter>/presets/*.json) sind reine Vorlagen: Laden mit Auswahl, „als Preset speichern", Upload (Browser liest die Datei und POSTet JSON). Kein adapterspezifischer Code im Core.
- Modbus-Adapter (
adapter/modbus): nutzt den State-Editor (Spalte =
Register) + Presets (Format in adapter/modbus/PRESET.md). Eigener, abhängigkeitsfreier Modbus-TCP-Client (modbus-tcp.js, Unit-ID pro Request)
- reine Dekodierung/Kodierung (
decode.js, byte-/word-order/scale gemäß
PRESET.md). Die Unit-ID ist Teil jedes Registers (zusammengesetzter Editor-Schlüssel keyFields:[unitId,address]) und bildet die erste Adressebene modbus://instanz/<unitId>/<adresse> — eine Instanz bedient so mehrere Units.
- States-Hauptseite (
/states):src/states/repository.jsführt die
berechneten Systemwerte (src/states/system-values.js), virtuelle States und gemeldete Adapter-States (Metadaten in adapter_states, Live-Werte aus dem Bus) zu einem Baum zusammen. Die bisherigen Output-Katalogimporte delegieren als Kompatibilitätsschicht dorthin. Adapter-Seite (/adapter): Verwaltung + generische Settings aus dem Manifest-Schema. Die Übersicht blendet Adapter ohne aktivierte Instanz standardmäßig aus; ein Schalter oben rechts blendet diese Karten bei Bedarf wieder ein. Die Sichtbarkeit wird im Client anhand des Live-Status (/adapter/status.json) nachgeführt.
- State-Picker
src/views/state-picker.js(analogvalue-catalog.js): Button
hinter Topic-Feldern öffnet den State-Baum (/states/catalog.json) und übernimmt prefix://instanz/adresse. Als Popover (Popover-API, showPopover()) umgesetzt, das wie ein Dropdown am Feld andockt (je nach Platz nach unten/oben) und im Top-Layer über <dialog>-Elementen liegt. Global über renderLayout eingehängt: statePickerAutoAttach() dekoriert per DOMContentLoaded jedes Eingabefeld, dessen name „topic" enthält, und beobachtet via MutationObserver nachträglich eingefügte Felder (dynamische Anlagen-/Wallbox-Zeilen). Einzelne Seiten müssen nichts tun; ein Feld kann sich per data-no-state-picker ausnehmen.
Betriebslevel / Lastmanagement
Zentraler Betriebslevel-Handler in src/operating-level/handler.js. Vollständige Anleitung zum Anbinden neuer Verbraucher: LEVEL_HANDLING.md.
- Priorität (1–5) = Freigabe-Level:
erlaubt ⇔ aktuelles Betriebslevel ≥ Priorität
(Priorität 4 ⇒ erlaubt bei Level 4/5, gesperrt bei 1–3).
- API:
register(id, priority, { onMustTurnOff })(Re-Registrierung überschreibt die
Priorität), unregister(id), requestTurnOn(id) (Einschalt-Freigabe), isAllowed(priority), currentOperatingLevel(). Der Handler abonniert operatingState.onOperatingLevelChanged und ruft bei Levelabfall onMustTurnOff() jedes nicht mehr erlaubten Verbrauchers auf.
- Drei Modi pro Verbraucher (
an/aus/automatik): nurautomatikläuft über das
Gate (registriert, Einschalten nur nach Freigabe, Zwangsabschaltung). an/aus übersteuern das Level bewusst und sind nicht registriert.
- Verbraucher: Filter- und Solarpumpe (
pool.solar,pool.filter) in
pool/automation.js sowie je Wallbox wallbox.<id> (Priorität des aktiven Lademodus) in wallbox/automation.js. getEffectivePriority(which, cfg) liefert während eines Filter-Probelaufs die Solarpumpen-Priorität für die Filterpumpe.
- Zusätzlicher phasenbezogener Lastabwurf:
grid-control/load-shed.js
bündelt die Teilnehmer aus Messen + Schalten, Pool und Wallbox, führt je Phase die aktive Abwurfstufe und entscheidet mit gemeinsamer Prioritätsreihenfolge über Abschaltung beziehungsweise spätere Freigabe. Auslöser ist dabei die separat konfigurierte Lastabwurf-Maximallast je Phase, nicht die Netz-Einschaltschwelle.
- Init in
app.js(operatingLevelHandler.init()) nach geladenem Betriebszustand, vor
prognosisBehavior.init. Neue Verbraucher registrieren sich aus ihrer eigenen Steuerschleife heraus, sobald sie aktiv sind.
Wichtige Entscheidungen / Eigenheiten
- Sessions statt Flag: Cookie-Name
ess_sid. „Merken" → 30-Tage-Cookie;
sonst Session-Cookie (serverseitig 12 h gültig).
- Passwörter gehasht (Node
crypto.scrypt). Default beim ersten Start:admin. - MQTT-Broker-Regeln in MQTT.md und in
mqtt/topics.jsumgesetzt. - ack-Unterscheidung beim Readback: Eingehende Nachrichten mit
ack:false
sind Schreibwünsche/Kommandos (u. a. das Echo eigener Schreibvorgänge auf dem Haupt-Topic) und werden nicht als Broker-Stand gecacht. Nur ack:true bzw. Rohwerte gelten als bestätigter Ist-Zustand (unwrapMqttMessage in topics.js, Filter in client.js). Grundlage der Schalt-Verifikation in Grid-Control.
- Slash-Schreib-Limitierung: State-IDs mit eingebettetem Slash (Modbus/Victron,
z. B. …3500_/ManualStart) lassen sich per MQTT nur lesen (Wildcard-Abo), nicht zuverlässig schreiben (der Broker bildet /→. falsch zurück). Lösung: für Schalt-Ziel-Topics slash-freie Namen verwenden. Siehe MQTT.md.
- Batterie = zentrales Element:
batterie.socist der einzige SoC-Wert
der gesamten Plattform. Der Pool-Akku-Override liest diesen State direkt aus dem Cache — kein eigenes Topic. Das Batterie-SoC-Icon in der Titelzeile ist permanent sichtbar (sobald konfiguriert).
- DB-Pfad via Env
HOME_ESS_DB, Port viaPORT.
Nächste sinnvolle Schritte (Roadmap)
- Last-Management / Regel-Engine (Basis umgesetzt): zentraler
Betriebslevel-Handler (operating-level/handler.js) schaltet registrierte Verbraucher nach Priorität gegen das prognosegeführte Betriebslevel — erste Verbraucher: Filter-/ Solarpumpe. Offen: weitere Verbraucher anbinden (Leitfaden: LEVEL_HANDLING.md).
- Watchdog/Reconnect-Härtung gemäß MQTT.md (stille Subscriptions erkennen).
- Session-Cleanup: abgelaufene
sessions-Zeilen periodisch löschen. - Drag&Drop für Touch (aktuell native HTML5-DnD, nur Maus/Desktop).
- Sample-Pflege:
sun_intensity_sampleswerden beim Sampling zwar gekürzt
(2 Tage) — bei langem Stillstand des Samplers ggf. separater Cleanup.
- Selbstkalibrierung (umgesetzt): 15-min-Kalibrierfaktor je Anlage/Bucket aus
gemessenem Schnitt vs. Open-Meteo-Strahlung (calibration.js, pv_calibration_buckets). Mögliche Verfeinerungen: Solar-Zeit- statt Wanduhr-Buckets (saisonstabilere Verschattung), UI-Kurve der Faktoren über den Tag, Persistenz des laufenden 15-min-Messfensters über Neustarts hinweg.
Konventionen für read-only Wert-Provider
- Die Snapshot-Builder
buildStromverbrauchSnapshot/buildPhotovoltaikSnapshot
schreiben in die DB. Sie laufen nur in den 60-s-Intervallen in app.js.
- Für häufige Auswertung: schreibfreie Provider
readStromverbrauchValues/
readPhotovoltaikValues / readBatterieData. Neue „Live"-Verbraucher immer diese read-only Varianten nutzen.
Laufzeit- und CPU-Verhalten
- Adapter können zusammen gelesene Werte per
host.publishStates()als Batch
melden. Der State-Bus aktualisiert dabei alle Frischezeitstempel, erzeugt aber nur ein gemeinsames Änderungsereignis. Der Modbus-Adapter gruppiert dafür zusammenhängende Register gleicher Unit, Registerart und Pollrate.
- Grid-Control verdichtet relevante Wert-Bursts auf höchstens einen laufenden und
einen folgenden Tick; fachfremde Events werden ignoriert. Der 2-s-Sicherheitstakt bleibt davon unabhängig bestehen.
- Output-Readbacks besitzen einen günstigen Bestätigungspfad ohne erneuten Aufbau
des gesamten Wertekatalogs. Wertekatalog, PV-Prognose und Verbrauchsmodell teilen parallele bzw. kurz gültige Berechnungen.
- Das gecachte Verbrauchsmodell enthält keinen mutierten Wallbox-Ladeplan. Jeder
Aufrufer materialisiert daraus einen frischen Plan mit aktuellem Hausakku-SoC, Kapazität, Wirkungsgraden und der gewählten Wallbox-Strategie.
- Periodische Jobs laufen über
job-scheduler.jsohne Selbstüberlappung. HOMEESS_PERF_DEBUG=1aktiviert einen minütlichen[perf]-Datensatz mit
Laufzeiten, Aufruf-/Cache-/Coalescing-Zählern, SQLite-Profil und Event-Loop-Lag.
Service-Verwaltung (systemd)
systemctl status home-ess # Status
systemctl restart home-ess # Neustart nach Code-Änderungen
systemctl stop home-ess # Stoppen
journalctl -u home-ess -f # Live-LogUnit-Datei: /etc/systemd/system/home-ess.service WorkingDirectory: /opt/home-ess, User: root, Restart: on-failure.
Lokaler Start / Test
npm install
npm start # Port 3000, Login mit "admin"
npm run dev # mit --watch
HOME_ESS_DB=/tmp/t.db PORT=3001 npm start # Wegwerf-DB für Tests
1 thought on “”