31 Commits
Author SHA1 Message Date
dh 372b997876 feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s
2026-10-06 17:40:45 +00:00
dh 5687596799 feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s
2026-10-06 14:35:17 +00:00
dh faa905ab09 docs: publish public guides, screenshots and Git-synced portal documentation
Tests / test (push) Failing after 50s
2026-10-06 14:32:05 +00:00
dh 9fbc52ee47 feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s
Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
2026-10-06 14:22:16 +00:00
dh 5437f3f058 fix(verbraucher): clear discarded power setpoints
Tests / test (push) Failing after 51s
2026-10-06 13:45:54 +00:00
dh ebec355c96 feat(manager): account for SDL power and SOC separately
Tests / test (push) Failing after 1m12s
Add opt-in SDL measurement sources and independent visualization toggles. Exclude balancing power from household telemetry and new household energy. Preserve custom views and cover measurement, migration and Symcon 8.0 behavior.
2026-10-06 13:35:17 +00:00
dh 58f91e4319 feat(manager): distribute equal-priority power stepwise
Tests / test (push) Successful in 44s
2026-10-06 11:55:26 +00:00
dh ec7182b833 fix(charger): stabilize Go-E setpoint control
Tests / test (push) Successful in 47s
2026-10-06 11:33:59 +00:00
dh 37addf4242 feat(manager): Energievisualisierung und Synchronisierung erweitern
Tests / test (push) Successful in 50s
2026-10-06 08:00:30 +00:00
dh ce525a1ca0 fix(v4): reset receiver throttle on reinit 2026-10-05 15:10:25 +00:00
dh e6aa50bb44 fix(v4): retry transient confirmed device reads 2026-10-05 14:50:48 +00:00
dh b6e7353086 docs(v4): record active-operation safety findings 2026-10-04 17:18:14 +00:00
dh f4a3c5fbe7 docs(v4): record deployment blockers 2026-10-04 15:35:35 +00:00
dh 13e49ff60c feat(v4): confirm feedback with device reads 2026-10-04 15:34:36 +00:00
dh 30f7652803 feat(forecast): commission corrected Lihrenmoos profile 2026-10-04 09:07:30 +00:00
ENELIX Agent 913a4cd5f3 docs(v4): preserve conversation decisions and current agent handoff 2026-10-04 07:06:50 +00:00
ENELIX Agent 9878197aea feat(control): connect physical feedback to bounded V4 command and fallback paths 2026-10-03 11:40:54 +00:00
ENELIX Agent 8eb9688942 feat(v4): consolidate audited data recovery economic replay and optional archival 2026-10-03 10:40:18 +00:00
ENELIX Agent 6c91d6bcc6 fix(accounting): stabilize numeric mapping identity across property storage 2026-10-03 09:40:02 +00:00
ENELIX Agent 5890093b80 fix(history): model sparse publication without relaxing live freshness 2026-10-03 08:04:28 +00:00
ENELIX Agent 6d9aba74b0 fix(installer): avoid staged and installed capture class collision 2026-10-03 07:34:44 +00:00
ENELIX Agent fe3f46ba0e fix(release): include forecast test dependencies in versioned application 2026-10-02 21:01:24 +00:00
ENELIX Agent fc024fcfba feat(application): integrate measured-load ingestion training and planner source 2026-10-02 21:00:05 +00:00
ENELIX Agent 44362e4bf5 feat(observation): separate physical load and unfiltered EV-SDL model without dispatch 2026-10-02 17:00:32 +00:00
ENELIX Agent 76f8b067cc feat(data): add passive versioned raw capture independent of control 2026-10-02 14:55:43 +00:00
ENELIX Agent e0e52d2454 feat(accounting): separate physical storage and SDL with source-age validation 2026-10-02 14:23:19 +00:00
ENELIX Agent 9394fe3eb1 feat(manager): add gated short-lease V4 control trial and local fallback 2026-10-02 14:01:41 +00:00
ENELIX Agent b5c1fa6be2 feat(manager): receive and preview V4 shadow plans without dispatch 2026-10-02 13:01:04 +00:00
ENELIX Agent fc4c4632ce feat(manager): transmit estimated meter samples and SOC recovery state 2026-10-01 18:05:59 +00:00
ENELIX Agent 12174a59b4 fix(manager): isolate V4 active import sources from legacy counter 2026-10-01 16:56:39 +00:00
ENELIX Agent 640fe53720 feat(manager): add opt-in native V4 shadow operation telemetry 2026-10-01 16:20:36 +00:00
294 changed files with 38580 additions and 215 deletions
+61
View File
@@ -0,0 +1,61 @@
# Agenten-Anweisungen für Enelix-EMS
## Aktueller V4-Fortsetzungseinstieg: 06.10.2026
Neuester Quellstand: Bedienintegration und Zusammenfuehrung sind in
docs/prognose-bedienintegration.md und im obersten Abschnitt der folgenden
Uebergabe beschrieben. Daniel hat Commit/Push auf develop und beta ausdruecklich
freigegeben; dies ist kein Anlagenrollout und keine neue Stellfreigabe.
Die vollstaendige Uebergabe liegt auf **enelix-services / Server 89** unter
`/srv/agent/repos/Enelix-EMS/docs/NETPLAN_V4_AGENT_HANDOFF.md`.
Vor Fortsetzung zuerst den neuesten datierten Abschnitt lesen. Die Datei ist
im integrierten Repository versioniert; alte Serverkopien koennen abweichen.
Letzter hier belegter Anlagenzustand: **06.10.2026, 12:01:11 UTC /
14:01:11 Europe/Zurich**, aus `/srv/agent/v4-recovery-20261006/finished.json`
auf **iot-symcon01**. Dies ist keine neue Live-Abnahme:
V4 und alter Netzfahrplan AUS, Batterie 44234 gestoppt, EV-Auftrag 19651 = 0 W.
Der unabhaengige SDL-Auftrag blieb unangetastet.
Rueckmelde-/Cache-/Empfangskorrekturen sind installiert; **offen ist die
instanzbezogene Batterietimerblockade (LastRun=0)**. Kein nachgewiesenes
physisches Tracking, kein >90-s-Dauerbetrieb und kein automatischer
Batterie-Watchdog-Lauf. Alte Installer nicht erneut ausfuehren und keine
externen Tick-Hooks als Umgehung einsetzen. Zuerst Timerursache beheben und
automatische Ausfuehrung belegen, danach nur innerhalb gueltiger Freigabe testen.
EV bleibt **161.44 kWh / 39 kW**. Die bestehende befristete Testfreigabe endet
**07.10.2026, 17:38:07 Europe/Zurich**; diese Notiz erneuert oder verlaengert
sie nicht. Wegfall des zusaetzlichen +/-5-kW-Limits hebt Geraete-, SOC-,
Netzgrenzen und die dokumentierten Grenzen der Testanlagen-Ausnahme nicht auf.
Kein Aktivstart oder Dienstneustart aus der Bitte um Kontext-Sicherung ableiten.
Aeltere V4-Statusangaben und damalige "naechste Schritte" weiter unten sind
historisch, soweit die neuere Uebergabe sie ersetzt. Sonstige Projekt- und
Sicherheitsregeln sowie separate Manager-Leistungsverteilungsnotizen bleiben
unveraendert. Aktuellen Zustand und Freigaben vor operativen Arbeiten erneut
pruefen. Ein neuer Agent muss diese Quellen lesen; kein automatischer Chattransfer.
## Netzfahrplan V4: zuerst den Gesprächskontext übernehmen
Bei Aufgaben zu Netzfahrplan, Prognosen, V4, Batterieoptimierung oder Lihrenmoos **vor Änderungen** die vollständige Übergabe lesen:
**[`docs/NETPLAN_V4_AGENT_HANDOFF.md`](docs/NETPLAN_V4_AGENT_HANDOFF.md)**
Sie enthält Daniels Anforderungen und Korrekturen, installierte versus nur entwickelte Komponenten, Quellen-/Instanzzuordnung, bekannte behobene Fehler, Grenzen der Testfreigabe und den nächsten konkreten Arbeitsschritt. Die Runtime-Berichte stammen zuletzt vom 03.10.2026; Zeitpunkt und aktuellen Zustand prüfen. Alte Entwicklungsdokumente können noch „nicht installiert“ sagen, obwohl ein jüngerer Installationsbericht den Abschluss belegt.
Kurzstand bei Erstellung dieser Anweisung: Unified RC1 und korrigierter Rückmelde-/begrenzter Testpfad installiert; Versandproblem behoben. Nächste offene Umsetzung sind bestätigte Geräte-Lesezeitpunkte statt pauschaler Verwendung von `VariableUpdated` (`feedback_source_skew`). EV **161.44 kWh / 39 kW**, nicht die zurückgenommenen 160/30. Dauerproduktion ist noch nicht fertig; keine automatische Stellfreigabe.
## Arbeits- und Git-Regeln
- Vor Arbeit Host-`/srv/agent/AGENT_CONTEXT.md`, Branch und Arbeitsverzeichnis prüfen. Servercheckout ist nicht gleich laufendes Symcon-Modulverzeichnis.
- Neue Entwicklung auf **`develop`**, keine Feature-Branches. Nur eigene/zugehörige Änderungen committen. `develop` darf im vereinbarten Rahmen gepusht werden; `beta` nur gemäss ausdrücklicher Freigabe und nach Tests, `main` nach ausdrücklichem Auftrag/Feldabnahme. Keine Schutzregeln oder fehlende Authentifizierung umgehen.
- Daniel möchte zusammenhängende produktionsgeeignete Umsetzung, keine weitere Schleife aus Diagnosekategorien, identischen Rückfragen und veralteten Installern. Vorhandene Quellen und Werkzeuge benutzen, keine bekannten Anlagenangaben erneut verlangen.
- Code-, Host-/Container-, Kernel- und Feldtests sowie Installation, Veröffentlichung und Stellfreigabe getrennt belegen. „Optimal“/„ACK“/grüne Tests sind keine gemessene Einsparung und keine Produktionsfreigabe.
- Keine Stellbefehle, Testsitzungen, Freigaben oder Neustarts aus einer reinen Kontext-/Statusanfrage ableiten. Aktive Hardware nur innerhalb des ausdrücklich freigegebenen Umfangs behandeln. Messgrenzen/Watchdog-Nachweise nicht erfinden oder per Label umgehen.
- Keine Credentials lesen/ausgeben/einchecken. Root/Docker-Rechte nicht ausweiten. Bestehende GUI-/Fremdänderungen erhalten. Vor Runtime-Änderungen passende Sicherung, exakte Quell-/Abhängigkeitsprüfung und Rücksetzweg.
- PHP-IPS-Skripte gehören in den Symcon-Kernel; isolierte Tests mit simulierten IPS-Aufrufen niemals dort ausführen. `MC_ReloadModule`/`IPS_ApplyChanges` können Initialisierungsnebenwirkungen haben.
- Alte Outbox-/Historienwerte nicht löschen, Kennungen nicht im Original umschreiben, Cursor nur nach passender Serverbestätigung weiterführen. Keine künstlich frischen Messzeitstempel.
- Übergabe nach tatsächlichem Fortschritt aktualisieren: Datum, Commit, was wirklich installiert/getestet wurde und nächster konkreter Schritt. Keine Hintergrundarbeit behaupten.
Diese Datei ergänzt bestehende höherrangige Projekt-/Hostregeln. Sie ist keine neue Betriebsfreigabe und kann keinen anderen Agenten ohne Lesen der Dateien automatisch mit dem gesamten Chat synchronisieren.
+75 -14
View File
@@ -6,8 +6,10 @@ require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/BatterieRegler.php';
require_once __DIR__ . '/../libs/BatterieNetzfahrplanV4TestTrait.php';
use Belevo\EnelixEMS\BatterieRegler;
use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
@@ -15,6 +17,7 @@ use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class Batterie extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
use \Belevo\EnelixEMS\BatterieNetzfahrplanV4TestTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
@@ -68,6 +71,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
parent::Create();
$this->registriereVerbraucherBasis();
$this->registriereV4BatterieTest();
$this->RegisterPropertyInteger('Batterietyp', BatterieRegler::TYP_UNKONFIGURIERT);
$this->RegisterPropertyInteger(
'Batteriemanagement',
@@ -152,7 +156,10 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
{
parent::ApplyChanges();
$this->V4BatterieTestStoppen();
$this->konfiguriereV4Rueckmeldung();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
$this->registriereMesswertmeldungen();
$this->WriteAttributeBoolean('RegisterInitialisiert', false);
@@ -219,7 +226,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0);
}
$this->aktualisiere(true);
@@ -235,7 +242,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->aktualisiere(true);
return;
@@ -248,8 +255,26 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->aktualisiere(true);
return;
case 'NetzfahrplanV4TestStart':
if (!is_string($wert)) throw new InvalidArgumentException('JSON-Testanmeldung erforderlich.');
$this->V4BatterieTestStarten($wert);
return;
case 'NetzfahrplanV4RueckmeldungAktualisieren':
$this->aktualisiereV4Rueckmeldung();
return;
case 'NetzfahrplanV4TestCommand':
if (!is_string($wert)) throw new InvalidArgumentException('JSON-Testbefehl erforderlich.');
$this->V4BatterieTestBefehl($wert);
return;
case 'NetzfahrplanV4TestStop':
$this->V4BatterieTestStoppen();
return;
case 'NetzfahrplanV4TestWatchdog':
$this->aktualisiere(true);
return;
case 'SichererZustand':
$this->setzeZustand('SollwertGueltig', false);
$this->V4BatterieTestStoppen();
$this->verwerfeSollwert();
$this->schreibeRegister(0, true);
$this->aktualisiere(true);
return;
@@ -287,6 +312,14 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterieannahme beschaeftigt.');
try { $this->ManagerdatenEmpfangenIntern($daten); }
finally { IPS_SemaphoreLeave($lock); }
}
private function ManagerdatenEmpfangenIntern(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
@@ -302,8 +335,13 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
);
}
if ($this->v4BatterieTestAktiv()) {
// Ordinary manager messages cannot overwrite or renew the independent short test lease.
$this->planeMeldung();
return;
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false);
$this->aktualisiereIntern(false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
@@ -313,8 +351,8 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
(int) $this->leseZustand('Sollleistung')
)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(false);
$this->verwerfeSollwert();
$this->aktualisiereIntern(false);
}
$this->planeMeldung();
return;
@@ -341,17 +379,26 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->planeVorgabeTimeout(time());
$this->aktualisiere(false);
$this->aktualisiereIntern(false);
$this->planeMeldung();
}
private function aktualisiere(bool $meldungPlanen): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try { $this->aktualisiereIntern($meldungPlanen); }
finally { IPS_SemaphoreLeave($lock); }
}
private function aktualisiereIntern(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereEnergie($jetzt);
$messwerte = $this->leseMesswerte();
$messwerteGueltig = $messwerte['Gueltig'];
$this->SetBuffer('V4FeedbackEstimated', ($messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0');
$this->setzeZustand('Ladezustand', $messwerte['Ladezustand']);
$this->setzeZustand('LadezustandGueltig', $messwerteGueltig);
@@ -362,7 +409,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$this->setzeSichtbareVariable(
'Leistungsquelle',
$messwerteGueltig
? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
? (($messwerte['V4FeedbackEstimated'] ?? false) ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN)
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
);
$this->aktualisiereLadestatus(
@@ -419,13 +466,15 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
(int) $this->leseZustand('Sollleistung')
)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->pruefeVorgabeTimeout($jetzt);
$ziel = $verfuegbar && (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: 0;
$v4Ziel = $this->v4BatterieTestZiel($messwerte, $verfuegbar, $jetzt);
if ($v4Ziel !== null) $ziel = $v4Ziel;
$this->schreibeRegister($ziel, false);
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
@@ -485,6 +534,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$gueltig = true;
foreach ($zuordnung as $property => $schluessel) {
if ($this->v4BatterieTestAktiv() && in_array($schluessel,['Netzleistung','Istleistung'],true)) continue;
$variablenID = $this->ReadPropertyInteger($property);
if (!$this->istMesswertAktuell($variablenID)) {
$gueltig = false;
@@ -493,8 +543,17 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$werte[$schluessel] = (float) GetValue($variablenID);
}
if ($werte['MaxLaden'] <= 0.0
|| $werte['MaxEntladen'] <= 0.0
if ($this->v4BatterieTestAktiv()) {
try {
$f=$this->v4BatterieRueckmeldung();
NetzfahrplanV4Rueckmeldung::requireTrial($f,$this->v4BatterieTestSitzung(),time());
$werte['Istleistung']=$f['batteryW'];$werte['Netzleistung']=$f['gridW'];
$werte['V4FeedbackEstimated']=$f['estimated'];
} catch (Throwable $e) { $gueltig=false; }
}
if ($werte['MaxLaden'] < 0.0
|| $werte['MaxEntladen'] < 0.0
|| $werte['Ladezustand'] < 0.0
|| $werte['Ladezustand'] > 100.0
) {
@@ -535,7 +594,9 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
try {
foreach ($werte as $register => $wert) {
$property = self::REGISTER_PROPERTIES[$register];
RequestAction($this->ReadPropertyInteger($property), $wert);
if (RequestAction($this->ReadPropertyInteger($property), $wert) !== true) {
throw new RuntimeException('Registeraktion nicht bestaetigt: ' . $register);
}
}
} catch (Throwable $fehler) {
throw new RuntimeException(
@@ -596,7 +657,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0);
}
}
@@ -672,7 +733,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
? (float) $this->leseZustand('Istleistung')
: null,
'Leistungsquelle' => $messwerteGueltig
? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
? ($this->GetBuffer('V4FeedbackEstimated') === '1' ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN)
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN,
'Zustand' => [
[
+29
View File
@@ -2,6 +2,35 @@
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
## Unreleased
- Manager: V4-Planempfang und Prognosebedienung im vorhandenen Bereich
Prognose / Forecast zusammengefasst. Explizite Start-/Stoppaktionen pruefen
Lizenz und bestehende Testfreigaben. Kein automatischer Start, keine neue
Dauerbetriebsfreigabe und keine Loeschung bestehender Laufzeitobjekte.
- Integration des bisherigen V4-Entwicklungsstands mit den aktuellen Manager-,
SDL- und Sollwertkorrekturen; V4-Stopp und Sollwertverwerfen bleiben erhalten.
- Optionaler nativer V4-Betriebsdatensender fuer den Schattenversuch (standardmaessig aus), separate Diagnose und sichere Batterie-/Messquellenzuordnung. Kein neuer Fahrplan wird lokal ausgefuehrt; vollstaendige Monatspeak-/Viertelstunden-Messnachweise und deren Historienadapter bleiben Voraussetzung.
- Verbraucher: Beim Verwerfen einer Manager-Vorgabe wird auch die gespeicherte
und sichtbare Sollleistung auf 0 W gesetzt. Gilt fuer Batterie, beide
Ladestationen, Pufferspeicher, Verbraucher 1-Stufig, Waermepumpe und
Warmwassererwaermer. Bereits ungueltige Altwerte werden beim Anwenden der
Instanzkonfiguration bereinigt. Gueltige Vorgaben, lokale Schutzprogramme,
Mindestlaufzeiten und der Nachrichtenvertrag bleiben erhalten.
- Manager: SDL-/Regelenergie als separate Istleistungsquelle mit optionalem SOC,
Leistungsfaktor und getrennten Anzeigeoptionen fuer Energiefluss und Diagramme.
SDL wird unabhaengig von der Anzeige aus Hauslast und neuer Hausenergie
herausgerechnet; die Prognosetelemetrie verwendet die korrigierte Hauslast.
Bestehende Visualisierungen werden ausschliesslich um eigene SDL-Eintraege
ergaenzt. Standardmaessig deaktiviert, keine automatische Quellenmigration,
keine rueckwirkende Datenkorrektur und keine Aenderung externer SDL-Steuerungen.
- Manager: Positives Budget wird bei gleicher Prioritaet nach der kleinsten
naechsten erreichbaren Sollleistung verteilt. Nur bei gleicher Leistungsstufe
entscheiden exakter Energiebezug und anschliessend die Instanz-ID. Einzelstufen
und ganzzahlige Bereiche teilen sich das Budget; reservierte Lasten und die
negative Defizitregelung bleiben unveraendert. Keine Konfigurationsmigration.
## 0.1 Build 1 - Erste Beta - 2026-09-29
### Enthalten
+6 -5
View File
@@ -144,6 +144,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
$this->WriteAttributeBoolean('GatewayVerbunden', false);
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
$this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', false);
@@ -179,7 +180,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->starteRegeluebergang();
} else {
$this->beendeRegeluebergang();
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->aktualisiere(true);
return;
@@ -192,7 +193,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
$this->starteRegeluebergang();
} else {
$this->beendeRegeluebergang();
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->aktualisiere(true);
return;
@@ -215,7 +216,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->aktualisiere(true);
return;
@@ -322,7 +323,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
true
)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->aktualisiere(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
@@ -559,7 +560,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
if (
+17 -6
View File
@@ -100,6 +100,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->RegisterAttributeInteger('PhasenerkennungSeit', 0);
$this->RegisterAttributeInteger('PhasenerkennungVersion', 0);
$this->RegisterAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
$this->RegisterAttributeInteger('GeraeteMaximalstrom', 32);
$this->RegisterAttributeInteger('ErkannterMaximalstrom', 32);
$this->RegisterAttributeInteger('FahrzeugstromKandidat', 0);
$this->RegisterAttributeInteger('FahrzeugstromKandidatSeit', 0);
@@ -138,6 +139,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
// Ein persistiertes Ladeende nach Neueinrichtung oder Modulupdate einmal neu pruefen.
$this->setzeZustand('FahrzeugGeladen', false);
@@ -189,7 +191,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->starteRegeluebergang();
} else {
$this->beendeRegeluebergang();
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->aktualisiere(true);
return;
@@ -202,7 +204,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->starteRegeluebergang();
} else {
$this->beendeRegeluebergang();
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->aktualisiere(true);
return;
@@ -223,7 +225,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->aktualisiere(true);
return;
@@ -236,7 +238,6 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->aktualisiere(false);
$this->sendeVerbraucherdaten();
return;
@@ -275,7 +276,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->aktualisiere(false, false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
@@ -315,6 +316,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$maximalstrom = min(
$this->ReadPropertyInteger('MaximalerLadestrom'),
max(6, $this->ReadAttributeInteger('GeraeteMaximalstrom')),
max(6, $this->ReadAttributeInteger('ErkannterMaximalstrom'))
);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $maximalstrom);
@@ -423,6 +425,15 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$this->ReadPropertyInteger('Geraetetyp'),
$antwort
);
if (isset($rohstatus['Sollstrom_A'])) {
$geraeteSollstrom = (int) $rohstatus['Sollstrom_A'];
if ($geraeteSollstrom === 0 || ($geraeteSollstrom >= 6 && $geraeteSollstrom <= 32)) {
$this->WriteAttributeInteger('LetzterGesetzterStrom', $geraeteSollstrom);
}
}
if (isset($rohstatus['Maximalstrom_A'])) {
$this->WriteAttributeInteger('GeraeteMaximalstrom', (int) $rohstatus['Maximalstrom_A']);
}
$warVerbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$verbunden = $rohstatus['Rohstatus'] !== 1;
@@ -550,7 +561,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$ersatz = 0;
foreach ($angebot as $leistungswert) {
if ($leistungswert > $sollleistung) {
+97 -9
View File
@@ -260,10 +260,14 @@
"type": "Label",
"caption": "Energiezaehler werden in kWh erwartet; ueber den Faktor koennen beispielsweise Wh (0,001) umgerechnet werden."
},
{
"type": "Label",
"caption": "Die Wechselrichter-Tabelle beschreibt die AC-Wandler der Anlage. PV-Flaechen und Batteriespeicher verweisen darauf, damit gemeinsame Leistungsgrenzen und die elektrische Kopplung eindeutig sind. Bei einem Hybridgeraet verwenden PV und Batterie dieselbe Wechselrichter-ID; bei einem AC-gekoppelten Speicher wird dessen separater Batteriewechselrichter erfasst."
},
{
"type": "List",
"name": "AnlagenWechselrichter",
"caption": "Wechselrichter",
"caption": "Wechselrichter / AC-Wandler",
"rowCount": 4,
"add": true,
"delete": true,
@@ -459,6 +463,10 @@
}
]
},
{
"type": "Label",
"caption": "Der Batterieeintrag beschreibt den Speicher selbst: Kapazitaet, Leistung, Ladezustand und Energiezaehler. Die Wechselrichter-ID ist nur die Verknuepfung zum zugehoerigen AC-Wandler und keine doppelte Batteriekonfiguration."
},
{
"type": "List",
"name": "AnlagenBatterien",
@@ -722,26 +730,61 @@
{
"type": "CheckBox",
"name": "EnergyPieAnzeigen",
"caption": "Energy Pie unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergyPieAnzeigen', $EnergyPieAnzeigen);"
"caption": "Energy Pie unter dem Manager anzeigen"
},
{
"type": "CheckBox",
"name": "EnergiediagrammeAnzeigen",
"caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergiediagrammeAnzeigen', $EnergiediagrammeAnzeigen);"
"caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen"
},
{
"type": "RowLayout",
"items": [
{
"type": "Select",
"name": "DiagrammPVModus",
"caption": "PV in Diagrammen",
"options": [
{"caption": "Summe", "value": 0},
{"caption": "Einzelne Anlagen", "value": 1},
{"caption": "Summe und einzelne Anlagen", "value": 2}
]
},
{
"type": "Select",
"name": "DiagrammBatterieModus",
"caption": "Batterien in Diagrammen",
"options": [
{"caption": "Summe", "value": 0},
{"caption": "Einzelne Speicher", "value": 1},
{"caption": "Summe und einzelne Speicher", "value": 2}
]
}
]
},
{
"type": "CheckBox",
"name": "LadestationenSeparatAnzeigen",
"caption": "Ladestationen separat in Energiefluss und Diagrammen anzeigen"
},
{
"type": "CheckBox",
"name": "VerbraucherSeparatAnzeigen",
"caption": "Uebrige Verbraucher separat in Energiefluss und Diagrammen anzeigen"
},
{
"type": "Label",
"caption": "Bestehende Energy Pies, Diagramme und Energiefluesse bleiben bei Apply unveraendert. Fuer eine Neuerzeugung den jeweiligen Schalter ausschalten, Apply ausfuehren, wieder einschalten und erneut Apply ausfuehren."
},
{
"type": "CheckBox",
"name": "FunFactsAnzeigen",
"caption": "Energy Facts unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormFunFactsAnzeigen', $FunFactsAnzeigen);"
"caption": "Energy Facts unter dem Manager anzeigen"
},
{
"type": "CheckBox",
"name": "EnergieflussAnzeigen",
"caption": "Energiefluss unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergieflussAnzeigen', $EnergieflussAnzeigen);"
"caption": "Energiefluss unter dem Manager anzeigen"
},
{
"type": "Select",
@@ -756,6 +799,7 @@
},
{
"type": "ExpansionPanel",
"name": "PrognoseForecast",
"caption": "Prognose / Forecast",
"items": [
{
@@ -792,6 +836,50 @@
"type": "ExpansionPanel",
"caption": "SDL / VGT",
"items": [
{
"type": "CheckBox",
"name": "SDLAktiv",
"caption": "SDL aktiv",
"onChange": "IPS_RequestAction($id, 'FormSDLAktiv', $SDLAktiv);"
},
{
"type": "ExpansionPanel",
"name": "SDLKonfiguration",
"caption": "SDL / Regelenergie Messung",
"visible": false,
"items": [
{
"type": "SelectVariable",
"name": "SDLLeistungVariableID",
"caption": "SDL Istleistung"
},
{
"type": "NumberSpinner",
"name": "SDLLeistungsfaktor",
"caption": "SDL Leistungsfaktor nach Watt",
"digits": 3
},
{
"type": "SelectVariable",
"name": "SDLSOCVariableID",
"caption": "SDL SOC in Prozent (optional)"
},
{
"type": "CheckBox",
"name": "SDLEnergieflussAnzeigen",
"caption": "SDL mit SOC im Energiefluss anzeigen"
},
{
"type": "CheckBox",
"name": "SDLDiagrammeAnzeigen",
"caption": "SDL mit SOC in den Diagrammen anzeigen"
},
{
"type": "Label",
"caption": "Nach Skalierung: positiv = Laden / Bezug, negativ = Entladen / Abgabe. SDL muss separat gemessen sein und darf nicht bereits in der normalen Batterieleistung enthalten sein. Die Hauslast fuer Prognosen wird unabhaengig von den Anzeigeoptionen korrigiert."
}
]
},
{
"type": "ValidationTextBox",
"name": "SDLAnschluss",
+280 -25
View File
@@ -11,6 +11,12 @@ require_once __DIR__ . '/../libs/Anlagentopologie.php';
require_once __DIR__ . '/../libs/StoerungsSnapshot.php';
require_once __DIR__ . '/../libs/EnergieMessung.php';
require_once __DIR__ . '/../libs/ManagerEnergieTrait.php';
require_once __DIR__ . '/../libs/ManagerSDLTrait.php';
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4TestTrait.php';
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4AktivtestTrait.php';
require_once __DIR__ . '/../libs/ManagerPrognoseFormular.php';
use Belevo\EnelixEMS\Anlagentopologie;
use Belevo\EnelixEMS\EinspeiseRegler;
@@ -18,6 +24,7 @@ use Belevo\EnelixEMS\EnergieMessung;
use Belevo\EnelixEMS\ManagerEnergieTrait;
use Belevo\EnelixEMS\Lizenzpruefung;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerPrognoseFormular;
use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\StoerungsSnapshot;
@@ -25,6 +32,10 @@ use Belevo\EnelixEMS\StoerungsSnapshot;
class Manager extends IPSModule implements ManagerSchnittstelle
{
use ManagerEnergieTrait;
use \Belevo\EnelixEMS\ManagerSDLTrait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4TestTrait;
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4AktivtestTrait;
private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201;
@@ -46,6 +57,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'HausverbrauchLeistungArchiv',
'NetzleistungArchiv',
'BatterieleistungArchiv',
'SDLLeistungArchiv',
'SDLSOCArchiv',
];
private const ENERGIE_VARIABLEN = [
'PVEnergie',
@@ -54,6 +67,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'EinspeisungEnergie',
'BatterieLadenEnergie',
'BatterieEntladenEnergie',
'SDLLadenEnergie',
'SDLEntladenEnergie',
];
private const VERBRAUCHER_LIZENZEN = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [
@@ -112,6 +127,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
public function Create(): void
{
parent::Create();
$this->registriereNetzfahrplanV4();
$this->registriereV4ManagerTest();
$this->registriereV4ManagerAktivtest();
$this->RegisterPropertyInteger('Rolle', 0);
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
@@ -146,6 +164,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyInteger('PrognoseSendeintervall', 60);
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyBoolean('SDLAktiv', false);
$this->RegisterPropertyInteger('SDLLeistungVariableID', 0);
$this->RegisterPropertyFloat('SDLLeistungsfaktor', 1.0);
$this->RegisterPropertyInteger('SDLSOCVariableID', 0);
$this->RegisterPropertyBoolean('SDLEnergieflussAnzeigen', false);
$this->RegisterPropertyBoolean('SDLDiagrammeAnzeigen', false);
$this->RegisterPropertyString('Lizenzcode', '');
// Verdeckte Altproperty fuer bestehende Instanzkonfigurationen.
$this->RegisterPropertyString('LizenzAnschluss', '{}');
@@ -171,6 +195,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyInteger('EnergieLoeschenMonate', 0);
$this->RegisterPropertyBoolean('EnergyPieAnzeigen', false);
$this->RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false);
$this->RegisterPropertyInteger('DiagrammPVModus', 0);
$this->RegisterPropertyInteger('DiagrammBatterieModus', 0);
$this->RegisterPropertyBoolean('LadestationenSeparatAnzeigen', true);
$this->RegisterPropertyBoolean('VerbraucherSeparatAnzeigen', true);
$this->RegisterPropertyBoolean('FunFactsAnzeigen', false);
$this->RegisterPropertyBoolean('EnergieflussAnzeigen', false);
$this->RegisterPropertyInteger('EnergieflussLeistungseinheit', 0);
@@ -203,6 +231,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeInteger('MessungLetzteEnergieaufzeichnung', 0);
$this->RegisterAttributeString('MessungEnergiePuffer', '{}');
$this->RegisterAttributeString('MessungZaehlerstaende', '{}');
$this->RegisterAttributeString('EnergiediagrammKonfiguration', '{}');
$this->RegisterAttributeString('EnergieflussKonfiguration', '{}');
$this->RegisterAttributeBoolean('EnergieflussNeuErzeugen', false);
$this->RegisterAttributeString('SDLMesskonfiguration', '');
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
$this->RegisterTimer('Regelzyklus', 0, '');
@@ -212,6 +244,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
);
$this->RegisterTimer(
'RegelungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RegelnVerzoegert', false);"
);
$this->RegisterTimer(
'PrognoseSenden',
0,
@@ -232,19 +269,30 @@ class Manager extends IPSModule implements ManagerSchnittstelle
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->v4ManagerTestAbbrechen('Manager neu initialisiert');
$this->SetTimerInterval('NetzfahrplanV4Senden', 0);
$this->stoppeNetzfahrplanV4Vorschau();
$this->initialisiereLizenzInstallationID();
$this->konfiguriereNetzfahrplanV4Daten();
$this->aktualisiereVariablen();
foreach (ManagerPrognoseFormular::laufzeitAnzeige() as $ident => $name) {
$objektID = $this->GetIDForIdent($ident);
IPS_SetName($objektID, $name);
IPS_SetHidden($objektID, true);
}
try {
$this->pruefeKonfiguration();
$this->konfiguriereEnergiearchiv();
$this->synchronisiereSDLMesskonfiguration();
$this->synchronisiereEnergievisualisierungen();
$this->konfiguriereEnergiearchiv();
} catch (Throwable $fehler) {
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', 0);
$this->SetTimerInterval('RegelungVerzoegert', 0);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval('StoerungenSenden', 0);
$this->SetTimerInterval('MesswerteAufzeichnen', 0);
@@ -256,6 +304,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->SetTimerInterval('RegelungVerzoegert', 0);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval(
'StoerungenSenden',
@@ -287,6 +336,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return;
}
$this->SetStatus(self::STATUS_AKTIV);
if ($this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
$this->SetTimerInterval('NetzfahrplanV4Senden', 90000);
}
$this->konfiguriereNetzfahrplanV4Vorschau();
$this->synchronisiereAnlagentopologie();
if ($this->prognoseTelemetrieKonfiguriert()) {
$this->SetTimerInterval(
@@ -416,6 +469,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->monatsgrenzenFuerFormular()
);
$this->setzeFormularParameter(
$formular['elements'],
'SDLKonfiguration',
'visible',
$this->ReadPropertyBoolean('SDLAktiv')
);
$formular = ManagerPrognoseFormular::integrieren($formular, $this->prognoseBedienzustand());
return json_encode($formular, JSON_THROW_ON_ERROR);
}
@@ -449,6 +510,53 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->regeln((bool) $wert);
return;
case 'RegelnVerzoegert':
$this->SetTimerInterval('RegelungVerzoegert', 0);
$this->regeln(false);
return;
case 'NetzfahrplanV4Empfangen':
$this->empfangeNetzfahrplanV4();
return;
case 'NetzfahrplanV4VorschauAktualisieren':
$this->aktualisiereNetzfahrplanV4Vorschau();
return;
case 'NetzfahrplanV4TestStart':
if (!is_string($wert)) throw new InvalidArgumentException('Explizite JSON-Testfreigabe erforderlich.');
$this->V4ManagerTestStarten($wert);
return;
case 'NetzfahrplanV4TestStop':
$this->V4ManagerTestStoppen();
return;
case 'NetzfahrplanV4Aktivtest':
$this->V4ManagerAktivtestSchalten((bool) $wert);
return;
case 'FormNetzfahrplanSchalten':
if (!is_bool($wert)) {
throw new InvalidArgumentException('Netzfahrplan-Schaltung muss ein boolescher Wert sein.');
}
if ($wert && !ManagerPrognoseFormular::startErlaubt($this->prognoseBedienzustand())) {
throw new RuntimeException('Netzfahrplan nicht freigegeben. Gespeicherte Konfiguration, Lizenz und Testfreigabe pruefen.');
}
try {
$this->V4ManagerAktivtestSchalten($wert);
} finally {
$this->aktualisierePrognoseBedienung();
}
return;
case 'NetzfahrplanV4DatenErfassen':
$this->ErfasseNetzfahrplanV4Daten();
return;
case 'NetzfahrplanV4Senden':
$this->sendeNetzfahrplanV4();
return;
case 'PrognoseSenden':
$this->sendePrognoseTelemetrie();
return;
@@ -480,6 +588,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (!$aktiv) {
$this->UpdateFormField('NetzfahrplanAktiv', 'value', false);
}
$this->aktualisierePrognoseBedienung($aktiv);
return;
case 'FormSDLAktiv':
$this->UpdateFormField('SDLKonfiguration', 'visible', (bool) $wert);
return;
case 'FormEnergyPieAnzeigen':
@@ -616,7 +729,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
];
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
$this->aktualisiereEnergieflusswerte();
$this->regeln(false);
$this->aktualisiereDiagrammMesswerte();
// Den synchronen Rueckruf in die noch meldende Verbraucherinstanz vermeiden.
$this->SetTimerInterval('RegelungVerzoegert', 100);
}
/** @param array<string, mixed> $daten */
@@ -680,6 +796,28 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
private function regeln(bool $erneuern): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try {
$trialBefore=(bool)$this->v4ManagerTestSession();
$this->regelnIntern($erneuern);
if ($trialBefore && !$this->v4ManagerTestSession() && $this->GetValue('Aktiv')
&& IPS_GetInstance($this->InstanceID)['InstanceStatus'] === self::STATUS_AKTIV) {
$this->WriteAttributeString('LetzteSollwerte','{}');
$this->regelnIntern(true); // exactly one fresh fallback, no cached pre-trial target
}
if ($this->v4ManagerTestSession()
&& (!$this->GetValue('Aktiv') || IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== self::STATUS_AKTIV)) {
$this->v4ManagerTestAbbrechen('Manager nicht mehr betriebsbereit');
}
} catch (Throwable $e) {
$this->v4ManagerTestAbbrechen('Regelungsfehler');
throw $e;
} finally { IPS_SemaphoreLeave($lock); }
}
private function regelnIntern(bool $erneuern): void
{
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
@@ -784,6 +922,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
: $this->nichtGeregelteGeraeteText($nichtGeregelt)
);
$this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
@@ -793,6 +932,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
$letzteSollwerte[(string) $instanzID] = $sollleistung;
} catch (Throwable $fehler) {
$this->v4ManagerTestAbbrechen('Andere Verbraucher-Stellwertuebergabe fehlgeschlagen');
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
}
}
@@ -1008,13 +1148,20 @@ class Manager extends IPSModule implements ManagerSchnittstelle
foreach ($ids as $instanzID) {
$eintrag = $cache[(string) $instanzID] ?? null;
$grund = '';
if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) {
$grund = 'Noch keine Meldung';
} elseif (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
$grund = 'Meldung ist veraltet';
} elseif (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) {
$grund = 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart;
$grund = $this->verbraucherMeldegrund($eintrag, $betriebsart, $timeout);
$synchronisierungsfehler = '';
if (in_array($grund, ['Noch keine Meldung', 'Meldung ist veraltet'], true)) {
try {
$this->SetBuffer('VerbraucherSynchronisierungAktiv', '1');
IPS_RequestAction($instanzID, 'Melden', true);
} catch (Throwable $fehler) {
$synchronisierungsfehler = $fehler->getMessage();
} finally {
$this->SetBuffer('VerbraucherSynchronisierungAktiv', '0');
}
$cache = $this->leseJsonAttribut('VerbraucherCache');
$eintrag = $cache[(string) $instanzID] ?? null;
$grund = $this->verbraucherMeldegrund($eintrag, $betriebsart, $timeout);
}
if ($grund !== '') {
@@ -1025,8 +1172,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
try {
$this->sendeManagerdaten($instanzID, $betriebsart, null);
} catch (Throwable $fehler) {
$stoerungen[] = $detail['Name'] . ': Betriebsart konnte nicht uebermittelt werden: '
. $fehler->getMessage();
$synchronisierungsfehler = $fehler->getMessage();
}
if ($synchronisierungsfehler !== '') {
$stoerungen[] = $detail['Name'] . ': Vollmeldung konnte nicht angefordert werden: '
. $synchronisierungsfehler;
}
continue;
}
@@ -1039,6 +1189,27 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return $aktuell;
}
/** @param array<string, mixed>|null $eintrag */
private function verbraucherMeldegrund(
?array $eintrag,
string $betriebsart,
int $timeout
): string {
if (!isset($eintrag['EmpfangenAm'], $eintrag['Daten'])
|| !is_array($eintrag['Daten'])
) {
return 'Noch keine Meldung';
}
if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
return 'Meldung ist veraltet';
}
if (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) {
return 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart;
}
return '';
}
private function setzeNichtGeregelteVerbraucher(string $grund): void
{
try {
@@ -1623,6 +1794,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'HausverbrauchLeistungArchiv' => 'Hausverbrauch',
'NetzleistungArchiv' => 'Netzleistung Aufzeichnung',
'BatterieleistungArchiv' => 'Batterieleistung',
'SDLLeistungArchiv' => 'SDL Istleistung',
];
$energievariablen = [
'PVEnergie' => 'PV-Energie',
@@ -1631,6 +1803,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'EinspeisungEnergie' => 'Einspeisung Energie',
'BatterieLadenEnergie' => 'Batterie geladen',
'BatterieEntladenEnergie' => 'Batterie entladen',
'SDLLadenEnergie' => 'SDL geladen',
'SDLEntladenEnergie' => 'SDL entladen',
];
$position = 200;
foreach ($leistungsvariablen as $ident => $name) {
@@ -1644,6 +1818,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$position += 10;
}
$this->RegisterVariableFloat('SDLSOCArchiv', 'SDL Ladezustand', '~Intensity.100', 390);
IPS_SetHidden($this->GetIDForIdent('SDLSOCArchiv'), !$this->ReadPropertyBoolean('MesswerteAnzeigen'));
if ($this->ReadPropertyBoolean('SDLAktiv')) {
$this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120);
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->RegisterVariableBoolean(
'NetzleistungGueltig',
@@ -1702,6 +1882,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if ($ident === 'SDLStatus' && $this->ReadPropertyBoolean('SDLAktiv')) {
continue;
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
@@ -1977,7 +2160,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
];
}
/** @return array{PV: float, Haus: float, Netz: float, Batterie: float} */
/** @return array{PV: float, Haus: float, Netz: float, Batterie: float, SDL: float} */
private function leseMessleistungen(): array
{
$pv = 0.0;
@@ -1990,12 +2173,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
$netz = (float) GetValue($this->ReadPropertyInteger('NetzleistungVariableID'))
* $this->ReadPropertyFloat('Netzleistungsfaktor');
$sdl = $this->aktuelleSDLLeistung();
return [
'PV' => $pv,
'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie),
'Haus' => EnergieMessung::hausleistung($pv, $netz, $batterie, $sdl),
'Netz' => $netz,
'Batterie' => $batterie,
'SDL' => $sdl,
];
}
@@ -2020,7 +2205,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
/** @return array<string, float>|null */
private function leseEnergieZaehlerDeltas(): ?array
private function leseEnergieZaehlerDeltas(
float $sdlLaden = 0.0,
float $sdlEntladen = 0.0,
bool $hausVollstaendig = true
): ?array
{
$quellen = $this->energieZaehlerquellen();
if ($quellen === null) {
@@ -2049,6 +2238,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$delta = $stand - (float) $alt[$schluessel];
if ($delta >= 0.0) {
$deltas[$ident] += $delta;
} elseif ($this->ReadPropertyBoolean('SDLAktiv')) {
$hausVollstaendig = false;
}
}
}
@@ -2060,13 +2251,17 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return array_fill_keys(self::ENERGIE_VARIABLEN, 0.0);
}
$deltas['Hausenergie'] = EnergieMessung::hausenergie(
$deltas['SDLLadenEnergie'] = $sdlLaden;
$deltas['SDLEntladenEnergie'] = $sdlEntladen;
$deltas['Hausenergie'] = $hausVollstaendig ? EnergieMessung::hausenergie(
$deltas['PVEnergie'],
$deltas['NetzbezugEnergie'],
$deltas['EinspeisungEnergie'],
$deltas['BatterieLadenEnergie'],
$deltas['BatterieEntladenEnergie']
);
$deltas['BatterieEntladenEnergie'],
$sdlLaden,
$sdlEntladen
) : 0.0;
return $deltas;
}
@@ -2077,13 +2272,31 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return;
}
$jetzt = time();
try {
$leistungen = $this->leseMessleistungen();
} catch (Throwable $fehler) {
// Keine falsche Hauslast aufzeichnen oder ueber die Messluecke integrieren.
$this->WriteAttributeInteger('MessungLetzterZeitstempel', 0);
$this->WriteAttributeString('MessungZaehlerstaende', '{}');
$this->setzeDiagnosewert('SDLStatus', 'Fehler: ' . $fehler->getMessage());
$this->protokolliere('Energiemessung', $fehler->getMessage());
return;
}
$letzterZeitstempel = $this->ReadAttributeInteger('MessungLetzterZeitstempel');
$puffer = $this->leseJsonAttribut('MessungEnergiePuffer');
$energieDeltas = $this->leseEnergieZaehlerDeltas();
$dauer = $letzterZeitstempel > 0 ? max(0, $jetzt - $letzterZeitstempel) : 0;
$sdlIntervallGueltig = $letzterZeitstempel > 0 && $dauer <= 300;
$integriert = EnergieMessung::energieDeltas(
$leistungen,
$this->ReadPropertyBoolean('SDLAktiv') && !$sdlIntervallGueltig ? 0 : min(300, $dauer)
);
$energieDeltas = $this->leseEnergieZaehlerDeltas(
$integriert['SDLLadenEnergie'],
$integriert['SDLEntladenEnergie'],
!$this->ReadPropertyBoolean('SDLAktiv') || $sdlIntervallGueltig
);
if ($energieDeltas === null && $letzterZeitstempel > 0) {
$sekunden = min(300, max(0, $jetzt - $letzterZeitstempel));
$energieDeltas = EnergieMessung::energieDeltas($leistungen, $sekunden);
$energieDeltas = $integriert;
}
foreach ($energieDeltas ?? [] as $ident => $delta) {
$puffer[$ident] = max(0.0, (float) ($puffer[$ident] ?? 0.0)) + $delta;
@@ -2102,10 +2315,15 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'HausverbrauchLeistungArchiv' => 'Haus',
'NetzleistungArchiv' => 'Netz',
'BatterieleistungArchiv' => 'Batterie',
'SDLLeistungArchiv' => 'SDL',
] as $ident => $quelle) {
$this->SetValue($ident, $leistungen[$quelle]);
}
$this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', $jetzt);
$soc = $this->aktuelleSDLSOC();
if ($soc !== null) {
$this->SetValue('SDLSOCArchiv', $soc);
}
}
$energieintervall = $this->ReadPropertyInteger('EnergieaufzeichnungMinuten') * 60;
@@ -2120,6 +2338,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt);
}
$this->aktualisiereDiagrammMesswerte(true);
$funFactsID = @IPS_GetObjectIDByIdent('EnergieFunFacts', $this->InstanceID);
if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) {
$this->aktualisiereFunFacts($funFactsID);
@@ -2474,6 +2693,35 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$aktiv && $netzfahrplanLizenziert
&& $this->peakShavingLizenziert($lizenzcode)
);
$this->aktualisierePrognoseBedienung(null, $lizenzcode);
}
private function prognoseBedienzustand(?bool $aktiv = null, ?string $lizenzcode = null): array
{
return [
'prognoseAktiv' => ($aktiv ?? true) && $this->ReadPropertyBoolean('PrognoseAktiv'),
'lizenziert' => $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN, $lizenzcode),
'senderAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv'),
'empfangAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv'),
'messdatenAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv'),
'legacyAktiv' => $this->ReadPropertyBoolean('NetzfahrplanAktiv'),
'managerAktiv' => $this->GetValue('Aktiv')
&& IPS_GetInstance($this->InstanceID)['InstanceStatus'] === self::STATUS_AKTIV,
'testErlaubt' => $this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt'),
'aktivtestErlaubt' => $this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'),
'watchdogVerzichtErlaubt' => $this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt'),
'aktivtestAktiv' => (bool) $this->GetValue('NetzfahrplanV4Aktivtest'),
'ende' => $this->ReadAttributeInteger('NetzfahrplanV4AktivtestEnde'),
'jetzt' => time(),
];
}
private function aktualisierePrognoseBedienung(?bool $aktiv = null, ?string $lizenzcode = null): void
{
$zustand = $this->prognoseBedienzustand($aktiv, $lizenzcode);
$this->UpdateFormField('PrognoseNetzfahrplanStart', 'enabled', ManagerPrognoseFormular::startErlaubt($zustand));
$this->UpdateFormField('PrognoseNetzfahrplanStatus', 'caption', ManagerPrognoseFormular::statustext($zustand));
$this->UpdateFormField('NetzfahrplanV4EmpfangAktiv', 'enabled', $zustand['lizenziert'] || $zustand['empfangAktiv']);
}
private function prognoseLizenzStatustext(bool $aktiv, bool $lizenziert): string
@@ -2618,11 +2866,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'Prognosestatus',
$this->ReadPropertyBoolean('PrognoseAktiv') ? 'Wartet' : 'NichtVerwendet'
);
foreach (['SDLAnschluss' => 'SDLStatus'] as $property => $variable) {
$konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
$aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true;
$this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet');
}
$this->aktualisiereSDLStatus();
$this->setzeDiagnosewert(
'Stoerueberwachungsstatus',
$this->ReadPropertyBoolean('StoerueberwachungAktiv') ? 'Wartet' : 'NichtVerwendet'
@@ -2631,6 +2875,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private function pruefeKonfiguration(): void
{
$this->pruefeSDLKonfiguration();
foreach (['MesswertMaxAlter', 'KeepAlive', 'VerbraucherTimeout'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
@@ -2647,6 +2892,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle
throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
}
}
foreach (['DiagrammPVModus', 'DiagrammBatterieModus'] as $property) {
if (!in_array($this->ReadPropertyInteger($property), [0, 1, 2], true)) {
throw new InvalidArgumentException($property . ' muss 0, 1 oder 2 sein.');
}
}
if (!in_array($this->ReadPropertyInteger('EnergieflussLeistungseinheit'), [0, 1], true)) {
throw new InvalidArgumentException(
'EnergieflussLeistungseinheit muss 0 oder 1 sein.'
);
}
$energieLoeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate');
$energieVerdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate');
if ($energieLoeschen > 0 && $energieLoeschen <= $energieVerdichten) {
+5 -4
View File
@@ -112,6 +112,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
parent::ApplyChanges();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
@@ -150,7 +151,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->regelzyklus(true);
return;
@@ -208,7 +209,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->regelzyklus(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
@@ -290,7 +291,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$ziel = 0;
@@ -301,7 +302,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$ziel = 0;
}
}
+6 -5
View File
@@ -118,6 +118,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
parent::ApplyChanges();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
$this->registriereMeldungen();
try {
@@ -158,7 +159,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0);
}
$this->aktualisiere(true);
@@ -214,7 +215,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->planeMeldung();
return;
@@ -254,7 +255,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
true
)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$aktiv = (bool) $this->GetValue('Aktiv');
@@ -474,7 +475,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0);
}
}
@@ -528,7 +529,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt));
$this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt);
$this->setzeZustand('Tageslaufzeit', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->setzeZustand('Rueckmeldefehler', false);
$this->WriteAttributeBoolean('Initialisiert', true);
}
+5 -4
View File
@@ -132,6 +132,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
parent::ApplyChanges();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
$this->registriereMeldungen();
try {
$this->pruefeKonfiguration();
@@ -172,7 +173,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0);
}
$this->aktualisiere(true);
@@ -230,7 +231,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->planeMeldung();
return;
@@ -286,7 +287,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
$this->WriteAttributeInteger('Laufbeginn', $laeuft ? $jetzt : 0);
$this->schalte(WaermepumpenRegler::STEUERZUSTAND_NORMAL, $jetzt);
$steuerzustand = WaermepumpenRegler::STEUERZUSTAND_NORMAL;
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$this->aktualisiereSperrerholung($laeuft, $jetzt);
@@ -828,7 +829,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
$ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit')
+ $this->ReadPropertyInteger('VorgabeTimeout');
if ($this->ReadAttributeInteger('LetzteVorgabeZeit') <= 0 || $jetzt >= $ablauf) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->SetTimerInterval('VorgabeTimeout', 0);
}
}
+4 -3
View File
@@ -122,6 +122,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
$this->initialisiereTemperaturen();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
try {
$this->pruefeKonfiguration();
@@ -219,7 +220,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$this->regelzyklus(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
@@ -264,7 +265,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
}
$ziel = 0;
@@ -276,7 +277,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) {
$this->setzeZustand('SollwertGueltig', false);
$this->verwerfeSollwert();
$ziel = 0;
}
}
+98
View File
@@ -0,0 +1,98 @@
# Forecast- und SDL-Integration, 6. Oktober 2026
## Auftrag und Umfang
Daniel Haefliger hat die Weiterfuehrung passender unveroeffentlichter Arbeiten,
die Integration der Prognosebedienung, SDL-Anzeigen und die Veroeffentlichung
auf develop und beta ausdruecklich beauftragt. main bleibt unveraendert.
- Manager: Prognose / Forecast ist der gemeinsame Bedienort. Die bisherigen
separaten Diagnosevariablen sind umbenannt und in der Visualisierung verborgen.
- Batterie: die bereits integrierte Bereinigung des separaten V4-Formulars bleibt
erhalten. Bestehende Idents, APIs und gespeicherte Freigaben werden aus
Kompatibilitaetsgruenden nicht umbenannt oder geloescht.
- Portal: derselbe Planner erscheint im vorhandenen Prognosebereich. Die exakte
ausgewaehlte Eingangsprognose wird als forecastPoints eingefroren, unabhaengig
vom durch publizierte Preise begrenzten Optimierungshorizont. Netzplan,
Batteriestellwerte und Kosten werden nicht kuenstlich verlaengert.
- Diagramme zeigen PV, Last, SDL-Szenario, Netz, Batterie, SOC, Preise und Kosten.
Fehlende Werte bleiben Luecken. Prognosewerte sind keine gemessenen Erfolge.
- Utils kapselt die generische Darstellung nicht bestimmbarer Energieanteile.
EMS entscheidet bei SDL-Bilanzierung, dass die Haus-PV-Anteile nicht eindeutig
aus dem SDL-haltigen Netzbezug hergeleitet werden koennen. Zaehler bleiben sichtbar.
## Migration und Erhaltung
Utils vor EMS aktualisieren. Bei alten Utils blendet EMS den eigenen Energy Pie
bei aktiver SDL aus und protokolliert den Aktualisierungsbedarf, statt falsche
Prozentwerte anzuzeigen oder die Manager-Regelung zu stoppen.
Bestehende Energy-Pie-Konfiguration, individuelle Flow-Knoten und Nicht-SDL-
Diagrammreihen bleiben erhalten. Visualisierungsschalter nicht aus-/einschalten:
das wuerde teilweise eine Neuerzeugung ausloesen. Historien, Energiezaehler,
Mess-Outbox und Betriebsfreigaben werden nicht zurueckgesetzt.
Die Testanlage wurde mit gesicherten, hashgeprueften Quelldateien aktualisiert.
Manager 17004 verwendet SDL-Leistung 25085 (W, Faktor +1) und SOC 23879 (%).
Diese Werte gehoeren zur vorhandenen virtuellen EV/SDL-Aufteilung und sind kein
unabhaengiger physischer SDL-Messnachweis. Der einzelne Batterie-Flow-Knoten wurde
von Proxy 19274 auf 42728 umgestellt; alle anderen Felder wurden erhalten.
Flow 57933 enthaelt zusaetzlich SDL. Leistungsdiagramm 53754 wuchs von vier auf
sechs Reihen, Energiediagramm 19668 von sechs auf neun. Keine Alt-Reihe entfernt.
Private Rueckfallsicherung auf der Anlage:
`/var/lib/symcon/backup/enelix-forecast-sdl-20261006T171851Z`.
Sie enthaelt Konfigurationen und darf nicht in Git, Logs oder Chat kopiert werden.
Der temporaere Beobachtungshook 12555 wurde wiederhergestellt.
## Pruefungen
- EMS PHPUnit: 360 Tests, 1771 Assertions, erfolgreich.
- Utils PHPUnit: 58 Tests, 350 Assertions, erfolgreich.
- Backend: 312 Tests, erfolgreich, isolierter aktueller develop-Stand mit Patch.
- Formular: 71 Pruefungen; RequestAction: 26; Regeltests: 96; Empfaenger: 32.
- Chart- und Energy-Pie-JavaScript: 14 Tests, erfolgreich.
- Portal-Mount: acht Tests, erfolgreich, inklusive Anlagen-/Ansichtswechsel,
Abmeldung, verspaeteter Antworten und unabhaengiger Fehlerbehandlung.
- Echter Chromium: Desktop 1280 px, Mobil 390 und 320 px; Kurven, Auswahl,
Maus/Tastatur, Lebenszyklus, CSS-Abgrenzung und Screenshots erfolgreich.
- Testanlage nach Installation: Manager, Batterie, Energy Pie und Flow Status 102;
SDL-Quellen und erhaltene Diagrammreihen kontrolliert.
## Offene Betriebsschritte, keine Produktionsfreigabe
Die Nachkontrolle 2026-10-06 17:26:21 UTC zeigte den bekannten Symcon-Timerfehler
nach Module Control Reload: alter Batterie-Timer 70 blieb laufend, neue
Melde-/Rueckmeldetimer hatten noch keinen Lauf. Ein kontrollierter Symcon-Neustart
und danach erneute Pruefung sind erforderlich. Status 102 allein ist kein Beleg
fuer funktionierende Timer. Die neue Netzfahrplansteuerung 46716 bleibt false.
Portal-Assets wurden ohne Containerneustart aktualisiert. Das Backend-Image ist
noch nicht aktualisiert: Docker-Socketzugriff fuer agent ist nicht erlaubt.
Vorbereitete, erfolgreich vorgepruefte Administratoruebergabe auf enelix-services:
```sh
sudo python3 /srv/agent/forecast-completion-20261006/deploy_forecast_backend.py --apply
```
Das Skript prueft zwei exakte Quelldateien, sichert Datenbank und Quellen,
baut/testet das Image vor dem Austausch und bewahrt die bisherige Image-ID fuer
Rollback. Keine Socketrechte werden geaendert. Keine Stellfreigabe wird erteilt.
Nach erfolgreicher Installation Neuberechnung und neue forecastPoints pruefen.
Bis dahin kennzeichnet die Anzeige alte Plaene und zeigt keine erfundenen Werte.
Unbefristeter Betrieb ist NICHT freigegeben: Nachweis eines geraeteseitigen
Ausfallschutzes, physischer Soll-/Ist-Nachlauf, sichere Null-/Stop-Reaktion und
Fortfuehrung ueber mehrere Planerneuerungen fehlen weiterhin. Die bestehende
befristete Zustimmung bis 7. Oktober 2026, 17:38:07 Europe/Zurich wird nicht
verlaengert. EV-Konto bleibt 161.44 kWh / 39 kW; physische Gesamtkapazitaet und
virtuelle SDL-Energie werden nicht als zusaetzliche EV-Energie angesetzt.
## Pull-Request-Text
Forecast-Bedienung im Manager konsolidieren, vollstaendige gespeicherte
Eingangsprognose im bestehenden Portal einbetten und SDL-Anzeigen vervollstaendigen.
Individuelle Visualisierungen und kompatible Kennungen bleiben bestehen;
nicht bestimmbare Energieanteile werden ehrlich als unbekannt dargestellt.
Automatisierte Tests und responsive Browserpruefung erfolgreich. Zwei explizite
Betriebsblocker bleiben dokumentiert; kein unbegrenzter Anlagenbetrieb aktiviert.
+406
View File
@@ -0,0 +1,406 @@
# ENELIX Netzfahrplan V4 – verbindlicher Gesprächs- und Arbeitskontext
## Fortsetzung 06.10.2026, 17:32 UTC: Forecast/SDL installiert, Betriebsblocker
Massgeblicher neuer Bericht: `docs/FORECAST_SDL_RELEASE_20261006.md`.
Portal-Assets sind auf license.enelix.ch im vorhandenen Prognosebereich aktiv
und ueber HTTPS hashgleich geprueft. Vollstaendige forecastPoints benoetigen noch
das vorbereitete Backend-Image-Update durch einen Administrator. agent hat keine
Docker-Socketrechte; keine Umgehung, kein ausgefuehrtes Backend-Deployment.
Testanlage: Utils und EMS aus gesichertem Kandidat geladen, SDL-Messung und
Anzeigen eingerichtet, individuelle Reihen/Knoten und Historien erhalten.
Alte Diagnoseanzeigen und die zwei V4-Diagnosekategorien verborgen, nichts
geloescht oder deren Beobachter abgeschaltet. Hook 12555 wiederhergestellt.
Nach Reload haengt erneut alter Batterietimer 70; neue Batterie-Meldetimer
laufen nicht. Kontrollierter Symcon-Neustart und Nachkontrolle erforderlich.
Schalter 46716 bleibt false; keine neue Stellprobe, keine Fristverlaengerung.
Unbefristete Steuerung ist ohne unabhaengigen Ausfallschutz nicht freigegeben.
Die automatisierten Softwaretests und responsive Chromium-Pruefung sind gruen;
dies ist ausdruecklich keine erfolgreiche physische Anlagenabnahme.
## Integration 06.10.2026: Freigabe fuer develop und beta, kein Anlagenrollout
Daniel hat nach der Bedienintegration Merge, Commit und Push auf **develop und
beta** ausdruecklich beauftragt. Der getestete Integrationsstand verbindet die
22 lokalen V4-Commits bis ce525a1 mit origin/develop bis 5437f3f und den
zugehoerigen V4-/Formularkorrekturen. Originalarbeitsverzeichnisse bleiben
unangetastet; Integration in /srv/agent/prognose-integration-20261006/repo auf
ubuntu. Keine Feature-Branches, kein Force-Push, main bleibt unveraendert.
Die Bedienung liegt im bisherigen Bereich Prognose / Forecast; der separate
V4-Bereich entfaellt. Einzelheiten und verbleibender Umfang stehen in
docs/prognose-bedienintegration.md. Es bleibt der vorhandene begrenzte Testpfad,
kein regulaerer Dauerbetrieb. Keine Laufzeitordner geloescht, keine Dienste
neu gestartet, keine Module geladen und kein neuer Stellversuch ausgefuehrt.
Die unten dokumentierte Batterietimerblockade bleibt offen. Die ausdrueckliche
Branchfreigabe ersetzt keine physische Anlagenabnahme oder Stellfreigabe.
Isoliert bestanden: 357 PHPUnit-Tests / 1758 Assertions, Syntax aller 150 PHP-
Dateien, 310 Backend-Tests, 16 Portal-Tests und die Bedien-/V4-Einzelchecks.
Kein neuer Symcon-Kernel-/GUI-Test, weil Docker im Agentzugang nicht nutzbar war.
Eine lokale Backend-Aenderung, die ungepruefte Messkonfigurationen automatisch
mit accountingEvidenceId versah, wurde nach fehlgeschlagenem Schutztest nicht
uebernommen; Originalarbeitskopie und laufendes Backend bleiben erhalten.
Commit-Autor ist gemaess bestehender Teamklarstellung dh_Agent <dh@belevo.ch>,
konfiguriertes Gitea-Konto dh. Die Repository-Abfrage bestaetigte Pushrecht;
die Profilabfrage war mangels read:user-Scope nicht verfuegbar. Fuer den
tatsaechlichen Veroeffentlichungsstand die Remote-Refs und den Merge-Commit
pruefen, nicht historische Ahead-/Behind-Angaben weiter unten verwenden.
## Kontext-Sicherung 06.10.2026: Lesereihenfolge und Quellen
Auf Daniels ausdruecklichen Wunsch wird der Arbeitsstand dauerhaft verlinkt.
Fuer den Laufzeitstand ist der unmittelbar folgende Abschnitt **06.10.2026,
12:01 UTC** massgeblich, nicht die aelteren Abschnitte 1, 10, 11 oder 15.
Insbesondere ist der bestaetigte Geraeteabruf bereits implementiert/installiert;
der naechste Schritt ist die dort belegte instanzbezogene Batterietimerblockade.
Der Abschlussnachweis `/srv/agent/v4-recovery-20261006/finished.json` auf
iot-symcon01 wurde erneut gelesen, aber kein neuer Stellversuch ausgefuehrt.
Git-Nachkontrolle in dieser Kontext-Sicherung: nach erfolgreichem Fetch steht
das Server-89-develop **22 Commits vor / 3 hinter origin/develop**; Remote-Stand
bei Fetch `58f91e4`. Kein sicherer Fast-Forward. Bestehende fremde/lokale
Aenderungen bleiben erhalten; kein Merge, Projekt-Commit oder Projekt-Push.
Das ist vom anderen Checkout auf ubuntu und dessen separat dokumentierter
Manager-Leistungsverteilung zu unterscheiden.
Die mitgesendeten Screenshots haben kein bestaetigtes Aufnahmedatum und sind
historische Anschauung, kein neuer Laufzeitnachweis. Der Aktualisierungsdialog
ist keine Freigabe, lokale Modulaenderungen zu verwerfen. Es wurden keine
Module aktualisiert, Dienste neu gestartet oder Anlagenfreigaben veraendert.
## Fortsetzung 06.10.2026, 12:01 UTC: Code installiert, Batterietimer blockieren Live-Abnahme
- Daniel hat nach der ausdruecklichen Rueckfrage den Test ohne zusaetzliches +/-5-kW-Limit bestaetigt. Dynamische Geraetegrenzen (hier bis 39 kW), SOC- und Netzgrenzen bleiben verbindlich. Die urspruengliche Freigabe endet unveraendert am 07.10.2026 um 17:38:07 Europe/Zurich (1791387487). Kein Neustart der 48-h-Frist. Der fehlende unabhaengige Hardware-Watchdog bleibt nur durch die bereits genehmigte Testanlagen-Ausnahme abgedeckt.
- Zusaetzliche allgemeine Arbeitsfreigabe: Routinemaessige Schreibzugriffe auf Lihrenmoos, tunel100 und Server89 benoetigen keine erneute Rueckfrage. Das hebt Sicherheitsgrenzen, technische Berechtigungen, Git-Regeln und konkrete Anlagenfreigaben nicht auf.
- Der vorstehend beschriebene Kandidat wurde inzwischen installiert und getestet. Weitere eng begrenzte Korrekturen dieser Fortsetzung: (1) Batteriebefehl meldet den konkreten Ablehnungsgrund; (2) physische Geraeteabfrage aus dem Batterie-Stellmutex getrennt, eigenstaendiger 1-s-Samplingtimer mit nicht wartender Lesesperre; (3) Stellpfad nutzt nur unveraenderten bestaetigten Cache, maximal 2 s alt nach Wand- und monotoner Zeit, mit Konfigurations-/Quellenbindungspruefung; (4) passendes Server-Pending fuehrt auch vor Aktivstart zu 15-s-Retry statt zum Verpassen des fertigen Plans, HTTP-429 behaelt normalen Backoff. Cache-90-s-Grenze und physische Zeitstempel wurden nicht verlaengert oder erfunden.
- Live-Installationen mit SHA-256-Pruefung und Backups: Fehlerdiagnose 11:31:18 UTC; Sampler 11:38:43 UTC; Empfang um 11:50 UTC. Arbeits- und Sicherungsordner: /srv/agent/v4-recovery-20261006 auf iot-symcon01. Berichte deployed.json, sampler-deployed.json, receiver-deployed.json und test-results.json. Geaenderte Quellen und Tests sind auch im bestehenden develop-Arbeitsverzeichnis auf enelix-services, fremde Aenderungen sind erhalten.
- Letzter Aktivversuch: 11:50:52 UTC, Session 13395dbe-0dbb-42b8-9063-511bd7166f41. Manager meldete danach controller_command_sent (seq 5, 18693 W); Batterie wurde wegen feedback_confirmed_cache_stale_or_invalid gestoppt. Danach Managerabbruch wegen ungueltiger lokaler Betriebsdaten, controllerStopAccepted=true. Kein belastbarer Nachweis von physischem Tracking oder >90-s-Dauerbetrieb. Ein gesendeter Befehl ist ausdruecklich keine Anlagenabnahme.
- KONKRETE OFFENE BLOCKADE: Saemtliche Timer der Batterieinstanz 44234 zeigen LastRun=0. Meldezyklus (5000 ms) und neuer Rueckmeldungstimer (1000 ms) sind lange ueberfaellig, Running=false. Manager- und uebrige Anlagentimer laufen. Instanzstatus 102; Instanz und alle Elternobjekte nicht deaktiviert; keine dauerhaft belegten Skriptthreads nachgewiesen. IPS_ApplyChanges(44234) bei bestaetigtem Null-Stopp um 11:57:31 UTC gab TRUE zurueck, hat die Timer aber nicht repariert. Die Ursache ist NICHT geklaert. Auch der Batterie-Watchdog hat bisher keinen automatischen Lauf nachgewiesen. Deshalb kein weiterer Aktivstart und keine Umgehung durch einen externen Tick-Hook.
- Weitere beobachtete Abbruchbedingung: Durch Reloads/Messluecken fehlte zeitweise laufende Viertelstundenenergie; der Planner verwirft Luecken >120 s weiterhin korrekt. Nach einem Quartalswechsel wurden wieder optimale Plaene empfangen. Zum Schluss meldete der Betriebssender zudem veraltete Verbraucher-Rueckmeldung, passend zum nicht laufenden Batterie-Meldezyklus. Keine Energiestaende, Cursor oder Datenbankwerte wurden in dieser Fortsetzung umgeschrieben.
- Endzustand 12:01:11 UTC: Schalter 46716=false, alter Netzfahrplan=false, Batterie status=stopped, EV-Sollwert 19651=0 W. SDL-Sollwert war 7600 W aus dem unabhaengigen bestehenden Betrieb und wurde nicht auf null gezwungen. Kein physischer V4-Regelungsnachweis. Verbindlicher Abschlussbericht: /srv/agent/v4-recovery-20261006/finished.json.
- Alle temporaeren Start-, Deploy- und Diagnosehooks wurden aus 12555.ips.php entfernt. Original aus /srv/agent/12555.ips.php.v4hook-backup wiederhergestellt, SHA-256 c702a4947ae200b23c3eecccabeb843ad6a55e97509292584ca2de779d5e4940. Nur v4viewsRun des urspruenglichen getrennten Beobachters verbleibt. Inhaltsuche in /var/lib/symcon/scripts fand keine Verweise auf die V4-Recovery-/Continuation-Hooks. Keine unbeobachtete Wiederanlaufautomatik hinterlassen.
- Tests erneut erfolgreich: 96 Regel-/Cache-/Lockpruefungen, 32 Empfangspruefungen, 8 Rueckmeldungs-Empfangspruefungen, 9 Geraeteabrufpruefungen und 1 Test der echten Registermethode mit synthetischen Aktionen, insgesamt 146. Syntaxpruefungen erfolgreich; git diff --check sauber. Das sind automatisierte Tests, kein Hardwareabnahmetest.
- Git bleibt develop, ahead 22 / behind 1 gegen origin/develop, kein sicherer Fast-Forward. Keine Projekt-Commits, Merges oder Pushes. Konto nicht fuer Schreibaktion verifiziert; weder beta noch main geaendert. Die fehlende Live-Abnahme schliesst eine Beta-Freigabe zusaetzlich aus.
- Naechster fachlicher Schritt: Instanzbezogene Symcon-Timerblockade beheben, automatische Sampler-/Melde-/Watchdog-Ausfuehrung belegen und erst dann einen kontrollierten Neustart des V4-Tests versuchen. Ein etwa erforderlicher Symcon-Dienstneustart betrifft weitere Anlagenfunktionen und wurde hier NICHT ausgefuehrt. Danach physisches Tracking, Planwechsel >90 s und bestaetigten Null-Stopp nachweisen. Alte Startmarker oder Installationshooks keinesfalls einfach loeschen/reaktivieren.
## Fortsetzung 06.10.2026: gepruefter Kandidat, Live-Eingriff noch gesperrt
- Daniel hat die Fortsetzung und die Aufhebung der 5-kW-Testgrenze beauftragt. Die bestehende 48-h-Frist, SOC-/Netz-/Geraetegrenzen und der bereits bekannte fehlende unabhaengige Hardware-Watchdog bleiben unveraendert.
- Aktuell auf iot-symcon01 gelesen: Refresh-Deployment 07:43:14 UTC; Full-Power-Deployment 08:46:59 UTC. Diese waren schon vor dieser Fortsetzung installiert. Snapshot 09:30:41 UTC: Schalter AUS, Batterie disabled, Manager stopped_fallback_pending wegen ungueltiger lokaler Betriebsdaten. Keine physische V4-Regelung belegt.
- Neuer Kandidat korrigiert ignorierte FALSE-Rueckgaben von IPS_RequestAction/RequestAction (Start, Command, Stop und Register), den Vor-I/O-Zeitvergleich beim Batterie-Scharfschalten und den blockierenden HTTP-Refresh im Regel-Tick. Separater Empfangstimer: 10 s Prueftakt, aktiver Abruf fruehestens nach 45 s, Fehler-Retry 15 s; Cache-Lebensdauer und Serverzeit bleiben maximal 90 s.
- Kandidat liegt auf develop im Arbeitsverzeichnis und isoliert unter /srv/agent/v4-continuation-20261006 auf Lihrenmoos. 87 Regeltest-, 29 Empfangs-, 8 Rueckmeldungspruefungen plus 1 echter Modulmethoden-Test mit synthetischen Aktionen bestanden; 5 geaenderte Live-PHP-Dateien syntaxgeprueft. Keine Hardwaretests daraus ableiten. Testbericht: test-results.json im Stage.
- Neue Tests decken insbesondere FALSE ohne Exception, Sekundengrenze beim Start, bytegleich behaltenen aktiven Cache, 15-s-Retry, 90-s-Ablauf und zukuenftige Empfangszeit ab. git diff --check erfolgreich.
- Die automatische Sicherheitspruefung hat die Ausfuehrung von /srv/agent/activate_v4_continuation.py abgelehnt, weil diese Live-Installation und Aktivstart koppelt. Der Benutzer wurde um ausdrueckliche Freigabe fuer Installation und Start ohne 5-kW-Limit bis zum bisherigen Ende (07.10.2026 17:38 Europe/Zurich) gebeten. NICHT ueber andere Werkzeuge umgehen. Die Aktivierung wurde nicht ausgefuehrt; 12555.ips.php ist unveraendert.
- Achtung: Der bestehende temporaere v4_direct_battery_command_hook.php sendet alle 30 s den letzten Managerbefehl erneut. Das kann den Replay-Schutz ausloesen. Vor freigegebenem Neustart kontrolliert entfernen. Vorbereitete Aktivierung sichert den alten Timer und ersetzt alte Schreib-/Diagnosehooks durch eine einmalige gesicherte Installation, genau einen Startversuch und reine Beobachtung. Sie ist noch nicht ausgefuehrt. Bei Live-Drift abbrechen.
- Git nach erfolgreichem Fetch: develop ist 22 Commits vor und 1 Commit hinter origin/develop (fremder Commit 37addf4). Fremde Lade-/GUI-Aenderungen sind erhalten. Kein Fast-Forward moeglich; kein Commit, Merge oder Push in dieser Fortsetzung. Kandidatenpatch: /srv/agent/v4-continuation-20261006.patch auf enelix-services.
- Naechster Schritt erst nach Freigabe: Stage/Live-Hashes und Zustand erneut pruefen, Kandidat installieren, genau einen Startversuch beobachten und dessen echte Batterie-Antwort pruefen. Danach >90 s mit Planwechsel, reale Leistungs-/Netzreaktion und sicheren Nullstell-Stopp nachweisen. Erst anschliessend regulaeren Testlauf belassen und temporaere Hooks entfernen. Kein stabiler Aktivbetrieb zugesichert.
Übergabe erstellt am 04.10.2026 auf ausdrücklichen Wunsch von Daniel Häfliger (BELEVO AG). Sie fasst die Anforderungen, Entscheidungen, Fehlerbehebungen und den zuletzt belegten Stand dieses Gesprächs zusammen. Sie ist **kein wörtliches Chatarchiv, keine neue Stellfreigabe und kein Nachweis der Produktionsreife**.
**Zeitbezug:** Die letzten hier belegten Betriebsberichte stammen vom **03.10.2026, ca. 14:02 Uhr Europe/Zurich**. Am 04.10. wurden für diese Übergabe die genannten Berichte und der Git-Stand erneut gelesen, aber keine neue Anlagenabnahme durchgeführt. Alte Messzahlen deshalb niemals als heutige Live-Werte ausgeben.
**Fortsetzung Prognose am 04.10.2026:** Der aktuelle Runtime-Stand wurde gelesen, ohne die alten Diagnose- oder Installationsschritte zu wiederholen. Der abgeleitete Datensatz `lihrenmoos-physical-published-v2` ist `model_ready` und erfüllt mit mehr als 24 nutzbaren äquivalenten Stunden die Trainingsschwelle. Die V4-Einstellungen stehen seit Revision 2 in `shadow` auf `forecastSource=corrected_profile` und `measurementDataset=lihrenmoos-physical-published-v2`; `liveEnabled` und Stellfreigabe bleiben aus. Der erste echte Lauf zeigte einen kompatibilitätsbedingten Stopp bei mikrosekundengenauen `observedAt`-Zeitstempeln. Die Korrektur rundet ausschliesslich die kausale Verfügbarkeit von Prognoseereignissen auf die nächste volle Sekunde auf; Rohmessungen bleiben streng ganzsekündlich. 310 Python- und 16 Portal-Tests sind erfolgreich. Repository und Runtime-Build-Kontext enthalten bytegleich den geprüften Fix; der laufende Container enthält ihn noch nicht, weil beide Agentzugänge weder Docker-Socket noch `sudo` erhalten. Der vorbereitete, syntaktisch geprüfte Root-Rollout liegt unter `/home/agent/services/netplan-v4-shadow/commissioning/finish_corrected_forecast_rollout.sh` und baut/testet vor dem Austausch, hält das vorige Image als Rollback fest und ändert keine Stellfreigabe. Daniel hat danach einen Aktivbetrieb angefragt; der aktuelle V4-Kern ist jedoch technisch `shadow-only`, und die weiterhin unbelegte Rückmeldung (`feedback_source_skew`, `usableForTrial=false`, `canDispatch=false`) verbietet ein blosses Umstellen. Keine Live-Freigabe wurde erteilt oder eingebaut. Git-Klarstellung durch Daniel: Commit-Autor `dh_Agent <dh@belevo.ch>`, Gitea-Konto `dh`; Commit/Push auf `develop` und `beta` am 04.10.2026 ausdrücklich freigegeben. Der Agentzugang besitzt derzeit keine HTTPS-Schreibanmeldung für `dh`.
**Fortsetzung bestätigte Rückmeldung am 04.10.2026:** Daniels Freigabe des modellierten `virtual_split` gilt für den ausdrücklich begrenzten Feldtest. Die physische Rückmeldung wurde um synchrone, durch den Gerätetreiber bestätigte Lesevorgänge erweitert: M-Bus verwendet `MBUS_UpdateValues`, ModBus Device und ModBus Address verwenden `ModBus_RequestRead`. Nur ein erfolgreicher Geräteaufruf erzeugt `confirmedAt`; `VariableUpdated` bleibt getrennte Herkunftsinformation. Die Regeltest-Gates verlangen `deviceReadConfirmed=true`. Das reproduzierbare inkrementelle Paket liegt auf der Testanlage unter `/srv/agent/netplan-v4-confirmed-feedback-20261004`; 18 Paketprüfsummen, 37 Rückmeldetests, 7 Geräteabruf-Tests und 71 Regeltest-Prüfungen sind erfolgreich. Es ist noch **nicht im IP-Symcon-Kernel installiert**, weil der lokale JSON-RPC-Zugang ohne Anmeldung mit HTTP 401 antwortet. Die Installation selbst lässt alten Netzfahrplan und beide Regeltestfreigaben aus. Ein realer Stellversuch bleibt zusätzlich durch den nicht belegten unabhängigen Geräte-Watchdog und die fehlende Serverfreigabe blockiert; der vorhandene Symcon-`VorgabeTimeout` ist kein unabhängiger Hardware-Nachweis. Auf `enelix-services` und der Testanlage ist kein nutzbarer Git-HTTPS-Credential-Helper hinterlegt; die Prüfung des Tunnel-Servers lief zweimal in eine externe Connector-Freigabezeitüberschreitung. Tokeninhalt wurde weder gelesen noch ausgegeben. Der Commit des Folgepakets ist lokal mit der vereinbarten Identitaet `dh_Agent <dh@belevo.ch>` erstellt. Der atomare Push auf `develop` und `beta` scheiterte ohne Teilaktualisierung, weil fuer `https://dh@git.belevo.ch` kein Passwort/Token bereitstand.
**Fortsetzung Aktivbereitschaft am 04.10.2026:** Die reale Stellkette wurde in den gespeicherten Testanlagen-Settings bis zu den Aktionszielen geprüft. Die ENELIX-Batterieinstanz 44234 schreibt über das Aktionsskript 31800 auf den EV-Sollwerteingang 19651 des Gateways 58448. Dieses Gateway verteilt im Zwei-Sekunden-Takt auf zwei GoodWe-Pfade über reine SetValue-Aktionsskripte und auf drei SolarEdge-Modbus-Adressen. Der ENELIX-`VorgabeTimeout` setzt bei laufendem Symcon nach 30 Sekunden auf 0 W zurück. Weder im Gateway noch in den drei GoodWe-Aktionsskripten wurde jedoch ein vom Symcon-Kernel unabhängiger Geräte-Watchdog gefunden; auch die SolarEdge-Modbus-Konfiguration weist nur Poll-/Write-Parameter, keinen Geräte-Timeout aus. Ein unbeaufsichtigter 1-2-Tage-Stellbetrieb bleibt deshalb gesperrt. Beide lokalen Regeltestfreigaben sind weiterhin aus, es wurde kein Stellbefehl ausgelöst. Der bestätigte Rückmeldeinstaller ist für einen gestoppten Kernel vorbereitet und geprüft, konnte mangels privilegierter `systemctl`-Berechtigung des Connector-Benutzers aber noch nicht ausgeführt werden. Auf `tunel100` ist ein Credential-Helper für das Konto `dh` vorhanden und ein `develop`-Dry-Run war erfolgreich; die danach nötige Connector-Freigabe lief erneut ab, bevor der lokale 19-Commit-Stand übertragen und veröffentlicht werden konnte.
## 1. Zuerst lesen: Wo wir tatsächlich stehen
- Daniel möchte die **fertige, produktionsgeeignete Anwendung**, nicht weitere isolierte Sammler, Diagnosekategorien oder wiederholte Bestätigungsrunden. Er hat die fortlaufende Umsetzung mehrfach beauftragt. Probleme im Code selbst beheben, Tests und Auslieferung bündeln; ihn nur für wirklich notwendige Root-/Symcon-Ausführung oder echte Anlagenfreigaben einbeziehen.
- Datenaufnahme, bestätigter Versand, historische Aufbereitung, Modellaufbau, V4-Optimierer, Planempfang und ein ausdrücklich begrenzter Regeltest sind implementiert. Der zuletzt installierte Teil verbindet korrigierte physische Rückmeldung mit Vorschau und dem **begrenzten** Test-Stellpfad.
- Der Versandblocker durch `100.0`/`100` ist **behoben und der Nachversand hat aufgeholt**. Nicht erneut beim Cursor oder der alten Kennungsdiagnose anfangen.
- Die letzte Feedback-Installation ist **erfolgreich abgeschlossen**, nicht mehr `waiting_for_registration`. Bericht: `corrected_feedback_installed_trial_disabled`, Rückmeldung `unavailable`, Grund **`feedback_source_skew`**.
- **Nächster konkreter Entwicklungsauftrag:** bestätigte, zum tatsächlich gelesenen Wert passende Geräte-Lesezeitpunkte an den lokalen Rückmeldepfad anschliessen. Bisher wird `VariableUpdated` als Live-Zeitbasis verwendet; unveränderte Modbus-Werte werden aber nicht zwingend bei jedem erfolgreichen Abruf erneut publiziert. Geräteabruf, Variablenpublikation und aktueller Lesezugriff müssen auseinandergehalten werden.
- Keine künstliche Zeitstempelverjüngung, pauschale Toleranzerhöhung, Nullersetzung, historische Interpolation als Live-Messung oder Rückkehr zum festgehaltenen virtuellen EV-Wert.
- **Weiterhin keine produktive V4-Batterieausführung:** alter intelligenter Netzfahrplan AUS; beide lokalen Regeltestfreigaben AUS; kein gestarteter Stellversuch. Bestehende lokale Regelung und SDL laufen unabhängig weiter.
- Ein uneingeschränkter Produktions-Dauerregler ist **noch offene Umsetzung**, nicht nur ein fehlender Haken. Automatik, reale Modellqualität, Geräteausfallverhalten und Mehranlagenfreigabe haben ebenfalls verbleibende Grenzen.
- Letzter fachlicher EMS-Commit: **`9878197`**, `develop`, bei Übergabeprüfung sauber und **15 Commits vor lokalem `origin/develop`**. Kein Remote-Fetch in dieser Prüfung. Vorherige Pushversuche scheiterten an Authentifizierung. Dokumentations-Commits dieser Übergabe können danach folgen.
## 2. Arbeitsweise und Freigaben
Daniel ist technisch versiert, will aber keine unnötige manuelle Kleinarbeit. Deutsch, Schweizer Schreibweise, konkrete nutzbare Befehle. Ein abgeschlossener Test ist nicht automatisch Installation, Installation nicht Stellfreigabe, `optimal` nicht reale Einsparung. Immer getrennt berichten: implementiert / isoliert getestet / Zielcontainer getestet / installiert / im Kernel geprüft / am Gerät erprobt / veröffentlicht.
**Nicht wieder in die alte Schleife zurückfallen:**
1. Keine neuen Diagnosekategorien als Ersatz für die eigentliche Integration.
2. Keine erneuten Fragen nach bereits bestätigten EV-Kapazitäten oder pauschal immer wieder nach Wechselrichtertypen. Bestehende Quellen nutzen. Eine tatsächlich fehlende sicherheitsrelevante Eigenschaft darf trotzdem nicht erfunden werden.
3. Nicht einfach „24 Stunden warten“, solange ein Software- oder Übertragungsfehler das Training verhindert.
4. Keine alten Einzelinstaller erneut ausführen; neueren Quellstand und parallele GUI-Arbeit nicht überschreiben.
5. Nicht behaupten, im Hintergrund weiterzuarbeiten. Nur tatsächlich ausgeführte Arbeit und belegte Ergebnisse berichten.
6. Die Regel „ein einziger konkreter Punkt“ betraf Einträge in **Offene Punkte**, nicht Entwicklungsaufträge. Implementierungsaufträge dürfen zusammenhängende Anforderungen bündeln.
**Git:** Keine Feature-Branches. Entwicklung auf `develop`; nach Tests `beta`; nach Feldtest `main`. Daniel hat im Gespräch Commit/Push für `develop` und damals auch `beta` ausdrücklich freigegeben. Diese Freigabe nicht als Auftrag interpretieren, einen aktuell ungetesteten Kandidaten blind auf `beta`/`main` zu schieben. `develop`-Push ohne erneute Nachfrage im bestehenden Rahmen erlaubt. Nicht force-pushen, nicht unzusammenhängende Änderungen übernehmen, keine Zugangsdaten verlangen/ausgeben. Historische Agenten-Commitidentität war `ENELIX Agent <agent@enelix.invalid>`; keine globale Git-Konfiguration ungeprüft ändern.
Die aktuelle Nachricht beauftragt **Kontextübergabe**. Sie erteilt keine zusätzliche Anlagen-, Runtime- oder Stellfreigabe. Bei späterer Fortsetzung greifen die bestehenden Sicherheits- und Branchregeln weiter.
## 3. Ursprüngliches fachliches Ziel
### 3.1 Prognosefamilien und Erweiterbarkeit
Im GUI manuell eine Familie wählen oder `auto`:
| Familienschlüssel / Fahrplan | PV | Last | Fahrplanvariable |
|---|---|---|---|
| `3` | `prog_var_1` | `prog_var_2` | `prog_var_3` |
| `13` | `prog_var_10` | `prog_var_11` | `prog_var_13` |
| `23` | `prog_var_21` | `prog_var_22` | `prog_var_23` |
Die neun Zahlen sind nicht neun unabhängige Fahrplanmodelle. Daniel akzeptierte die Auswahl der vollständigen Modellfamilie. Registry und Datenadapter so gestalten, dass weitere Familien und später weitere flexible Anlagen ergänzt werden können. Nicht behaupten, drei bereits vorhandene Lastmethoden seien identisch mit den neu aufgebauten physischen Profilen; Datenquelle und Modellversion müssen sichtbar sein.
`auto` soll jene Familie wählen, die in der jüngeren Vergangenheit **wirtschaftlich am besten abgeschnitten hätte**, mit gleichen Anfangsbedingungen, Tarifen, SOC-/Restenergiebewertung und Randbedingungen. Keine Auswahl allein nach R², keine Zukunftsinformation im historischen Replay. Mindestabdeckung, Mindestdauer, Rückblick und Wechselmarge verhindern häufige/schlecht belegte Wechsel.
### 3.2 Netzfahrplan / Optimierung
- Bis zu **48 Stunden**, grundsätzlich **5-Minuten-Schritte** (höchstens 576), rollende Neuberechnung.
- Ausgangsverlauf: korrekt abgegrenzte Verbraucherlast minus PV plus separat berücksichtigte externe Flüsse. Darauf flexible Verbraucher optimieren; aktuell EV-Batterie, später Ladestationen, Boiler usw.
- Batterie: aktueller virtueller EV-SOC, dessen zugehörige EV-Gesamtkapazität, aktuelle maximale Lade-/Entladeleistung, Min-/Max-SOC, Reserve, Hysterese, Verfügbarkeit und lokale Einschränkungen. Keine zukünftige Energie vor dem Laden nutzen.
- **90 % Roundtrip-Wirkungsgrad**, nicht zweimal 90 %. Implementierung verteilt Verluste mit `sqrt(0.9)` auf Laden/Entladen.
- Netzladen ist ausdrücklich erlaubt/gewünscht, wenn freigegeben und wirtschaftlich sinnvoll. Nicht generell Nachtbezug verbieten oder tagsüber Entladung verbieten.
- Kosten pro Intervall: Bezugsenergie mal Bezugspreis minus Einspeiseenergie mal Einspeisepreis. Leistungswerte in W/kW korrekt über Zeit in kWh umrechnen. Gesamtkosten minimieren, nicht mathematisch auf exakt null zwingen; negative Kosten sind möglich, nicht garantiert.
- Statische oder dynamische, in der Anlage konfigurierte Bezugspreise und Einspeisevergütungen verwenden. GUI-Preisänderungen speichern und Neuberechnung anfordern. PV-Vergütung, Netzladekosten, Verluste und Peakpreis gehören in dieselbe Wirtschaftlichkeitsbetrachtung.
- Wenn dynamische Preise noch nicht veröffentlicht sind, den **ausführbaren wirtschaftlichen Horizont auf den bekannten Preisbereich begrenzen** (`published_only`); kein frei erfundener Preis für den Rest der 48 h. Volle Prognose und ausführbaren Preishorizont unterscheiden; Ende passend zur vollständigen Abrechnungsviertelstunde.
- Restenergie am Horizont fair bewerten/absichern, damit die Batterie nicht am Planende oder zu fast wertlosen Mittagstarifen grundlos leerverkauft wird.
- Bezug/Einspeisung sowie Laden/Entladen nicht als unphysikalische gleichzeitige Arbitrage zulassen.
- Einspeisebegrenzung, zulässige Abregelung/entgangene Vergütung und lokale harte Netzgrenzen berücksichtigen. Einen unerreichbaren Plan nicht als eingehalten darstellen.
### 3.3 Monatspeak
- Leistungstarif auf den relevanten **15-Minuten-Netzbezug**; persistenter Monatsmaximalwert, nicht momentane Spitzenleistung. Für den laufenden Monat zusätzliche Kosten nur für Erhöhung über den bereits erreichten Peak; Monatswechsel korrekt behandeln.
- Bereits verstrichene Energie der aktuellen Viertelstunde mitnehmen. Fehlende Messbasis nicht durch eine konfigurierte Grenze ersetzen.
- Am Monatsanfang nicht künstlich jede Aktivität verhindern: Restmonatsbewertung/empirische Aussicht ist vorgesehen, mit ausgewiesener Unsicherheit. Ein erwarteter künftiger Peak ist **keine bereits bezahlte kostenlose Freigabe**.
- Anlagen können **keine feste Bezugsgrenze** haben. Falls im Manager statische oder monatliche Peakgrenzen konfiguriert sind, diese zusätzlich respektieren.
- Tarifeinheiten und tatsächliche Abrechnungsdefinition prüfen; gemessene, geschätzte und nur konfigurierte Peakwerte getrennt kennzeichnen.
### 3.4 Training, Reaktion und Validierung
Tägliches oder wöchentliches Nachtraining nach GUI-Einstellung; Parameter-/Preisänderungen sollen Neuberechnung auslösen. Getrennte versionierte Historien und Modelle, keine alten belasteten Daten als bereinigt umbenennen. Ein Cold-Start-Modell nach Mindestdatenbasis ist noch keine validierte Feldprognose. Vergleich gegen die bisherige Batterie-Betriebsweise, nicht nur gegen „Anlage ohne Batterie“.
## 4. Lihrenmoos: bestätigte Anlage und massgebliche Korrekturen
- Installation: **`e3a08f9e-af12-4695-99bd-8b51c0520021`**.
- Symcon Manager **`17004`**, ENELIX-Batterieinstanz **`44234`**, virtuelles Asset **`anlage01-virtual-ev`**, bestehendes EV-/SDL-Gateway **`58448`**.
- **Massgeblich bestätigt: EV-Kapazität `161.44 kWh`, EV-Leistung `39 kW`.** Die vorher genannten `160 kWh`, `30 kW` und „je 10 kW pro WR“ hat Daniel ausdrücklich als Erinnerungskorrektur zurückgenommen. Daraus keine neue Verteilung auf Einzelgeräte ableiten.
- Virtueller EV-SOC und die EV-Kapazität bilden ein gemeinsames Energiekonto. Keine SOC-/Energie-Resets oder Umrechnung auf 322 kWh. **SDL-Reserve ist bereits ausgegliedert** und darf nicht nochmals von EV abgezogen werden.
- Vom Nutzer genannte physische Ausstattung: zwei GoodWe à **50 kW AC**, je **156 kWh** Batterie; SolarEdge **10 kW AC / 10 kWh** Batterie. Summe physische Kapazitäten rechnerisch 322 kWh, nicht die EV-Kapazität. AC-Nennleistung ist kein unabhängiger Nachweis jeder aktuell zulässigen Batterie-Ladeleistung.
- Dach-PV ungefähr **20 kWp Ost + 20 kWp West**.
- Exakte Gerätetypen/Firmware, jede AC-/DC-Messgrenze und autonomes Verhalten beim Befehlsausfall sind durch diese Nennwerte nicht automatisch bestätigt. Vorhandene Dokumente/Quellen verwenden, statt Angaben zu erfinden oder Daniel wiederholt pauschal zu befragen.
- Historischer Tarif-Snapshot: Bezug `CKW Dynamisch Home`, statischer Fallbackwert 0.246 CHF/kWh, Einspeisung „Preis selber eingeben“ 0.10 CHF/kWh, Peakparameter 5.0. **Nicht als aktuelle Tarifvorgabe neu setzen**, vor Verwendung aktuelle Konfiguration lesen.
## 5. Bilanzierung und SDL: nicht wieder die alte Fehlerquelle einbauen
Der alte Prognosesender verwendete sinngemäss `PV + Netz − virtuelle EV-Batterieleistung`. Weil nur die virtuelle EV-Batterie in der Topologie stand, konnte **SDL als Hausverbrauch gelernt** werden. Zusätzlich hält das bestehende Gateway unter bestimmten Bedingungen gefilterte virtuelle EV-/SDL-Istleistungen fest und schreibt sie erneut. Neuer Variablenzeitstempel allein machte diese Werte nicht zur frischen physikalischen Messung.
**Neue Grundidee, bei passender elektrischer Messgrenze:**
`Verbraucherlast = Netzbezug + PV-Erzeugung − gesamte physische Batterieladung`
`Grundlast = Verbraucherlast − separat geplante flexible Verbraucher`
Grundlast bedeutet hier zeitabhängiger nicht separat geplanter Verbrauch, **keine Konstante**. Nicht separat geplante Boiler/Wallboxen bleiben vorerst darin. Später beim Übergang zu eigenständiger Planung genau einmal herausrechnen.
**SolarEdge-Sonderfall:** PV-Variable 20335 war bereits aus skaliertem AC-Ausgang plus Batterieleistung berechnet und auf null begrenzt. Neuer Lastadapter `solar_terminal_v1` verwendet den AC-Ausgang aus 37975/41853 direkt und zieht den SolarEdge-Batteriesaldo nicht nochmals ab. Herkunft, Vorzeichen, AC/DC und Null-Clipping beachten. Algebraischer Bilanzschluss allein ist kein unabhängiger Zählernachweis.
SDL wird **nicht vom V4-Flexibilitätsoptimierer gesteuert**. Bekannten externen Fahrplan berücksichtigen; bei unbekanntem Verlauf nicht still null einsetzen. Die vorhandene Schattenplanung kann den aktuellen SDL-Auftrag als ausdrücklich gekennzeichnete Fortschreibung verwenden. Das ist kein veröffentlichter Zukunftsfahrplan. Für Dauerproduktion sind belastbare externe Grenzen/Reservebehandlung und Konfliktauflösung noch zu prüfen. Ein aktueller SDL-Wert null garantiert keine SDL-freie kommende halbe Stunde.
Bestehendes Gateway: `/var/lib/symcon/modules/Symcon_Belevo_Energiemanagement_testing/Bat_EV_SDL_V4/module.php`. Aktionsbrücke historisch `scripts/31800.ips.php`. `State=false` war dort **kein nachgewiesener Nullstell-/Notstopp**. EV-Stopp darf nicht pauschal unabhängige SDL-Aufträge löschen. Virtuelle Energiekonten/Filter nicht ungeprüft umstellen.
## 6. Quellzuordnung für die nächste Arbeit
Werte/Identitäten mit aktueller Quellenkonfiguration abgleichen; Tabelle ist zuletzt geprüfte Zuordnung, keine Erlaubnis, Register umzulegen.
| Grösse | Variable | Ursprung/Umrechnung |
|---|---:|---|
| Original-Netzleistung | 40348 | Parent 11490, `Power_8`, kW ×1000 → W, Bezug positiv |
| Netz-Anzeige | 49301 | Abgeleitet; nicht mit Originalquelle verwechseln |
| GoodWe 1 PV | 48459 | Parent 19742, `A_3_3_35301`, W |
| GoodWe 2 PV | 53802 | Parent 57658, `A_3_3_35301`, W |
| GoodWe 1 Batterie physisch | 47725 | Parent 19742, `A_6_3_35182`, Faktor −1 → Laden positiv |
| GoodWe 2 Batterie physisch | 35724 | Parent 57658, `A_6_3_35182`, Faktor −1 |
| SolarEdge Batterie physisch | 21447 | Parent 30789, `Value`, Faktor +1 |
| SolarEdge abgeleitete PV | 20335 | Parent 36915; nicht unabhängige PV-Messung |
| SolarEdge AC-Rohwert | 37975 | Parent 35514, `Value`, ReadAddress 40083 |
| SolarEdge AC-Skalierung | 41853 | Parent 48996, `Value`, ReadAddress 40084 |
| Virtuelle EV-Istleistung | 52020 | Parent 58448, `Aktuelle_Leistung_EV`; Filterhaltewert möglich |
| Virtuelle SDL-Istleistung | 25085 | Parent 58448, `Aktuelle_Leistung_SDL` |
| EV-Auftrag | 19651 | Parent 58448, `Nennleistung_Soll_EV` |
| SDL-Auftrag | 38943 | Parent 58448, `Nennleistung_Soll_SDL` |
| Gateway aktiv | 23483 | Parent 58448, `State`; kein Gerätewatchdog-Nachweis |
| EV-SOC | 32871 | Parent 58448, `SoC_EV` |
| SDL-SOC | 23879 | Parent 58448, `SDL_Pos` |
| EV verfügbare Ladeleistung | 50230 | Parent 58448, `P_EV_laden` |
| EV verfügbare Entladeleistung | 43899 | Parent 58448, `P_EV_entladen` |
| GoodWe 1 / 2 / SolarEdge SOC | 27361 / 23109 / 51938 | Originalzuordnung siehe Capture-Konfiguration |
| Wirkenergie Bezug T1 | 59607 | Parent 11490, `Energy_0`, kWh |
| Wirkenergie Bezug T2 | 26620 | Parent 11490, `Energy_1`; Zählerarchivierung aktiviert |
**Nicht wieder 53476 als nachgewiesenen Bezugszähler verwenden:** Im Gespräch zeigte 53476 / Quelle 32797 (`Energy_6`) einen unplausibel anderen Verlauf gegenüber Netzleistung und T1. Alte Historie 53476 wurde bewusst nicht überschrieben. Archivinstanz 16207. Neuzeitlicher Peak muss aus geeigneter T1/T2-Summe bzw. ausdrücklich zugelassener vollständiger Viertelstundenschätzung stammen.
Poller laut zuletzt gelesenem Snapshot: GoodWe 1 Parent 19742 `60000 ms`, GoodWe 2 Parent 57658 `5000 ms`; SolarEdge Batterie Parent 30789 `2000 ms`, AC Parent 35514 `1000 ms`, Skalierung Parent 48996 `20000 ms`. M-Bus Parent 11490 hatte `Interval=0`, trotzdem laufend frische Originalwerte. Nicht daraus schliessen, die Erfassung sei aus; zusätzliche bestehende Aufrufwege prüfen. Poller nie allein als erfolgreichen Geräteabruf interpretieren.
## 7. Hosts, Code, Dienste und Berechtigungen
### enelix-services / Connector Server_89
- Host `enelix-services`, IPv4 `87.106.60.89`, Agentbenutzer `agent`. Erlaubte Wurzeln `/home/agent/services`, `/srv/agent`.
- Entwicklung: **`/srv/agent/repos/Enelix-EMS`**, Gitea `ENELIX/Enelix-EMS` auf `git.belevo.ch`. Weiteres Projekt `ENELIX/Enelix-Utils`; historische Referenz `dh/Symcon_Belevo_Energiemanagement_testing`. Andere Hosts/Klone nicht ohne Nachweis gleichsetzen.
- Versionierter neuer Servercode: **`services/netplan-v4/`** im EMS-Repo.
- Runtime/Build-Staging: **`/home/agent/services/netplan-v4-shadow`**, SQLite `data/netplan-v4.sqlite`, Compose `compose.yaml`, interner V4-Port 9100.
- Bisheriger Forecast/API-Dienst: `/home/agent/services/prognosis-manager-enelix2`; Forecast Engine interner Port 9000. Nicht durch neue V4-Arbeit unbemerkt ersetzen.
- Portal: `/home/agent/services/license`, `server.mjs`, `public/`, `compose.yaml`. Parallele GUI-Arbeit möglich. Unified-RC1 aktualisierte nur V4 und zugehöriges Planner-JS, nicht pauschal alle Portalprozesse.
- Vor Veränderungen `/srv/agent/AGENT_CONTEXT.md` lesen. Agent hatte zuletzt keinen Docker-Socket-/Root-Zugriff; den Nutzer Root-Ausführung nur für tatsächlich benötigte Deployment-Schritte erledigen lassen. Keine Berechtigungen auf Docker-Socket ausweiten.
### iot-symcon01 / Connector Testanlage_Lihrenmoos
- IP-Symcon 8.0, Dienst `symcon.service`, Kerneldateien `/var/lib/symcon`, Logs `/var/log/symcon`, Programme `/usr/share/symcon`.
- Installiertes EMS-Repo: `/var/lib/symcon/modules/Enelix-EMS`. Das ist produktionsnah laufender Code, nicht der Entwicklungscheckout auf Server_89.
- Staging, Tests, Diagnose: `/srv/agent/netplan-v4-...`.
- Hostregeln `/srv/agent/AGENT_CONTEXT.md` lesen. Keine allgemeinen Settings-/Credential-Exports. Für gezielte schreibgeschützte Zustandsprüfung nur erlaubte Felder extrahieren, niemals ganze secret-bearing Konfiguration ausgeben.
- **PHP mit IPS-Funktionen gehört in Symcon**, nicht in die Linux-Bash und nicht in gewöhnliches CLI-PHP. Isolierte CLI-Tests mit simulierten IPS-Funktionen umgekehrt niemals im echten Kernel ausführen.
- `MC_ReloadModule`/`IPS_ApplyChanges` können bestehende Initialisierungen und deren Nebenwirkungen ausführen. Nicht pauschal als ohne Auswirkungen versprechen. Code- und Settings-Sicherung, Hash-/Driftprüfung, Rücksetzweg nötig.
- Bei verzögerter Modulregistrierung wurde ein Wiederholen angefordert. Die letzte Installation ist inzwischen fertig: **jetzt nicht wiederholen**.
## 8. Installierter Daten-/Prognosepfad
1. Manager erfasst 23 numerische Quellen alle 30 s, mit originalen Zeitstempeln und Qualitätsmerkmalen. Keine zusätzlichen Modbus-Abfragen durch den bisherigen Sammler.
2. Lokale append-only Tagesdateien/Outbox. Versand regulär höchstens einmal/min, bis 120 Aufnahmen pro Batch; geordneter, bestätigter Cursor. Backoff bei Fehlern; Originale bleiben erhalten. `scheduled` kann reguläre Sendepause oder Backoff bedeuten; getrennte letzte erfolgreiche Bestätigung beachten.
3. Server-Datensatz **`lihrenmoos-physical-v1`** speichert Originalaufnahmen. Abgeleiteter Datensatz **`lihrenmoos-physical-published-v2`** referenziert dieselben Daten für die verbesserte historische Zeitbehandlung.
4. `equal_endpoint_v1`: begrenzte rückblickende Schätzung bei gleichen Werten an getrennten Originalzeitpunkten. Verfügbarkeit der späteren Bestätigung wird mitgeführt; keine Leckage in damalige Entscheidungen. Unterschiedliche Werte, lange Ausfälle, widersprüchliche Quellen oder Sammellücken nicht unbemerkt überbrücken. Live-Grenzen unverändert.
5. Worker verarbeitet im 5-Minuten-Takt. Mindestbasis 24 **verwertbare äquivalente Stunden**, nicht bloss seit Start verstrichene Zeit. Neue Tagesprofile, versionierte Modelle, konfigurierbare tägliche/wöchentliche Neubildung. Kaltstart/Validierung sind getrennt.
6. Neue Datenquelle muss als `corrected_profile` mit passendem `measurementDataset` gewählt werden, sobald verwendbar; zuletzt blieb **`forecastSource=legacy`, `family=3`**. Nicht bei Installation automatisch umgeschaltet. `optimal` auf der Legacy-Quelle ist kein Nachweis der neuen Prognosequalität.
7. Wirtschaftlicher Vergleich Unified-RC1 ist angeschlossen, aber v1 ist **eingefrorener Tagesplan + simulierte lokale Netzzielnachführung**, nicht vollständiges rollendes MPC-Replay. Eine netzladefähige Batterie; gleiche Anfangsbedingungen, Restenergie, Verluste und einmalige Monatspeakabrechnung. Historischer SDL-Auftrag als gekennzeichnete Schätzung. Vollständige Modellautomatik/Produktversprechen nicht über diesen Scope hinaus behaupten.
8. Archivierung ist implementiert, **AUS als Standard** (native `NetzfahrplanV4ArchivAktiv`, serverseitig `NETPLAN_V4_ARCHIVE_ENABLED`). Verifizierte komprimierte Archive ersetzen kein externes Backup und lösen keine unendliche Speicherkapazität. Keine unbestätigten Outbox-Daten löschen.
## 9. Behobene Fehler – nicht erneut aufrollen
- Früherer numpy-Fehler `assignment destination is read-only`, falscher Import `battery_optimizer_new`, Dateirechte im unprivilegierten Container: damalige Deployment-/Codefehler, nicht aktuelle Blocker.
- Direkt nach Start `Connection refused`, danach HTTP 409 „bereits ein Lauf aktiv“: Start-/Parallelitätszustand, nicht durch wiederholte Neustarts lösen.
- Native Betriebsdaten fehlten; anschließend Sender installiert. Tarifimporter und Portal-Rate-Limit wurden repariert. Alte 429-Meldungen nicht ohne neue Evidenz zur heutigen Ursache erklären.
- Klasse `NetzfahrplanV4Messaufnahme` doppelt aus Staging und Modulpfad geladen: Installer auf eindeutige installierte Bibliothek umgestellt. `require_once` verhindert nicht verschiedene Dateien mit derselben Klasse.
- Gemischter 120er-Block: 45 importierte/75 native Aufnahmen, keine doppelten/rückwärtslaufenden Zeiten, aber andere Kennung allein durch `splitToleranceW:100.0` vs `100`. Normalisierung korrigiert; beide vollständigen Konfigurationen exakt geprüft. Kompatibilität nur für diese anlagen- und datensatzgebundene Gleichwertigkeit registriert; Ursprungskennung/Evidence gespeichert. Keine Hashs in Originaljournalen ersetzt, kein Cursor-Sprung.
- **Nachweis der Reparatur:** am 03.10. 12:58 Schweizer Zeit 1'782 statt 1'320 Aufnahmen, jüngste Aufnahme 12:58:09, 25 s alt, vollständiger ehemals blockierter Batch vorhanden, Manager `acknowledged`/Fehlerzahl 0. Quelle `[F]` unten. Keine Garantie über den danach liegenden Zeitraum ableiten.
- Historische Datenqualität verbesserte sich von 0 brauchbaren Fenstern auf 104/8.66 h, nach Nachversand 130/10.82 h; zuletzt Worker 140/11.65 h. Prozentangaben zur zeitlichen Abdeckung sind **keine Messgenauigkeit und keine Prognosegüte**.
## 10. Installierter Rückmelde- und begrenzter Stellpfad
Gemeinsamer Code: `NetzfahrplanV4Rueckmeldung.php`, `BatterieNetzfahrplanV4RueckmeldungTrait.php`, `ManagerNetzfahrplanV4EmpfangTrait.php`, `ManagerNetzfahrplanV4TestTrait.php`, `BatterieNetzfahrplanV4TestTrait.php`, `NetzfahrplanV4Regeltest.php`, Manager-/Batterie-Module.
- Zweifaches Lesen mit Identitäts- und Originalzeitprüfung; momentan Live-Alter maximal 60 s, Quellenversatz maximal 30 s.
- Modus in Lihrenmoos **`virtual_split`**, **`allowEstimatedForTrial=false`**. Auch gültige Rückmeldung oder Vorschau erteilt keine Freigabe.
- Wenn SDL-Auftrag exakt null, physische Batteriesumme statt gefilterter virtueller EV-Anzeige verwenden; unveränderter Nullauftrag ist Zustand, kein Messgeräte-Heartbeat. Eine neue EV-Vorgabe bedeutet noch keine sofortige physische Reaktion.
- Bei SDL ungleich null: zeitliche Abdeckung von Auftragsänderung, Tracking-Toleranz und explizite Schätzkennzeichnung. Gegenläufige EV-/SDL-Flüsse sind nicht eindeutig identifizierbar und bleiben für diesen Test gesperrt.
- Netzziel wird in **Gesamt-Batterieleistung**, nicht zusätzlichen Ladeauftrag übersetzt. Erwartete Änderungen anderer Verbraucher genau einmal berücksichtigen.
- Separate interne Server-Testsitzung, leere Default-Allowlist `NETPLAN_V4_CONTROL_TRIAL_PLANTS`, manuelle Familie, Anlagen-/Asset-/Instanz-/Revisionsbindung, beide lokalen Freigaben `NetzfahrplanV4RegeltestErlaubt`, bewusster lokaler Start, gültiger Plan, Bilanz- und Gerätewatchdog-Nachweis.
- Test max. 30 min / 5 kW je Richtung; Serverauthority maximal 90 s; Batteriebefehl maximal 10 s; Batterie-Softwarewatchdog 1 s, Manager-Testtakt 2 s. Nicht einfach Limits für Produktion hochsetzen.
- Batterie liest vor Ausgabe unabhängig neu und prüft SOC, Reserve, Hysterese, Verfügbarkeit, Sperrzeiten und Netzgrenzen. Unmögliches Netzziel → Testabbruch, keine Erfolgsbehauptung.
- Abbruch widerruft Sitzung vor Nullstellversuch; Stoppfehler bleibt sichtbar. Danach maximal eine **frisch berechnete** normale Zuteilung, keine Rückkehr zu gecachtem Test-/Vor-Testwert. Keine Wiederbelebung nach Neustart oder doppeltem Befehl.
- `shadow_seen` bedeutet Empfang, **nicht ausgeführt**. `canDispatch=false` in Vorschau bedeutet keine Vorschau-Freigabe. Bei einer separat gestarteten Testsitzung wäre `runMode=shadow` alleine trotzdem kein hinreichender Nachweis für null Stellbefehle.
- Softwarewatchdog funktioniert nur bei laufendem Kernel/Kommunikation. Kein Nachweis für Rechnerabsturz, Stromausfall oder unerreichbaren Wechselrichter. Bereits vorhandenes Verhalten prüfen, nicht erfinden und nicht Schutzgates entfernen.
## 11. Aktueller Blocker: bestätigter Geräteabruf statt falscher Zeitbasis
Letzter Installationsbericht `[A]`:
```
status: corrected_feedback_installed_trial_disabled
feedback.status: unavailable
feedback.reason: feedback_source_skew
batteryW: null
gridW: null
usableForTrial: false
canDispatch: false
```
Das ist **kein Installationsfehler**. Die Meldung „Modellanteil: nein“ im Installer ist im Fehlerfall irreführend: fehlendes Feld wird als false dargestellt, obwohl die Berechnung gar nicht erfolgreich war. Kleine UI-/Diagnosekorrektur mitnehmen.
Letzte Zeituntersuchung `[B]` (237 Aufnahmen / 2 h): 48 Aufnahmen mit Versatz >30 s. Netz median 1 s/max 2 s, GoodWe 1 median 1 s/max 48 s, GoodWe 2 median 1 s/max 46 s, SolarEdge median 12 s/max 59 s; SolarEdge in 223 Aufnahmen unter den ältesten Quellen. Keine der vier Quellen in genau diesem Zeitfenster über 60 s. Beispiel 13:58:38: drei Quellen neu, SolarEdge 31 s alt → Ablehnung. Frühere mehrminütige Lücken trotzdem nicht damit wegargumentieren.
**Nächste Umsetzung, ohne neue Diagnosekategorie:**
1. Installierten Gerätemodultyp und unterstützten Leseweg verifizieren. Im Gespräch wurde `ModBus_RequestRead()` als möglicher Ansatz genannt. API-/Erfolgs-/Zeitsemantik und Unterschied `ModBus Address` vs `ModBus Device` anhand offizieller aktueller Dokumentation und vorhandenem Code prüfen; nicht voraussetzen, dass ein boolescher Erfolg alle Register atomar aktualisiert oder einen unabhängigen Gerätezeitpunkt liefert.
2. Erfolgreiche vollständige Antwort mit genau den daraus stammenden Werten, Zuordnung, Status und Bestätigungszeit binden. `VariableUpdated`, Geräteabrufbestätigung und Sammlerzeit getrennt erhalten.
3. Begrenzte Abfragefrequenz, Timeout, Serialisierung, Kommunikationslast und Lesekohärenz berücksichtigen. Keine Netz-I/O in jedem Vorschau-/Regelcallback vervielfachen, keine blockierenden Abfragen unter dem Stellregler-Lock. Existierende erfolgreiche Polls nach Möglichkeit nutzen.
4. Wenn keine echte Bestätigung vorliegt, unsicher/gesperrt bleiben. Keine künstlichen `SetValue`-Herzschläge und keine permissive Freigabe bloss wegen des Pollerintervalls.
5. In bestehenden Batterie-/Managerpfad integrieren; Tests: unveränderte Werte mit erfolgreichem Abruf, fehlgeschlagene/partielle Antwort, Zeitversatz, Race, Wiederholung, Neustart, erneute unabhängige Batterieprüfung und Rückfall.
6. Zusammenhängenden Kandidaten bauen und testen; neue Runtime-Installation nur im bekannten gesicherten Verfahren. Bisherige Installer nicht nochmals ausführen.
Diese Arbeit wurde am Gesprächsende **noch nicht implementiert oder installiert**. Sie ist nicht durch die vorliegende Dokumentationsübergabe erledigt.
## 12. Produktionsreife: verbleibende echte Arbeiten
- Verlässlicher bestätigter Live-Messpfad wie oben; unklares AC/DC/externes SDL nicht durch Labels als verifiziert deklarieren.
- Neue Lastprofile mit tatsächlicher ausreichender Datenbasis/Validierung aufbauen und gezielt zur Schattenrechnung schalten. Aktuelle Einstellungen/Modelle vorher lesen, nicht alte Momentaufnahmen fortschreiben.
- **Dauerbetrieb** mit sauberem Authority-/Zustands-/Neustart-/Fallback-Verhalten implementieren. Bisher nur begrenzter Pilot, keine fertige kontinuierliche Produktionsfreigabe.
- Reale Callback-/Registerstrecke und separat freigegebener Stellversuch; korrekte Vorzeichen, verfügbare Leistungen, Netzziel/Peak-/Exportgrenzen, Abschalten/Verbindungsausfall, lokale Priorität und SDL-Konflikte prüfen.
- Modellautomatik über den deklarierten Frozen-Day-v1-Scope hinaus nur nach Erweiterung/Nachweis bewerben; Grenzen transparent halten.
- Lebenszyklus/Backups/Recovery/Observability, Mehranlagenisolierung und parametrisierte Inbetriebnahme. Einige Installer sind bewusst auf Lihrenmoos gebunden; für andere Anlagen nicht IDs blind übernehmen.
- Git-Authentifizierung/Veröffentlichung lösen; `develop` → geprüfte `beta` → Feldabnahme `main`. Browser-Login, Tailscale oder Portforwarding stellen nicht automatisch Git-Schreibauthentifizierung bereit. Keine Veröffentlichung behaupten, die nicht passiert ist.
## 13. Wichtige Meilensteine und nicht erneut auszuführende Installer
| Paket | Status bis Gesprächsende |
|---|---|
| V4-Schattenserver / Native Operation / Tarife | Installiert, Betrieb historisch belegt |
| Passiver Empfänger `/srv/agent/netplan-v4-receiver-stage/install.php` | Installiert, automatische `shadow_seen`-ACKs belegt |
| Rohsammler `/srv/agent/netplan-v4-data-capture-stage` | Separater früherer Sammler; Originaldateien erhalten |
| Leseexport `/srv/agent/netplan-v4-data-review-stage` | Einmaliger Export wegen restriktiver Originalrechte; heute keine erneute allgemeine Exportpflicht |
| Getrennter Beobachter `/srv/agent/netplan-v4-separated-observer-stage` | 23 Quellen, Dateien gezielt lesbar; weiterhin Originalbestand |
| Managerdaten `/srv/agent/netplan-v4-application-build/install.php` | Installiert, Klasse-/Kennungsfehler später behoben |
| `deploy_history_timing.py` | Installiert, neue Historienversion v2 |
| `deploy_unified_release.py --approve-numeric-mapping-compatibility` + `/srv/agent/netplan-v4-unified-release/install.php` | Installiert, Aliasfreigabe exakt registriert, Nachversand aufgeholt |
| `/srv/agent/netplan-v4-feedback-stage/install.php` | **Fertig installiert nach einmaliger Registrierungsschleife**, aktuell Zeitversatzprüfung |
Fachliche Commitfolge (lokales `develop`; nicht als Remote-/Runtime-Gleichstand missverstehen):
`b5c1fa6` Empfänger, `9394fe3` begrenzter Test, `e0e52d2` Bilanzierung, `76f8b06` Capture, `44362e4` getrennte Beobachtung, `fe3f46b` Datenanwendung, `6d9aba7` Klassenladen, `5890093` Historienzeit, `6c91d6b` Zahlennormalisierung, `8eb9688` Unified RC1, **`9878197` korrigierte Rückmelde-/Testkette**. Einige weitere Commits können dazwischenliegen; `git log` ist massgeblich.
## 14. Quellenindex und Tests – zuerst Nachweise lesen, nicht neu installieren
**[A] Letzte erfolgreiche Rückmeldeinstallation (Testhost):**
`/srv/agent/netplan-v4-feedback-stage/INSTALL_RESULT.json` – Ende `2026-10-03T11:59:58Z`. Der frühere `waiting_for_registration`-Bericht ist überholt. Die Sicherung stammt vom ersten Dateidurchlauf, nicht vom zweiten `already_installed`-Durchlauf.
**[B] Letzte Zeit-/Pipeline-Auswertung (Server):**
`/home/agent/services/qa/feedback-timing-20261003/TIMING_REVIEW.json` – `2026-10-03T12:02:13Z`, 140 brauchbare Fenster/11.646 h, Quelle v2 noch `collecting`.
**[C] Unified-Serverinstallation:**
`/home/agent/services/netplan-v4-shadow/unified-releases/20261003T105407.036202Z/REPORT.json`; Runtime `unified-rc1`, Mappingkompatibilität registriert, Einstellungen unverändert.
**[D] Unified-Managerinstallation (Testhost):**
`/srv/agent/netplan-v4-unified-release/INSTALL_RESULT.json`.
**[E] Historieninstallation:**
`/home/agent/services/netplan-v4-shadow/history-timing-releases/20261003T092245.175100Z/REPORT.json`.
**[F] Erfolgreiches Aufholen des Datenwegs:**
`/home/agent/services/qa/unified-release-20261003/FLOW-20261003T105834.324945Z.json`.
**[G] Installiertes und versioniertes Verhalten:**
`docs/netplan-v4-corrected-feedback.md`, `docs/netplan-v4-unified-release.md`, `docs/netplan-v4-history-timing.md`, `docs/netplan-v4-numeric-mapping-fix.md`, `docs/netplan-v4-application.md`, `docs/netplan-v4-controlled-trial.md`, `docs/netplan-v4-accounting.md`. Alte „noch nicht installiert“-Abschnitte dieser Entwicklungsdokumente durch aktuelle Installationsberichte einordnen; nicht mit echten Runtime-Nachweisen verwechseln.
**[H] Rückmelde-/Testsuite (Testhost):**
`/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`, `TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`, `MANIFEST.json`, `PACKAGE_HASHES.json`, `feedback-config.json`.
125 isolierte Funktionsprüfungen, 4 vollständige Modul-Linkageprüfungen, 10 Installationsszenarien, 13'824 übereinstimmende normale Leistungsangebotsfälle. Keine Aussage, dass dieselbe Zahl Tests im echten Kernel/Wechselrichter gelaufen sei.
**[I] Unified-Suite (Server):**
`/home/agent/services/qa/unified-release-20261003/PYTHON_TEST_RESULTS.txt`, `NODE_TEST_RESULTS.txt`, `PUBLICATION.json`, `DELIVERY_STATUS.json`.
306 Python- und 16 Portaltests; native separate Tests. Spätere Änderungen brauchen erneute relevante Tests, nicht bloss Übernahme dieser Zahlen.
**[J] Wiederaufbau und Code:**
EMS `examples/V4CorrectedFeedback/`, `examples/V4UnifiedRelease/`, `services/netplan-v4/commissioning/`. Staging ist nicht dieselbe Quelle wie das laufende Modul. Dateien, Hashs und Abhängigkeiten vor Änderungen vergleichen; Paralleländerungen abbrechen statt überschreiben.
**[K] Quellen-/Konfigurationsdiagnose (Testhost):**
`/srv/agent/netplan-v4-application-build/inspect_source_update_policy.py`, `inspect_delivery_state.py`, `MAPPING_IDENTITY_DIAGNOSIS.json`, `MAPPING_EQUIVALENCE.json`; `netplan-v4-feedback-stage/review_current_feedback_state.py`. Diese Skripte zuerst lesen; manche erzeugen nur einen Bericht, manche lesen gespeicherte statt live aktuelle Settings. Alte Settings-Snapshots können eine bereits installierte Eigenschaft noch nicht enthalten.
**Sicherheitsnotiz:** In einem alten Upload-Skript wurden Klartextzugangsdaten bemerkt. Nicht lesen, verbreiten, in Dokumentation/Git übernehmen oder für neue Integration verwenden. Separate autorisierte Rotation/Secret-Auslagerung bleibt Aufgabe; die Werte gehören nicht in diese Übergabe.
## 15. Übernahmeroutine für einen neuen Agenten
1. Host-`AGENT_CONTEXT.md`, Repo-`AGENTS.md` und diese Datei vollständig lesen; bei fehlendem Remote-Stand zuerst den Servercheckout prüfen.
2. `git status`/Branch/letzte Commits sowie `[A]`, `[B]`, `[C]` lesen. Zeitstempel nennen; keine alten Zähler als heutige Werte ausgeben. Diese Übernahme selbst ändert keine Anlage.
3. Nur notwendige gezielte Read-only-Prüfungen. Kein weiterer vollständiger Neustart der Analyse oder Wiederholung bereits erledigter Installer.
4. Bei Fortsetzungsauftrag direkt Abschnitt 11 umsetzen und anschliessend echte Restarbeiten aus Abschnitt 12 abarbeiten. Anforderungen, Testfall, Implementierung und Rollout in einem Paket halten.
5. Nach jedem Abschluss diese Übergabe mit Datum, Codecommit, tatsächlichem Installations-/Teststatus und nächstem konkreten Arbeitsschritt ergänzen. Keine gescheiterten Schritte in „fertig“ umdeuten. Bei Widerspruch aktuelle geprüfte Laufzeit und ausdrückliche spätere Nutzerkorrektur nennen.
**Kurzübernahme:** „Netzfahrplan V4 in ENELIX fortsetzen. Datenkennung/Versand repariert, Unified RC1 und korrigierter Feedback-/Testpfad installiert. Aktuell `feedback_source_skew`; bestätigte Geräte-Lesezeitpunkte in die bestehende lokale Rückmeldung integrieren. EV 161.44 kWh/39 kW. Keine neue Diagnosekategorie, keine alten Installer, kein automatischer Stelltest. Dauerproduktion ist noch nicht fertig; Tests/Abnahme/Veröffentlichung sauber getrennt halten.“
+23
View File
@@ -41,6 +41,29 @@ berechnet sein Leistungsangebot neu und meldet es zurueck. Eine vorhandene
Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr
zulaessig ist.
### Verworfene Sollwerte und Anzeige
Beim Verwerfen einer gueltigen Vorgabe setzen alle Verbrauchermodule sowohl
`SollwertGueltig=false` als auch den gespeicherten und gegebenenfalls sichtbaren
Wert `Sollleistung=0`. An den Manager geht weiterhin `Sollleistung_W=null`;
die Anzeige 0 W ist keine neue gueltige Manager-Vorgabe und kein Nachweis einer
physisch ausgeschalteten Last.
Lokale Schutzprogramme, Mindestlaufzeiten und bestehende Lade-Uebergaenge
bleiben unveraendert. Berechnet ein Modul danach eine lokale Sollleistung,
darf es diese weiterhin anzeigen. Bei Ladestationen bleibt waehrend eines
Regeluebergangs insbesondere der letzte Geraetebefehl erhalten, auch wenn die
alte Manager-Vorgabe bereits verworfen und ihre Anzeige auf 0 gesetzt wurde.
Wiederholte Verwerfungen einer bereits ungueltigen Manager-Vorgabe lassen eine
inzwischen neu berechnete lokale Schutzleistung unveraendert.
Nach einem Modulupdate bereinigt `ApplyChanges` bereits gespeicherte ungueltige
Altwerte. Gueltige Vorgaben einschliesslich 0 W und negativer Batterieleistung
bleiben erhalten. Keine neuen Properties, Variablen-IDs oder Vertragsversion;
es ist keine manuelle Konfigurationsmigration erforderlich. Das Update ist
kontrolliert anzuwenden, da die regulaere Instanzinitialisierung wie bisher
Geraeteaktionen ausloesen kann.
## Verbraucher an Manager
```json
+89 -9
View File
@@ -73,6 +73,12 @@ Die drei Regelungsvariablen `Aktiv`, `Betriebsart` und `Netzleistung` sind immer
| `PrognoseSendeintervall` | Integer | `60` s; Intervall fuer den Upload aktueller Messwerte, zulaessig sind 60 bis 3600 Sekunden. |
| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. |
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
| `SDLAktiv` | Boolean | `false`; separat gemessene SDL-/Regelenergie in der Anlagenbilanz beruecksichtigen. Keine SDL-Steuerung. |
| `SDLLeistungVariableID` | Integer | `0`; bei aktiver SDL erforderliche numerische Istleistungsquelle. |
| `SDLLeistungsfaktor` | Float | `1`; Umrechnung nach W, positiv Laden/Bezug, negativ Entladen/Abgabe. Fuer kW `1000`, bei umgekehrtem Vorzeichen negativ. |
| `SDLSOCVariableID` | Integer | `0`; optionaler separater SDL-Ladezustand in Prozent, 0 bis 100. |
| `SDLEnergieflussAnzeigen` | Boolean | `false`; SDL mit optionalem SOC als eigenen Speicher im aktivierten Energiefluss anzeigen. |
| `SDLDiagrammeAnzeigen` | Boolean | `false`; SDL-Leistung, Lade-/Entladeenergie und optionalen SOC in aktivierten Diagrammen anzeigen. |
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
@@ -134,6 +140,59 @@ entfernt beziehungsweise die native Energy Distribution ausgeblendet:
- responsive Energy Facts mit Solar-, Netz-, Haus- und Vergleichswerten,
- native Energy Distribution fuer PV, Netz, Haus und Batterie.
## SDL / Regelenergie
`SDL aktiv` blendet die Quellen- und Anzeigeauswahl ein. Die SDL-Istleistung
beschreibt einen **separat gemessenen AC-Zweig am selben Netzanschlusspunkt**.
Sie darf weder in der normalen Batterieleistung noch in einer PV-Leistung
enthalten sein. Eine identische Variablen-ID fuer SDL und Netz/PV/Batterie wird
abgewiesen. Ueberschneidungen in extern gebildeten Summen muss die
Anlagenkonfiguration ausschliessen. Ein gemeinsamer Speicher mit nur einem
Gesamtleistungsmesswert kann damit nicht in EMS- und SDL-Anteile zerlegt werden.
Bei aktiver SDL lautet die Hauslast `max(0, PV + Netz - Batterie - SDL)`.
Die Korrektur geschieht vor der Begrenzung auf null und unabhaengig davon,
ob SDL angezeigt wird. Diese Hauslast wird an den Prognosedienst gesendet;
Netzleistung und der kapazitaetsgewichtete SOC der normalen Batterien bleiben
unveraendert. SDL-SOC ersetzt keinen Batterie-SOC und wird nicht als
planbare EMS-Speicherkapazitaet exportiert. Ungueltige SDL-Leistungswerte
unterbrechen die Messaufzeichnung und den jeweiligen Telemetrieversand,
statt als null in die Hauslast einzugehen. Der SDL-Status ist bei aktiver
SDL auch ohne eingeschaltete Diagnosevariablen sichtbar.
`SDLLeistungArchiv` zeichnet Watt auf, `SDLSOCArchiv` gueltige Prozentwerte.
`SDLLadenEnergie` und `SDLEntladenEnergie` integrieren die getrennten Richtungen
in kWh. Die Abtastung erfolgt wie bei den anderen Leistungen minuetlich, nicht
als abrechnungsgenaue SDL-Energiemessung. Auch bei vorhandenen physikalischen
Energiezaehlern wird die Hausenergie um die im selben Intervall integrierte
SDL-Ladung vermindert und um SDL-Entladung erhoeht. Bei Messluecken ueber
fuenf Minuten, Quellenwechseln oder Zaehlerresets wird keine unvollstaendige
Hausenergie nachberechnet; Historie und bereits erfasste Zaehler bleiben erhalten.
Die Anzeigeoptionen wirken beim Anwenden direkt auf die vorhandenen
Manager-Visualisierungen. Nur die eigenen SDL-Knoten und -Datensaetze werden
ergaenzt oder entfernt. Andere Knoten, Farben, Datensaetze und sonstige
Anpassungen bleiben bestehen. Im Energiefluss verwendet SDL dieselbe W/kW-
Einheit wie die anderen Manager-Knoten; ein ungueltiger SOC wird dort nicht
als aktueller Zusatzwert angezeigt. Ohne separaten SDL-Knoten enthaelt der
Manager-Sammelknoten Batteriespeicher die normale Batterie plus SDL, damit
die dargestellte Leistungsbilanz vollstaendig bleibt. Im Leistungsdiagramm
erscheint die signierte SDL-Istleistung, im Energiediagramm erscheinen Laden
und Entladen getrennt. SOC verwendet in beiden Diagrammen eine eigene rechte
Prozentachse. Die allgemeinen Schalter fuer Energiefluss, Diagramme und
Energieaufzeichnung bleiben erforderlich.
Migration: SDL bleibt nach dem Modulupdate standardmaessig aus. Bestehende
Installationen behalten damit ihre bisherige Bilanz. Erst Quellen und
Vorzeichen pruefen, SDL aktivieren und anwenden. Historische Hauslasten oder
bereits trainierte Prognosen werden nicht rueckwirkend korrigiert; neue
Prognoseeingaben sind ab Aktivierung bereinigt. Ausschalten entfernt nur
SDL-Anzeigen, nicht die aufgezeichnete Historie. Der alte `SDLAnschluss`
bleibt lesbar; externe SDL-Regelung und Stellbefehle werden nicht veraendert.
Native Darstellungsvertraege: [Energy Distribution](https://www.symcon.de/en/service/documentation/module-reference/energy/energy-distribution/)
und [Diagramme](https://www.symcon.de/en/service/documentation/basics/media/charts/).
## Anlagenweite Einspeisebegrenzung
Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der
@@ -310,15 +369,36 @@ Das gilt auch bei `AenderungMoeglich=false`: Ein fixes Angebot wie `[11000]`
wird mit genau `11000 W` zugeteilt.
Die Verbraucher werden nach der gemeldeten PV- beziehungsweise Peak-Priorität
sortiert. Bei gleicher Priorität bevorzugt der Manager wie Enelix 1 den
Verbraucher mit der bisher kleineren bezogenen Energie; verglichen wird in
2-kWh-Gruppen. Erst wenn auch diese Gruppe gleich ist, entscheidet stabil die
Instanz-ID. Zuerst reserviert der Manager für jeden Verbraucher den kleinsten
erlaubten Leistungswert. Nur das danach
verbleibende Budget wird in Prioritätsreihenfolge aufgefüllt. Einzelwerte und
ganzzahlige Bereiche werden direkt verarbeitet; Lücken werden nie durch
unzulässige Werte geschlossen. Eine verbleibende Abweichung wird im
`Verbraucherstatus` dokumentiert.
sortiert. Zuerst reserviert der Manager die richtungsneutralen erlaubten Werte
sowie die gemessene Leistung fester oder gesperrter Verbraucher.
Positives Restbudget wird innerhalb jeder Priorität schrittweise verteilt:
Zuerst kommt die kleinste nächste erreichbare **absolute Sollleistung**, nicht
die kleinste Erhöhung. Bei gleicher nächster Sollleistung entscheidet die exakt
gezählte bezogene Energie in Wh, danach stabil die Instanz-ID. Die bisherigen
2-kWh-Gruppen werden für diese Vergabe nicht mehr verwendet. Vom Budget wird nur
die Differenz zum bereits zugeteilten Sollwert abgezogen. Ein nicht finanzierbarer
Schritt wird übersprungen; andere passende Schritte und danach tiefere
Prioritäten können das verbleibende Budget nutzen.
Beispiel bei gleicher Priorität: A bietet `[0,100,200,400,800]` bei 10 kWh,
B bietet `[0,100,110,500,780,1500]` bei 12 kWh. Die Zuteilungsfolge bei
ausreichendem Budget ist `A100, B100, B110, A200, A400, B500, B780, A800, B1500`.
Die zugehörigen Gesamtbudgets sind 100, 200, 210, 310, 510, 900, 1180, 1580 und
2300 W. Energie entscheidet nur bei gleichen nächsten Leistungsstufen, nicht
mehr über die vollständige Versorgung eines Verbrauchers vor allen anderen.
Ganzzahlige Bereiche folgen derselben Reihenfolge wie einzelne Wattstufen,
werden aber über gemeinsame Leistungsniveaus effizient verarbeitet. Lücken
werden nie durch unzulässige Werte geschlossen. Bei negativem Restbudget bleibt
die bestehende Defizitregelung einschliesslich ihrer Energiegruppen unverändert.
Eine verbleibende Abweichung wird im `Verbraucherstatus` dokumentiert.
Für das Update sind keine neuen Properties oder eine Migration nötig. Bestehende
Energiezähler, Sperrzeiten und Freigaben bleiben erhalten. Nach dem Modulupdate
gilt die neue Vergabe beim nächsten Regellauf. Vor einem Update einer laufenden
Anlage Einstellungen sichern und die neuen Sollwerte kontrolliert prüfen;
die Git-Veröffentlichung allein aktualisiert keine laufende Installation.
Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb
der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten
+99
View File
@@ -0,0 +1,99 @@
# V4: getrennte Last-/Batterie-/SDL-Bilanz (Kandidat, keine Stellfreigabe)
## Status 2026-10-02
`NetzfahrplanV4Bilanzierung` ist ein reiner Rechen-/Validierungsbaustein. Noch keine
Einbindung in die laufende Telemetrie, den Trainer oder den Kontrolltest. Er ruft
keine IPS-, Netzwerk-, Archiv- oder Stellfunktionen auf. Ergebnis ist immer
`controlEligible=false`, auch bei geschlossener mathematischer Bilanz.
34 isolierte PHP-Pruefungen wurden mit der gleichen SHA256-Datei auf iot-symcon01
bestanden. Das ist kein Nachweis fuer die reale Hardware-/Messgrenzenzuordnung
und kein vollstaendiger PHPUnit- oder Symcon-Kernel-Test.
## Gefundener Unterschied in Lihrenmoos
Installierter Manager `Manager/module.php::leseMessleistungen()` verwendet:
Haus = PV + Netz - Summe der konfigurierten Batterie-Istleistungen
Konfiguriert ist hier die virtuelle EV-Leistung 52020, nicht die gesamte physische
Batterieleistung. Daher bleibt der externe SDL-Anteil und eine allfaellige
Abweichung der virtuellen Aufteilung im an den Prognosedienst gesendeten Hauswert.
`sendePrognoseTelemetrie()` sendet genau diesen errechneten Wert. Die parallel
existierenden Hausvariablen 46714 und 40713 sind NICHT automatisch diese Quelle.
Der bisherige Gateway `Bat_EV_SDL_V4` liest die physische Batterieleistung aus
47725, 35724 und 36380 und invertiert diese Vorzeichen. EV und SDL sind daraus
abgeleitete, separat gefilterte virtuelle Konten, keine zwei unabhaengigen Zaehler.
Die Filter koennen alte Teilwerte halten. `UpdateActualPowerSplit` aktualisiert die
Ausgabevariablen trotzdem. Ein neuer Ausgabezeitstempel beweist deshalb nicht die
Frische aller darunter liegenden Originalmessungen.
## Kandidat fuer die korrekte Bilanz
Mit einheitlicher Messgrenze und Vorzeichen Batterie+ = Laden, Netz+ = Bezug:
Verbraucherlast = Netz + PV - gesamte physische Batterieleistung
Grundlast = Verbraucherlast - separat geplante flexible Verbraucher
Zuordnungsrest = physische Batterie - virtuell steuerbare Batterie - externe virtuelle Konten
Externer Netzeffekt = externe virtuelle Konten + Zuordnungsrest
Netz = Grundlast + flexible Last + steuerbare Batterie + externer Netzeffekt - PV
Der Zuordnungsrest wird nicht erneut als Hausverbrauch trainiert, sondern separat
angezeigt. Eine grosse Abweichung sperrt die Datenqualitaet. Gegenlaeufige virtuelle
Konten (z.B. EV +42 kW, SDL -40 kW, physisch +2 kW) bleiben korrekt getrennt.
Spaetere Ladestationen/Boiler sind ueber `flexible_load` erweiterbar. Solange sie
nicht separat geplant werden, verbleiben sie in der nicht separat steuerbaren Last.
Keine Messgroesse wird doppelt subtrahiert. Ein negatives Grundlastergebnis wird
nicht heimlich auf null begrenzt. Numerische Nullen sind echte Werte; fehlende,
nicht endliche oder umtypisierte Werte sind keine Nullen.
## Originalzeit und Datenqualitaet
Jede Quelle hat explizite Variable/Parent/Ident, Faktor, maximales Alter und optionale
Ursprungsabhaengigkeiten. Alle Quellen werden zweimal gelesen. Wertewechsel waehrend
der Aufnahme werden abgewiesen; Zeitstempel werden nie kuenstlich verjuengt.
Abhaengigkeiten sind zyklenfrei, doppelte Quellen werden verhindert.
Eine virtuelle Summenvariable erbt das aelteste Datum ihrer physischen Eingangsquellen.
Konfigurierbare Alters-/Synchronitaetspruefung ist erforderlich. Diagnoseberechnungen
mit ungeeigneten Daten bleiben `quality_hold`, nicht `base_load`-Trainingsfreigabe.
Die momentanen Beispielgrenzen (60 s Alter, 30 s Versatz) sind konservative
Diagnoseparameter, keine garantierten Geraeteeigenschaften.
Die konkrete Solar-PV-Herkunft sowie AC/DC-/Wirkungsgradgrenzen aller Hybridgeraete
sind noch zu verifizieren. Eine algebraisch geschlossene Gleichung beweist diese
physische Messgrenze NICHT. Die vorgeschlagene Quellkonfiguration ist nicht freigegeben.
## Migration in die Prognose (noch offen)
1. Originalmessquellen und Messgrenze dokumentieren und Qualitaet ueberwachen.
2. Korrigierte Grundlast und externe Kanaele als NEUE versionierte Zeitreihen erfassen.
3. Bestehende Historien nicht ueberschreiben oder als bereinigte Historie umbenennen.
Rekonstruktion nur bei ausreichenden zeitlich passenden Originaldaten.
4. Prognosemodell auf der korrigierten Basis trainieren/validieren.
5. Zukunfts-SDL separat liefern. Unbekannt ist nicht null; eine geschaetzte Annahme
muss explizit bezeichnet werden und darf nicht als veroeffentlichter SDL-Fahrplan gelten.
6. Erst nach diesen Pruefungen eine echte Bilanzierungsreferenz im Trial-Gate verwenden.
`mappingId` aus dieser Klasse ist ein Konfigurationsfingerabdruck, KEIN solcher Nachweis.
## Gateway-Ausfallverhalten (nicht getestet/freigegeben)
Quelltextpruefung des installierten Alt-Gateways ergab keinen Frischetest fuer
`Nennleistung_Soll_EV` im Weg `Update -> ApplySetpoints`. Der zwischengespeicherte
EV-Sollwert wird weiter verwendet, solange die Gateway-Schleife laeuft. Der Eingang
wird von `scripts/31800.ips.php` geschrieben; dieser enthaelt ebenfalls keine Lease.
Der vorhandene 30-s-Timeout des EMS-Batterieadapters ist ein Schutz innerhalb von
Symcon, nicht ein unabhaengiger Wechselrichter-Timeout bei Rechnerausfall.
`State=false` im Alt-Gateway beendet `Update` ohne dort Nullwerte zu schreiben.
Diesen Schalter nicht als nachgewiesenen Hardware-Notstopp verwenden.
Keine Kommunikationsunterbrechung, kein Abschalttest und keine neue Stellwertausgabe
wurden an der realen Anlage durchgefuehrt. Auch aus dem fehlenden Code kann NICHT
gefolgert werden, dass das Geraet selbst keinen Timeout hat. Exakte Typen/Firmware
und das wirksame geraeteseitige Verhalten muessen noch festgestellt werden.
Wichtig fuer spaetere Anpassung: Ein EV-Befehlsablauf darf nicht pauschal den
externen SDL-Auftrag loeschen. Ein Kernel-interner Timer ersetzt wiederum keinen
Schutz bei komplettem Kernel-/Rechner-/Kommunikationsausfall.
+131
View File
@@ -0,0 +1,131 @@
# V4 application data and forecast integration
## Implemented application path
`ManagerNetzfahrplanV4DatenTrait` records the configured raw power/SOC/counter sources
inside the existing Manager. This replaces the need for permanent standalone
observation categories once the native delivery path has been accepted. It never
issues device commands. New acquisition properties default disabled.
The private outbox at `data/enelix-v4/<installationId>` is append-only and delivery
is acknowledged by dataset and capture time. Unacknowledged data survives network
failures and 429 responses. The native payload budget is below the existing portal
1 MiB request-body limit. Existing authentication and V4 rate limiting are reused.
Server application source is now versioned under `services/netplan-v4`, rather than
existing only as an untracked server working tree. No live measurements, databases,
credentials, dependency binaries or settings exports are included in this directory.
The existing worker handles per-plant dataset ingestion, physical five-minute
rollup, model training, model storage and corrected-load substitution into the V4
optimizer. It continues to consume the existing PV forecasts and price/operation
inputs. The public device route can append measurements but cannot alter source
meaning, register a dataset, change live permissions or arm a controlled trial.
## Explicit settings and model meaning
- `forecastSource=legacy` preserves the existing forecast input by default.
- `forecastSource=corrected_profile` plus `measurementDataset` uses the trained
corrected physical load. Missing models are reported; legacy house values are
not substituted silently.
- `trainingCadence=daily|weekly` is now connected to real profile fitting in the
application worker, independently from the optimizer refresh frequency.
- Corrected load profiles are tagged `physical-profile-v1`: robust daily profile,
recent-day profile and weekday/weekend profile paired with PV families 3/13/23.
They are not claimed to be the previous load models unchanged. Economic automatic
selection still requires the separate, not yet finished cost-replay integration.
- Initial models are explicitly bootstrap models. Later candidates record causal
holdout errors; an overlapping validation window cannot certify a promotion.
- Current SDL request persistence is only a labelled shadow scenario. Unknown or
stale SDL is not silently zero, and the scenario is not a guaranteed future
schedule or a robust SDL-aware production control policy.
## Lihrenmoos package scope
Effective EV capacity 161.44 kWh and power 39 kW remain unchanged; SDL reserve is
already excluded. The prepared dataset `lihrenmoos-physical-v1` uses a configured
physical estimate with SolarEdge signed terminal power counted once. It requires
at least 24 equivalent usable hours before the first bootstrap profile; the
history window is 28 days. Coverage policy 95% with maximum 10-second unsupported
portion is an explicit modelling assumption. Gaps and source ages remain visible.
It is not an independent electrical metering-boundary proof.
Server entry point on the service host:
python3 /home/agent/services/netplan-v4-shadow/commissioning/deploy_application.py \
--plant e3a08f9e-af12-4695-99bd-8b51c0520021 --apply
Default without --apply checks source only. The explicit apply command rebuilds
and tests V4 in Python 3.11, tests portal routing, backs up the V4 database and
recreates only V4 and the portal. It configures the dataset but DOES NOT select it
for the existing plan automatically. No forecasts/tariff importer or controllers
are restarted by the server command. A previous-image rollback is prepared.
Then, inside Lihrenmoos Symcon:
require '/srv/agent/netplan-v4-application-build/install.php';
This is a data-only patch of the currently installed passive manager, with file
backup and library reload. It does NOT install the development control-trial
changes in Manager/Batterie. The first runtime invocation imports at most 48h of
the existing 23-channel observer as a durable backlog, preserving the original
files, then activates acquisition and delivery. If Symcon module registration is
not immediately available, the installer reports that a single repeat is needed.
The two temporary standalone samplers are not stopped automatically by this
initial package; disable them only after native batch acceptance and history
continuity are confirmed. This package creates no new root diagnostic category.
## Validation performed before deployment
219 Python tests passed in the isolated host QA runtime, including actual synthetic
measurement -> profile -> existing optimizer -> stored shadow plan. 16 Node proxy
checks passed. 21 PHP data-path checks and 5 installer scenarios passed with mocked
IPS/HTTP and temporary files on the test host. Full staged PHP syntax passed.
No new container or new manager data integration has yet been run in production.
Evidence: service `APPLICATION_PIPELINE_TEST_RESULTS.txt`,
`APPLICATION_PORTAL_TEST_RESULTS.txt`, `commissioning/application-source/RELEASE.json`;
test host `netplan-v4-application-build/PREPARATION.json` and `TEST_RESULTS.txt`.
## Remaining product scope (do not disguise as complete)
This integrates application data and load forecasting, not completed production
commissioning. Economic replay-based automatic family selection, full corrected
feedback/control integration, long-term outbox/server retention and real live/
failure acceptance remain open. Existing trial gates are unchanged. No sensor,
accounting or hardware evidence identifier is fabricated by data ingestion.
## Installer class-path correction (2026-10-03)
The original commissioning installer loaded `source/libs/NetzfahrplanV4Messaufnahme.php`
while importing observation history. A later Manager callback loads the installed
copy at `modules/Enelix-EMS/libs/NetzfahrplanV4Messaufnahme.php`. PHP's once-only file
inclusion does not deduplicate class declarations across those different files.
The dependent `NetzfahrplanV4Bilanzierung` class is subject to the same collision.
The tested installer-only fix is preserved in
`examples/V4ApplicationData/application-class-loading.patch` (SHA256
`6d53d74d4059bb1ae3a446350637a4f4a4a73cc2fd64d2fc2f0967bd23ec7173`).
When building a commissioning package, load both classes only from the installed
module tree, after validating their expected source hashes. Before any require,
check both already-loaded origins without autoload. Reject a foreign staged origin
with a controlled error; do not hide it by wrapping the class declaration in a guard.
Include `installed_capture_bootstrap.php` in the package checksum map. Do not change
the installed-module manifest or bless unrelated source changes.
The Lihrenmoos staging entrypoint has this fix applied, with backups of its original
installer and checksum map. No running modules, data files, timers, native callbacks,
server components or actuator settings were changed by the repair. Actual completion
still requires the user's fresh Symcon invocation of the corrected installer.
Validation: the original two-path failure was reproduced with the real PHP classes.
Thirteen additional isolated installer scenarios passed, including callback/reload
loading order, preloaded dependencies, history import, repeat/finished import,
foreign-class refusal, partial-import preservation, source drift, registration wait
and first installation. These are PHP CLI tests with mocked IPS in temporary trees,
not a completed test in the actual Symcon kernel.
Evidence on the test host:
`/srv/agent/netplan-v4-application-build/class-loading-fix/TEST_RESULTS.json` and
`FIX_RESULT.json`. The test harness and exact candidate source are retained there.
+96
View File
@@ -0,0 +1,96 @@
# V4: begrenzter Regeltest (Entwicklungskandidat, NICHT in Betrieb)
Stand 2026-10-02. Diese Erweiterung ist weder Produktionsfreigabe noch ein
Installations-/Startauftrag. Bestehende Schattenplaene und ihre `shadow_seen`
Bestaetigungen bleiben unveraendert. Ein Vorschauwert allein darf niemals
als Stellfreigabe interpretiert werden.
## Freigabekette
1. Serverseitige separate Allowlist `NETPLAN_V4_CONTROL_TRIAL_PLANTS` (standardmaessig
leer). Interner authentifizierter `planner/trial/arm`-Aufruf mit expliziter
Bestaetigung, neuer Sitzung, gepruefter Plan-ID/Revision, Batteriezuordnung,
Zeitfenster, Leistungsgrenzen und dokumentierter Geraete-Watchdog-Abnahme.
Die bestehenden oeffentlichen Portal-/Geraeterouten proxien diesen Aufruf NICHT.
2. `NetzfahrplanV4RegeltestErlaubt` lokal im Manager UND im Batteriemodul (jeweils
Standard false). Alte `NetzfahrplanAktiv`-Freigabe muss false bleiben.
3. Zusaetzlicher bewusster lokaler Start einer bestimmten Sitzung. Ein Reload,
Netzwiederkehr oder gespeicherter Haken startet keine Sitzung automatisch.
4. Der Manager prueft den empfangenen Schattenplan und die getrennte
`controlled_trial_authority` gemeinsam. Sie sind gebunden an Anlage, Batterie,
beide Instanz-IDs, Revision, manuelle Modellfamilie und stabilen Planungskontext.
5. Die Batterie nimmt nur einen separaten `controlled_trial_command` mit eigener
Sequenz und kurzer Gueltigkeit an. Normale Managerbefehle erneuern diese
Gueltigkeit nicht. `shadow_seen` bleibt eine Empfangs-, keine Ausfuehrungsmeldung.
Die Serverantwort bleibt eine Schattenplan-Antwort (`liveEnabled:false`). Eine
getrennte Testfreigabe ist unter `controlledTrial` und
`controlledTrialAuthorized` sichtbar. Der Schattenmodus allein ist bei kuenftig
bewusst gestartetem Test deshalb kein Nachweis fuer 'keine Batteriebewegung'.
Die Controller-Diagnosen benennen eine angenommene Sollvorgabe, nicht bereits
physisch gemessene Umsetzung oder Ersparnis.
## Bewusst begrenzter Pilotumfang
- Genau eine eindeutig zugeordnete Batterie je Test; Familie manuell festgelegt.
- 30 bis 1800 Sekunden Sitzung, maximal 5000 W Laden und 5000 W Entladen.
- Server-, Manager- und Batteriegrenze koennen strenger sein; nie grosszuegig
ersetzen oder still auf 39 kW hochsetzen.
- Gesamtsollleistung, NICHT Zusatzleistung zur bereits fliessenden Batterieleistung.
- Andere Verbraucher bleiben im alten Verteiler. Ihre erwartete Leistungsaenderung
wird einmal bei der Batteriekorrektur beruecksichtigt.
- Batterie wird nur einem Stellwertpfad zugeteilt, nicht gleichzeitig dem alten
und neuen Verteiler. Ist der Test ungueltig, stoppt er und es wird eine NEUE
normale Verteilung angefordert; kein alter gespeicherter Sollwert wird restauriert.
- Physische Grenzverletzung, die innerhalb der Pilotleistung nicht behebbar ist,
beendet den Test zugunsten der bestehenden lokalen Regelung.
## Ausfall- und Zeitverhalten
- Serverfreigabe maximal 90 Sekunden, lokal regelmaessig frisch abgerufen.
Serverseitiger Widerruf wirkt nicht magisch sofort: Cachefrist beachten.
- Einzelner Batteriebefehl maximal 10 Sekunden; Batterie kontrolliert im laufenden
Kernel mit 1-Sekunden-Timer zusaetzlich zu Messwert-Callbacks.
- Manager liefert kurze Befehle im Test mit zusaetzlichem 2-Sekunden-Takt.
- Monotone Laufzeit und Kalenderzeit werden getrennt geprueft. Eine rueckwaerts
springende Systemuhr darf eine Sitzung nicht verlaengern.
- Abbruch widerruft die Sitzung VOR dem Nullstellversuch. Schreibfehler werden als
`stop_failed`/nicht bestaetigter Stopp sichtbar, nicht als sicherer Zustand.
- Reserve, Hysterese, aktuelle Lade-/Entladeleistung, SOC und Verfuegbarkeit werden
auch im Batteriemodul erneut geprueft. Sicherheitsreduktionen werden nicht durch
die Umschaltsperre verzoegert; Richtungswechsel laufen ueber null.
- Laufzeitsitzungen liegen nur im Buffer, nicht als automatische Wiederanlauf-Freigabe.
Ein persistenter Marker sorgt bei Wiederinitialisierung fuer einen erneuten
Nullstellversuch, falls ein Testwert zuvor ausgegeben worden sein koennte.
WICHTIGE GRENZE: PHP-Timer und Software-Nullstellversuche funktionieren nur bei
laufendem Kernel und erreichbarem Wechselrichter. Sie sind KEIN physischer
Not-Aus und kein Beweis fuer Abschaltung bei Serverabsturz, Stromausfall oder
unterbrochener Stellwertverbindung. Dafuer ist ein separat nachgewiesener
Befehlsausfall-/Watchdogmechanismus am Geraet bzw. Gateway erforderlich.
Das Feld `NetzfahrplanV4GeraeteWatchdogNachweis` ist eine Referenz auf die gepruefte
Dokumentation/Abnahme, keine automatische Hardwarepruefung und kein Defaultwert.
## Noch offene Freigabepunkte (nicht mit Platzhaltern umgehen)
- Der laufende Lihrenmoos-Prognosestrom ist noch `house_total`. Fuer Testfreigabe
muss `base_load` mit nachgewiesener Quelle/SDL-Abgrenzung und
`accountingEvidenceId` aus dem geprueften Adapter vorliegen. Ein Labelwechsel
oder ein erfundener Nachweis reicht nicht; Quelle und Messbilanz pruefen.
- Geraete-/Gateway-Watchdog bei ausbleibenden Stellbefehlen nachweisen.
- Vollstaendiges Manager-/Batterie-Callbackverhalten im IP-Symcon-Kernel pruefen.
Die isolierten Tests verwenden echte neue Traits, aber simulierte IPS-Aufrufe,
Messwerte, Zeit und Register. Reale Modbus-/VGT-Stellreaktion steht aus.
- Zielcontainerpruefung des neuesten Serverstands und Feldabnahme.
- Keine durchgehende unbeaufsichtigte Produktion, kein automatischer Mehranlagenstart.
Modellvergleich, Trainingsanbindung, Mehranlagenabnahme und Betriebs-Release
bleiben weitere Aufgaben.
## Quelltests
`php tests/V4ControlTrial/checks.php` nur im separaten CLI-Testprozess ausfuehren,
niemals im laufenden Symcon-Skripteditor. Die Datei verweigert einen realen
Symcon-Kernel. Eine PHPUnit-Huelle bindet sie an die normale Testsuite an.
Der Server-Test verwendet ausschliesslich synthetische Daten/Temporaerdatenbanken.
Keine Testfunktion liest Zugangsdaten oder schreibt eine reale Stellvariable.
+108
View File
@@ -0,0 +1,108 @@
# V4 corrected physical feedback -> local bounded control
## Scope of this delivery
The shared physical feedback reader is now connected to the actual Manager preview,
the existing explicit bounded-trial command path, and an independent reread in the
Battery module immediately before hardware output. This is not a new observer.
The installer leaves both local trial permissions disabled and cannot arm a server
trial. Normal operation continues through the existing local controller.
Lihrenmoos binding: Manager 17004, Battery 44234, virtual asset
`anlage01-virtual-ev`. Effective 161.44 kWh / 39 kW unchanged. Source definitions
come from the existing versioned capture config and current saved topology, not
from new guessed device models. No old histories, sender cursors, virtual energy
accounts, SDL requests, archive policies or inverter polling are changed.
## Feedback and meaning
`NetzfahrplanV4Rueckmeldung` checks original value timestamps and object identities
in two read passes. Live max age is bounded to 60s and inter-source skew to 30s.
History interpolation/publication estimates are not accepted as live feedback.
The mapping fingerprint normalizes numbers and ordering, so a JSON property round
trip does not create a new identity. A mapping change cancels an active session.
Two adapters exist: sum of explicitly assigned physical meters, and a virtual
EV/SDL partition model. For the latter, gateway state and current requests are
read but unchanged zero commands are not mistaken for failed sensor heartbeats.
When SDL request is exactly zero, current physical battery power is used rather
than the filtered virtual EV display. A new EV request does not invent an immediate
physical response or force the measured sign toward the desired sign.
When SDL is nonzero, source timestamps must cover the latest request change and
physical tracking error must remain within the configured small tolerance. The
partition is explicitly estimated, not separately measured. Opposing EV/SDL flows
cannot be uniquely identified; they remain ineligible for trial control. Unknown
or stale physical sources never fall back to held filtered EV values.
`allowEstimatedForTrial` is false in the prepared Lihrenmoos config. Configuring
feedback or seeing a valid preview therefore does NOT accept the estimate for a
trial and does NOT satisfy accounting/device-watchdog evidence or other gates.
This adapter does not claim to resolve arbitrary simultaneous SDL activity.
## Actual control integration
The same coherent grid/battery snapshot is used in the Manager. Battery command
is a TOTAL power, not a delta added to the existing command. Planned changes in
other consumers are counted once and remain explicitly separate from measured grid.
A separate explicit server authority, both local consents, accounting evidence,
device-watchdog evidence, a deliberate session start and fresh plan remain required.
The existing trial is limited to 1800s / 5000W per direction; individual command
leases remain <=10s and cannot be renewed by replay or ordinary manager messages.
The local feedback mapping fingerprint and grid caps are bound to the session.
Immediately before writing, Battery rereads physical feedback and applies current
SOC, reserve, hysteresis, availability and change-lock constraints. Its resulting
command must still meet the bound grid limits; an unreachable target revokes the
trial rather than claiming that the planned grid value was achieved. It cannot
claim physical performance merely because a register call returned successfully.
Abort revokes the lease and attempts zero, and the Manager wrapper then runs at
most one fresh ordinary allocation, clearing cached pre-trial targets. It does
not recurse forever or revive an old plan. A failed stop stays explicitly failed.
The watchdog still requires a functioning kernel. Independent hardware failure
behavior is NOT certified by software tests.
Outside a V4 session the existing battery power path is unchanged. The compatible
BatterieRegler library includes the already developed optional reserve charging
limit; with its default (maximal charge) 13,824 old/new ordinary-offer test cases
match exactly. That comparison is not an on-device dynamics test.
## Installation
A single prepared Symcon script is the next runtime action:
require '/srv/agent/netplan-v4-feedback-stage/install.php';
It hash-checks nine changed files and all version-matched dependencies, backs up
existing source, installs dependencies before modules and reloads the ENELIX
library. Reload/ApplyChanges may execute existing initialization routines; this
is not promised to be a zero-effect library reload. It configures only the new
feedback mapping on Battery. No server redeployment is required. No shared classes
are included from staging, avoiding the earlier duplicate-class problem.
A delayed module-registration result requests one repeat. A valid installation
can still report an unavailable feedback sample; that is not reinterpreted as zero.
Any preexisting trial permission, unsaved change, or concurrent source change stops
the installer. Source write failure rolls back its own changed files; reload errors
are reported for review and do not automatically authorize anything.
## Validation and remaining acceptance
125 isolated PHP functional checks cover the reader, real receiver, actual trial
traits with a simulated register driver, the extracted actual Manager fallback
wrapper, and the real Battery message builder with optional diagnostic variables absent.
Source quality is stored internally; a last partition estimate is not relabelled as a
measured value just because a trial ends or a diagnostic variable is hidden. Four full module linkage checks and ten isolated installer scenarios also
pass. Full PHP syntax and dependency hashes are checked. Neither these fixtures nor
the ordinary-offer comparison run the real Symcon kernel or an inverter.
Evidence: test host `/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`,
`TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`.
Runtime installation, real feedback reception, and a separately authorized field
trial remain outstanding. This finishes the code connection for bounded control;
it is NOT an unrestricted continuous-production controller or a hardware
commissioning certificate. Corrected model selection and actual real-world savings
must be checked against their own data. Existing safety gates are not bypassed.
+67
View File
@@ -0,0 +1,67 @@
# Passive V4 data capture - user inventory and raw acquisition
Status 2026-10-02: source implementation, CLI/mocked tests and staged user installation.
Not part of the live manager, not a control or training release.
## Lihrenmoos user-reported inventory (not applied to running configuration)
- GoodWe 1/2: each 50 kW AC, each 156 kWh reported physical battery capacity.
- SolarEdge: 10 kW AC, 10 kWh reported physical battery capacity.
- EV allocation: 30 kW / 160 kWh total, understood as 10 kW EV per inverter.
- PV roof: approximately 20 kWp east and 20 kWp west. Tilt/individual strings unknown.
- Nominal battery sum: 322 kWh. 162 kWh nominal difference to EV allocation is NOT a
verified usable SDL capacity. AC ratings are not verified battery charge/discharge ratings.
- Exact types, firmware, common AC meter boundary and device watchdog remain unverified.
- Current saved manager topology still reports 161.44 kWh / 39 kW. Gateway currently
lists 130/130/8 kWh and 57/57/5 kW. These may use a different usable/nominal basis.
Do not rebase virtual SOC or change current controls based on these notes alone.
## Why collect before boundary approval
Record the actual original numerical channels now, with source metadata and timestamps.
The algebraic base-load candidate is useful for diagnosis, but NEVER a certified base-load
training sample until the physical AC/DC boundary and historical recipe are verified.
No dependence on hardware-watchdog approval for passive recording.
21 channels: PCC power, legacy displayed PCC power, PV for three inverters, three physical
battery powers, EV/SDL virtual powers and SOCs, three physical SOCs, dynamic EV limits,
T1/T2 import counters, EV/SDL requested setpoints (READ ONLY).
A cyclic script reads values already present in Symcon every 30 seconds. It does not issue
additional Modbus polls. Original VariableUpdated/VariableChanged are retained; these are
Symcon timestamps, not independent device-measurement timestamps. Two-pass reads detect
concurrent changes; missing/non-numeric/re-used channels remain explicit quality failures.
## Storage and interpretation
Daily append-only raw-YYYYMMDD.jsonl in the separate capture stage data directory, UTC
capture times plus original source timestamps, immutable mapping/inventory snapshots by
SHA256. Raw errors and quality_hold are recorded too; nothing is invented as zero.
The raw observations are a source for later documented 5-minute aggregation, not themselves
aligned 5-minute means. No interpolation, training publication or future SDL assumption.
A 512 MiB journal quota, 64 MiB daily limit, 256 KiB record cap and 100 MiB free-space
reserve bound recording. Limits fail visibly without deleting old data. File permissions
0640; no upload credentials, request bodies, complete instance configurations or secrets.
A separate status variable and STATUS.json show latest capture and failures. Process restart
retains files; an incomplete final JSONL row is preserved for manual review, not extended.
## Install / stop
Staged user entry point: /srv/agent/netplan-v4-data-capture-stage/install.php
Execute in the Symcon PHP editor (not a Linux shell):
require '/srv/agent/netplan-v4-data-capture-stage/install.php';
Installer checks exact source hashes and numeric source identities before creating a NEW
root category ENELIX_V4_PASSIVE_CAPTURE, a status string, collector script and stop script.
IPS_SetScriptTimer enables only this new script at 30 seconds. Repeating installation is
idempotent. Unrelated/edited scripts or object identity collisions are not overwritten.
Stop using the newly created script 'Nur diese Messaufnahme stoppen'; it disables only
this collection timer, retains collected files, and does not stop the manager or battery.
No module reload, existing archive change, existing timer/poller change, service restart,
forecast selection, battery reservation or actuator command is included.
Current tests: 36 core/file tests + 19 mocked native lifecycle tests with PHP CLI on the
test host. First real capture and future timer execution require the user activation.
These tests do not certify hardware mappings or a Symcon-kernel run.
+90
View File
@@ -0,0 +1,90 @@
# Historical publication timing (2026-10-03)
## Implemented scope
`services/netplan-v4/netplan_v4/history_timing.py` implements optional, bounded
retrospective estimates between consecutive identical source values at DIFFERENT
original publication timestamps. It is used by the existing measurement pipeline,
model training and forecast source substitution. It is NOT a real-time estimator,
a measurement certificate, an actuator authority or a repair of device communications.
No source `maxAgeSeconds` is changed. When a regular hold expires, only its tail
until the next qualifying original publication may be estimated. Strict processing
remains the default. The following forbid the estimate: unequal endpoints,
nonfinite/missing observations, conflicting same-timestamp values, collector gaps,
invalid source intervals or a span beyond the explicit source-specific bound.
There is no extrapolation past the last observation and no fabricated zero.
Each window reports `publicationEstimatedSeconds`, per-source estimated portions,
uncovered-source seconds and `availableNotBefore`. Training/validation must not use
a confirmation or receipt before it was actually available. Backfilled windows
cannot establish a supposedly causal holdout at a date before their availability.
All physical windows remain configured estimates; even full support is not proof
that the physical waveform stayed constant between the two endpoint readings.
## Evidence and model assumptions
Read-only source configuration projection on the test server, snapshot
2026-10-03T07:47:24Z: GoodWe 1 instance 19742 (ModBus Device) has Poller 60000 ms;
GoodWe 2 instance 57658 has Poller 5000 ms; SolarEdge scale instance 48996
(ModBus Address) has Poller 20000 ms. No polling setting was changed.
Projection program: /srv/agent/netplan-v4-application-build/inspect_source_update_policy.py.
Official Symcon documentation accessed on 2026-10-03:
https://www.symcon.de/de/service/dokumentation/modulreferenz/geraete/modbus-rtu-tcp/vorlagen/
It documents value publication for ModBus Device on changes or when the variable is
older than 60 seconds, including ordinary and virtual addresses. VariableUpdated
is therefore not automatically the last successful device poll. The documentation
is not a proof that a particular installed device link was healthy.
Explicit Lihrenmoos HISTORICAL estimate bounds:
- GoodWe 1 PV/physical storage: equal original publications at most 130 s apart
(60 s publication suppression + configured 60 s poll + 10 s scheduling allowance).
- GoodWe 2 PV/physical storage: at most 75 s (60 + 5 + 10).
- SolarEdge scale: equal scale publications at most 90 s apart. This is a modelling
bound based on the configured 20 s polling and observed sparse scale publication,
not a manufacturer guarantee and not proof of the same internal implementation
as ModBus Device. Raw AC power remains under the original strict limit.
The 10 s margins are explicit scheduling assumptions, NOT measured timing guarantees.
Gaps of many minutes (e.g. the previously observed GoodWe gaps above 600 s) are NOT
bridged. Nonzero changes are never interpolated by this policy.
## Versioned dataset without restarting collection
`lihrenmoos-physical-published-v2` references immutable observations in
`lihrenmoos-physical-v1`. No observations are copied, deleted, relabelled or resent.
Registration requires the same installation and identical original mapping,
formula, coverage and training configuration. Cycles/reference chains and device
uploads directly into a derived dataset are rejected. Original ingestion continues
unchanged in the Manager. The 24 usable-hour minimum stays in force.
Models/windows are stored separately for v1/v2 and record their timing policy. An
existing selected model/family is not automatically switched by this release.
The current SDL request still uses the strict current-state requirements; no
historical bridge grants a future SDL schedule or a live battery permission.
## Tests and release
254 isolated Python tests passed (219 previous + 35 new including simulated
installation/recovery). Includes the actual service pipeline from referenced
observations to model and optimizer using synthetic records. No new target-container
or field-history evaluation has been run with this release yet; do not infer an
improved real coverage percentage or production acceptance from unit tests.
Evidence: /home/agent/services/qa/history-timing-20261003/ALL_TEST_RESULTS.txt.
Prepared command on enelix-services (root required for Docker):
python3 /home/agent/services/netplan-v4-shadow/commissioning/deploy_history_timing.py \
--plant e3a08f9e-af12-4695-99bd-8b51c0520021 --apply
Default without --apply validates staged source only. The apply path backs up source
files, builds and runs the exact target tests, backs up the V4 SQLite database, and
recreates ONLY the V4 service. A failed test restores source without a service
restart; failed post-deploy validation attempts the previous image and preserves
additive data. Concurrently modified files are not silently overwritten.
No portal/forecast/tariff/Symcon restart, no new sensor requests, no change to
161.44 kWh / 39 kW, SOC accounting, old raw samplers or actuator permissions.
The new dataset is registered but not selected for the active forecast. A root
execution and successful report are still required to install it. Previous release
manifests remain historical records; do not bypass them to reapply an older build.
+52
View File
@@ -0,0 +1,52 @@
# Numeric mapping identity defect - 2026-10-03
## Confirmed from installed code and read-only evidence
The pending batch contains 120 observations: zero duplicate timestamps, zero
backwards pairs, but 75 identity failures. The initial 45 observations in this
batch are imported observer records; later native captures use the saved manager
configuration. Evidence: test-host `PENDING_DELIVERY_REVIEW.json`.
The configured mapping fingerprint is
`517d1907631c7f152096e21759bb3452cdd0b4b1b73f91f1c7cd3bd62d8aaa8b`.
Reproducing native configuration loading generates
`3a1a9cf42aae49f55385d2350da74cae780ae12f170e4976849591e48a393994`.
The sole difference in the compared configurations is numeric representation of
`accounting.splitToleranceW`: prepared float 100.0 versus saved integer 100.
Both inventory fingerprints and all measurement definitions match.
`NetzfahrplanV4Bilanzierung::konfigurieren` validated this field using a function
which returns a float, but discarded the normalized return value. Other numeric
factors were already assigned from that validator.
## Tested source correction
Assign the returned float to `splitToleranceW`. Equal numeric settings now retain
the original fingerprint through a plain JSON property round trip. Actual numeric
changes still produce distinct fingerprints, and boolean/string values are still
rejected. Input configurations are not mutated.
42 isolated PHP checks passed on the test host: 34 accounting regressions plus
8 numeric identity regressions. A separate check with the actual prepared capture
configuration reproduces the configured fingerprint after the property round trip.
Evidence: `/srv/agent/netplan-v4-application-build/NUMERIC_MAPPING_TEST_RESULTS.json`
and `NUMERIC_MAPPING_TEST_RESULTS.txt`. Library SHA256:
`ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199`.
## Not deployed / recovery still incomplete
No installed library, original outbox, cursor, server validator, settings, dataset
registration or actuator permission was changed in this turn. Future-capture
normalization alone does not recover already stored native observations. The
server correctly rejects an entire batch containing a mismatched mapping.
An untested proposal to send distinct immutable dataset versions in separate
batches was removed from the development source and preserved only as a draft at
`/home/agent/services/qa/numeric-mapping-20261003/UNTESTED_SENDER_DRAFT.patch`.
Metadata preparation files are not an installation or permission to ingest into
an unregistered dataset. Tool writes for the complete server recovery package were
blocked; no alternative deployment or identity-check bypass was performed.
Next recovery must preserve original records and provenance, retain exact
per-dataset identity validation, acknowledge only accepted data and include tests
for the mixed 45/75 batch, retries, partial ACKs and no cursor/data loss. Do not
skip the rejected block, rewrite hashes in raw journals or re-run old installers.
+48
View File
@@ -0,0 +1,48 @@
# V4 plan reception and local preview (observation only)
This module does not execute a battery command. The existing regulator and its
setpoints remain unchanged. `NetzfahrplanAktiv` must remain false.
The optional `NetzfahrplanV4EmpfangAktiv` property defaults to false. When enabled
with the existing shadow sender, one bounded GET is made before the next native
operation POST, avoiding reading the plan immediately after queuing its replan.
A successful inspection acknowledges `shadow_seen` once per plan/step. This is
not `applied`. An HTTP failure clears the local preview; it does not change the
old control mode or its connection credentials.
The V4 service must first support `receiverProtocolVersion=1`, installation IDs
in envelope and plan, per-input event references, and the stable controlContext
used at calculation time. Old or cross-plant responses are deliberately refused.
The client checks fresh/lastRun/pending status, exact configuration revision,
model-family registry, known price coverage, contiguous half-open five-minute
intervals, asset mapping and power balance. Numerical feasibility checks are
not evidence of economic performance or permission to control devices.
A cache watchdog refreshes the diagnostic display every 10 seconds, without any
network requests. Cached response older than 90 seconds, plan older than 900
seconds, an expired step, invalid local measurement or policy change invalidates
the preview. Missing targets are JSON null and '-' in the display, never zero.
Restart/ApplyChanges clears the cache. Existing control functions are not edited.
The preview is currently limited to ONE battery. Multiple batteries are still
accepted by the server planner; local allocation is explicitly unsupported in
this stage. Preview feedback uses actual grid and battery power, local capacity,
available power, minimum/maximum SOC, discharge hysteresis, network-charging
permission and import/export/month limits. It exposes any remaining grid-limit
violation instead of pretending the battery can resolve it. It is a momentary
calculation only, NOT the commissioned real-time regulator. PV curtailment and
external SDL/base-load separation must still be validated before any live test.
Two read-only variables below the manager expose a HTML summary and detailed
JSON. No EnableAction, consumer RequestAction, SendData or actor property writes
are used. The existing form.json and web portal assets are not modified.
Offline checks:
php tests/V4Receiver/protocol_checks.php
php tests/V4Receiver/receiver_checks.php
The PHPUnit wrapper runs these in separate subprocesses, so fake IPS and curl
functions cannot contaminate other module tests. The mocked transport uses no
real credentials or connections. Full Symcon kernel testing and installation
are separate commissioning steps; these unit tests are not field acceptance.
+88
View File
@@ -0,0 +1,88 @@
# Separate physical load / EV-SDL observation (NO dispatch or training permission)
## Implemented 2026-10-02
- `NetzfahrplanV4Messsicht` recalculates independent physical-load, physical-storage,
unfiltered modelled allocation and SolarEdge-origin views from explicitly identified
raw snapshots. Quality failures in held virtual accounts do not veto otherwise
consistent physical inputs. Missing/stale physical inputs still invalidate their
respective view. No automatic relaxation of age/skew limits.
- EV/SDL model: delta = physical - requestedEV - requestedSDL; distribute delta in
proportion to the absolute requested powers. The SDL component is obtained from
physical minus modelled EV to preserve numerical closure. This is an accounting
convention, NOT independently measured EV and SDL and NOT a verified response.
- Command changes more recent than the oldest physical feedback are flagged. Unknown
change timestamps, tracking mismatch, aged/missing origins and idle unallocated
power remain explicit. There is no held-value filter and no forced convergence of
displayed power to the requested value. All proposals keep `canDispatch=false`.
- Actual legacy filter outputs are displayed solely for comparison. They are not
reused as inputs of physical-load or unfiltered partition calculations. No changes
to the legacy filter, physical driver, EV/SDL setpoints, SOC integration or accounts.
## New read-only origin finding
Selected event 38921 writes legacy PV variable 20335 using:
a = 10 ** GetValue(41853) * GetValue(37975) + GetValue(21447)
if (a < 0) a = 0
SetValue(20335, a)
Arithmetic statements were inspected without exposing arbitrary scripts/credentials.
Verified saved configuration: variable 37975 parent 35514, register 40083; scale
41853 parent 48996, register 40084; battery 21447 parent 30789. An independently
updated 20335 is therefore not an independent PV reading and cannot certify all
input timestamps. A second constrained arithmetic read confirmed the condition
`if ($a < 0)`; this clamp applies to the legacy calculated PV value, not to the
new signed terminal-power observation. Neither proves the full AC/DC boundary.
The staged observer additionally records the ORIGINAL mantissa and exponent, not
only their periodically rewritten derived PV result. The alternative candidate
uses the scaled terminal power ONCE for this inverter (removing its derived-PV
and battery terms). GoodWe AC/DC boundaries and full-site physical boundary are
still unverified. This is not a definitive corrected house meter or live forecast.
## Standalone installation / operational scope
Stage on test host: `/srv/agent/netplan-v4-separated-observer-stage`.
User invocation inside Symcon only:
require '/srv/agent/netplan-v4-separated-observer-stage/install.php';
Creates only a NEW root category, three textual diagnostic variables, observe and
stop scripts and a NEW 30-second timer. Reads 23 existing numeric variables twice;
no Modbus polling requests, network calls, archive settings, existing module or
control timer updates. Existing 21-channel raw collector continues untouched.
New daily journals contain the selected numeric raw snapshot and derived views.
A new immutable mapping/inventory reference binds the corrected user EV allocation
161.44 kWh / 39 kW. Original collector metadata and its correction addendum remain
unchanged. No rescaling or resetting of virtual SOC/energy accounts.
Derived journal and LATEST/STATUS reports are 0644 WITHIN mode-0700 private stage
and data directories, allowing the authorized agent to inspect these selected
new outputs without repeated manual exports. No permissions on original journals,
Symcon directories or unrelated data are changed. Quotas reuse 64 MiB/day, 512 MiB
in total and 100 MiB disk reserve; exceeding them stops recording visibly and does
not delete old data. No network transfer or cloud trainer is activated.
## Validation and limitations
40 pure view checks + 32 native-mock checks passed on PHP 8.3 CLI at test host.
Regression: 36 raw-capture + 19 native-capture + 34 accounting checks passed with
new additive `raw_power`/`scale` units. Total 161 isolated checks. Full relevant PHP
source lint passed. Not a full PHPUnit run, Symcon kernel trial or hardware test.
Tests cover opposite EV/SDL requests, held output, non-unique partition labelling,
negative terminal power, scale/sentinel validity, old dependency timestamps, changed
commands, missing source, no duplicate native objects, unchanged existing signals,
append preservation, source drift, private output restrictions and stop behavior.
The observer is PREPARED, not started by the assistant. It does not replace the
installed receiver or its existing preview calculation. The preview/driver must
only be switched after measurement-boundary and feedback policy acceptance. Model
training and five-minute reconstruction must consume the new versioned snapshots
separately; there is no training eligibility or zero future-SDL assumption here.
Device-side command-loss protection and exact model/firmware identification remain
open. Official SolarEdge PDF fetch was unavailable (403/error); no device-specific
register suitability or watchdog behavior was inferred from a third-party source.
+77
View File
@@ -0,0 +1,77 @@
# V4 Unified RC1 - actual scope and release boundary
This release consolidates application data recovery, native delivery status, economic
family comparison, and optional verified archival. It is NOT a production actuation
release. No claim that all original product requirements or hardware acceptance are
complete. Sources are in the existing develop branch, not feature branches.
## Delivered code
* Audited recovery for the exact 100.0/100 splitToleranceW serialization defect.
The internal operator endpoint validates the two complete JSON representations,
their exact SHA256, installation, inventory, and every field. Only that numeric
representation may differ. Device-supplied arbitrary aliases are never accepted.
Existing rows/measurements are not rewritten. The original received mapping and
proof reference are recorded with the observation. A mixed 45/75 block and retries
are regression tested. Adoption requires an explicit deployment flag.
* Native normalization prevents recurrence. The sender preserves a separate last
successful receipt even during scheduled/backoff states, reports safe numeric HTTP
status (no response or credential text), and continues trying to deliver a durable
prefix if the current capture cannot be appended. No cursor reset or skipped block.
* Economic comparison is actually connected, not an empty score list. At the start
of a local calendar day the worker freezes comparable plans for families 3/13/23
with the then-known forecasts, prices and common battery/limit state. Completed
days are evaluated against subsequently acquired physical residual data. Initial
energy, losses, common terminal energy valuation and 15-minute demand charge are
included; monthly maxima are charged once over a comparison period, not per day.
GUI lookback/minimum days/coverage/switch margin are applied by the selector.
* v1 economic scope: one grid-charge-enabled battery, frozen day-ahead plans, with
local grid-target tracking simulated during the day. It is NOT a full receding-
horizon replay. Historical external SDL uses the then-recorded SDL request and is
clearly marked as an estimate, not independently measured delivery. Missing
actuals, unmatched contexts and breaches cannot become zero cost or a successful
comparison. Broader device topologies stay collecting; no fake equivalence.
* Lossless archive implementation and tests are present for acknowledged old native
outbox days and old server observations/plans. Archives are verified before plain
rows/files are retired. Active plans, acknowledged plans and unacknowledged data
are excluded. Archiving is OFF by default; enable only with an explicit storage
policy (native NetzfahrplanV4ArchivAktiv; server NETPLAN_V4_ARCHIVE_ENABLED=1).
Compressed archives are retained: this is not an external backup or infinite disk.
## Deployment is not data/model/actuator acceptance
Server entry: commissioning/deploy_unified_release.py. Default checks sources only.
--apply builds and tests a separate candidate context with no network/production
mounts before replacing the V4 service. It preserves the running plant allowlist,
backs up SQLite consistently, and uses a pinned built image. Only the V4 planner JS
asset is updated; unrelated GUI work, portal runtime, tariffs, forecasting, Symcon
and actuators are untouched. Rollback restores previous source/image; additive audit
rows remain. Database schema changes do not overwrite old observations.
--approve-numeric-mapping-compatibility explicitly authorizes the demonstrated pair
of equivalent configuration representations. Without it, strict original validation
remains and backlog containing the legacy serialization is still rejected.
Lihrenmoos native stage: /srv/agent/netplan-v4-unified-release/install.php.
This patches three data-only libraries, reloads the existing library, compares all
pre-existing properties and lets the existing data timer resume. No raw replay,
force-skip or actuator callback from the installer. Archiving stays disabled by default.
## Remaining product work - do not label as complete
1. The full production dispatcher is not installed. Earlier controlled-trial code
still has bounded pilot grants; continuous production authority and complete
corrected physical/virtual feedback integration are NOT delivered by this RC.
2. The actual host/container/kernel and field acceptance of this release are not
replaced by CLI mocks. Real source validity and independent command-loss behavior
are not asserted by a measurement algebra or an override.
3. The replay limitation above must be accepted as a beta feature, or extended to
continuous rolling reoptimization and additional asset types before advertising
the original full automatic-selection requirement.
4. Native multi-plant commissioning, external backups, retained archive lifecycle,
model quality on field data and publication authentication remain operational
release criteria. The tests do not demonstrate actual cost savings.
Do not re-run obsolete piecemeal installers after this release, bypass their source
checks, turn the previous grid schedule on, or turn 'productionReady' true manually.
The retained EV capacity is 161.44 kWh / 39 kW; SDL reserve is already outside EV.
+111
View File
@@ -0,0 +1,111 @@
# Prognosebedienung im bestehenden Manager
Stand: 06.10.2026. Gepruefter Integrationsstand zur von Daniel freigegebenen
Veroeffentlichung auf develop und beta. Kein Deployment und keine neue Stellfreigabe.
## Umfang
Der separate Formularbereich `V4 Planempfang / Prognosemanager` entfaellt.
Empfang, Planpruefung, Lizenzportal-Verweis und explizites Ein-/Ausschalten
stehen im bestehenden Bereich `Prognose / Forecast`.
Der neue Formularaufruf `FormNetzfahrplanSchalten` delegiert an den vorhandenen
`V4ManagerAktivtestSchalten`-Pfad. Er prueft beim Start nochmals gespeicherte
Prognosekonfiguration, Netzfahrplanlizenz, Managerstatus und bestehende lokale
Testfreigaben. Ein Formularaufruf allein erteilt keine Berechtigung. Ausschalten
bleibt auch nach Lizenzverlust moeglich. Der bestehende Stopp beinhaltet eine
frisch berechnete normale Regelung nach Widerruf der V4-Sitzung; er ist kein
anlagenweiter Not-Aus und veraendert keine unabhaengigen SDL-Auftraege.
Die Anzeige nennt weiterhin Testbetrieb und das bestehende Testende. Es gibt
keine neue Dauerbetriebsfreigabe, keinen Reset der 48-Stunden-Frist und keine
Aenderung an Watchdog-Ausnahmen, Messwertgrenzen oder Geraeteschutzpruefungen.
## Kompatibilitaet
- Keine Properties, Attribute, Objekt-IDs, Timer, Datenpfade oder Backend-APIs werden migriert.
- Prognosemodelle, Optimierer, Planempfang, ACKs, Messaufnahme und Versand bleiben unveraendert.
- Die vorhandene Portalpruefung fuer `grid_schedule` bleibt massgeblich. Keine Portal- oder Lizenzbestellung wird geaendert.
- `NetzfahrplanAktiv` bedeutet weiterhin den alten Regler. Bei vorhandener V4-Konfiguration wird dessen ausgeschalteter Schalter ausgeblendet. Ein eingeschalteter Altregler bleibt zum Ausschalten sichtbar.
- Nicht auf V4 eingerichtete Installationen behalten den bisherigen Schalter. Diese Aenderung provisioniert keine neue V4-Installation.
- `PrognoseAktiv` wird nicht zum automatischen V4-Startsignal. Bestehende Aufnahme-/Sender-Properties und die bisherige Laufzeitvariable bleiben erhalten.
## Dateien
- `Manager/form.json`: stabiler Anker fuer den bestehenden Prognosebereich.
- `Manager/module.php`: Formularintegration und gepruefter Bedienaufruf.
- `libs/ManagerPrognoseFormular.php`: reine Formularzusammenstellung ohne I/O.
- `libs/ManagerNetzfahrplanV4EmpfangTrait.php`: nur bisherigen Formulargenerator entfernt; Empfangscode unveraendert.
- `tests/PrognoseFormular/`: Formular- und echte RequestAction-Pruefungen mit simulierten IPS-/Stellaufrufen.
- `tests/V4Receiver/receiver_checks.php`: Strukturpruefung an die zusammengefuehrte Oberflaeche angepasst.
## Tests
Isoliert im PHP-8.3.6-CLI auf einer Quellkopie, nicht im Symcon-Kernel:
| Suite | Erfolgreiche Pruefungen |
|---|---:|
| PrognoseFormular/checks.php | 59 |
| PrognoseFormular/manager_checks.php | 26 |
| V4Receiver/receiver_checks.php | 32 |
| V4ControlTrial/checks.php | 96 |
| V4Feedback/receiver_checks.php | 8 |
| V4Feedback/device_read_checks.php | 9 |
| V4ControlTrial/register_checks.php | 1 |
| Gesamt | 231 |
Zusaetzliche Gesamttests des zusammengefuehrten Stands am 06.10.2026:
- PHP 8.3.6 / PHPUnit 9.6.36: 357 Tests, 1758 Assertions erfolgreich.
- Syntaxpruefung: alle 150 PHP-Dateien erfolgreich.
- Backend: 310 Python-Tests erfolgreich mit den vorhandenen QA-Abhaengigkeiten.
- Portal: 16 Node-Tests erfolgreich.
- Konfliktmarker- und Patch-Pruefung erfolgreich.
Die Pruefungen liefen in isolierten Quellkopien. Die PHP-Laufzeit wurde nur im
Agent-Arbeitsordner entpackt, ohne Systeminstallation. Kein Symcon-Kernel-/GUI-
oder Hardwaretest; der vorhandene Docker-Zugang war nicht nutzbar.
Eine vorbestehende, nicht committe Aenderung an measurement_pipeline.py wurde
bewusst nicht uebernommen: Sie erzeugte fuer jede konfigurierte Messgrenze ein
accountingEvidenceId trotz measurementBoundaryVerified=false und verletzte den
bestehenden Backendtest. Die Datei bleibt im Integrationsstand bytegleich zum
bisherigen versionierten V4-Stand. Die fremde Arbeitskopie und das laufende
Backend wurden nicht veraendert. Eine spaetere Freigabe dieser Messgrenze braucht
einen eigenstaendigen fachlichen Nachweis, keine Anpassung des Tests an das Label.
## Offener Abschluss
Der Auftrag ist mit dieser Bedienintegration noch nicht vollstaendig umgesetzt:
- Die dokumentierte Batterietimerblockade und die fehlende Anlagenabnahme werden hier nicht behoben.
- Ein regulaerer, lizenzierter Dauerbetrieb anstelle des begrenzten Testpfads ist nicht implementiert.
- Die separaten Laufzeitvariablen und Diagnoseordner werden noch nicht entfernt oder ausgeblendet.
- In gespeicherten Settings vom 06.10.2026, 13:06:07 UTC sind nur die Kategorien 21196 (`ENELIX_V4_SEPARATED_OBSERVATION`) und 57590 (`ENELIX_V4_PASSIVE_CAPTURE`) eindeutig als V4-Diagnosebereiche erkannt. Darin liegen weiterhin Beobachtungs-/Aufnahmeskripte. Keine pauschale Loeschung nach Namen.
- Der allgemeine `Testordner` 10249 enthaelt auch Abrechnung, Schnittstellen und weitere nicht zu dieser Integration gehoerende Objekte. Er bleibt unangetastet.
- Vor einem Rollout aktuelle Moduldateien sichern, mit dem getesteten Stand vergleichen und Abhaengigkeiten pruefen. Keine pauschalen Modulupdates, Reloads oder Dienstneustarts aus diesem Dokument ableiten.
- Die Divergenz wurde in einem separaten develop-Checkout auf ubuntu zusammengefuehrt: Server-89-Historie bis ce525a1 und origin/develop bis 5437f3f. Die Originalarbeitsverzeichnisse mit fremden Aenderungen bleiben erhalten. Daniel hat Merge, Commit und Push auf develop und beta ausdruecklich freigegeben; main bleibt unveraendert.
## Uebergabe und Ruecksetzung
Die fruehere Einzelpatch-Uebergabe ist durch den zusammengefuehrten Quellstand
ersetzt. Die Ausgangshistorie und die zugehoerigen lokalen V4-Quellen sind unter
/srv/agent/prognose-integration-20261006 gesichert. Der gepruefte Kandidat
enthaelt keine temporaren Live-Hooks, Zugangsdaten oder fremden uncommitteten
Ladestationsaenderungen. Ruecksetzungen nur additiv nach Driftpruefung; keine
fremden Aenderungen verwerfen. Fuer einen spaeteren Runtime-Rollout sind eine
eigene Sicherung, Abhaengigkeitspruefung und Anlagenabnahme erforderlich.
## Vorschlag PR-Text
**Titel:** Manager: Prognosebedienung im bestehenden Forecast-Bereich buendeln
**Aenderung:** Separaten V4-Formularbereich entfernen, bestehende Funktionen
unter Prognose / Forecast anbieten und explizite Bedienaktionen zusaetzlich
gegen Lizenz-, Konfigurations- und Freigabestatus pruefen.
**Unveraendert:** Prognose-/Optimierungsbackend, Empfangsprotokoll, Anlagenlimits,
befristete Testfreigaben, Datenhistorie und SDL. Keine automatische Aktivierung.
**Pruefung:** 357 PHPUnit-Tests, 310 Backend-Tests, 16 Portal-Tests sowie die
oben genannten isolierten Bedien-/V4-Checks; kein Hardware- oder Symcon-GUI-Abnahmetest.
+59
View File
@@ -0,0 +1,59 @@
# Erste Schritte mit Enelix
Vom ersten Überblick zur eingerichteten Anlage: Hier siehst du die Reihenfolge und woran du erkennst, dass du zum nächsten Schritt gehen kannst. Die verlinkten Anleitungen erklären die einzelnen Einstellungen.
**Für Betreiber:** Beginne mit [Enelix verstehen](index.md). Lass die technische Einrichtung fachkundig durchführen und dir anschließend die Bedienung deiner eigenen Anlage zeigen. **Für Installateure:** Arbeite die folgenden Schritte in dieser Reihenfolge ab. Beginne mit einem einzigen Verbraucher.
## 1. Die Anlage vorbereiten
Du brauchst IP-Symcon ab Version 8.0, Zugang zur Verwaltungskonsole und die tatsächlichen Mess- und Geräteanschlüsse deiner Anlage. Besonders wichtig ist die **Netzleistung am Netzanschlusspunkt**: Sie zeigt, ob insgesamt Strom bezogen oder eingespeist wird. Die PV-Produktion allein ersetzt diese Messung nicht.
Halte fest, welches Gerät zuerst geregelt werden soll und welche Regelung es bisher steuert. Sichere die bestehende Konfiguration. Zwei Regler dürfen nicht unkoordiniert dieselben Ausgänge bedienen.
**Ergebnis:** Du kannst die relevanten Messwerte in IP-Symcon ansehen, kennst die zulässigen Gerätegrenzen und hast einen Rücksetzweg. Weiter mit [Voraussetzungen und Installation](installation.md).
## 2. Die Bibliotheken installieren
Füge **Enelix EMS** über die Modulverwaltung von IP-Symcon hinzu. Installiere **Enelix Utils** zusätzlich, wenn du beispielsweise dessen Diagramme oder Geräteanbindungen benötigst. Verwende die Repository-Adressen und den freigegebenen Kanal aus der [Installationsanleitung](installation.md#2-bibliotheken-hinzufügen).
Eine installierte Bibliothek stellt zunächst die Modulvorlagen bereit. Sie richtet noch keinen vollständigen Manager und keine Verbindung zu deinen Geräten ein.
**Ergebnis:** Die benötigten Bibliotheken sind ohne Warnung vorhanden und ihre Module stehen zum Anlegen von Instanzen zur Verfügung.
## 3. Den Manager einrichten
Lege eine **Manager-Instanz** an. Lass ihre Regelung zunächst ausgeschaltet. Binde die passende Lizenz, wähle die Netzleistungsmessung und prüfe Einheit und Vorzeichen. Stelle nur Grenzen ein, die für diese Anlage freigegeben sind.
In der normierten Messung bedeutet **positiv: Netzbezug**, **negativ: Einspeisung**. Ein falsch zugeordnetes Vorzeichen kann zu falschen Regelentscheidungen führen.
**Ergebnis:** Der Manager hat einen gültigen Lizenzstatus und erkennt eine aktuelle, plausible Netzleistung. Die [Manager-Anleitung](manager.md) führt durch Lizenzierung, Messquelle und Betriebsgrenzen.
## 4. Einen Verbraucher hinzufügen
Lege die passende Verbraucherinstanz an, zum Beispiel einen Warmwassererwärmer oder eine Ladestation. Richte dort Geräteverbindung, Messwerte, Leistungsgrenzen und Schutzwerte ein. Anschließend wählst du diese Instanz **im Manager** aus.
Mit der Priorität bestimmst du die Reihenfolge bei der Verteilung. Kleinere Zahlen bedeuten höhere Priorität. Auch ein hoch priorisiertes Gerät erhält nur eine Vorgabe, die zu seinem gemeldeten Zustand und den eingestellten Grenzen passt.
**Ergebnis:** Gerät und Manager sind bewusst zugeordnet. Die Rückmeldungen sind plausibel; die Regelung bleibt bis zur Funktionsprüfung ausgeschaltet. Die [Verbraucheranleitung](verbraucher.md) erklärt die Unterschiede der Gerätetypen.
![Grundeinstellungen des Managers mit Netzleistung und manueller Verbraucherauswahl](images/symcon-manager.png "Hier kommt alles zusammen: oben die Netzleistungsmessung, unten die ausgewählten Verbraucher. Echte Aufnahme der Testanlage vom 6. Oktober 2026, IP-Symcon 8.0. Angezeigte Werte sind Testeinstellungen, keine Empfehlung für deine Anlage. Zum Vergrössern auf das Bild klicken.")
## 5. Unter Aufsicht prüfen
Prüfe zuerst Lizenzstatus, Messwerte und Störmeldungen. Teste anschließend mit der fachkundigen Person eine kleine, zulässige Vorgabe und vergleiche sie mit dem tatsächlichen Verhalten am Gerät. Prüfe auch Abschaltung und den Umgang mit fehlenden Messwerten. Erst danach kommen weitere Verbraucher hinzu.
**Ergebnis:** Es ist nachvollziehbar, welches Gerät wann reagiert und wie die Anlage in einen sicheren Zustand zurückkehrt. Eine Anzeige in der Konsole allein ist noch kein Nachweis einer funktionierenden Geräteansteuerung.
## 6. Die Anlage im Alltag nutzen
Lass dir in deiner Visualisierung die Netzleistung, die wichtigsten Verbraucherzustände und mögliche Störmeldungen zeigen. Im Manager zeigt **Betriebsart**, ob die Regelung inaktiv, im PV- oder im Peak-Betrieb ist. Die Freigaben am einzelnen Gerät bleiben ebenfalls wichtig.
Wenn ein Gerät nicht wie erwartet arbeitet, ändere nicht wahllos Prioritäten oder Schutzwerte. Prüfe zuerst Freigabe, Verbindung, Messwerte und Störtext. Die Abschnitte [Im Alltag](manager.md#im-alltag) und [FAQ](faq.md) helfen dabei.
**Ergebnis:** Du weisst, wo du den Anlagenzustand abliest, welche Bedienhandlungen vorgesehen sind und wen du bei einer Störung kontaktierst.
## Optional: Auswertungen und weitere Funktionen
Mit [Enelix Utils](utils.md) kannst du passende Zusatzfunktionen einrichten, etwa ein Energiediagramm oder einen Verbrauchskostenreport. Richte erst die benötigten Messquellen und Archivdaten ein; ein installiertes Auswertungsmodul hat nicht automatisch alle Daten deiner Anlage.
Welche Lizenzen du für deinen Ausbau benötigst und was sie kosten, siehst du in der [aktuellen Preisliste](preise.md).
+73
View File
@@ -0,0 +1,73 @@
# Häufige Fragen
## Brauche ich für die Dokumentation ein Konto?
Nein. Anleitungen, Modulreferenzen, FAQ und Preisliste sind öffentlich. Ein Konto wird für die persönlichen Funktionen des Lizenzportals benötigt.
## Welche IP-Symcon-Version wird benötigt?
Enelix EMS und Enelix Utils setzen IP-Symcon ab Version 8.0 voraus. Maßgeblich ist der Kernel der Installation, nicht nur die Version der Verwaltungskonsole.
## Muss ich EMS und Utils gemeinsam installieren?
Nein. Utils ist eine eigenständige Bibliothek. Installiere sie zusätzlich, wenn du ihre Module oder entsprechende Manager-Visualisierungen benötigst. Die Funktions- und Lizenzvoraussetzungen des gewählten Moduls gelten weiterhin.
## Welchen Updatekanal soll ich wählen?
Verwende den für deine Anlage freigegebenen Kanal: `main` entspricht Stable, `beta` Beta und `develop` Testing. Vergleiche den installierten Stand mit dem oben angegebenen Dokumentationskanal. Testsoftware benötigt eine kontrollierte Inbetriebnahme.
## Warum bleibt der Manager inaktiv?
Prüfe `Aktiv`, Lizenzstatus und Netzleistungsmessung. Fehlende, veraltete oder falsch normierte Messwerte verhindern korrekte Regelung. Kontrolliere anschließend Verbraucherzuordnung und Störtext. Die [Manager-Anleitung](manager.md) beschreibt die Reihenfolge.
## Wo ordne ich einen Verbraucher zu?
Im Manager. Verbraucher haben keine eigene Manager-ID-Property. Die automatische Suche zeigt Kandidaten; ausgewählt und aktiviert werden sie gezielt.
## Warum wird ein Verbraucher trotz Überschuss nicht eingeschaltet?
Mögliche Ursachen sind lokale Deaktivierung, fehlende Zuordnung, fehlende Lizenzmenge, Gerätefehler, ein zu kleines Budget, Temperaturbedingungen oder Mindestzeiten. Prüfe auch das angebotene Leistungsraster und den aktuellen Betriebszustand. Bei einer Ladestation gehören Fahrzeugstatus, Ladefreigabe und Solarladen dazu.
## Was bedeuten positive und negative Leistungen?
Am Netzanschlusspunkt bedeutet positiv Netzbezug und negativ Einspeisung. Bei Batterien bedeutet positiv Laden und negativ Entladen. Die Messfaktoren müssen die Gerätewerte auf diese Konvention und Watt normieren.
## Wie funktionieren Prioritäten?
Kleinere Zahlen haben Vorrang. PV- und Peak-Priorität werden pro Verbraucher eingestellt. Mindestleistungen und technische Sperren bleiben verbindlich. Der aktuelle schrittweise Verteilalgorithmus ist in der [Manager-Referenz](../module/Manager/README.md) beschrieben.
## Schaltet der Manager-Aus-Schalter die gesamte Anlage sicher ab?
Nein. Er ersetzt keine elektrische Freischaltung oder unabhängige Schutztechnik. Verbraucher können lokale Betriebs- und Schutzfunktionen besitzen. Für Arbeiten an der Anlage gilt das dafür vorgesehene Sicherheitsverfahren.
## Kann ich einen Lizenzcode für mehrere Installationen verwenden?
Die Aktivierung bindet den Code an eine Installations-ID. Eine andere Installation wird abgewiesen. Sichere bei einer Migration die vollständige Manager-Instanz einschließlich ihrer Attribute; kläre einen erforderlichen Gerätewechsel über das Portal beziehungsweise den Betreiber.
## Was passiert ohne Internetverbindung?
Eine bereits bestätigte Manager-Freigabe kann im dokumentierten Entwicklungsstand bis `offlineUntil` genutzt werden, ungefähr 14 Tage nach Ausstellung. Danach wird die Regelung bis zu einer gültigen Serverantwort gesperrt. Verlasse dich auf den tatsächlich angezeigten Lizenzstatus; Cloud-Geräte und Prognosen haben zusätzliche Netzwerkabhängigkeiten.
## Was unterscheidet Lizenz und Ersteinrichtung?
Die direkte Lizenzbestellung enthält die gewählten Lizenzen. Beim Systemkonfigurator kommen die noch nicht bezahlten Einrichtungskosten hinzu. Die [Preisliste](preise.md) zeigt beide Positionen getrennt. Prüfe vor Abschluss die vollständige Bestellung.
## Sind Prognoselizenzen automatisch verlängerte Abonnements?
Der aktuelle Portalstand verwendet datierte Jahresberechtigungen und manuelle Verlängerung, keine automatische Stripe-Abonnementverlängerung. Die Preisseite zeigt die im Katalog hinterlegte Laufzeit. Der intelligente Netzfahrplan benötigt den Manager mit Peak Shaving.
## Sind die Preise aktuell und inklusive MwSt.?
Die [Preisliste](preise.md) liest den aktuellen Backend-Katalog. Sie zeigt Netto- und berechnete Bruttopreise einschließlich des aktuell gültigen, im Backend konfigurierten MwSt.-Satzes. Bei einem Abruffehler werden keine Ersatzpreise angezeigt.
## Aktualisiert ein Git-Update auch meine Anlage?
Nein. Dokumentationsabgleich und Modulupdate sind getrennt. IP-Symcon-Module werden über die Modulverwaltung aktualisiert, mit Sicherung und Nachkontrolle.
## Warum zeigt das Energiediagramm keine Werte?
Prüfe die drei Pflichtzähler, deren Archivierung, die Einheit beziehungsweise den Faktor und den gewählten Zeitraum. Das Modul benötigt Energiezähler, nicht nur momentane Leistungsmessungen.
## Welche Daten sollte ich bei einem Fehler bereithalten?
Notiere Modulname, Version beziehungsweise Kanal, Instanzstatus, genaue Fehlermeldung, Zeitpunkt und die letzten Änderungen. Ergänze relevante Messwerte mit Einheiten. Entferne Lizenzcodes, Kontodaten, Passwörter und Tokens aus Screenshots und Protokollen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+63
View File
@@ -0,0 +1,63 @@
# Enelix verstehen
**Enelix ist ein Energiemanagementsystem, kurz EMS.** Es hilft dabei, den Strom einer Anlage gezielt zu nutzen: zum Beispiel Solarstrom für Warmwasser oder das Elektroauto einzusetzen und den Bezug aus dem Stromnetz zu begrenzen.
Enelix läuft in **IP-Symcon** und besteht aus zwei Bibliotheken: **Enelix EMS** enthält Manager und Verbrauchermodule; **Enelix Utils** ergänzt Geräteanbindungen und Auswertungen. Du richtest einen **Manager** ein und wählst aus, welche **Verbraucher** er steuern darf. Über IP-Symcon erhält er die Messwerte und Geräteverbindungen. Die laufende Regelung übernimmt anschließend das System innerhalb der eingestellten Grenzen.
Du musst dafür nicht programmieren. Die technische Einrichtung und die Prüfung der Geräteansteuerung gehören jedoch in fachkundige Hände. Diese Einführung erklärt dir erst das Prinzip und danach den Weg zur eigenen Anlage.
## Ein Beispiel aus dem Alltag
Mittags erzeugt deine Solaranlage mehr Strom, als das Haus gerade braucht. Statt den gesamten Überschuss einzuspeisen, kann Enelix ihn einem eingerichteten Verbraucher anbieten, etwa dem Warmwassererwärmer oder einer Ladestation.
**Vereinfachtes Beispiel:** Die Solaranlage liefert 6 kW, im Haus werden 2 kW gebraucht. Damit bleiben zunächst 4 kW Überschuss. Der Manager berücksichtigt, welche Geräte gerade Energie benötigen, welche Leistung sie annehmen können und welche Priorität eingestellt ist. Er verteilt daraus passende Vorgaben. Zieht eine Wolke auf oder steigt der Hausverbrauch, passt er die Verteilung an.
Nicht jedes Gerät kann beliebig wenig Leistung aufnehmen oder sofort abschalten. Deshalb berücksichtigt die Einrichtung auch Leistungsstufen, Mindestlaufzeiten, Temperaturen und weitere Gerätegrenzen. Die Zahlen im Beispiel erklären nur das Prinzip und sind keine Einstellvorgaben.
## So setzt sich Enelix zusammen
| Baustein | Einfach erklärt |
| --- | --- |
| **IP-Symcon** | Die Softwarebasis deiner Anlage. Hier liegen Messwerte, Geräteanbindungen und die Enelix-Instanzen. |
| **Enelix EMS** | Die Bibliothek für das Energiemanagement. Sie enthält den Manager und die passenden Module für Verbraucher und Batterien. |
| **Manager** | Die zentrale Regelung. Er beobachtet die Netzleistung und verteilt Vorgaben an die Geräte, die du ihm zugeordnet hast. |
| **Verbraucher** | Ein angebundenes Gerät, etwa Warmwassererwärmer, Wärmepumpe oder Ladestation. Sein Modul kennt die gerätespezifischen Grenzen und setzt die Vorgaben um. |
| **Enelix Utils** | Eine zweite Bibliothek mit Zusatzfunktionen, zum Beispiel Energiediagramm, Shelly-Anbindung und Verbrauchskostenreport. Sie kann auch unabhängig vom EMS genutzt werden. |
| **Lizenzportal** | Hier verwaltest du die benötigten Lizenzen. Die Regelung deiner Geräte läuft in IP-Symcon, nicht auf dieser Dokumentationsseite. |
Die beiden Bibliotheken sind also zwei zusammenpassende Werkzeugkästen, keine zwei getrennten EMS. Für die Regelung beginnst du mit **Enelix EMS**. **Enelix Utils** kommt dazu, wenn du dessen Zusatzmodule oder die entsprechenden Darstellungen nutzen möchtest.
![Ein Manager und mehrere Verbraucher im Objektbaum der Enelix-Demoanlage](images/symcon-objektbaum.png "So sieht das in IP-Symcon aus: Der markierte Manager und die Verbraucher sind eigene Instanzen. Echte Aufnahme der Testanlage vom 6. Oktober 2026, IP-Symcon 8.0. Namen und Gerätegrössen sind Beispiele. Zum Vergrössern auf das Bild klicken.")
## Was macht der Manager genau?
1. **Messen:** Er liest, ob deine Anlage gerade Strom aus dem Netz bezieht oder einspeist.
2. **Bedarf berücksichtigen:** Die ausgewählten Verbraucher melden ihren Zustand und ihre möglichen Leistungen.
3. **Verteilen:** Der Manager berücksichtigt Betriebsart, Prioritäten und eingestellte Grenzen.
4. **Umsetzen:** Das jeweilige Verbrauchermodul übersetzt die Vorgabe in die passende Geräteansteuerung.
Beim **Solarladen beziehungsweise PV-Betrieb** steht die Nutzung des Solarüberschusses im Vordergrund. **Peak Shaving** bedeutet, hohe Bezugsspitzen gegenüber einer eingestellten Grenze zu begrenzen. Diese Funktion benötigt die entsprechende Lizenz und eine passende Einrichtung. Ein Batteriemodul kann zusätzlich Laden und Entladen in die Regelung einbinden.
Enelix erkennt und steuert nicht automatisch jedes Gerät im Gebäude. Messquellen, Geräteverbindungen und die Zuordnung zum Manager müssen bewusst eingerichtet und geprüft werden.
## Welche Begriffe brauche ich zum Start?
- Eine **Bibliothek** ist ein installierbares Paket, hier Enelix EMS oder Enelix Utils.
- Ein **Modul** ist die Vorlage für eine Funktion, zum Beispiel „Manager“ oder „Wassererwärmer“.
- Eine **Instanz** ist die konkret eingerichtete Verwendung eines Moduls in deiner Anlage. Zwei Ladestationen erhalten zum Beispiel je eine eigene Instanz.
- Eine **Variable** enthält einen Wert oder einen bedienbaren Zustand in IP-Symcon, etwa Netzleistung, Temperatur oder eine Freigabe.
- Die **Visualisierung** ist die Oberfläche, auf der du deine Anlage im Alltag ansehen und bedienen kannst. Die technische Einrichtung erfolgt in der Verwaltungskonsole.
## Wie geht es weiter?
**Du möchtest die erste Anlage einrichten?** Folge den [Ersten Schritten](erste-schritte.md). Sie führen von den Voraussetzungen bis zur kontrollierten Inbetriebnahme und verlinken jeweils die passende Anleitung.
**Deine Anlage ist bereits eingerichtet?** Unter [Manager bedienen](manager.md#im-alltag) findest du die wichtigsten Zustände und die ersten Prüfungen bei Problemen. Die [häufigen Fragen](faq.md) helfen bei typischen Unklarheiten.
**Du suchst einzelne Einstellungen?** Die [Verbraucheranleitung](verbraucher.md), die [Utils-Anleitung](utils.md) und die Modulreferenzen in der Navigation gehen ins Detail. Die [Preisseite](preise.md) zeigt die aktuellen Preise aus dem Lizenzportal.
## Stand und Sicherheit
Enelix befindet sich im kontrollierten Testbetrieb. Die Software ersetzt keine elektrischen Schutzfunktionen und keine fachkundige Inbetriebnahme. Ein ausgeschalteter Manager ist kein elektrischer Not-Aus.
Diese Dokumentation folgt dem unten angegebenen Git-Kanal. Prüfe, ob deine installierte Version dazu passt. Ein Update der Dokumentation aktualisiert deine laufende IP-Symcon-Anlage nicht.
+53
View File
@@ -0,0 +1,53 @@
# Enelix installieren
## 1. Voraussetzungen prüfen
- IP-Symcon ab Version 8.0 und Zugriff auf die Verwaltung.
- Netzwerkzugriff von IP-Symcon auf die Enelix-Git-Repositories; erforderliche Repository-Berechtigungen müssen vorhanden sein.
- Für lizenzierte Funktionen: ein Portal-Konto und eine passende Lizenz. Das Symcon-System muss den Lizenzserver per HTTPS erreichen können.
- Funktionsfähige Geräteanbindungen mit bekannten Mess- und Stellvariablen. IP-Adressen, Zugangsdaten und elektrische Grenzen stammen aus deiner Anlage.
Sichere vor Änderungen die IP-Symcon-Konfiguration einschließlich Instanzen und Attributen. Halte fest, welche bestehende Regelung aktiv ist und wie du sie kontrolliert wiederherstellen kannst. Zwei Regler dürfen nicht unkoordiniert dieselben Ausgänge steuern.
## 2. Bibliotheken hinzufügen
Öffne in der IP-Symcon-Verwaltung den Bereich **Module** und füge die benötigten Repository-URLs hinzu:
| Bibliothek | Repository |
| --- | --- |
| Enelix EMS | `https://git.belevo.ch/ENELIX/Enelix-EMS.git` |
| Enelix Utils | `https://git.belevo.ch/ENELIX/Enelix-Utils.git` |
Wähle einen für deine Umgebung freigegebenen Kanal. Installiere Utils, wenn du dessen Zusatzmodule oder die entsprechenden Manager-Visualisierungen nutzen möchtest.
| Branch | Kanal | Einordnung |
| --- | --- | --- |
| `main` | Stable | stabiler Freigabekanal |
| `beta` | Beta | freigegebene Feldtests |
| `develop` | Testing | Entwicklung und Integrationstests |
Die Kanalbezeichnung allein ist keine Anlagenfreigabe. Beachte zusätzlich den Status der jeweiligen Version. Die öffentliche Dokumentation kann einen neueren Stand zeigen als deine Installation.
Zugangsdaten gehören in die vorgesehene Laufzeitkonfiguration, nicht in Repository-URLs, Skripte oder Screenshots. Kopiere die Modulordner nicht manuell: Sonst kann die Modulverwaltung URL, Branch und Aktualisierungen nicht zuverlässig verwalten.
## 3. Installation kontrollieren
Prüfe, dass die Bibliothek ohne Warnsymbol angezeigt wird und URL sowie Branch sichtbar sind. Lege zunächst nur die benötigten Instanzen an. Für das EMS beginnst du mit dem [Manager](manager.md), danach folgen die [Verbraucher](verbraucher.md).
Lass Manager und Verbraucher während der Grundeinrichtung deaktiviert. Prüfe zuerst Messwerte, Einheiten, Vorzeichen und die Zuordnung der Stellvariablen. Eine gültige Konfiguration ersetzt keinen physischen Funktionstest.
## 4. Lizenz und Einrichtung
Im [Lizenzportal](/) kannst du ein Konto anlegen und die erforderlichen Lizenzen wählen. Die [Preisliste](preise.md) unterscheidet Lizenzkosten und Ersteinrichtung. Eine direkte Lizenzbestellung und eine Bestellung über den Systemkonfigurator sind unterschiedliche Abläufe.
Bei Verwendung des Systemkonfigurators prüfst du die erzeugte Konfiguration vor der Ausführung in IP-Symcon. Führe ein Einrichtungspaket nicht ungeprüft auf einer bestehenden Anlage aus. Bewahre Sicherung und Rücksetzweg auf.
## 5. Aktualisieren
1. Änderungsumfang und passenden Kanal prüfen.
2. Konfiguration, Instanzen und Attribute sichern; bei Regelungsänderungen einen sicheren Anlagenzustand herstellen.
3. Updates über die IP-Symcon-Modulverwaltung beziehen.
4. Instanzstatus, Lizenzbindung, Messquellen und Verbraucherzuordnung kontrollieren.
5. Änderungen an Ausgängen unter Aufsicht prüfen, bevor der normale Betrieb wieder freigegeben wird.
Eine neue Manager-Instanz erhält eine neue Installations-ID. Ein Update einer bestehenden Instanz ist deshalb nicht gleichbedeutend mit Löschen und Neuanlegen.
+53
View File
@@ -0,0 +1,53 @@
# Manager einrichten und bedienen
Der Manager liest die Netzleistung und verteilt Leistung im PV- oder Peak-Betrieb. Er steuert ausschließlich die ausgewählten Verbraucher.
## 1. Instanz und Lizenz vorbereiten
Lege eine Manager-Instanz aus Enelix EMS an. Die Variable `Aktiv` bleibt zunächst ausgeschaltet. Öffne die Konfiguration, trage den Aktivierungscode aus dem Lizenzportal im Bereich **Lizenzierung** ein und wähle **Lizenz prüfen und binden**. Kontrolliere den Lizenzstatus und speichere die Konfiguration anschließend mit **Übernehmen** oder **OK**.
Ohne gültige Manager-Berechtigung bleibt die Instanz mit Status `203` gesperrt. Ein Code ist an die Installations-ID gebunden; verwende ihn nicht zum Einrichten einer zweiten Anlage.
## 2. Netzleistung einstellen
Wähle die Messvariable am Netzanschlusspunkt über `NetzleistungVariableID`. Die Normierung erfolgt mit `Netzleistungsfaktor`:
- positive Leistung bedeutet Netzbezug;
- negative Leistung bedeutet Einspeisung;
- die normierte Einheit ist Watt, nicht Kilowatt.
Prüfe das Vorzeichen bei einem bekannten Betriebszustand. `MesswertMaxAlter` begrenzt das zulässige Alter der Quelle. Eine unveränderte Messung kann weiterhin aktuell sein; entscheidend ist die echte Aktualisierung der Quelle. Fehlende oder veraltete Netzleistung verhindert neue Regelvorgaben.
## 3. Betriebsgrenzen festlegen
`SollwertSolarladen` legt die gewünschte Netzleistung im PV-Betrieb fest. Beginne mit der für deine Anlage freigegebenen Einstellung.
Für Peak Shaving benötigt der Manager die entsprechende Lizenz. `Lastspitzenmodus` kennt **Aus**, **Konstant** und **Monatlich**. Bei konstantem Modus wird `Lastspitzengrenze` in Watt verwendet; im monatlichen Modus werden zwölf Monatswerte gepflegt. **Aus** deaktiviert nur die Peak-Begrenzung, nicht das Solarladen.
Eine Einspeisebegrenzung benötigt geeignete Mess- und Stellregister der Wechselrichter. Übernimm keine beispielhaften Grenzen als Anlagenwerte. Die vollständigen Einstellungen stehen in der [Manager-Referenz](../module/Manager/README.md).
## 4. Verbraucher auswählen
Nutze die automatische Suche oder die manuelle Zuordnung. Gefundene Instanzen müssen gezielt ausgewählt und für die Zuordnung aktiviert werden. Die Verbindung wird im Manager gepflegt; im Verbraucher gibt es keine Manager-ID-Property.
Prioritäten stellst du am jeweiligen Verbraucher ein. Kleinere Zahlen bedeuten höhere Priorität. Die aktuelle Verteilregel und ihre Beispiele stehen in der [Manager-Referenz](../module/Manager/README.md).
![Manager-Konfiguration mit Messquelle, Betriebsgrenzen und ausgewählten Verbrauchern](images/symcon-manager.png "Ausschnitt der Grundeinstellungen in IP-Symcon 8.0, aufgenommen in der Testanlage am 6. Oktober 2026. Die Liste unten zeigt die manuell zugeordneten Verbraucher. Die dargestellten Werte sind keine Einstellvorgaben. Zum Vergrössern auf das Bild klicken.")
## 5. Anlage und Visualisierung ergänzen
Unter Anlagentopologie werden Wechselrichter, PV-Flächen und Batterien mit ihren Messquellen erfasst. Verwende eindeutige Kennungen; PV-Flächen und Batterien müssen auf einen vorhandenen Wechselrichter verweisen.
Für die Energieaufzeichnung werden Netz- und PV-Leistung benötigt. Vollständige Energiezähler verbessern die Bilanzierung; ohne vollständigen Zählersatz integriert der Manager Leistungswerte. Energiezähler und Leistungswerte sind nicht austauschbar.
Die Optionen für Energy Pie, Diagramme, Energy Facts und Energiefluss verwalten die zugehörigen Visualisierungen. Prüfe bestehende angepasste Darstellungen vor Änderungen. Prognosefunktionen sind optional und benötigen passende Berechtigungen sowie eine gültige Topologie.
## 6. Kontrolliert in Betrieb nehmen
Prüfe Lizenzstatus, Netzleistung, Verbraucherstatus und Störtext bei ausgeschalteter Regelung. Nimm zunächst einen Verbraucher unter Aufsicht in Betrieb und vergleiche Sollwert mit physischem Verhalten. Prüfe außerdem Abschaltung und Verhalten bei fehlenden Messwerten, bevor weitere Geräte hinzukommen.
## Im Alltag
`Aktiv` schaltet die Manager-Regelung ein oder aus. `Betriebsart` zeigt **Inaktiv**, **PV** oder **Peak**. Verbraucher können eigene Mindestzeiten, Temperaturanforderungen und lokale Schutzfunktionen haben. Ein deaktivierter Manager ist deshalb kein universeller elektrischer Not-Aus. Verwende für Arbeiten an der Anlage die vorgesehenen technischen Sicherheitsmaßnahmen.
Bei Problemen kontrollierst du zuerst `Lizenzstatus`, `NetzleistungGueltig`, `Sammelstoerung`, `Stoertext` und anschließend die betroffene Verbraucherinstanz. Diagnosevariablen und Debug-Logging können gezielt eingeblendet werden; teile keine Lizenzcodes oder Zugangsdaten.
+17
View File
@@ -0,0 +1,17 @@
# Preise
Alle im öffentlichen Backend-Katalog geführten Produkte, mit Lizenzpreis, Laufzeit und Ersteinrichtung. Die Beträge werden direkt aus dem Lizenzportal geladen.
## So liest du die Preisliste
Die Lizenzpreise gelten pro aufgeführter Einheit. Einrichtungskosten sind einmalige separate Positionen und werden nicht automatisch zum Lizenzpreis addiert. Bei der direkten Lizenzbestellung wird keine Ersteinrichtung berechnet; der Systemkonfigurator berücksichtigt die noch nicht bezahlte Einrichtung.
Jahresberechtigungen werden mit ihrer Laufzeit ausgewiesen. Für den intelligenten Netzfahrplan ist der Manager mit Peak Shaving erforderlich. Der Verbrauchskostenreport benötigt eine Grundlizenz und die passenden Zählerkontingente.
Die Bruttowerte sind aus den Einzelpreisen und dem aktuellen Backend-MwSt.-Satz berechnet. Der Checkout berechnet die MwSt. auf den Bestellgesamtbetrag; bei mehreren Positionen können Rundungsdifferenzen gegenüber der Summe einzelner Bruttowerte entstehen. Maßgeblich ist die Bestellung vor dem Abschluss.
## Weitere Kosten und Verfügbarkeit
Die Liste umfasst den Enelix-Produktkatalog, keine externen Kosten für IP-Symcon, Hardware, Installationsarbeiten oder Drittanbieter. Die Anzeige eines Katalogprodukts bestätigt keine technische Freigabe für jede Anlage. Prüfe Gerätekompatibilität und den angebotenen Bestellumfang im Portal.
Das Portal ist derzeit als Entwicklungsumgebung gekennzeichnet; Zahlungen erfolgen im Stripe-Sandbox-Modus. Eine produktive Zahlungsfreigabe wird durch diese Preisliste nicht erteilt.
+37
View File
@@ -0,0 +1,37 @@
# Enelix Utils nutzen
Die Utils-Bibliothek enthält eigenständige Zusatzmodule. Installiere sie über die IP-Symcon-Modulverwaltung und lege nur die benötigten Instanzen an. Ein EMS-Manager ist für die Bibliothek nicht erforderlich; einzelne Module besitzen eigene Voraussetzungen und gegebenenfalls eigene Lizenzanforderungen.
## Energiediagramm
Wähle die archivierten, fortlaufenden Zähler für Produktion, Einspeisung und Netzbezug. Eine direkte Verbrauchsquelle ist optional. Kontrolliere `Zaehlerfaktor` und die Einheit kWh. Leistungsvariablen in Watt sind keine Energiezähler.
Wähle einen Zeitraum mit vorhandenen Archivdaten und prüfe Energiebilanz, Eigenverbrauch und Autarkie. Fehlende Archivdaten werden als Diagnose angezeigt. [Energiediagramm-Referenz](../../../Enelix-Utils/docs/module/Energiediagramm/README.md).
## Shelly Modul
Richte zuerst den nativen MQTT-Datenfluss in IP-Symcon und MQTT am Shelly-Gerät ein. Das Modul verarbeitet Shelly-NG-Meldungen der Generationen 2, 3 und 4; alte Gen1-Topics sind nicht Teil dieser Anbindung.
Wähle die gewünschten Datenpunktgruppen. Aktiviere einen Topic-Filter nur, wenn er benötigt wird, und trage dann den tatsächlichen Präfix ein. Prüfe Online-Status und Messwerte, bevor Schaltausgänge getestet werden. Broker- und Gerätezugangsdaten werden nicht im Modul verwaltet. [Shelly-Referenz](../../../Enelix-Utils/docs/module/Shelly-Modul/README.md).
## Verbrauchskostenreport
Dieses Modul benötigt seine Grundlizenz sowie passende Kontingente für Strom- und Nebenzähler. Ordne Benutzer und Zähler zu, pflege Tarifzeiträume und Einheiten und prüfe den gewählten Abrechnungszeitraum.
Die Aktion `CreateReport` erstellt das PDF-Medium `ReportPDF`. Für einen aktivierten QR-Zahlteil sind vollständige strukturierte Empfängerangaben erforderlich. Steuer- und Abrechnungseinstellungen müssen fachlich geprüft werden; ein Code-Standardwert ist keine steuerliche Festlegung. [Verbrauchskostenreport-Referenz](../../../Enelix-Utils/docs/module/Verbrauchskostenreport/README.md).
## CC100 Hardware
Das Modul bindet die dokumentierten Ein- und Ausgänge der CC100-Hardware ein. Prüfe Kanalzuordnung, Signalanpassung und physische Verdrahtung anhand der [CC100-Referenz](../../../Enelix-Utils/docs/module/CC100-Hardware/README.md). Teste Ausgänge erst nach Prüfung der angeschlossenen Verbraucher und des sicheren Anlagenzustands.
## Virtuelle Batterie
Trage die physischen Batterien mit Kapazitäten, Leistungsgrenzen, Ladezustands- und Leistungsquellen ein. Die virtuelle Batterie ist die einzige Instanz, die deren physische Sollwerte schreibt. Ein EMS-Batteriemodul nutzt die Eigenverbrauchs-Proxyregister; eine VGT-Anbindung nutzt die SDL-Variablen.
Prüfe Reserven, Zeitüberschreitungen und Messwertalter. Teste Eigenverbrauch und SDL zuerst getrennt, anschließend gemeinsam. SDL hat bei der Verteilung Vorrang. [Referenz zur virtuellen Batterie](../../../Enelix-Utils/docs/module/Virtuelle-Batterie/README.md).
## VGT-Schnittstelle
Ordne den MQTT-Parent zu und übernimm den vereinbarten Topic-Suffix. Wähle Geräteart, Messquellen und bedienbare Zielvariable. Im Batteriemodus wird an die virtuelle Batterie angebunden, nicht direkt an physische Register.
Prüfe zuerst die Rückmeldungen. Steueraufträge und Wiederherstellungsfunktionen dürfen erst im freigegebenen Testumfang erprobt werden. [VGT-Referenz](../../../Enelix-Utils/docs/module/VGT-Schnittstelle/README.md).
+55
View File
@@ -0,0 +1,55 @@
# Verbraucher einrichten
Ein Verbraucher übersetzt ein Leistungsangebot und die Manager-Vorgabe in die konkrete Geräteansteuerung. Die Einrichtung erfolgt zuerst am Gerät und anschließend im Manager.
## Gemeinsamer Ablauf
1. Passendes Modul als Instanz anlegen und `Aktiv` zunächst ausgeschaltet lassen.
2. Messquellen, Geräteanschluss und bedienbare Stellvariablen eintragen. Ein angezeigter Variablenwert allein beweist keine erfolgreiche Geräteansteuerung.
3. Leistungsgrenzen, Mindestzeiten und Schutzwerte aus den tatsächlichen Gerätedaten übernehmen.
4. `PrioritaetPV` und `PrioritaetPeak` festlegen. Kleinere Werte bedeuten höhere Priorität.
5. Konfiguration speichern und Instanzstatus sowie Messwerte prüfen.
6. Den Verbraucher im Manager auswählen. Keine Manager-ID im Verbraucher eintragen.
7. Unter Aufsicht Freigabe, kleine Sollwerte, Rückmeldung und Abschaltung prüfen.
`Meldeintervall` bestimmt die Rückmeldungen. `VorgabeTimeout` begrenzt die Gültigkeit einer nicht erneuerten Vorgabe. Mindestlaufzeiten und Schaltsperren können schnelle Änderungen verhindern; ändere sie nicht nur, um eine Reaktion zu erzwingen.
## Einstufiger Verbraucher
Trage die elektrische `Nennleistung` und eine Boolean-Schaltvariable mit funktionierender Aktion unter `SchaltkontaktVariableID` ein. Eine optionale `RueckmeldungVariableID` bestätigt den physischen Schaltzustand. Mindest-Ein-/Aus-Zeiten und Tagesmindestlaufzeit müssen zum Gerät passen.
Bei fälliger Tagesmindestlaufzeit kann das Gerät auch ohne PV-Überschuss Leistung anfordern. Prüfe das gewünschte Peak-Verhalten ausdrücklich. [Vollständige Referenz](../module/Verbraucher-1-Stufig/README.md).
## Warmwassererwärmer
Wähle den Temperaturfühler und konfiguriere jede positive Leistungsstufe mit eigener Boolean-Schaltvariable. Der Zustand `0 W` wird automatisch ergänzt. Stufen müssen elektrisch zulässig und eindeutig sein. Prüfe Mindesttemperatur, Hysterese, Zeitplan und Legionellenfunktion fachkundig; die Software ersetzt keine unabhängigen Temperatur- und Überhitzungsschutzfunktionen. [Vollständige Referenz](../module/Wassererwaermer/README.md).
## Pufferspeicher
Hinterlege Puffertemperatur, Außentemperatur, Heizkurve und Schaltkontakte der Leistungsstufen. Eine optionale Solltemperaturquelle der Wärmepumpe kann eingebunden werden. Kontrolliere die resultierende Einschaltschwelle und Hysterese. Im dokumentierten Stand bietet der Pufferspeicher im Peak-Betrieb nur `0 W` an. [Vollständige Referenz](../module/Pufferspeicher/README.md).
## Wärmepumpe
Wähle Sperre/Erhöhung oder SG Ready, ordne die beiden Kontakte zu und kontrolliere ihre tatsächliche Logik. Hinterlege Nennleistung und die verpflichtende Leistungs- oder Boolean-Betriebsrückmeldung. Prüfe Herstellerfreigabe, Kontaktbelegung und Mindestzeiten. [Vollständige Referenz](../module/Waermepumpe/README.md).
## Ladestation direkt anbinden
Für unterstützte go-e-Varianten und smart-me Pico wird **Ladestation Stand-Alone** verwendet. Wähle den richtigen Typ. Bei go-e gehören IP-Adresse oder Hostname ohne Protokoll, Pfad oder Parameter in die Geräteadresse. Pico benötigt seine vorgesehenen Geräte- und Kontodaten.
Prüfe Stromgrenzen, Fahrzeug-/Phasenerkennung und Sperrzeiten. `Aktiv`, `Ladefreigabe` und `Solarladen` haben unterschiedliche Aufgaben. Kontrolliere sie zusammen mit dem Fahrzeugstatus, wenn keine Ladeleistung angeboten wird. [Vollständige Referenz](../module/Ladestation-Stand-Alone/README.md).
## Easee über Gateway anbinden
Richte zuerst ein **Easee Gateway** für das Konto ein. Lege je Ladestation eine Instanz **Ladestation Gateway** mit passender Seriennummer an und verbinde sie mit dem Gateway. Zugangsdaten bleiben im Gateway. Prüfe Verbindung, Fahrzeugstatus und Phasenmeldung, bevor du die Station im Manager aktivierst.
[Gateway-Referenz](../module/Easee-Gateway/README.md) und [Ladestations-Referenz](../module/Ladestation-Gateway/README.md).
## Batterie
Wähle den passenden Batterieadapter. Hinterlege dynamische Lade-/Entladegrenzen, Ladezustand, Netzleistung, Istleistung und die erforderlichen bedienbaren Register. Positive Batterieleistung bedeutet Laden, negative Entladen. Prüfe besonders Watt gegenüber Kilowatt und die Herstellerabbildung.
Setze Ladezustandsgrenzen und Reserve gemäß Anlagenfreigabe. Bei einer virtuellen Batterie dürfen physische Register nicht gleichzeitig von mehreren Reglern beschrieben werden. Prüfe zunächst ohne aktive Enelix-Regelung alle Vorzeichen und Messwerte. [Vollständige Referenz](../module/Batterie/README.md).
## Diagnose
Prüfe nacheinander lokale Freigabe, Managerzuordnung, Lizenzumfang, Geräteverbindung, aktuelle Messwerte und Störtext. Ein Verbraucher kann verfügbar sein und trotzdem wegen einer Mindestzeit nur seine aktuelle Leistung anbieten. Details zu `Verfuegbar`, `AenderungMoeglich` und `SollwertGueltig` stehen in der [gemeinsamen Schnittstelle](../Schnittstelle.md).
@@ -0,0 +1,66 @@
# V4: separate Wirkenergie-Bezugsquelle (Entwicklung, nicht live)
## Lihrenmoos - Befund und geplante Zuordnung
User hat die erfolgreiche Aktivierung der T2-Zaehlerarchivierung fuer 26620
im Archiv 16207 bestaetigt. Der exakte Beginn einer lueckenlosen Messhistorie
ist daraus NICHT nachgewiesen; keine historische Null setzen.
Die vorbereitete separate V4-Konfiguration fuer diese Anlage lautet:
```json
[
{"VariableID":59607,"ElternID":11490,"Ident":"Energy_0","FaktorZuKWh":1.0,"Messgroesse":"WirkenergieBezug"},
{"VariableID":26620,"ElternID":11490,"Ident":"Energy_1","FaktorZuKWh":1.0,"Messgroesse":"WirkenergieBezug"}
]
```
Property: NetzfahrplanV4BezugszaehlerQuellen. Diese Datei setzt KEINE Property.
IDs duerfen nicht fuer andere Anlagen kopiert werden. Zuordnung ist durch
Variablenstruktur, Standardtelegramm und Leistungs-/Energiedifferenz stark
plausibilisiert; Abgleich mit Rohtelegramm/Zaehleranzeige steht noch aus.
53476, dessen gespeicherte Historie, die alte Manager-Property
NetzbezugEnergieVariableID und alle vorhandenen Spiegelereignisse bleiben
unveraendert. V4 liest seine eigenen Quellen, erzeugt keine Spruenge in alten
Summenzaehlern und kann keine Blindenergie durch einen Faktor korrigieren.
## Vertrag
Jede Quelle hat eine ausdrueckliche Messgroesse und Umrechnung nach kWh.
Variable, Elterninstanz und Ident werden bei jedem Lesen geprueft. Ein
fehlender, veralteter, ungueltiger oder zeitlich unpassender Teilwert verwirft
die Summe. Ein aktueller echter Nullwert ist gueltig. Zwei aufeinanderfolgende
Lesevorgaenge reduzieren das Risiko einer gemischten Momentaufnahme; sie sind
kein atomarer Zugriff auf das physische Zaehlertelegramm.
Die Summe wird unter bezugszaehler in der lokalen V4-Diagnose ausgegeben, mit
den Beobachtungszeitpunkten jedes Teilregisters. VariableUpdated ist ein
Symcon-Aktualisierungszeitpunkt, kein Beweis einer exakten Abrechnungsgrenze.
Die Diagnose hat deshalb billingEvidence=false und historyComplete=false.
Eine falsche Messgroessen-Deklaration kann ohne Rohtelegramm nicht automatisch
erkannt werden; Installationspruefung bleibt erforderlich.
Ein aus Quell-IDs, Eltern-ID, Idents und Umrechnungsfaktoren gebildeter
stabiler SHA256-Schluessel bindet die spaeteren Messnachweise an genau diese
Quelle. Die Reihenfolge von T1/T2 aendert den Schluessel nicht. Quell-/Faktor-
wechsel dagegen invalidieren alte Nachweise. symcon:53476 wird nicht mehr
als V4-Messnachweis akzeptiert. Der Schluessel beweist Zuordnung, nicht den
Wahrheitsgehalt eines gelieferten Monatspeaks.
## Noch offen
Der Erzeuger fuer exakte Viertelstundenenergie und den vollstaendigen
Monatspeak ist weiterhin nicht implementiert. Keine Ableitung eines bereits
bezahlten Monatspeaks aus einer Managergrenze oder unvollstaendiger Historie.
Auch 15 Minuten neuer Aufzeichnung beweisen nicht den bisherigen Monatspeak.
Die vorbereitete native Summe ist noch nicht im laufenden Symcon installiert.
V4 bleibt ohne belastbare Nachweise in awaiting_inputs.
## Nachweis dieses Schritts
44 synthetische Offline-Szenarien mit PHP CLI 8.4.23 erfolgreich:
20 Quellen-/Summenpruefungen, 24 native Betriebsdatenkonvertierungen.
Die vom Server exportierten Betriebsdaten-, Trait- und Fixture-Dateien wurden
vor dem Test per SHA256 verglichen. Keine Symcon-Laufzeitpruefung und keine
neue Zielcontainer-/vollstaendige PHPUnit-Pruefung in diesem Schritt.
+96
View File
@@ -0,0 +1,96 @@
# V4: native Betriebsdaten, ausschliesslich Schattenbetrieb
Stand 2026-10-01: Der optionale Sender und die Diagnose sind implementiert.
Kein V4-Plan wird damit uebernommen, kein Aktor geschrieben, kein V1-Sollwert
geaendert. Der Sender ist nach Installation standardmaessig AUS.
## Konfiguration im Manager
- NetzfahrplanV4SchattenAktiv: bool, Standard false. Eigener 60-s-Timer,
unabhaengig vom bestehenden Prognose-Timer. Fehler betreffen nur den Sendestatus.
- NetzfahrplanV4NetzladenErlaubt: bool, Standard false. Betrifft ausschliesslich
die im Schattenplan angenommene Berechtigung, keine reale Netzladefreigabe.
- NetzfahrplanV4BatterieOptionen: JSON-Objekt je Topologie-ID. Optional
SOCKapazitaet_kWh und MaxSOC_Prozent (sonst 100). Bei unterschiedlicher Nenn-
und Nutzkapazitaet ist SOCKapazitaet_kWh zwingend explizit anzugeben.
- NetzfahrplanV4BezugszaehlerQuellen: JSON-Liste, Standard []. Eigene, explizite
Wirkenergie-Bezugsquellen fuer V4; KEIN Rueckfall auf den Altzaehler.
Details und Lihrenmoos-Kandidat: Netzfahrplan-V4-Bezugszaehler.md.
- NetzfahrplanV4MessnachweisVariableID: JSON-Stringvariable, Standard 0.
Ein vollstaendiger historischer/registerbasierter Messnachweis-Produzent
ist NOCH NICHT integriert. Fehlende Nachweise bleiben fehlend.
Die Quelle muss der gemeinsame Netzanschluss sein. Topologiebatterien werden
nur einer eindeutigen aktiven Batterieinstanz mit derselben SOC- UND
Leistungsmessquelle zugeordnet. Externe SDL-Batterien werden nicht automatisch
hinzugefuegt. SDL-/Grundlastbereinigung bleibt eine separate, noch offene Aufgabe.
BMS-Grenzen werden mit Topologieleistungen begrenzt. Normale Betriebsreserve
und technisches Minimum werden nicht verwechselt: die hoehere Grenze gilt.
Der erweiterte Sender uebermittelt Entladesperre und Wiederfreigabe-SOC separat
von der maximalen Entladeleistung. Der neue Python-Rechenkern darf die Sperre
im Zukunftsplan erst nach vorherigem Laden bis zur Wiederfreigabe aufheben.
SOC unterhalb der Betriebsreserve, aber oberhalb des technischen Minimums,
bleibt unveraendert erhalten: Es ist Wiederaufladung statt erfundener Energie
vorgesehen. Unterhalb des technischen Minimums wird kein normaler Plan erzeugt.
Zusaetzlich wird meterObservation mit frischen Empfangswerten der Netzleistung
und T1/T2-Summe uebermittelt. Dessen laufende Viertelstundenintegration ist eine
explizite Planungsschaetzung, KEIN verifizierter Abrechnungsnachweis. Verwendung
von Schaetzungen muss im V4-Dienst ausdruecklich aktiviert werden. Historische
Peakannahmen werden getrennt von verifizierten Maxima gespeichert.
Diese Protokollerweiterung benoetigt zuerst den passenden Backendstand. Der
bisherige laufende V4-Container und die Testanlage sind noch nicht aktualisiert.
Offline-Szenarien und Quelltextpruefungen ersetzen keinen Symcon-Laufzeittest.
## Diagnose ohne Netzwerk oder Stellbefehl
Im Symcon-Skript nach Installation des neuen Modulstands:
```php
<?php
print_r(json_decode(ENELIX_GetNetzfahrplanV4Diagnose(17004), true));
echo ENELIX_GetNetzfahrplanV4Sendestatus(17004);
```
Die Diagnose enthaelt KEINE Tokens oder sonstigen Zugangsdaten. Sie ist auch
bei ausgeschaltetem Sender verfuegbar. Die Instanz-ID ist fuer Lihrenmoos aus
der bestehenden Konfiguration bekannt, nicht fuer andere Anlagen zu kopieren.
Nicht ungeprueft deployen: Host-CLI- und Zielsystemtests getrennt nachweisen.
## Messnachweis-Vertrag (Beispiel, KEINE realen Messwerte)
```json
{
"version": 1,
"meterId": "symcon-active-import:<SHA256-der-konfigurierten-Quellen>",
"measuredAt": "2026-10-01T12:05:00Z",
"measuredPeaks": {
"2026-10": {"kw": 18.4, "source": "meter_month_register"}
},
"quarterPast": {
"start": "2026-10-01T12:00:00Z",
"measuredSeconds": 300,
"importKwh": 0.5
}
}
```
Zulaessige Herkunft: meter_month_register, verified_month_history oder
verified_new_month. Der Nachweis-Produzent muss deren Wahrheit garantieren.
Managergrenzen sind NIE ein gemessener Monatspeak. Der Monatswert muss den
vollstaendigen bisherigen Abrechnungsmonat abdecken. Fuer quarterPast muessen
Zaehlerzeitpunkt, abgedeckte Sekunden und Entscheidungszeitpunkt exakt passen;
veraltete Energie wird nicht hochgerechnet oder als aktuelle Messung ausgegeben.
Die kumulative kWh-Variable allein erfuellt diesen Vertrag noch nicht.
Die V4-API meldet bei fehlendem Monatspeak/quarterPast weiterhin awaiting_inputs.
Ein HTTP stored bestaetigt nur den Eingang der Telemetrie, nicht einen gueltigen
Fahrplan und schon gar nicht dessen Ausfuehrung. Backend und Portal-Bruecke
muessen separat aktiviert werden. Keine neue Modellwahl oder Aktorfreigabe hier.
## Tests
24 offline Konvertierungsszenarien und 20 Quellen-/Summen-Szenarien sowie PHPUnit-Strukturpruefungen. Werte sind
synthetisch; keine reale HTTP-Anbindung oder Symcon-Laufzeit damit behauptet.
@@ -0,0 +1,71 @@
--- a/install.php
+++ b/install.php
@@ -19,6 +19,8 @@
}
$before=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$report['files']=v4StageInstall(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
+ require_once __DIR__.'/installed_capture_bootstrap.php';
+ $report['classLoading']=v4ApplicationLoadInstalledCapture('/var/lib/symcon/modules/Enelix-EMS',__DIR__.'/MANIFEST.json');
$controls=[];
foreach (IPS_GetModuleList() as $mid) if ((IPS_GetModule($mid)['Prefix']??'')==='MC') $controls=array_merge($controls,IPS_GetInstanceListByModuleID($mid));
if (count($controls)!==1||!function_exists('MC_ReloadModule')) throw new RuntimeException('Module Control nicht eindeutig.');
@@ -38,7 +40,7 @@
$marker=$dir.'/observer-import.json';
if (!is_file($marker)&&!IPS_GetProperty($manager,'NetzfahrplanV4MessdatenAktiv')) {
if (glob($dir.'/raw-*.jsonl')!==[]) throw new RuntimeException('Messdaten ohne Importabschluss vorhanden; nicht doppelt importieren.');
- require_once __DIR__.'/source/libs/NetzfahrplanV4Messaufnahme.php';
+ // Reuse the hash-checked installed class; never load the staged copy.
$count=0;$last=0;$cutoff=time()-172800;
$files=glob('/srv/agent/netplan-v4-separated-observer-stage/data/raw-*.jsonl');sort($files,SORT_STRING);
foreach ($files as $file) {
--- /dev/null
+++ b/installed_capture_bootstrap.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+/** Installer-only bootstrap. Load the same files the installed Manager uses.
+ * No IPS calls, source edits, state changes, or autoload of an unknown class.
+ */
+function v4ApplicationLoadInstalledCapture(string $target, string $manifestFile): array
+{
+ if (is_link($target) || realpath($target) !== $target || !is_dir($target)) {
+ throw new RuntimeException('Unerwartetes Modulverzeichnis.');
+ }
+ $manifest = json_decode((string) file_get_contents($manifestFile), true, 64, JSON_THROW_ON_ERROR);
+ if (($manifest['mode'] ?? null) !== 'data_application_only') {
+ throw new RuntimeException('Unerwarteter Installationsumfang.');
+ }
+ $classes = [
+ 'Belevo\\EnelixEMS\\NetzfahrplanV4Bilanzierung' => 'libs/NetzfahrplanV4Bilanzierung.php',
+ 'Belevo\\EnelixEMS\\NetzfahrplanV4Messaufnahme' => 'libs/NetzfahrplanV4Messaufnahme.php',
+ ];
+ // Check BOTH origins before loading either file, including the transitive dependency.
+ foreach ($classes as $class => $relative) {
+ $path = $target . '/' . $relative;
+ $expected = $manifest['files'][$relative]['after'] ?? null;
+ if (!is_string($expected) || !preg_match('/^[a-f0-9]{64}$/D', $expected)
+ || is_link($path) || !is_file($path) || realpath($path) !== $path
+ || hash_file('sha256', $path) !== $expected) {
+ throw new RuntimeException('Installierte Messbibliothek weicht vom geprueften Paket ab: ' . $relative);
+ }
+ if (class_exists($class, false)) {
+ $origin = (new ReflectionClass($class))->getFileName();
+ if ($origin !== $path) {
+ throw new RuntimeException('Messbibliothek bereits aus anderem Pfad geladen. Installationsaufruf separat ausfuehren, ohne weitere Sammler-Includes.');
+ }
+ }
+ }
+ foreach ($classes as $class => $relative) {
+ $path = $target . '/' . $relative;
+ if (!class_exists($class, false)) {
+ require_once $path;
+ }
+ if (!class_exists($class, false) || (new ReflectionClass($class))->getFileName() !== $path) {
+ throw new RuntimeException('Installierte Messbibliothek konnte nicht eindeutig geladen werden.');
+ }
+ }
+ return ['source' => 'installed_module_only', 'stagedClassesLoaded' => false,
+ 'files' => array_values($classes)];
+}
@@ -0,0 +1,41 @@
{
"scope": "confirmed_feedback_trial_disabled",
"managerId": 17004,
"batteryInstanceId": 44234,
"files": {
"libs/NetzfahrplanV4Rueckmeldung.php": {
"before": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631",
"after": "0b261f6534c5ca9864018ea4c2caf97e6ce6913d92f13dcc926383b4f401a673"
},
"libs/NetzfahrplanV4Geraeteabruf.php": {
"before": null,
"after": "2b69a2ed27554139db6c996c906a4afe430918381e6359d23e8bb3369cd9be47"
},
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
"before": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7",
"after": "1de81499da07ad814c98ba778ca728e34817bdfcf29cc7e518f7b5f8fd7733b7"
}
},
"testSupport": {
"libs/BatterieNetzfahrplanV4TestTrait.php": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2",
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4"
},
"tests": {
"tests/V4Feedback/checks.php": "b1aacc540ba11adabdd140aaded9e2968d1328f261ca15756e6002db3427330a",
"tests/V4Feedback/device_read_checks.php": "5c5d9e50dd1d0f80cb01a2d95e63018eeec558ba78c07afde208e3c0ef304cc3",
"tests/V4ControlTrial/checks.php": "2f12e94aeb207e6939a645f2f45b5f54e57c18530360ed3b16aa52ff89a1ef39",
"tests/V4ControlTrial/additional_checks.php": "df01119f2b9c0befe1d14ae6b6ddf39c75d3687b669142e9b24b5f9f4821f2c3",
"tests/V4ControlTrial/feedback_checks.php": "e87d41dff8e3ecd414a59b3dd7a234b34183d37acca8cddaf847b82eae92e60c",
"tests/V4Receiver/fixture.php": "0eeb65e63250aff5ca364a82c2b2a71f9f229be2d9d412e3037014979deb793a"
},
"configurationSha256": "e393c1f01b30febc1d0785233474be43e98d849b152b4b2450b72efa6b73d540",
"dependencies": {
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66",
"Batterie/module.php": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe",
"Manager/module.php": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
}
}
+18
View File
@@ -0,0 +1,18 @@
# V4 confirmed feedback for Lihrenmoos
This incremental package adds synchronous device reads before V4 feedback is evaluated.
- Grid input 40348 is refreshed through its M-Bus device.
- GoodWe and SolarEdge inputs are refreshed through their ModBus parent instances.
- A successful driver call is required; cached variable timestamps alone are not accepted.
- Daniel Haefliger accepted the existing `virtual_split` model for the bounded field test on 2026-10-04.
- The installer keeps the legacy schedule and both V4 trial permissions disabled.
- A real control trial still requires separate server authorization and evidence of an independent device-side watchdog.
Build a private staging directory:
```sh
python3 examples/V4ConfirmedFeedback/build_stage.py /srv/agent/netplan-v4-confirmed-feedback
```
Run the staged PHP checks before copying the package to the test plant. Execute `install.php` only from the IP-Symcon script editor on Lihrenmoos.
@@ -0,0 +1,49 @@
"""Create the reviewed Lihrenmoos confirmed-feedback package; never installs into Symcon."""
from pathlib import Path
import hashlib
import json
import sys
here = Path(__file__).resolve().parent
repo = here.parents[1]
if len(sys.argv) != 2:
raise SystemExit("Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY")
target = Path(sys.argv[1]).resolve()
allowed_roots = [Path("/srv/agent"), Path("/home/agent/services/qa")]
if not any(target != root and target.is_relative_to(root) for root in allowed_roots):
raise SystemExit("Use a private agent staging directory, not live module paths")
if target.exists():
raise SystemExit("Destination must not exist")
manifest = json.loads((here / "MANIFEST.json").read_text())
contents = {}
for group in ("files", "testSupport", "tests"):
for name, record in manifest[group].items():
expected = record["after"] if group == "files" else record
source = repo / name
if source.is_symlink() or not source.resolve().is_relative_to(repo):
raise SystemExit("Unsafe source: " + name)
data = source.read_bytes()
if hashlib.sha256(data).hexdigest() != expected:
raise SystemExit("Reviewed source changed: " + name)
contents["source/" + name] = data
for name, expected in manifest["dependencies"].items():
if hashlib.sha256((repo / name).read_bytes()).hexdigest() != expected:
raise SystemExit("Dependency changed: " + name)
for name in ("install.php", "file_installer.php", "MANIFEST.json", "feedback-config.json", "README.md"):
contents[name] = (here / name).read_bytes()
if hashlib.sha256(contents["feedback-config.json"]).hexdigest() != manifest["configurationSha256"]:
raise SystemExit("Configuration changed")
target.mkdir(parents=True, mode=0o700)
for name, data in contents.items():
path = target / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
path.chmod(0o640)
(target / "PACKAGE_HASHES.json").write_text(
json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in contents.items()}, indent=2) + "\n"
)
print("Private confirmed-feedback package created:", target, "; both trial permissions remain disabled.")
@@ -0,0 +1,71 @@
{
"version": 1,
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"assetId": "anlage01-virtual-ev",
"managerId": 17004,
"batteryInstanceId": 44234,
"mode": "virtual_split",
"allowEstimatedForTrial": true,
"maxSkewSeconds": 30,
"maxAgeSeconds": 60,
"idleToleranceW": 50.0,
"trackingToleranceW": 200.0,
"sources": [
{
"key": "grid",
"variableId": 40348,
"parentId": 11490,
"ident": "Power_8",
"factorToW": 1000,
"role": "grid"
},
{
"key": "physical_goodwe1",
"variableId": 47725,
"parentId": 19742,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_goodwe2",
"variableId": 35724,
"parentId": 57658,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_solaredge",
"variableId": 21447,
"parentId": 30789,
"ident": "Value",
"factorToW": 1,
"role": "physical"
},
{
"key": "ev_requested",
"variableId": 19651,
"parentId": 58448,
"ident": "Nennleistung_Soll_EV",
"factorToW": 1.0,
"role": "ev_request"
},
{
"key": "sdl_requested",
"variableId": 38943,
"parentId": 58448,
"ident": "Nennleistung_Soll_SDL",
"factorToW": 1.0,
"role": "sdl_request"
},
{
"key": "gateway_active",
"role": "gateway_active",
"variableId": 23483,
"parentId": 58448,
"ident": "State",
"factorToW": 1.0
}
]
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/** Atomic incremental installer for the confirmed-feedback update. */
function v4ConfirmedWrite(string $path, string $content, int $mode = 0644): void
{
if (is_link($path)) {
throw new RuntimeException('Symlink wird nicht ersetzt.');
}
$temp = tempnam(dirname($path), '.v4-confirmed-');
if ($temp === false) {
throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
}
try {
if (file_put_contents($temp, $content, LOCK_EX) !== strlen($content)
|| !chmod($temp, $mode)
|| !rename($temp, $path)) {
throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
}
} finally {
if (is_file($temp)) {
unlink($temp);
}
}
}
function v4ConfirmedFiles(string $stage, string $target): array
{
$allowed = [
'libs/NetzfahrplanV4Rueckmeldung.php',
'libs/NetzfahrplanV4Geraeteabruf.php',
'libs/BatterieNetzfahrplanV4RueckmeldungTrait.php',
];
$manifest = json_decode((string) file_get_contents($stage . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($manifest['scope'] ?? null) !== 'confirmed_feedback_trial_disabled'
|| count($manifest['files'] ?? []) !== count($allowed)
|| array_diff(array_keys($manifest['files']), $allowed)
|| array_diff($allowed, array_keys($manifest['files']))) {
throw new RuntimeException('Unerwarteter Dateiumfang.');
}
if (realpath($target) !== $target || is_link($target)) {
throw new RuntimeException('Modulpfad unerwartet.');
}
foreach ($manifest['dependencies'] as $name => $hash) {
if (!preg_match('~^(libs/[A-Za-z0-9]+\\.php|Batterie/module\\.php|Manager/module\\.php)$~D', $name)
|| is_link($target . '/' . $name)
|| hash_file('sha256', $target . '/' . $name) !== $hash) {
throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: ' . $name);
}
}
$before = [];
$source = [];
$already = true;
foreach ($allowed as $name) {
$path = $target . '/' . $name;
$candidate = $stage . '/source/' . $name;
if (is_link($path) || is_link($candidate) || realpath(dirname($path)) !== $target . '/' . dirname($name)) {
throw new RuntimeException('Unerwarteter Dateipfad.');
}
$source[$name] = (string) file_get_contents($candidate);
if (hash('sha256', $source[$name]) !== $manifest['files'][$name]['after']) {
throw new RuntimeException('Paketpruefsumme geaendert: ' . $name);
}
token_get_all($source[$name], TOKEN_PARSE);
$before[$name] = is_file($path) ? file_get_contents($path) : null;
$hash = $before[$name] === null ? null : hash('sha256', $before[$name]);
if (!in_array($hash, [$manifest['files'][$name]['before'], $manifest['files'][$name]['after']], true)) {
throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: ' . $name);
}
if ($hash !== $manifest['files'][$name]['after']) {
$already = false;
}
}
if ($already) {
return ['status' => 'already_installed', 'backup' => null, 'filesChanged' => []];
}
$backup = $stage . '/backups/' . gmdate('Ymd\\THis\\Z') . '-' . bin2hex(random_bytes(4));
if (!mkdir($backup, 0700, true)) {
throw new RuntimeException('Sicherung fehlgeschlagen.');
}
foreach ($allowed as $name) {
if ($before[$name] !== null) {
$destination = $backup . '/' . $name;
if (!is_dir(dirname($destination))) {
mkdir(dirname($destination), 0700, true);
}
v4ConfirmedWrite($destination, $before[$name], 0600);
}
}
v4ConfirmedWrite(
$backup . '/MANIFEST.json',
json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n",
0600
);
$written = [];
try {
foreach ($allowed as $name) {
if ((is_file($target . '/' . $name) ? file_get_contents($target . '/' . $name) : null) !== $before[$name]) {
throw new RuntimeException('Parallele Aenderung waehrend Installation.');
}
if ($before[$name] === $source[$name]) {
continue;
}
v4ConfirmedWrite($target . '/' . $name, $source[$name]);
$written[] = $name;
}
} catch (Throwable $error) {
foreach (array_reverse($written) as $name) {
$path = $target . '/' . $name;
if (!is_link($path) && is_file($path)
&& hash_file('sha256', $path) === $manifest['files'][$name]['after']) {
if ($before[$name] === null) {
unlink($path);
} else {
v4ConfirmedWrite($path, $before[$name]);
}
}
}
throw $error;
}
return ['status' => 'installed', 'backup' => $backup, 'filesChanged' => $written];
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs confirmed reads and accepted virtual_split mapping; never starts a trial.
if (!function_exists('IPS_GetKernelVersion')) {
throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
}
require_once __DIR__ . '/file_installer.php';
$manager = 17004;
$battery = 44234;
$locked = false;
$changedMapping = false;
$oldMapping = null;
$report = [
'scope' => 'confirmed_feedback_trial_disabled',
'startedAt' => gmdate('c'),
'actuatorPermissionGranted' => false,
'serverUpdateRequired' => true,
'acceptedModel' => 'virtual_split',
];
try {
$hashes = json_decode((string) file_get_contents(__DIR__ . '/PACKAGE_HASHES.json'), true, 32, JSON_THROW_ON_ERROR);
foreach ($hashes as $name => $hash) {
if (str_contains($name, '..') || str_starts_with($name, '/') || is_link(__DIR__ . '/' . $name)
|| hash_file('sha256', __DIR__ . '/' . $name) !== $hash) {
throw new RuntimeException('Vorbereitetes Paket veraendert.');
}
}
foreach ([
$manager => '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
$battery => '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
] as $id => $module) {
if (!IPS_InstanceExists($id) || IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== $module) {
throw new RuntimeException('Modulzuordnung ungueltig.');
}
if (IPS_HasChanges($id)) {
throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
}
}
$beforeManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$beforeBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
if (($beforeManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($beforeManager['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false
|| ($beforeBattery['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false) {
throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
}
$configuration = json_decode((string) file_get_contents(__DIR__ . '/feedback-config.json'), true, 32, JSON_THROW_ON_ERROR);
$manifest = json_decode((string) file_get_contents(__DIR__ . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($configuration['managerId'] ?? null) !== $manager
|| ($configuration['batteryInstanceId'] ?? null) !== $battery
|| ($configuration['mode'] ?? null) !== 'virtual_split'
|| ($configuration['allowEstimatedForTrial'] ?? null) !== true
|| hash_file('sha256', __DIR__ . '/feedback-config.json') !== $manifest['configurationSha256']) {
throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
}
$desired = json_encode($configuration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$previousConfiguration = $configuration;
$previousConfiguration['allowEstimatedForTrial'] = false;
$previousDesired = json_encode($previousConfiguration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$oldMapping = $beforeBattery['NetzfahrplanV4RueckmeldungKonfiguration'] ?? '{}';
if (!in_array($oldMapping, [$previousDesired, $desired], true)) {
throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
}
$controls = [];
foreach (IPS_GetModuleList() as $module) {
if ((IPS_GetModule($module)['Prefix'] ?? '') === 'MC') {
$controls = array_merge($controls, IPS_GetInstanceListByModuleID($module));
}
}
if (count($controls) !== 1 || !function_exists('MC_ReloadModule')) {
throw new RuntimeException('Module Control nicht eindeutig.');
}
$locked = IPS_SemaphoreEnter('ENELIX.V4.ConfirmedFeedbackInstall', 3000);
if (!$locked) {
throw new RuntimeException('Installation bereits aktiv.');
}
$report['files'] = v4ConfirmedFiles(__DIR__, '/var/lib/symcon/modules/Enelix-EMS');
if (MC_ReloadModule($controls[0], 'Enelix-EMS') === false) {
throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
}
$afterManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$afterBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $afterManager], [$beforeBattery, $afterBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value) {
throw new RuntimeException('Bestehender Parameter beim Reload veraendert: ' . $key);
}
}
}
if (!function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$report['status'] = 'waiting_for_registration';
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
} else {
if ($afterBattery['NetzfahrplanV4RueckmeldungKonfiguration'] !== $desired) {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $desired);
$changedMapping = true;
IPS_ApplyChanges($battery);
}
$finalManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$finalBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $finalManager], [$beforeBattery, $finalBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if ($key !== 'NetzfahrplanV4RueckmeldungKonfiguration'
&& (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value)) {
throw new RuntimeException('Bestehender Parameter unerwartet veraendert: ' . $key);
}
}
}
if (($finalManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($finalManager['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false
|| ($finalBattery['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false) {
throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
}
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($battery), true, 32, JSON_THROW_ON_ERROR);
$report['feedback'] = array_intersect_key($feedback, array_flip([
'status', 'kind', 'method', 'reason', 'reasons', 'estimated', 'batteryW', 'gridW',
'sourceOldestAt', 'sourceVariableOldestAt', 'confirmedAt', 'checkedAt', 'configHash',
'deviceReadConfirmed', 'usableForTrial', 'canDispatch',
]));
$report['status'] = 'confirmed_feedback_installed_trial_disabled';
$report['originalParametersPreserved'] = true;
echo "OK: Geraeteabfrage bestaetigt die physischen Messwerte; virtual_split ist fuer den begrenzten Regeltest akzeptiert.\n";
echo 'Rueckmeldung: ' . ($feedback['status'] ?? 'available')
. '; Geraeteabfrage: ' . (($feedback['deviceReadConfirmed'] ?? false) ? 'bestaetigt' : 'nicht bestaetigt')
. '; Modellanteil: ' . (($feedback['estimated'] ?? false) ? 'ja' : 'nein') . ".\n";
if (isset($feedback['reason'])) {
echo 'Aktueller Hinweis: ' . $feedback['reason'] . "\n";
}
echo "Beide Regeltestfreigaben AUS. Kein Stellbefehl ohne separat nachgewiesenen Geraete-Watchdog und Serverfreigabe.\n";
}
} catch (Throwable $error) {
$report['status'] = 'needs_review';
$report['reason'] = substr($error->getMessage(), 0, 260);
if ($changedMapping && $oldMapping !== null) {
try {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $oldMapping);
IPS_ApplyChanges($battery);
$report['mappingRestored'] = true;
} catch (Throwable $ignored) {
$report['mappingRestored'] = false;
}
}
echo 'FEHLER: ' . $report['reason'] . "\nKeine V4-Stellfreigabe erteilt.\n";
} finally {
$report['finishedAt'] = gmdate('c');
v4ConfirmedWrite(__DIR__ . '/INSTALL_RESULT.json', json_encode($report, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n", 0644);
if ($locked) {
IPS_SemaphoreLeave('ENELIX.V4.ConfirmedFeedbackInstall');
}
echo 'CONFIRMED FEEDBACK INSTALL REPORT: ' . __DIR__ . "/INSTALL_RESULT.json\n";
}
@@ -0,0 +1,65 @@
{
"scope": "corrected_feedback_trial_disabled",
"managerId": 17004,
"batteryInstanceId": 44234,
"files": {
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
"before": null,
"after": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7"
},
"libs/BatterieNetzfahrplanV4TestTrait.php": {
"before": null,
"after": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2"
},
"libs/BatterieRegler.php": {
"before": "035988c54d4d0cc3a59d234f1c08a8edbf5fcac42e09a6bcea61d3dd8270771b",
"after": "beecc9c817b722336367e1c574c1a38afc8f3f5346442170d67c101b980adf7b"
},
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": {
"before": "d083cfa6ae8e8026c134d2b53f34bb6fbe342fb7332a3e7da1b881de33e13e97",
"after": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66"
},
"libs/ManagerNetzfahrplanV4TestTrait.php": {
"before": null,
"after": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18"
},
"libs/NetzfahrplanV4Regeltest.php": {
"before": null,
"after": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249"
},
"libs/NetzfahrplanV4Rueckmeldung.php": {
"before": null,
"after": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631"
},
"Batterie/module.php": {
"before": "34503645f7e21dd1e5bf0a3f754f6592175dd67f9a263bf431f3801209555401",
"after": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe"
},
"Manager/module.php": {
"before": "83e80883c8f9b817c0af5e239b20bcb019c7cf7f7943ee5bcc6493ac5a743532",
"after": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
}
},
"configurationSha256": "f66cfb9ebf5e02259b902f876a4c78756a51b3f2f2c240fd92c0f01161268c56",
"dependencies": {
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4",
"libs/Nachrichtenvertrag.php": "215ad7d4c5240b39b48cb7672f5057afa532aafdb34039862a1a22985a9ee839",
"libs/VerbraucherBasisTrait.php": "10fd9f54283c378104bce11d623d5581558355d26fc2f85bf04b316542874507",
"libs/VerbraucherSchnittstelle.php": "6329d423dca7b551f01fd884204be45be43f92cd6da6e4faedf2c3c9b4654a15",
"libs/ManagerNetzfahrplanV4Trait.php": "e27a3595e4ed8cdb62e2561149ef6a50adb9194e0239eb25c1216aee2716fa88",
"libs/ManagerNetzfahrplanV4DatenTrait.php": "59ae0ae283839ca82c58db9c071dcf28d0ab95e44f7a74c6473aca837ce416c4",
"libs/NetzfahrplanV4Datenarchiv.php": "57ef7a60c273c5270b3e93ff5528d7d294e708725c9edfe81ac7ed819587f983",
"libs/NetzfahrplanV4Messaufnahme.php": "ebe873bb714041e505a4f500b34c9bae3a5a0b93ac459161584ced758507a6e9",
"libs/NetzfahrplanV4Bilanzierung.php": "ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199",
"libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"libs/ManagerEnergieTrait.php": "2f89b0ad31ee6dd99180a7225173036eb6212b2ea7157e57fee23f0f8b441a51",
"libs/EnergieMessung.php": "16c64e1cbefde81c25f913aaca6546f0d30cc8f2fddecea0768a3760a98e97d2",
"libs/StoerungsSnapshot.php": "c871c1dd58d7fb1ef6c06985de066518688ef080f3cbdadda192d66cedcf49e1",
"libs/Anlagentopologie.php": "1b7e95d103a3048ed38c464a424f2f12821a147b269e149fd6b89bca84878c47",
"libs/Lizenzpruefung.php": "cb93cb49b93ed3ebb2060dba28b5b2dec2f29548d6ccff1afd9c7cb4624ad77e",
"libs/EinspeiseRegler.php": "8b982be5b95275c6dd6611a2bf2b1f89b991ba53cf94e46df0eed3249add65e0",
"libs/ManagerRegler.php": "608a4abebbe45da37a16316a530fa3f5ab21cb880bb341acbdcd3a9aaa93c6ee",
"libs/ManagerSchnittstelle.php": "0aae8c8c85eb252c577fcb0b5a50a12f3247e4a0bb083579122854f6c35878b3"
}
}
@@ -0,0 +1,25 @@
"""Recreate a reviewed Lihrenmoos package from this commit; never install in Symcon."""
from pathlib import Path
import hashlib,json,sys
here=Path(__file__).resolve().parent;repo=here.parents[1]
if len(sys.argv)!=2:raise SystemExit('Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY')
target=Path(sys.argv[1]).resolve()
allowed=[Path('/srv/agent'),Path('/home/agent/services/qa')]
if not any(target!=p and target.is_relative_to(p) for p in allowed):raise SystemExit('Use a private agent staging directory, not live module paths')
if target.exists():raise SystemExit('Destination must not exist')
m=json.loads((here/'MANIFEST.json').read_text());contents={}
for n,v in m['files'].items():
p=repo/n
if p.is_symlink() or not p.resolve().is_relative_to(repo):raise SystemExit('Unsafe source')
data=p.read_bytes()
if hashlib.sha256(data).hexdigest()!=v['after']:raise SystemExit('Reviewed source changed: '+n)
contents['source/'+n]=data
for n,v in m['dependencies'].items():
if hashlib.sha256((repo/n).read_bytes()).hexdigest()!=v:raise SystemExit('Dependency changed: '+n)
for n in ['install.php','file_installer.php','MANIFEST.json','feedback-config.json']:contents[n]=(here/n).read_bytes()
if hashlib.sha256(contents['feedback-config.json']).hexdigest()!=m['configurationSha256']:raise SystemExit('Configuration changed')
target.mkdir(parents=True,mode=0o700)
for n,data in contents.items():
p=target/n;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(0o640)
(target/'PACKAGE_HASHES.json').write_text(json.dumps({n:hashlib.sha256(v).hexdigest() for n,v in contents.items()},indent=2)+'\n')
print('Private staging package created:',target,'; no running module, settings or control changes.')
@@ -0,0 +1,71 @@
{
"version": 1,
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"assetId": "anlage01-virtual-ev",
"managerId": 17004,
"batteryInstanceId": 44234,
"mode": "virtual_split",
"allowEstimatedForTrial": false,
"maxSkewSeconds": 30,
"maxAgeSeconds": 60,
"idleToleranceW": 50.0,
"trackingToleranceW": 200.0,
"sources": [
{
"key": "grid",
"variableId": 40348,
"parentId": 11490,
"ident": "Power_8",
"factorToW": 1000,
"role": "grid"
},
{
"key": "physical_goodwe1",
"variableId": 47725,
"parentId": 19742,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_goodwe2",
"variableId": 35724,
"parentId": 57658,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_solaredge",
"variableId": 21447,
"parentId": 30789,
"ident": "Value",
"factorToW": 1,
"role": "physical"
},
{
"key": "ev_requested",
"variableId": 19651,
"parentId": 58448,
"ident": "Nennleistung_Soll_EV",
"factorToW": 1.0,
"role": "ev_request"
},
{
"key": "sdl_requested",
"variableId": 38943,
"parentId": 58448,
"ident": "Nennleistung_Soll_SDL",
"factorToW": 1.0,
"role": "sdl_request"
},
{
"key": "gateway_active",
"role": "gateway_active",
"variableId": 23483,
"parentId": 58448,
"ident": "State",
"factorToW": 1.0
}
]
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/** File-only installation helpers. No module classes loaded from the staging tree. */
function v4FeedbackWrite(string $path, string $content, int $mode=0644): void
{
if (is_link($path)) throw new RuntimeException('Symlink wird nicht ersetzt.');
$temp=tempnam(dirname($path),'.v4-feedback-');
if ($temp===false) throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
try {
if (file_put_contents($temp,$content,LOCK_EX)!==strlen($content) || !chmod($temp,$mode) || !rename($temp,$path)) throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
} finally { if(is_file($temp))unlink($temp); }
}
function v4FeedbackFiles(string $stage,string $target): array
{
$allowed=['libs/NetzfahrplanV4Rueckmeldung.php','libs/BatterieNetzfahrplanV4RueckmeldungTrait.php','libs/NetzfahrplanV4Regeltest.php',
'libs/BatterieNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4EmpfangTrait.php','libs/BatterieRegler.php','Batterie/module.php','Manager/module.php'];
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
if (($m['scope']??null)!=='corrected_feedback_trial_disabled' || count($m['files']??[])!==count($allowed) || array_diff(array_keys($m['files']),$allowed)) throw new RuntimeException('Unerwarteter Dateiumfang.');
if (realpath($target)!==$target || is_link($target)) throw new RuntimeException('Modulpfad unerwartet.');
foreach($m['dependencies'] as $name=>$hash) {
if (!preg_match('~^libs/[A-Za-z0-9]+\.php$~D',$name) || is_link($target.'/'.$name) || hash_file('sha256',$target.'/'.$name)!==$hash) throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: '.$name);
}
$before=[];$source=[];$already=true;
// Explicit dependency order. Both top-level modules are replaced only after all dependencies.
foreach($allowed as$name) {
$path=$target.'/'.$name;$candidate=$stage.'/source/'.$name;
if (is_link($path)||is_link($candidate)||realpath(dirname($path))!==$target.'/'.dirname($name)) throw new RuntimeException('Unerwarteter Dateipfad.');
$source[$name]=(string)file_get_contents($candidate);
if (hash('sha256',$source[$name])!==$m['files'][$name]['after']) throw new RuntimeException('Paketpruefsumme geaendert: '.$name);
token_get_all($source[$name],TOKEN_PARSE);
$before[$name]=is_file($path)?file_get_contents($path):null;
$hash=$before[$name]===null?null:hash('sha256',$before[$name]);
if (!in_array($hash,[$m['files'][$name]['before'],$m['files'][$name]['after']],true)) throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: '.$name);
if($hash!==$m['files'][$name]['after'])$already=false;
}
if($already)return ['status'=>'already_installed','backup'=>null];
$backup=$stage.'/backups/'.gmdate('Ymd\THis\Z').'-'.bin2hex(random_bytes(4));
if(!mkdir($backup,0700,true))throw new RuntimeException('Sicherung fehlgeschlagen.');
foreach($allowed as$name) {
if($before[$name]!==null) {
$dest=$backup.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);
v4FeedbackWrite($dest,$before[$name],0600);
}
}
v4FeedbackWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600);
$written=[];
try {
foreach($allowed as$name) {
if((is_file($target.'/'.$name)?file_get_contents($target.'/'.$name):null)!==$before[$name])throw new RuntimeException('Parallele Aenderung waehrend Installation.');
if($before[$name]===$source[$name])continue;
v4FeedbackWrite($target.'/'.$name,$source[$name]);$written[]=$name;
}
} catch(Throwable $e) {
foreach(array_reverse($written)as$name) {
$p=$target.'/'.$name;
if(!is_link($p)&&is_file($p)&&hash_file('sha256',$p)===$m['files'][$name]['after']) {
if($before[$name]===null)unlink($p);else v4FeedbackWrite($p,$before[$name]);
}
}
throw $e;
}
return ['status'=>'installed','backup'=>$backup,'filesChanged'=>$written];
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs code and readonly feedback mapping; never starts a trial.
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
require_once __DIR__.'/file_installer.php';
$manager=17004;$battery=44234;$locked=false;$changedMapping=false;$oldMapping=null;
$report=['scope'=>'corrected_feedback_trial_disabled','startedAt'=>gmdate('c'),'actuatorPermissionGranted'=>false,'serverUpdateRequired'=>false];
try {
$hashes=json_decode((string)file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,32,JSON_THROW_ON_ERROR);
foreach($hashes as$name=>$hash) {
if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Vorbereitetes Paket veraendert.');
}
foreach([$manager=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',$battery=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']as$id=>$module) {
if(!IPS_InstanceExists($id)||IPS_GetInstance($id)['ModuleInfo']['ModuleID']!==$module)throw new RuntimeException('Modulzuordnung ungueltig.');
if(IPS_HasChanges($id))throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
}
$beforeM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$beforeB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
if(($beforeM['NetzfahrplanAktiv']??null)!==false || ($beforeM['NetzfahrplanV4RegeltestErlaubt']??false)!==false || ($beforeB['NetzfahrplanV4RegeltestErlaubt']??false)!==false)throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
$c=json_decode((string)file_get_contents(__DIR__.'/feedback-config.json'),true,32,JSON_THROW_ON_ERROR);
$manifest=json_decode((string)file_get_contents(__DIR__.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
if(($c['managerId']??null)!==$manager || ($c['batteryInstanceId']??null)!==$battery || ($c['allowEstimatedForTrial']??null)!==false
|| hash_file('sha256',__DIR__.'/feedback-config.json')!==$manifest['configurationSha256'])throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
$desired=json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION);
$oldMapping=$beforeB['NetzfahrplanV4RueckmeldungKonfiguration']??'{}';
if(!in_array($oldMapping,['','{}',$desired],true))throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
$controls=[];foreach(IPS_GetModuleList()as$module)if((IPS_GetModule($module)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($module));
if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.');
$locked=IPS_SemaphoreEnter('ENELIX.V4.FeedbackInstall',3000);if(!$locked)throw new RuntimeException('Installation bereits aktiv.');
$report['files']=v4FeedbackFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
$afterM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$afterB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
foreach([[$beforeM,$afterM],[$beforeB,$afterB]]as$pair)foreach($pair[0]as$key=>$value)if(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value)throw new RuntimeException('Bestehender Parameter beim Reload veraendert: '.$key);
if(!array_key_exists('NetzfahrplanV4RueckmeldungKonfiguration',$afterB)||!array_key_exists('NetzfahrplanV4RegeltestErlaubt',$afterM)
||!function_exists('ENELIX_GetV4BatterieRueckmeldung')||!function_exists('ENELIX_GetV4ManagerTestStatus')) {
$report['status']='waiting_for_registration';
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
} else {
if($afterM['NetzfahrplanV4RegeltestErlaubt']!==false||$afterB['NetzfahrplanV4RegeltestErlaubt']!==false)throw new RuntimeException('Unerwartete Testfreigabe.');
if($afterB['NetzfahrplanV4RueckmeldungKonfiguration']!==$desired) {
IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$desired);$changedMapping=true;IPS_ApplyChanges($battery);
}
$finalM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$finalB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
foreach([[$beforeM,$finalM],[$beforeB,$finalB]]as$pair)foreach($pair[0]as$key=>$value)if($key!=='NetzfahrplanV4RueckmeldungKonfiguration'&&(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value))throw new RuntimeException('Bestehender Parameter unerwartet veraendert: '.$key);
if(($finalM['NetzfahrplanAktiv']??null)!==false||($finalM['NetzfahrplanV4RegeltestErlaubt']??null)!==false||($finalB['NetzfahrplanV4RegeltestErlaubt']??null)!==false)throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($battery),true,32,JSON_THROW_ON_ERROR);
$report['feedback']=array_intersect_key($f,array_flip(['status','kind','method','reason','reasons','estimated','batteryW','gridW','sourceOldestAt','checkedAt','configHash','usableForTrial','canDispatch']));
$report['status']='corrected_feedback_installed_trial_disabled';$report['originalParametersPreserved']=true;
echo "OK: Korrigierte physische Rueckmeldung mit Manager-Vorschau und Batterietreiber verbunden.\n";
echo 'Rueckmeldung: '.($f['status']??'available').'; Modellanteil: '.(($f['estimated']??false)?'ja':'nein').".\n";
if(isset($f['reason']))echo 'Aktueller Hinweis: '.$f['reason']."\n";
echo "Beide Regeltestfreigaben AUS. Keine V4-Ausgabe aktiviert; vorhandene Regelung, Energiekonten und Datensender bleiben bestehen.\n";
}
} catch(Throwable $e) {
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,260);
if($changedMapping && $oldMapping!==null) {
try{IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$oldMapping);IPS_ApplyChanges($battery);$report['mappingRestored']=true;}catch(Throwable $ignored){$report['mappingRestored']=false;}
}
echo 'FEHLER: '.$report['reason']."\nKeine V4-Stellfreigabe erteilt.\n";
} finally {
$report['finishedAt']=gmdate('c');v4FeedbackWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644);
if($locked)IPS_SemaphoreLeave('ENELIX.V4.FeedbackInstall');
echo 'FEEDBACK INSTALL REPORT: '.__DIR__."/INSTALL_RESULT.json\n";
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/runtime.php';
/** Creates ONLY dedicated capture objects. No module reload, device settings or archives. */
function v4captureEnsure(int $parent, string $ident, int $type, string $name, string $marker, array &$created): int
{
$existing = v4captureChild($parent, $ident);
if ($existing !== null) {
v4captureAssertObject($existing, $parent, $ident, $type, $marker);
return $existing;
}
$id = match ($type) { 0 => IPS_CreateCategory(), 2 => IPS_CreateVariable(3), 3 => IPS_CreateScript(0) };
if (!is_int($id) || $id <= 0) throw new RuntimeException('Cannot create capture object');
$created[] = $id;
if (!IPS_SetParent($id, $parent) || !IPS_SetIdent($id, $ident) || !IPS_SetName($id, $name) || !IPS_SetInfo($id, $marker)) {
throw new RuntimeException('Cannot label capture object');
}
return $id;
}
function v4captureScript(int $id, string $content, array $created): void
{
$current = IPS_GetScriptContent($id);
if ($current === $content) return;
if (!in_array($id, $created, true)) throw new RuntimeException('Existing capture script changed; not overwritten');
if (!IPS_SetScriptContent($id, $content) || IPS_GetScriptContent($id) !== $content) throw new RuntimeException('Cannot store capture script');
}
function v4captureInstall(string $stage): array
{
if (!function_exists('IPS_GetKernelVersion') || version_compare(IPS_GetKernelVersion(),'8.0','<')) {
throw new RuntimeException('Nur im IP-Symcon-Skripteditor der Testanlage ausfuehren.');
}
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
if (!IPS_InstanceExists($c['managerId'])
|| IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID'] !== '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}') {
throw new RuntimeException('Expected manager not found');
}
// Verify layout only; stale/missing sensor updates remain diagnostic raw data, not fake zeros.
foreach (array_merge($c['accounting']['sources'], $c['extraSources']) as $s) {
if (!IPS_VariableExists($s['variableId'])) throw new RuntimeException('Expected source missing: ' . $s['key']);
$o = IPS_GetObject($s['variableId']); $v = IPS_GetVariable($s['variableId']);
if ($o['ParentID'] !== $s['parentId'] || $o['ObjectIdent'] !== $s['ident'] || !in_array($v['VariableType'],[1,2],true)) {
throw new RuntimeException('Source identity changed: ' . $s['key']);
}
}
$data = $stage . '/data';
if (is_link($data) || !is_dir($data) || !is_writable($data)) throw new RuntimeException('Dedicated capture directory not writable');
$key = 'ENELIX.V4.CaptureInstall.' . $c['installationId'];
if (!IPS_SemaphoreEnter($key, 3000)) throw new RuntimeException('Capture installation busy');
$created = []; $script = null; $timerSet = false;
$report = ['startedAt' => gmdate('c'), 'scope' => 'passive_raw_capture_only', 'controlEnabled' => false,
'existingModulesChanged' => false, 'archivesChanged' => false, 'existingHistoriesChanged' => false];
try {
$category = v4captureEnsure(0,'ENELIX_V4_PASSIVE_CAPTURE',0,'V4 - separate Messaufnahme (keine Steuerung)',$marker,$created);
v4captureEnsure($category,'CaptureStatus',2,'Aufzeichnungsstatus (nur lesen)',$marker,$created);
$script = v4captureEnsure($category,'RawCapture',3,'Messwerte alle 30 Sekunden erfassen',$marker,$created);
$stop = v4captureEnsure($category,'StopCapture',3,'Nur diese Messaufnahme stoppen',$marker,$created);
$include = '<?php' . "\n" . 'require_once ' . var_export($stage . '/source/examples/V4DataCapture/runtime.php',true) . ';' . "\n";
v4captureScript($script, $include . 'v4captureRun(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
v4captureScript($stop, $include . 'v4captureStop(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
$first = v4captureRun($stage, $category);
if ($first['status'] !== 'recorded') throw new RuntimeException('Capture not stored: ' . ($first['reason'] ?? $first['status']));
// This new timer never invokes a manager, gateway, battery or Modbus function.
if (!IPS_SetScriptTimer($script, 30)) throw new RuntimeException('Cannot start dedicated capture timer');
$timerSet = true;
$report += ['status' => 'capture_installed', 'categoryId' => $category, 'scriptId' => $script,
'stopScriptId' => $stop, 'intervalSeconds' => 30, 'firstCapture' => $first,
'futureTimerExecutionVerified' => false];
echo "OK: Separate Rohdatenaufnahme eingerichtet, alle 30 Sekunden.\n";
echo 'Erste Aufnahme: ' . $first['capturedAt'] . '; Quellen: ' . $first['sourceCount'] . ".\n";
echo 'Bilanzqualitaet: ' . $first['accountingQuality'] . " (noch keine Trainings-/Stellfreigabe).\n";
echo "Bestehende Manager-, Batterie-, Archiv- und Pollingkonfiguration unveraendert.\n";
} catch (Throwable $e) {
$report['status'] = 'needs_review'; $report['reason'] = substr($e->getMessage(),0,300);
if ($timerSet && $script !== null) IPS_SetScriptTimer($script,0);
// Never delete unrelated objects or stored data during recovery.
echo 'FEHLER: ' . $report['reason'] . "\n";
} finally {
$report['createdObjects'] = $created; $report['finishedAt'] = gmdate('c');
try { v4captureAtomicJson($stage . '/INSTALL_RESULT.json', $report); }
finally { IPS_SemaphoreLeave($key); }
echo 'CAPTURE REPORT: ' . $stage . "/INSTALL_RESULT.json\n";
}
return $report;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/libs/NetzfahrplanV4Messaufnahme.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme;
/** Native bindings for a dedicated passive capture category. No network or actuator calls. */
function v4captureConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage) !== $stage) throw new RuntimeException('Invalid capture stage');
$manifest = json_decode((string)file_get_contents($stage . '/MANIFEST.json'), true, 64, JSON_THROW_ON_ERROR);
$expected = ['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php',
'source/examples/V4DataCapture/runtime.php','source/examples/V4DataCapture/install.php',
'config.json','USER_REPORTED_INVENTORY.json'];
if (($manifest['scope'] ?? null) !== 'passive_raw_capture_only' || array_keys($manifest['hashes'] ?? []) !== $expected) {
throw new RuntimeException('Unexpected capture manifest');
}
foreach ($expected as $name) {
$p = $stage . '/' . $name;
if (is_link($p) || !is_file($p) || realpath($p) !== $p || hash_file('sha256', $p) !== $manifest['hashes'][$name]) {
throw new RuntimeException('Reviewed capture source changed: ' . $name);
}
}
$c = json_decode((string)file_get_contents($stage . '/config.json'), true, 64, JSON_THROW_ON_ERROR);
if (($c['managerId'] ?? null) !== 17004 || ($c['installationId'] ?? null) !== 'e3a08f9e-af12-4695-99bd-8b51c0520021'
|| ($c['reportedInventorySha256'] ?? null) !== $manifest['hashes']['USER_REPORTED_INVENTORY.json']) {
throw new RuntimeException('Wrong commissioning installation or inventory');
}
return NetzfahrplanV4Messaufnahme::configuration($c);
}
function v4captureMarker(array $c): string
{
return 'ENELIX passive raw capture v1|' . $c['installationId'];
}
function v4captureChild(int $parent, string $ident): ?int
{
$found = null;
foreach (IPS_GetChildrenIDs($parent) as $id) {
if (IPS_GetObject($id)['ObjectIdent'] !== $ident) continue;
if ($found !== null) throw new RuntimeException('Duplicate capture object');
$found = $id;
}
return $found;
}
function v4captureAssertObject(int $id, int $parent, string $ident, int $type, string $marker): void
{
$o = IPS_GetObject($id);
if ($o['ParentID'] !== $parent || $o['ObjectIdent'] !== $ident || $o['ObjectType'] !== $type || $o['ObjectInfo'] !== $marker) {
throw new RuntimeException('Unexpected capture object ownership');
}
if ($type === 2 && IPS_GetVariable($id)['VariableType'] !== 3) throw new RuntimeException('Capture status is not a string');
}
function v4captureAtomicJson(string $file, array $value): void
{
if (is_link($file)) throw new RuntimeException('Capture report symlink refused');
$tmp = tempnam(dirname($file), '.capture-');
if ($tmp === false) throw new RuntimeException('Cannot create report');
try {
$data = json_encode($value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n";
if (file_put_contents($tmp, $data, LOCK_EX) !== strlen($data) || !chmod($tmp, 0640) || !rename($tmp, $file)) {
throw new RuntimeException('Cannot store capture report');
}
} finally { if (is_file($tmp)) unlink($tmp); }
}
function v4captureKeepSnapshot(string $path, string $bytes): void
{
if (is_link($path)) throw new RuntimeException('Metadata symlink refused');
if (is_file($path)) {
if (hash_file('sha256', $path) !== hash('sha256', $bytes)) throw new RuntimeException('Immutable capture metadata differs');
return;
}
$h = fopen($path, 'xb');
if ($h === false) throw new RuntimeException('Cannot store capture metadata');
try {
if (fwrite($h, $bytes) !== strlen($bytes) || !fflush($h) || !chmod($path, 0640)) throw new RuntimeException('Capture metadata write failed');
} finally { fclose($h); }
}
function v4captureRead(int $id): array
{
if (!IPS_VariableExists($id)) throw new RuntimeException('Missing source');
$v = IPS_GetVariable($id); $o = IPS_GetObject($id);
if (!in_array($v['VariableType'], [1,2], true)) throw new RuntimeException('Non-numeric source');
// Capture layer reads all sources twice; source timestamps are NOT replaced with now.
return ['value' => GetValue($id), 'updated' => (int)$v['VariableUpdated'], 'changed' => (int)$v['VariableChanged'],
'parentID' => $o['ParentID'], 'ident' => $o['ObjectIdent']];
}
function v4captureRun(string $stage, int $category): array
{
if (!function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Run inside IP-Symcon only');
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker);
$statusId = v4captureChild($category, 'CaptureStatus');
if ($statusId === null) throw new RuntimeException('Capture status missing');
v4captureAssertObject($statusId, $category, 'CaptureStatus', 2, $marker);
$lock = 'ENELIX.V4.RawCapture.' . $c['installationId'];
if (!IPS_SemaphoreEnter($lock, 0)) return ['status' => 'busy', 'controlEnabled' => false];
try {
try {
$record = NetzfahrplanV4Messaufnahme::capture($c, 'v4captureRead', static fn(): int => time());
v4captureKeepSnapshot($stage . '/data/mapping-' . $record['mappingSha256'] . '.json', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
v4captureKeepSnapshot($stage . '/data/inventory-' . $record['reportedInventorySha256'] . '.json', (string)file_get_contents($stage . '/USER_REPORTED_INVENTORY.json'));
$path = NetzfahrplanV4Messaufnahme::append($stage . '/data', $record);
$status = ['status' => 'recorded', 'capturedAt' => $record['capturedAt'], 'journal' => $path,
'sourceCount' => count($record['raw']), 'rawIssueCount' => count($record['issues']),
'accountingQuality' => $record['assessment']['status'] ?? 'unavailable',
'qualityIssues' => $record['assessment']['issues'] ?? $record['issues'],
'baseLoadCandidateW' => $record['assessment']['baseLoadCandidateW'] ?? null,
'sourceSkewSeconds' => $record['assessment']['sourceSkewSeconds'] ?? null,
'trainingEligible' => false, 'controlEnabled' => false, 'measurementBoundaryVerified' => false];
v4captureAtomicJson($stage . '/data/STATUS.json', $status);
} catch (Throwable $e) {
$status = ['status' => 'capture_error', 'at' => gmdate('c'), 'reason' => substr($e->getMessage(),0,250),
'trainingEligible' => false, 'controlEnabled' => false];
try { v4captureAtomicJson($stage . '/data/STATUS.json', $status); } catch (Throwable $ignored) { }
}
// Only this newly created diagnosis variable, never a meter or controller variable.
SetValue($statusId, json_encode($status, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
return $status;
} finally { IPS_SemaphoreLeave($lock); }
}
function v4captureStop(string $stage, int $category): void
{
$c = v4captureConfig($stage); $marker = v4captureMarker($c);
v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker);
$script = v4captureChild($category, 'RawCapture'); $status = v4captureChild($category, 'CaptureStatus');
if ($script === null || $status === null) throw new RuntimeException('Capture objects missing');
v4captureAssertObject($script, $category, 'RawCapture', 3, $marker);
v4captureAssertObject($status, $category, 'CaptureStatus', 2, $marker);
if (!IPS_SetScriptTimer($script, 0)) throw new RuntimeException('Cannot stop capture timer');
$r = ['status' => 'stopped', 'at' => gmdate('c'), 'oldDataRetained' => true, 'controlEnabled' => false];
SetValue($status, json_encode($r, JSON_THROW_ON_ERROR));
v4captureAtomicJson($stage . '/data/STATUS.json', $r);
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/runtime.php';
function v4viewsEnsure(int $parent,string $ident,int $type,string $name,string $marker,array &$created): int
{
$id=v4viewsChild($parent,$ident);if($id!==null){v4viewsAssert($id,$parent,$ident,$type,$marker);return $id;}
$id=match($type){0=>IPS_CreateCategory(),2=>IPS_CreateVariable(3),3=>IPS_CreateScript(0)};
if(!is_int($id)||$id<=0)throw new RuntimeException('Cannot create observer object');$created[]=$id;
if(!IPS_SetParent($id,$parent)||!IPS_SetIdent($id,$ident)||!IPS_SetName($id,$name)||!IPS_SetInfo($id,$marker))throw new RuntimeException('Cannot label observer object');
return $id;
}
function v4viewsScript(int $id,string $source,array $created): void
{
if(IPS_GetScriptContent($id)===$source)return;
if(!in_array($id,$created,true))throw new RuntimeException('Existing observer script differs; not overwritten');
if(!IPS_SetScriptContent($id,$source)||IPS_GetScriptContent($id)!==$source)throw new RuntimeException('Cannot write observer script');
}
function v4viewsInstall(string $stage): array
{
if(!function_exists('IPS_GetKernelVersion')||version_compare(IPS_GetKernelVersion(),'8.0','<'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
if(!IPS_InstanceExists($c['managerId'])||IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID']!=='{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}')throw new RuntimeException('Expected manager missing');
foreach(array_merge($c['accounting']['sources'],$c['extraSources']) as $s){
if(!IPS_VariableExists($s['variableId']))throw new RuntimeException('Expected observation source missing');
$o=IPS_GetObject($s['variableId']);$v=IPS_GetVariable($s['variableId']);
if($o['ParentID']!==$s['parentId']||$o['ObjectIdent']!==$s['ident']||!in_array($v['VariableType'],[1,2],true))throw new RuntimeException('Source identity differs');
}
if(is_link($stage.'/data')||!is_dir($stage.'/data')||!is_writable($stage.'/data')||(fileperms($stage.'/data')&0077)!==0)throw new RuntimeException('Private data directory required');
$lock='ENELIX.V4.ViewsInstall.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,3000))throw new RuntimeException('Observer installation busy');
$created=[];$script=null;$newTimer=false;$report=['scope'=>'passive_separated_observation','startedAt'=>gmdate('c'),
'oldCollectorChanged'=>false,'modulesChanged'=>false,'pollingChanged'=>false,'socAccountsChanged'=>false,'trainingEnabled'=>false,'controlEnabled'=>false];
try{
$cat=v4viewsEnsure(0,'ENELIX_V4_SEPARATED_OBSERVATION',0,'V4 - getrennte Last und EV-SDL (nur Beobachtung)',$marker,$created);
foreach(['PhysicalLoad'=>'Physische Last - Kandidat, keine Trainingsfreigabe','AllocationModel'=>'EV-SDL-Modell - KEINE separate Messung','ObservationStatus'=>'Beobachtungsstatus'] as $key=>$name)v4viewsEnsure($cat,$key,2,$name,$marker,$created);
$script=v4viewsEnsure($cat,'Observe',3,'Getrennte Messsicht alle 30 Sekunden',$marker,$created);
$stop=v4viewsEnsure($cat,'Stop',3,'Nur diese zusaetzliche Beobachtung stoppen',$marker,$created);
$include='<?php'."\n".'require_once '.var_export($stage.'/source/examples/V4SeparatedObservation/runtime.php',true).';'."\n";
v4viewsScript($script,$include.'v4viewsRun('.var_export($stage,true).', '.$cat.');'."\n",$created);
v4viewsScript($stop,$include.'v4viewsStop('.var_export($stage,true).', '.$cat.');'."\n",$created);
$first=v4viewsRun($stage,$cat);if($first['status']!=='recorded')throw new RuntimeException('First derived observation failed');
if(!IPS_SetScriptTimer($script,30))throw new RuntimeException('Cannot activate observer timer');$newTimer=true;
$report+=['status'=>'observer_installed','categoryId'=>$cat,'scriptId'=>$script,'stopId'=>$stop,'firstObservation'=>$first,'futureTimerExecutionVerified'=>false];
echo "OK: Getrennte Last-/EV-SDL-Beobachtung eingerichtet, alle 30 Sekunden.\n";
echo 'Erste Aufnahme: '.$first['capturedAt'].'; Quellen: '.$first['sourceCount'].".\n";
echo 'Physische Last: '.$first['physicalLoadStatus'].'; EV-/SDL-Modell: '.$first['allocationModelStatus'].".\n";
echo "Keine Trainings-/Stellfreigabe. Bisheriger Sammler, Manager, Gateway, SDL und Polling unveraendert.\n";
}catch(Throwable $e){
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,180);
if($newTimer&&$script!==null)IPS_SetScriptTimer($script,0);
echo 'FEHLER: '.$report['reason']."\n";
}finally{
$report['createdObjects']=$created;$report['finishedAt']=gmdate('c');
try{v4viewsJson($stage.'/INSTALL_RESULT.json',$report);}finally{IPS_SemaphoreLeave($lock);}
echo 'OBSERVATION REPORT: '.$stage."/INSTALL_RESULT.json\n";
}
return $report;
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/** Separate read-only observations. Only newly owned objects/files may be changed. */
function v4viewsConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage)!==$stage || (fileperms($stage)&0077)!==0) throw new RuntimeException('Private observer stage required');
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,64,JSON_THROW_ON_ERROR);
$files=['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php','source/libs/NetzfahrplanV4Messsicht.php',
'source/examples/V4SeparatedObservation/runtime.php','source/examples/V4SeparatedObservation/install.php','config.json','policy.json','INVENTORY.json'];
if (($m['scope']??null)!=='passive_separated_observation' || array_keys($m['hashes']??[])!==$files) throw new RuntimeException('Unexpected observer manifest');
foreach($files as $name){$p=$stage.'/'.$name;if(is_link($p)||!is_file($p)||realpath($p)!==$p||hash_file('sha256',$p)!==$m['hashes'][$name])throw new RuntimeException('Observer source drift: '.$name);}
require_once $stage.'/source/libs/NetzfahrplanV4Messsicht.php';
$c=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::configuration(json_decode((string)file_get_contents($stage.'/config.json'),true,64,JSON_THROW_ON_ERROR));
$p=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::policy(json_decode((string)file_get_contents($stage.'/policy.json'),true,64,JSON_THROW_ON_ERROR),$c);
if($c['reportedInventorySha256']!==$m['hashes']['INVENTORY.json'])throw new RuntimeException('Inventory identity differs');
return [$c,$p];
}
function v4viewsMarker(array $c): string { return 'ENELIX separated physical observer v1|'.$c['installationId']; }
function v4viewsChild(int $parent,string $ident): ?int
{
$result=null;foreach(IPS_GetChildrenIDs($parent) as $id)if(IPS_GetObject($id)['ObjectIdent']===$ident){if($result!==null)throw new RuntimeException('Duplicate observer object');$result=$id;}
return $result;
}
function v4viewsAssert(int $id,int $parent,string $ident,int $type,string $marker): void
{
$o=IPS_GetObject($id);if($o['ParentID']!==$parent||$o['ObjectIdent']!==$ident||$o['ObjectType']!==$type||$o['ObjectInfo']!==$marker)throw new RuntimeException('Observer object ownership mismatch');
if($type===2 && IPS_GetVariable($id)['VariableType']!==3)throw new RuntimeException('Observer diagnosis must be string');
}
function v4viewsRead(int $id): array
{
if(!IPS_VariableExists($id))throw new RuntimeException('Missing numeric source');
$a=IPS_GetVariable($id);$o=IPS_GetObject($id);
if(!in_array($a['VariableType'],[1,2],true))throw new RuntimeException('Non-numeric source');
return ['value'=>GetValue($id),'updated'=>(int)$a['VariableUpdated'],'changed'=>(int)$a['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
}
function v4viewsJson(string $path,array $data): void
{
if(is_link($path)||realpath(dirname($path))!==dirname($path)||(fileperms(dirname($path))&0077)!==0)throw new RuntimeException('Private observer output required');
$tmp=tempnam(dirname($path),'.views-');if($tmp===false)throw new RuntimeException('Cannot create observer report');
try{$json=json_encode($data,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT|JSON_PRESERVE_ZERO_FRACTION)."\n";
if(file_put_contents($tmp,$json,LOCK_EX)!==strlen($json)||!chmod($tmp,0644)||!rename($tmp,$path))throw new RuntimeException('Cannot store observer report');
}finally{if(is_file($tmp))unlink($tmp);}
}
function v4viewsRun(string $stage,int $category): array
{
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Run inside Symcon');
[$c,$policy]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$ids=[];foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){
$id=v4viewsChild($category,$key);if($id===null)throw new RuntimeException('Observer object missing');v4viewsAssert($id,$category,$key,2,$marker);$ids[$key]=$id;}
$lock='ENELIX.V4.SeparatedView.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,0))return ['status'=>'busy','controlEnabled'=>false];
try{
try{
$raw=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::capture($c,'v4viewsRead',static fn():int=>time());
$view=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::calculate($raw,$c,$policy);
$record=['schemaVersion'=>1,'kind'=>'physical_and_allocation_observation','installationId'=>$c['installationId'],'capturedAt'=>$raw['capturedAt'],
'capture'=>$raw,'views'=>$view,'trainingEligible'=>false,'controlEnabled'=>false];
$dir=$stage.'/data';if(is_link($dir)||realpath($dir)!==$dir||(fileperms($dir)&0077)!==0)throw new RuntimeException('Private data directory required');
$path=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::append($dir,$record);
// Derived-only files readable to authorized agent through a mode-0700 stage/data directory.
// Original collector files and permissions are never touched.
if(!chmod($path,0644))throw new RuntimeException('Cannot expose selected derived observation');
$status=['status'=>'recorded','capturedAt'=>$raw['capturedAt'],'sourceCount'=>count($raw['raw']),'journal'=>$path,
'physicalLoadStatus'=>$view['physicalLoad']['status'],'allocationModelStatus'=>$view['allocationModel']['status'],
'solarOriginStatus'=>$view['solarOrigin']['status'],'trainingEligible'=>false,'controlEnabled'=>false];
v4viewsJson($dir.'/LATEST.json',$record);
SetValue($ids['PhysicalLoad'],json_encode(['captureAt'=>$raw['capturedAt'],'physical'=>$view['physicalLoad'],'solarAcAlternative'=>$view['solarAcAlternative'],'trainingEligible'=>false],JSON_THROW_ON_ERROR));
SetValue($ids['AllocationModel'],json_encode(['captureAt'=>$raw['capturedAt'],'model'=>$view['allocationModel'],'storage'=>$view['storageFeedback'],'canDispatch'=>false],JSON_THROW_ON_ERROR));
}catch(Throwable $e){
$status=['status'=>'observation_error','at'=>gmdate('c'),'errorType'=>get_class($e),'sourceLine'=>$e->getLine(),'trainingEligible'=>false,'controlEnabled'=>false];
// Never leave a stale numeric proposal in the new diagnosis variables.
SetValue($ids['PhysicalLoad'],json_encode($status,JSON_THROW_ON_ERROR));SetValue($ids['AllocationModel'],json_encode($status,JSON_THROW_ON_ERROR));
try{v4viewsJson($stage.'/data/LATEST.json',$status);}catch(Throwable $ignored){}
}
SetValue($ids['ObservationStatus'],json_encode($status,JSON_THROW_ON_ERROR));v4viewsJson($stage.'/data/STATUS.json',$status);return $status;
}finally{IPS_SemaphoreLeave($lock);}
}
function v4viewsStop(string $stage,int $category): void
{
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$id=v4viewsChild($category,'Observe');if($id===null)throw new RuntimeException('Observer script missing');v4viewsAssert($id,$category,'Observe',3,$marker);
if(!IPS_SetScriptTimer($id,0))throw new RuntimeException('Cannot stop observer');
$s=['status'=>'stopped','at'=>gmdate('c'),'controlEnabled'=>false,'existingCollectorChanged'=>false];
foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){$var=v4viewsChild($category,$key);if($var===null)throw new RuntimeException('Observer status missing');v4viewsAssert($var,$category,$key,2,$marker);SetValue($var,json_encode($s,JSON_THROW_ON_ERROR));}
v4viewsJson($stage.'/data/STATUS.json',$s);v4viewsJson($stage.'/data/LATEST.json',$s);
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
function v4UnifiedWrite(string $path,string $text,int $mode=0644):void
{
$tmp=tempnam(dirname($path),'.v4-rc-');if($tmp===false)throw new RuntimeException('Temporaere Datei fehlt.');
try {if(file_put_contents($tmp,$text,LOCK_EX)!==strlen($text)||!chmod($tmp,$mode)||!rename($tmp,$path))throw new RuntimeException('Datei konnte nicht geschrieben werden.');}
finally {if(is_file($tmp))unlink($tmp);}
}
function v4UnifiedFiles(string $stage,string $target):array
{
$m=json_decode(file_get_contents($stage.'/MANIFEST.json'),true,64,JSON_THROW_ON_ERROR);
$names=['libs/NetzfahrplanV4Datenarchiv.php','libs/NetzfahrplanV4Bilanzierung.php','libs/ManagerNetzfahrplanV4DatenTrait.php'];
if($m['scope']!=='unified_native_data_only'||array_keys($m['files'])!==$names)throw new RuntimeException('Falscher Installationsumfang.');
$old=[];$new=[];$complete=true;
foreach($names as$name){
$p=$target.'/'.$name;$s=$stage.'/source/'.$name;
if(is_link($p)||is_link($s)||realpath(dirname($p))!==realpath($target).'/libs')throw new RuntimeException('Unerwarteter Dateipfad.');
$new[$name]=file_get_contents($s);token_get_all($new[$name],TOKEN_PARSE);
if(hash('sha256',$new[$name])!==$m['files'][$name]['after'])throw new RuntimeException('Vorbereitete Datei geaendert.');
$old[$name]=is_file($p)?file_get_contents($p):null;$h=$old[$name]===null?null:hash('sha256',$old[$name]);
if($h!==$m['files'][$name]['after'])$complete=false;
if($h!==$m['files'][$name]['before']&&$h!==$m['files'][$name]['after'])throw new RuntimeException('Parallele Modulaenderung; nichts ueberschrieben.');
}
if($complete)return ['status'=>'already_installed'];
foreach($names as$name)if(($old[$name]===null?null:hash('sha256',$old[$name]))!==$m['files'][$name]['before'])throw new RuntimeException('Teilstand zuerst pruefen.');
$backup=$stage.'/backups/'.gmdate('YmdTHis').'-'.bin2hex(random_bytes(4));
if(!mkdir($backup.'/libs',0700,true))throw new RuntimeException('Sicherung fehlt.');
foreach($names as$name)if($old[$name]!==null)v4UnifiedWrite($backup.'/'.$name,$old[$name],0600);
v4UnifiedWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600);
$written=[];
try {foreach($names as$name){$p=$target.'/'.$name;if((is_file($p)?file_get_contents($p):null)!==$old[$name])throw new RuntimeException('Parallele Dateiaenderung.');v4UnifiedWrite($p,$new[$name]);$written[]=$name;}}
catch(Throwable $e){foreach(array_reverse($written)as$name){$p=$target.'/'.$name;if(hash_file('sha256',$p)!==$m['files'][$name]['after'])continue;if($old[$name]===null)unlink($p);else v4UnifiedWrite($p,$old[$name]);}throw $e;}
return ['status'=>'installed','backup'=>$backup];
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
require_once __DIR__.'/file_installer.php';
$manager=17004;$lock=false;$report=['scope'=>'unified_native_data_only','startedAt'=>gmdate('c'),'newActuatorPermission'=>false,'cursorReset'=>false,'outboxRewritten'=>false];
try {
$package=json_decode(file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,64,JSON_THROW_ON_ERROR);
foreach($package as$name=>$hash)if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Installationspaket wurde geaendert.');
if(!IPS_InstanceExists($manager)||IPS_GetInstance($manager)['ModuleInfo']['ModuleID']!=='{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}')throw new RuntimeException('Erwarteter Manager fehlt.');
if(IPS_HasChanges($manager))throw new RuntimeException('Offene Manageraenderungen zuerst speichern oder verwerfen.');
if(IPS_GetProperty($manager,'NetzfahrplanAktiv')!==false||IPS_GetProperty($manager,'NetzfahrplanV4MessdatenAktiv')!==true)throw new RuntimeException('Erwartet: Datenaufnahme EIN, alter Netzfahrplan AUS.');
$controls=[];foreach(IPS_GetModuleList()as$mid)if((IPS_GetModule($mid)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($mid));
if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.');
$lock=IPS_SemaphoreEnter('ENELIX.V4.UnifiedRelease',3000);if(!$lock)throw new RuntimeException('Installation bereits aktiv.');
$before=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
$report['files']=v4UnifiedFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
// No staged application classes are loaded into this script context.
if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
$after=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
foreach($before as$key=>$value)if(!array_key_exists($key,$after)||$after[$key]!==$value)throw new RuntimeException('Bestehender Parameter unerwartet geaendert: '.$key);
$report['status']='native_data_candidate_installed';$report['parametersUnchanged']=true;
echo "OK: Einheitliche Messdatenkennung und dauerhafte Versandbestaetigung installiert. Archivierung vorbereitet, standardmaessig AUS.\n";
echo "Der bestehende Datentimer setzt den Versand fort. Sendezeiger und Originalbestand wurden nicht umgeschrieben.\n";
echo "Keine V4-Stellfreigabe und kein Wechsel der Prognosequelle.\n";
} catch(Throwable $e) {
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,250);
echo 'FEHLER: '.$report['reason']."\nKeine neue Stellfreigabe.\n";
} finally {
$report['finishedAt']=gmdate('c');v4UnifiedWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644);
if($lock)IPS_SemaphoreLeave('ENELIX.V4.UnifiedRelease');
echo 'UNIFIED MANAGER REPORT: '.__DIR__."/INSTALL_RESULT.json\n";
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
require_once __DIR__.'/NetzfahrplanV4Geraeteabruf.php';
/** Shared confirmed physical reading used by receiver and battery before a trial command. */
trait BatterieNetzfahrplanV4RueckmeldungTrait
{
private function registriereV4Rueckmeldung(): void
{
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
$this->RegisterTimer('NetzfahrplanV4RueckmeldungAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', 0);");
}
private function konfiguriereV4Rueckmeldung(): void
{
$this->SetBuffer('V4ConfirmedFeedback', '');
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
$configured = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration')), ['', '{}'], true);
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', $configured ? 1000 : 0);
}
/** No transport I/O under the battery control lock. Original sample times never move. */
private function v4BatterieRueckmeldung(): array
{
$json = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
$sample = json_decode($this->GetBuffer('V4ConfirmedFeedback'), true);
$mono = (float) $this->GetBuffer('V4ConfirmedFeedbackMono');
$now = time();$age = hrtime(true) / 1e9 - $mono;
if (!is_array($sample) || !is_int($sample['checkedAt'] ?? null)
|| $sample['checkedAt'] > $now || $now - $sample['checkedAt'] > 2
|| $age < 0 || $age > 2
|| $this->GetBuffer('V4ConfirmedFeedbackConfig') !== hash('sha256', $json)) {
throw new \RuntimeException('feedback_confirmed_cache_stale_or_invalid');
}
$config = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
if (($config['batteryInstanceId'] ?? null) !== $this->InstanceID
|| !in_array($config['managerId'] ?? 0, $this->zugeordneteManagerIDs(), true)) {
throw new \RuntimeException('feedback_cache_binding_invalid');
}
foreach ($config['sources'] as $source) {
if (!IPS_VariableExists($source['variableId'])) throw new \RuntimeException('feedback_variable_missing');
$object = IPS_GetObject($source['variableId']);
if ($object['ParentID'] !== $source['parentId'] || $object['ObjectIdent'] !== $source['ident']) {
throw new \RuntimeException('feedback_cache_source_changed');
}
}
return $sample;
}
/** Independent acquisition; never waits behind another read or takes the actuator lock. */
private function aktualisiereV4Rueckmeldung(): void
{
$json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
if (strlen($json)>65536) throw new \RuntimeException('feedback_configuration_too_large');
$c=json_decode($json,true,32,JSON_THROW_ON_ERROR);
if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid');
if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid');
$lock='ENELIX.V4.Feedback.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,0)) return;
try {
$reader=static function(int $id):array {
if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing');
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id);
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
};
$result=null;
for ($attempt=1;$attempt<=3;++$attempt) {
try {
$result=NetzfahrplanV4Geraeteabruf::snapshot(
$c,
static function(int $id):string {
if (!IPS_InstanceExists($id)) throw new \RuntimeException('feedback_parent_instance_missing');
return (string)(IPS_GetInstance($id)['ModuleInfo']['ModuleID']??'');
},
static function(int $id,string $module):bool {
if ($module===NetzfahrplanV4Geraeteabruf::MBUS_DEVICE) {
if (!function_exists('MBUS_UpdateValues') && !function_exists(__NAMESPACE__.'\\MBUS_UpdateValues')) throw new \RuntimeException('feedback_mbus_refresh_unavailable');
return MBUS_UpdateValues($id)===true;
}
if (in_array($module,[NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE,NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS],true)) {
if (!function_exists('ModBus_RequestRead') && !function_exists(__NAMESPACE__.'\\ModBus_RequestRead')) throw new \RuntimeException('feedback_modbus_refresh_unavailable');
return ModBus_RequestRead($id)===true;
}
return false;
},
$reader,
static fn():int=>time(),
static function(int $microseconds):void { usleep($microseconds); }
);
break;
} catch (\InvalidArgumentException $e) {
if (!str_starts_with($e->getMessage(),'feedback_source_changed_after_confirmed_read:') || $attempt>=3) throw $e;
usleep(150000);
}
}
if (!is_array($result)) throw new \RuntimeException('feedback_snapshot_unavailable');
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ConfirmedFeedbackConfig', hash('sha256', $json));
$this->SetBuffer('V4ConfirmedFeedbackMono', (string) (hrtime(true) / 1e9));
} catch (\Throwable $error) {
$this->SetBuffer('V4ConfirmedFeedback', '');
throw $error;
} finally {
IPS_SemaphoreLeave($lock);
}
}
/** Read only. A returned current value is NOT actuator permission. */
public function GetV4BatterieRueckmeldung(): string
{
try {
try { $r=$this->v4BatterieRueckmeldung(); }
catch (\Throwable) {
$this->aktualisiereV4Rueckmeldung();
$r=$this->v4BatterieRueckmeldung();
}
}
catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200),
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false,'deviceReadConfirmed'=>false]; }
return json_encode($r,JSON_THROW_ON_ERROR);
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php';
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
trait BatterieNetzfahrplanV4TestTrait
{
use BatterieNetzfahrplanV4RueckmeldungTrait;
private function registriereV4BatterieTest(): void
{
$this->registriereV4Rueckmeldung();
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
$this->RegisterAttributeString('NetzfahrplanV4TestStatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4TestWatchdog', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', 0);");
}
private function v4BatterieTestSitzung(): array
{
$s = json_decode($this->GetBuffer('V4BatteryTrialSession') ?: '{}', true);
return is_array($s) ? $s : [];
}
private function v4BatterieTestAktiv(): bool
{
return ($this->v4BatterieTestSitzung()['active'] ?? false) === true;
}
/** Local test arming by the bound manager; never called by ordinary forecast traffic. */
public function V4BatterieTestStarten(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieStartIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieStartIntern(string $json): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Batterietest lokal nicht erlaubt.');
$s = json_decode($json, true, 32, JSON_THROW_ON_ERROR);$now = time();
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
if (!is_array($s)
|| !in_array($s['kind'] ?? null, ['local_control_trial_consent', 'local_active_test_consent'], true)
|| ($s['batteryInstanceId'] ?? null) !== $this->InstanceID || $this->v4BatterieTestAktiv()) {
throw new RuntimeException('Batterie-Testanmeldung ungueltig oder bereits aktiv.');
}
NetzfahrplanV4Regeltest::uuid($s['sessionId'] ?? null);
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $s['sessionId']) throw new RuntimeException('Testsitzung bereits beendet/verwendet.');
NetzfahrplanV4Regeltest::uuid($s['installationId'] ?? null);
$manager = NetzfahrplanV4Regeltest::integer($s['managerId'] ?? null, 'Manager', 1, 99999);
$managerPermission = $activeTest ? 'NetzfahrplanV4AktivtestErlaubt' : 'NetzfahrplanV4RegeltestErlaubt';
if (!in_array($manager, $this->zugeordneteManagerIDs(), true)
|| IPS_GetProperty($manager, $managerPermission) !== true) {
throw new RuntimeException('Manager nicht fuer Batterietest freigegeben/zugeordnet.');
}
$evidence = $this->ReadPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis');
$watchdogWaived = $activeTest
&& $this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')
&& ($s['watchdogWaived'] ?? null) === true
&& ($s['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
if (!$watchdogWaived && (strlen($evidence) < 8 || $evidence !== ($s['actuatorWatchdogEvidenceId'] ?? null))) {
throw new RuntimeException('Externer Geraete-Watchdog muss separat nachgewiesen sein.');
}
$maxDuration = $activeTest
? NetzfahrplanV4Regeltest::MAX_ACTIVE_TEST_SECONDS
: NetzfahrplanV4Regeltest::MAX_TEST_SECONDS;
$end = NetzfahrplanV4Regeltest::integer($s['expiresAt'] ?? null, 'Testende', $now + 1, $now + $maxDuration);
$s['maxChargeW'] = NetzfahrplanV4Regeltest::number($s['maxChargeW'] ?? null, 'Testladen', 1, 1e9);
$s['maxDischargeW'] = NetzfahrplanV4Regeltest::number($s['maxDischargeW'] ?? null, 'Testentladen', 1, 1e9);
if (!is_string($s['assetId'] ?? null) || $s['assetId'] === '') throw new RuntimeException('Batterie-ID fehlt.');
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
}
$feedback = $this->v4BatterieRueckmeldung();
$now = time();
if ($now >= $end) throw new RuntimeException('Testsitzung waehrend Geraeteabruf abgelaufen.');
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
$s['commandMonotonicDeadline'] = hrtime(true) / 1e9 + 10;
$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
// Old ordinary values may not resume after the test without a NEW manager message.
$this->setzeZustand('SollwertGueltig', false);
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 1000);
$this->WriteAttributeString('NetzfahrplanV4TestStatus', '{"status":"armed_waiting_command","physicalResponseVerified":false}');
}
public function V4BatterieTestBefehl(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieBefehlIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieBefehlIntern(string $json): void
{
$s = $this->v4BatterieTestSitzung();$now = time();$mono = hrtime(true) / 1e9;
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Batterietest gesperrt.');
}
$c = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
NetzfahrplanV4Regeltest::pruefeBatteriebefehl($c, $s, $now, $mono);
$s['lastSequence'] = $c['sequence'];$s['lastWallClockAt'] = $now;
$s['commandMonotonicDeadline'] = $mono + min(10, $c['expiresAt'] - $now);
$this->SetBuffer('V4BatteryTrialCommand', json_encode($c, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->aktualisiereIntern(true);
if (!$this->v4BatterieTestAktiv() || $this->ReadAttributeString('Registerfehler') !== '') {
$state = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$detail = $this->ReadAttributeString('Registerfehler') ?: ($state['reason'] ?? '');
throw new RuntimeException('Batterie konnte Testbefehl nicht sicher uebernehmen: ' . substr($detail, 0, 300));
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode([
'status' => 'controller_command_accepted', 'sessionId' => $s['sessionId'],
'sourceShadowPlanId' => $c['sourceShadowPlanId'], 'sequence' => $c['sequence'],
'commandExpiresAt' => $c['expiresAt'], 'actualRequestedW' => $this->ReadAttributeInteger('LetzterSollwert'),
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->beendeV4BatterieTest('Befehl abgelehnt: ' . substr($e->getMessage(), 0, 500), true);
throw $e;
}
}
/** Explicit local abort or ApplyChanges: revoke before any register action. */
private function beendeV4BatterieTest(string $reason, bool $writeNow): void
{
$oldStatus = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$active = $this->v4BatterieTestAktiv() || in_array($oldStatus['status'] ?? '', ['controller_command_accepted', 'armed_waiting_command', 'stop_pending', 'stop_failed'], true);
$this->SetBuffer('V4BatteryTrialSession', '{}');$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 0);
if (!$active) return;
$this->setzeZustand('SollwertGueltig', false);$this->setzeZustand('Sollleistung', 0);
$report = ['status' => 'stop_pending', 'reason' => $reason, 'physicalResponseVerified' => false];
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
if ($writeNow) {
try { $this->schreibeRegister(0, true);$report['status'] = 'stopped'; }
catch (Throwable $e) {
$report['status'] = 'stop_failed';
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
throw $e;
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
}
}
public function V4BatterieTestStoppen(): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->beendeV4BatterieTest('Lokaler Testabbruch', true); }
finally { IPS_SemaphoreLeave($lock); }
}
public function GetV4BatterieTestStatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4TestStatus');
}
/** Called inside the existing battery update, immediately before writing registers. */
private function v4BatterieTestZiel(array $measurements, bool $available, int $now): ?int
{
if (!$this->v4BatterieTestAktiv()) return null;
$s = $this->v4BatterieTestSitzung();
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Lokale Testfreigabe entzogen.');
}
$feedback = $this->v4BatterieRueckmeldung();
// The confirmed device read may cross a wall-clock second. All freshness,
// watchdog and switching decisions below use the post-read clocks.
$now = time();$mono = hrtime(true) / 1e9;
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
if (!$c) {
if ($mono >= $s['commandMonotonicDeadline']) throw new RuntimeException('Kein Startbefehl innerhalb Watchdog.');
return 0;
}
$w = NetzfahrplanV4Regeltest::batterieZiel($c, $s, $measurements, [
'available' => $available, 'reserve' => $this->ReadPropertyFloat('ReserveLadezustand'),
'minimum' => $this->ReadPropertyFloat('MindestLadezustand'),
'blocked' => (bool) $this->leseZustand('HystereseAktiv'),
'rechargeLimitW' => $this->ReadPropertyBoolean('NachladenMitMaximalleistung') ? null : $this->ReadPropertyInteger('MaximaleNachladeleistung'),
], $now, $mono);
// Respect switching lock for increases/reversals; safety reductions and zero are immediate.
$last = $this->ReadAttributeInteger('LetzterSollwert');
if ($now < $this->ReadAttributeInteger('AenderungFreigabeAb') && $w !== $last) {
if ($last * $w < 0) $w = 0;
elseif (abs($w) > abs($last)) $w = $last;
}
// Final hardware-policy/ramp clipping must not invalidate the binding or grid limits.
if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed');
$expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w;
if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1)
|| ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) {
throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit');
}
$s['lastWallClockAt'] = $now;
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
return $w;
} catch (Throwable $e) {
$this->beendeV4BatterieTest($e->getMessage(), false);
return 0; // revoke stale value even if ordinary cached setpoint existed
}
}
}
+14 -5
View File
@@ -8,11 +8,11 @@ final class EnergieMessung
{
private const WATTSEKUNDEN_PRO_KWH = 3600000.0;
public static function hausleistung(float $pv, float $netz, float $batterie): float
public static function hausleistung(float $pv, float $netz, float $batterie, float $sdl = 0.0): float
{
return max(
0.0,
self::endlich($pv) + self::endlich($netz) - self::endlich($batterie)
self::endlich($pv) + self::endlich($netz) - self::endlich($batterie) - self::endlich($sdl)
);
}
@@ -21,7 +21,9 @@ final class EnergieMessung
float $netzbezug,
float $netzeinspeisung,
float $batterieLaden,
float $batterieEntladen
float $batterieEntladen,
float $sdlLaden = 0.0,
float $sdlEntladen = 0.0
): float {
return max(
0.0,
@@ -30,18 +32,22 @@ final class EnergieMessung
+ self::endlich($batterieEntladen)
- self::endlich($netzeinspeisung)
- self::endlich($batterieLaden)
- self::endlich($sdlLaden)
+ self::endlich($sdlEntladen)
);
}
/**
* @param array{PV: float, Haus: float, Netz: float, Batterie: float} $leistungen
* @param array{PV: float, Haus: float, Netz: float, Batterie: float, SDL?: float} $leistungen
* @return array{
* PVEnergie: float,
* Hausenergie: float,
* NetzbezugEnergie: float,
* EinspeisungEnergie: float,
* BatterieLadenEnergie: float,
* BatterieEntladenEnergie: float
* BatterieEntladenEnergie: float,
* SDLLadenEnergie: float,
* SDLEntladenEnergie: float
* }
*/
public static function energieDeltas(array $leistungen, int $sekunden): array
@@ -51,6 +57,7 @@ final class EnergieMessung
$haus = self::endlich($leistungen['Haus']);
$netz = self::endlich($leistungen['Netz']);
$batterie = self::endlich($leistungen['Batterie']);
$sdl = self::endlich($leistungen['SDL'] ?? 0.0);
return [
'PVEnergie' => max(0.0, $pv) * $faktor,
@@ -59,6 +66,8 @@ final class EnergieMessung
'EinspeisungEnergie' => max(0.0, -$netz) * $faktor,
'BatterieLadenEnergie' => max(0.0, $batterie) * $faktor,
'BatterieEntladenEnergie' => max(0.0, -$batterie) * $faktor,
'SDLLadenEnergie' => max(0.0, $sdl) * $faktor,
'SDLEntladenEnergie' => max(0.0, -$sdl) * $faktor,
];
}
+15 -1
View File
@@ -91,7 +91,7 @@ final class LadestationAdapter
}
/**
* @return array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int}
* @return array{Rohstatus: int, Istleistung_W: float, Sollstrom_A?: int, Maximalstrom_A?: int, Iststrom_A?: float, Phasenzahl?: int}
*/
public static function dekodiereStatus(int $geraetetyp, string $antwort): array
{
@@ -115,6 +115,20 @@ final class LadestationAdapter
'Rohstatus' => (int) $daten['car'],
'Istleistung_W' => (float) $daten['nrg'][11] * $leistungsfaktor,
];
if (isset($daten['amp']) && is_numeric($daten['amp'])) {
$sollstrom = (int) $daten['amp'];
$freigabeEntzogen = isset($daten['alw']) && $daten['alw'] === false;
$erzwungenAus = $geraetetyp === self::GO_E_GEMINI
&& isset($daten['frc'])
&& (int) $daten['frc'] === 1;
$status['Sollstrom_A'] = $freigabeEntzogen || $erzwungenAus ? 0 : $sollstrom;
}
if (isset($daten['ama']) && is_numeric($daten['ama'])) {
$maximalstrom = (int) $daten['ama'];
if ($maximalstrom >= 6 && $maximalstrom <= 32) {
$status['Maximalstrom_A'] = $maximalstrom;
}
}
if (isset($daten['nrg'][4], $daten['nrg'][5], $daten['nrg'][6])
&& is_numeric($daten['nrg'][4])
&& is_numeric($daten['nrg'][5])
+659 -50
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
trait ManagerEnergieTrait
{
private function konfiguriereEnergiearchiv(): void
@@ -23,9 +26,13 @@ trait ManagerEnergieTrait
);
foreach (self::LEISTUNGS_VARIABLEN as $ident) {
$variableID = $this->GetIDForIdent($ident);
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
$loggen = $aktiv && (!str_starts_with($ident, 'SDL') || $this->ReadPropertyBoolean('SDLAktiv'))
&& ($ident !== 'SDLSOCArchiv' || $this->ReadPropertyInteger('SDLSOCVariableID') > 0);
if (!$this->setzeArchivLogging($archivID, $variableID, $loggen)) {
continue;
}
AC_SetAggregationType($archivID, $variableID, 0);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
AC_SetGraphStatus($archivID, $variableID, $loggen);
$regeln = [-1 => $leistungstyp];
$loeschen = $this->ReadPropertyInteger('LeistungLoeschenMonate');
if ($loeschen > 0) {
@@ -38,7 +45,9 @@ trait ManagerEnergieTrait
foreach (self::ENERGIE_VARIABLEN as $ident) {
$variableID = $this->GetIDForIdent($ident);
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
if (!$this->setzeArchivLogging($archivID, $variableID, $aktiv)) {
continue;
}
AC_SetAggregationType($archivID, $variableID, 1);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
$regeln = [-1 => $energietyp];
@@ -55,12 +64,57 @@ trait ManagerEnergieTrait
}
}
foreach (IPS_GetChildrenIDs($this->InstanceID) as $variableID) {
$objekt = IPS_GetObject($variableID);
$ident = (string) ($objekt['ObjectIdent'] ?? '');
if ((int) ($objekt['ObjectType'] ?? -1) !== 2
|| !str_starts_with($ident, 'Diagramm')
) {
continue;
}
$istEnergie = str_contains($ident, 'Energie')
|| str_contains($ident, 'BatterieLaden_')
|| str_contains($ident, 'BatterieEntladen_');
if (!$this->setzeArchivLogging($archivID, $variableID, $aktiv)) {
continue;
}
AC_SetAggregationType($archivID, $variableID, $istEnergie ? 1 : 0);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
$regeln = [-1 => $istEnergie ? $energietyp : $leistungstyp];
$verdichten = $istEnergie
? $this->ReadPropertyInteger('EnergieVerdichtenMonate')
: 0;
if ($verdichten > 0) {
$regeln[$verdichten] = 3;
}
$loeschen = $istEnergie
? $this->ReadPropertyInteger('EnergieLoeschenMonate')
: $this->ReadPropertyInteger('LeistungLoeschenMonate');
if ($loeschen > 0) {
$regeln[$loeschen] = 7;
}
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
$geaendert[] = $variableID;
}
}
IPS_ApplyChanges($archivID);
foreach (array_unique($geaendert) as $variableID) {
AC_ReAggregateVariable($archivID, $variableID);
}
}
private function setzeArchivLogging(int $archivID, int $variableID, bool $aktiv): bool
{
// Archiv-Metadaten sind nur bei aktivierter Aufzeichnung aenderbar.
if (!$aktiv && AC_GetLoggingStatus($archivID, $variableID)) {
AC_SetGraphStatus($archivID, $variableID, false);
}
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
return $aktiv;
}
/** @param array<int, int> $regeln */
private function setzeVerdichtung(int $archivID, int $variableID, array $regeln): bool
{
@@ -129,6 +183,7 @@ trait ManagerEnergieTrait
}
$this->entferneDiagrammUnterobjekt('Leistungsdiagramm');
$this->entferneDiagrammUnterobjekt('Energiediagramm');
$this->WriteAttributeString('EnergiediagrammKonfiguration', '{}');
}
private function synchronisiereFunFacts(bool $anzeigen): void
@@ -154,9 +209,7 @@ trait ManagerEnergieTrait
return;
}
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
return;
}
if ($instanzID !== 0) {
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
@@ -166,6 +219,9 @@ trait ManagerEnergieTrait
}
IPS_SetHidden($instanzID, true);
}
$this->WriteAttributeString('EnergieflussKonfiguration', '{}');
$this->WriteAttributeBoolean('EnergieflussNeuErzeugen', true);
}
private function entferneInstanzUnterobjekt(string $ident, string $modulID): void
{
@@ -216,6 +272,10 @@ trait ManagerEnergieTrait
if ($modulID !== self::ENERGY_PIE_MODULE_ID) {
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
}
IPS_SetHidden($instanzID, false);
$this->synchronisiereEnergyPieAnteile($instanzID);
return 'Bestehende Energy-Pie-Messquellen und Darstellung bleiben unveraendert.';
}
IPS_SetName($instanzID, 'Energy Pie');
IPS_SetPosition($instanzID, 500);
@@ -229,16 +289,41 @@ trait ManagerEnergieTrait
] as $property => $wert) {
IPS_SetProperty($instanzID, $property, $wert);
}
$this->synchronisiereEnergyPieAnteile($instanzID, false);
IPS_ApplyChanges($instanzID);
return 'Energy Pie wurde erstellt oder aktualisiert.';
}
private function synchronisiereEnergyPieAnteile(int $instanzID, bool $anwenden = true): void
{
// Physischer Netzbezug enthaelt SDL, die Hausenergie dagegen nicht.
$berechenbar = !$this->ReadPropertyBoolean('SDLAktiv');
$konfiguration = json_decode(IPS_GetConfiguration($instanzID), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($konfiguration) || !array_key_exists('EnergieanteileBerechenbar', $konfiguration)) {
IPS_SetHidden($instanzID, !$berechenbar);
if (!$berechenbar) {
IPS_LogMessage('Enelix Manager ' . $this->InstanceID,
'Energy Pie ausgeblendet: Enelix Utils aktualisieren, damit bei aktiver SDL keine unbestimmbaren Energieanteile angezeigt werden.');
}
return;
}
IPS_SetHidden($instanzID, false);
if (IPS_GetProperty($instanzID, 'EnergieanteileBerechenbar') === $berechenbar) {
return;
}
IPS_SetProperty($instanzID, 'EnergieanteileBerechenbar', $berechenbar);
if ($anwenden) {
IPS_ApplyChanges($instanzID);
}
}
private function erstelleEnergiefluss(): string
{
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
throw new RuntimeException('Das Symcon-Modul Energy Distribution ist nicht installiert.');
}
$neuErzeugen = $this->ReadAttributeBoolean('EnergieflussNeuErzeugen');
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
$instanzID = IPS_CreateInstance(self::ENERGY_FLOW_MODULE_ID);
@@ -252,7 +337,16 @@ trait ManagerEnergieTrait
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
if (!$neuErzeugen) {
$this->visualisierungsKonfiguration('EnergieflussKonfiguration');
$this->synchronisiereSDLEnergiefluss($instanzID);
IPS_SetHidden($instanzID, false);
$this->aktualisiereEnergieflusswerte();
return 'Bestehende Energiefluss-Konfiguration bleibt unveraendert.';
}
}
$this->visualisierungsKonfiguration('EnergieflussKonfiguration');
IPS_SetName($instanzID, 'Energiefluss');
IPS_SetPosition($instanzID, 540);
IPS_SetHidden($instanzID, false);
@@ -289,7 +383,6 @@ trait ManagerEnergieTrait
$this->energieflussKnoten($netzID, 'Stromnetz', 'grid'),
$this->energieflussKnoten($hausID, 'Hausverbrauch', 'consumer'),
];
$hatBatterieverbraucher = false;
$position = 640;
foreach ($verbraucher as $eintrag) {
$istID = $this->energieflussLeistungsvariable(
@@ -311,17 +404,13 @@ trait ManagerEnergieTrait
true,
$sollID
);
$hatBatterieverbraucher = $hatBatterieverbraucher
|| $eintrag['Typ'] === 'storage';
$position += 10;
}
if (!$hatBatterieverbraucher) {
$knoten[] = $this->energieflussKnoten(
$batterieID,
'Batteriespeicher',
'storage'
);
}
IPS_SetProperty($instanzID, 'Calculation', true);
IPS_SetProperty(
@@ -332,6 +421,8 @@ trait ManagerEnergieTrait
IPS_SetProperty($instanzID, 'EnableLimits', false);
IPS_SetProperty($instanzID, 'Restrictions', '[]');
IPS_ApplyChanges($instanzID);
$this->WriteAttributeBoolean('EnergieflussNeuErzeugen', false);
$this->synchronisiereSDLEnergiefluss($instanzID, true);
$this->aktualisiereEnergieflusswerte($verbraucher);
return 'Der Energiefluss wurde erstellt oder aktualisiert.';
@@ -360,7 +451,10 @@ trait ManagerEnergieTrait
private function energieflussLeistungsprofil(): string
{
if ($this->ReadPropertyInteger('EnergieflussLeistungseinheit') !== 1) {
$konfiguration = $this->visualisierungsKonfiguration(
'EnergieflussKonfiguration'
);
if ($konfiguration['EnergieflussLeistungseinheit'] !== 1) {
return '~Power';
}
@@ -388,18 +482,55 @@ trait ManagerEnergieTrait
return $variableID;
}
/** @return array<string, bool|int> */
private function visualisierungsKonfiguration(string $attribut): array
{
$konfiguration = $this->leseJsonAttribut($attribut);
$gueltig = isset(
$konfiguration['DiagrammPVModus'],
$konfiguration['DiagrammBatterieModus'],
$konfiguration['LadestationenSeparatAnzeigen'],
$konfiguration['VerbraucherSeparatAnzeigen'],
$konfiguration['EnergieflussLeistungseinheit']
);
if ($gueltig) {
return $konfiguration;
}
$konfiguration = [
'DiagrammPVModus' => $this->ReadPropertyInteger('DiagrammPVModus'),
'DiagrammBatterieModus' => $this->ReadPropertyInteger('DiagrammBatterieModus'),
'LadestationenSeparatAnzeigen' =>
$this->ReadPropertyBoolean('LadestationenSeparatAnzeigen'),
'VerbraucherSeparatAnzeigen' =>
$this->ReadPropertyBoolean('VerbraucherSeparatAnzeigen'),
'EnergieflussLeistungseinheit' =>
$this->ReadPropertyInteger('EnergieflussLeistungseinheit'),
];
$this->WriteAttributeString(
$attribut,
json_encode($konfiguration, JSON_THROW_ON_ERROR)
);
return $konfiguration;
}
/**
* @return list<array{
* InstanzID: int,
* Name: string,
* Typ: string,
* Istleistung_W: float,
* Sollleistung_W: float
* Sollleistung_W: float,
* Energie_Wh: float
* }>
*/
private function energieflussVerbraucher(): array
{
private function energieflussVerbraucher(
string $konfigurationsAttribut = 'EnergieflussKonfiguration'
): array {
$konfiguration = $this->visualisierungsKonfiguration($konfigurationsAttribut);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$energien = $this->leseJsonAttribut('VerbraucherEnergie');
$ergebnis = [];
foreach ($this->konfigurierteAktiveVerbraucherIDs() as $instanzID) {
if (!IPS_InstanceExists($instanzID)) {
@@ -416,17 +547,30 @@ trait ManagerEnergieTrait
(string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
);
$katalog = self::VERBRAUCHER_LIZENZEN[$modulID]['Katalog'] ?? '';
if ($katalog === 'battery') {
continue;
}
if ($katalog === 'ev_charger'
&& !$konfiguration['LadestationenSeparatAnzeigen']
) {
continue;
}
if ($katalog !== 'ev_charger'
&& !$konfiguration['VerbraucherSeparatAnzeigen']
) {
continue;
}
$verbraucherEnergie = $energien[(string) $instanzID]['Energie_Wh'] ?? 0.0;
$ergebnis[] = [
'InstanzID' => $instanzID,
'Name' => IPS_GetName($instanzID),
'Typ' => match ($katalog) {
'battery' => 'storage',
'ev_charger' => 'wallbox',
default => 'consumer',
},
'Typ' => $katalog === 'ev_charger' ? 'wallbox' : 'consumer',
'Istleistung_W' => $istleistung,
'Sollleistung_W' => $sollleistung,
'Energie_Wh' => is_numeric($verbraucherEnergie)
? (float) $verbraucherEnergie
: 0.0,
];
}
@@ -443,7 +587,6 @@ trait ManagerEnergieTrait
}
$verbraucher = $verbraucher ?? $this->energieflussVerbraucher();
$verbraucherleistung = 0.0;
$hatBatterieverbraucher = false;
foreach ($verbraucher as $eintrag) {
$this->setzeEnergieflussLeistungswert(
'EnergieflussIst_' . $eintrag['InstanzID'],
@@ -453,12 +596,8 @@ trait ManagerEnergieTrait
'EnergieflussSoll_' . $eintrag['InstanzID'],
(float) $eintrag['Sollleistung_W']
);
if ($eintrag['Typ'] === 'storage') {
$hatBatterieverbraucher = true;
} else {
$verbraucherleistung += max(0.0, (float) $eintrag['Istleistung_W']);
}
}
try {
$leistungen = $this->leseMessleistungen();
@@ -472,11 +611,18 @@ trait ManagerEnergieTrait
'EnergieflussHaus',
max(0.0, $leistungen['Haus'] - $verbraucherleistung)
);
if (!$hatBatterieverbraucher) {
$this->setzeEnergieflussLeistungswert(
'EnergieflussBatterie',
$leistungen['Batterie']
$leistungen['Batterie'] + ($this->sdlAnzeigen('SDLEnergieflussAnzeigen') ? 0.0 : $leistungen['SDL'])
);
$this->setzeEnergieflussLeistungswert('EnergieflussSDL', $leistungen['SDL']);
$soc = $this->aktuelleSDLSOC();
if ($soc !== null) {
$this->SetValue('SDLSOCArchiv', $soc);
}
$flussID = $this->findeUnterobjekt('Energiefluss');
if ($flussID > 0) {
$this->synchronisiereSDLEnergiefluss($flussID);
}
}
@@ -486,33 +632,489 @@ trait ManagerEnergieTrait
if (!is_int($variableID) || $variableID <= 0 || !IPS_VariableExists($variableID)) {
return;
}
$faktor = $this->ReadPropertyInteger('EnergieflussLeistungseinheit') === 1
? 0.001
: 1.0;
$konfiguration = $this->visualisierungsKonfiguration(
'EnergieflussKonfiguration'
);
$faktor = $konfiguration['EnergieflussLeistungseinheit'] === 1 ? 0.001 : 1.0;
SetValue($variableID, $watt * $faktor);
}
private function erstelleEnergiediagramme(): string
{
$leistung = [
$this->chartDatensatz('PVLeistungArchiv', 'PV', '#F4C542', '~Power'),
$this->chartDatensatz('HausverbrauchLeistungArchiv', 'Haus', '#22A06B', '~Power'),
$this->chartDatensatz('NetzleistungArchiv', 'Netz', '#E95D5D', '~Power'),
$this->chartDatensatz('BatterieleistungArchiv', 'Batterie', '#8B5CF6', '~Power'),
];
$energie = [
$this->chartDatensatz('PVEnergie', 'PV', '#F4C542', '~Electricity'),
$this->chartDatensatz('Hausenergie', 'Haus', '#22A06B', '~Electricity'),
$this->chartDatensatz('NetzbezugEnergie', 'Netzbezug', '#E95D5D', '~Electricity'),
$this->chartDatensatz('EinspeisungEnergie', 'Einspeisung', '#2F80ED', '~Electricity'),
$this->chartDatensatz('BatterieLadenEnergie', 'Batterie laden', '#8B5CF6', '~Electricity'),
$this->chartDatensatz('BatterieEntladenEnergie', 'Batterie entladen', '#F59E0B', '~Electricity'),
];
$this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510);
$this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520);
$konfiguration = $this->visualisierungsKonfiguration(
'EnergiediagrammKonfiguration'
);
$pvAnlagen = $this->diagrammPVAnlagen();
$batterien = $this->diagrammBatterien();
$verbraucher = $this->energieflussVerbraucher(
'EnergiediagrammKonfiguration'
);
$this->registriereDiagrammVariablen($pvAnlagen, $batterien, $verbraucher);
$this->aktualisiereDiagrammMesswerte(true);
$leistung = [];
$energie = [];
if ($konfiguration['DiagrammPVModus'] !== 1) {
$leistung[] = $this->chartDatensatz(
'PVLeistungArchiv',
'PV gesamt',
'#F4C542',
'~Power'
);
$energie[] = $this->chartDatensatz(
'PVEnergie',
'PV gesamt',
'#F4C542',
'~Electricity',
'bar'
);
}
if ($konfiguration['DiagrammPVModus'] !== 0) {
foreach ($pvAnlagen as $index => $anlage) {
$farbe = $this->diagrammFarbe($index);
if ($anlage['LeistungVariableID'] > 0) {
$leistung[] = $this->chartDatensatz(
$anlage['LeistungIdent'],
$anlage['Name'],
$farbe,
'~Power'
);
}
if ($anlage['EnergieVariableID'] > 0) {
$energie[] = $this->chartDatensatz(
$anlage['EnergieIdent'],
$anlage['Name'],
$farbe,
'~Electricity',
'bar'
);
}
}
}
$hausTitel = $verbraucher === [] ? 'Haus' : 'Haus ohne separat dargestellte Verbraucher';
$leistung[] = $this->chartDatensatz(
'DiagrammHausLeistung',
$hausTitel,
'#22A06B',
'~Power'
);
$leistung[] = $this->chartDatensatz(
'NetzleistungArchiv',
'Netz',
'#E95D5D',
'~Power'
);
$energie[] = $this->chartDatensatz(
'DiagrammHausEnergie',
$hausTitel,
'#22A06B',
'~Electricity',
'bar'
);
$energie[] = $this->chartDatensatz(
'NetzbezugEnergie',
'Netzbezug',
'#E95D5D',
'~Electricity',
'bar'
);
$energie[] = $this->chartDatensatz(
'EinspeisungEnergie',
'Netzeinspeisung',
'#2F80ED',
'~Electricity',
'bar'
);
if ($konfiguration['DiagrammBatterieModus'] !== 1) {
$leistung[] = $this->chartDatensatz(
'BatterieleistungArchiv',
'Batterie gesamt',
'#8B5CF6',
'~Power'
);
$energie[] = $this->chartDatensatz(
'BatterieLadenEnergie',
'Batterie laden gesamt',
'#8B5CF6',
'~Electricity',
'bar'
);
$energie[] = $this->chartDatensatz(
'BatterieEntladenEnergie',
'Batterie entladen gesamt',
'#F59E0B',
'~Electricity',
'bar'
);
}
if ($konfiguration['DiagrammBatterieModus'] !== 0) {
foreach ($batterien as $index => $batterie) {
$farbe = $this->diagrammFarbe($index + count($pvAnlagen));
if ($batterie['LeistungVariableID'] > 0) {
$leistung[] = $this->chartDatensatz(
$batterie['LeistungIdent'],
$batterie['Name'],
$farbe,
'~Power'
);
}
if ($batterie['LadeenergieVariableID'] > 0) {
$energie[] = $this->chartDatensatz(
$batterie['LadeenergieIdent'],
$batterie['Name'] . ' laden',
$farbe,
'~Electricity',
'bar'
);
}
if ($batterie['EntladeenergieVariableID'] > 0) {
$energie[] = $this->chartDatensatz(
$batterie['EntladeenergieIdent'],
$batterie['Name'] . ' entladen',
$farbe,
'~Electricity',
'bar'
);
}
}
}
foreach ($verbraucher as $index => $eintrag) {
$farbe = $this->diagrammFarbe(
$index + count($pvAnlagen) + count($batterien)
);
$leistung[] = $this->chartDatensatz(
'DiagrammVerbraucherLeistung_' . $eintrag['InstanzID'],
$eintrag['Name'],
$farbe,
'~Power'
);
$energie[] = $this->chartDatensatz(
'DiagrammVerbraucherEnergie_' . $eintrag['InstanzID'],
$eintrag['Name'],
$farbe,
'~Electricity',
'bar'
);
}
$leistungID = $this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510);
$energieID = $this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520);
$this->synchronisiereSDLDiagramm($leistungID, false);
$this->synchronisiereSDLDiagramm($energieID, true);
return 'Die Diagramme Leistungen und Energien wurden erstellt oder aktualisiert.';
}
/** @return list<array<string, mixed>> */
private function diagrammPVAnlagen(): array
{
$verwendet = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenPVFlaechen') as $flaeche) {
$wechselrichterID = trim((string) ($flaeche['WechselrichterID'] ?? ''));
if ($wechselrichterID !== '') {
$verwendet[$wechselrichterID] = true;
}
}
$ergebnis = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenWechselrichter') as $eintrag) {
$id = trim((string) ($eintrag['ID'] ?? ''));
if ($id === '' || ($verwendet !== [] && !isset($verwendet[$id]))) {
continue;
}
if ($verwendet === []
&& !in_array((string) ($eintrag['Typ'] ?? ''), ['pv', 'hybrid'], true)
) {
continue;
}
$suffix = substr(hash('sha256', 'pv:' . $id), 0, 12);
$leistungID = (int) ($eintrag['IstleistungVariableID'] ?? 0);
$energieID = (int) ($eintrag['ErzeugungsenergieVariableID'] ?? 0);
$ergebnis[] = [
'Name' => trim((string) ($eintrag['Name'] ?? '')) ?: $id,
'LeistungVariableID' => $this->istNumerischeVariable($leistungID)
? $leistungID
: 0,
'Leistungsfaktor' => (float) ($eintrag['Istleistungsfaktor'] ?? 1.0),
'EnergieVariableID' => $this->istNumerischeVariable($energieID)
? $energieID
: 0,
'Energiefaktor' => (float) ($eintrag['Erzeugungsenergiefaktor'] ?? 1.0),
'LeistungIdent' => 'DiagrammPVLeistung_' . $suffix,
'EnergieIdent' => 'DiagrammPVEnergie_' . $suffix,
];
}
return $ergebnis;
}
/** @return list<array<string, mixed>> */
private function diagrammBatterien(): array
{
$ergebnis = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenBatterien') as $eintrag) {
$id = trim((string) ($eintrag['ID'] ?? ''));
if ($id === '') {
continue;
}
$suffix = substr(hash('sha256', 'batterie:' . $id), 0, 12);
$leistungID = (int) ($eintrag['LeistungVariableID'] ?? 0);
$ladenID = (int) ($eintrag['LadeenergieVariableID'] ?? 0);
$entladenID = (int) ($eintrag['EntladeenergieVariableID'] ?? 0);
$ergebnis[] = [
'Name' => trim((string) ($eintrag['Name'] ?? '')) ?: $id,
'LeistungVariableID' => $this->istNumerischeVariable($leistungID)
? $leistungID
: 0,
'Leistungsfaktor' => (float) ($eintrag['Leistungsfaktor'] ?? 1.0),
'LadeenergieVariableID' => $this->istNumerischeVariable($ladenID)
? $ladenID
: 0,
'EntladeenergieVariableID' => $this->istNumerischeVariable($entladenID)
? $entladenID
: 0,
'Energiefaktor' => (float) ($eintrag['Energiefaktor'] ?? 1.0),
'LeistungIdent' => 'DiagrammBatterieLeistung_' . $suffix,
'LadeenergieIdent' => 'DiagrammBatterieLaden_' . $suffix,
'EntladeenergieIdent' => 'DiagrammBatterieEntladen_' . $suffix,
];
}
return $ergebnis;
}
/**
* @param list<array<string, mixed>> $pvAnlagen
* @param list<array<string, mixed>> $batterien
* @param list<array<string, mixed>> $verbraucher
*/
private function registriereDiagrammVariablen(
array $pvAnlagen,
array $batterien,
array $verbraucher
): void {
$position = 800;
$this->diagrammMessvariable(
'DiagrammHausLeistung',
'Diagramm Hausleistung',
'~Power',
$position
);
$this->diagrammMessvariable(
'DiagrammHausEnergie',
'Diagramm Hausenergie',
'~Electricity',
$position + 1
);
$position += 10;
foreach ($pvAnlagen as $anlage) {
if ($anlage['LeistungVariableID'] > 0) {
$this->diagrammMessvariable(
$anlage['LeistungIdent'],
$anlage['Name'] . ' Leistung',
'~Power',
$position
);
}
if ($anlage['EnergieVariableID'] > 0) {
$this->diagrammMessvariable(
$anlage['EnergieIdent'],
$anlage['Name'] . ' Energie',
'~Electricity',
$position + 1
);
}
$position += 10;
}
foreach ($batterien as $batterie) {
if ($batterie['LeistungVariableID'] > 0) {
$this->diagrammMessvariable(
$batterie['LeistungIdent'],
$batterie['Name'] . ' Leistung',
'~Power',
$position
);
}
if ($batterie['LadeenergieVariableID'] > 0) {
$this->diagrammMessvariable(
$batterie['LadeenergieIdent'],
$batterie['Name'] . ' Ladeenergie',
'~Electricity',
$position + 1
);
}
if ($batterie['EntladeenergieVariableID'] > 0) {
$this->diagrammMessvariable(
$batterie['EntladeenergieIdent'],
$batterie['Name'] . ' Entladeenergie',
'~Electricity',
$position + 2
);
}
$position += 10;
}
foreach ($verbraucher as $eintrag) {
$this->diagrammMessvariable(
'DiagrammVerbraucherLeistung_' . $eintrag['InstanzID'],
$eintrag['Name'] . ' Leistung',
'~Power',
$position
);
$this->diagrammMessvariable(
'DiagrammVerbraucherEnergie_' . $eintrag['InstanzID'],
$eintrag['Name'] . ' Energie',
'~Electricity',
$position + 1
);
$position += 10;
}
}
private function diagrammMessvariable(
string $ident,
string $name,
string $profil,
int $position
): int {
$this->RegisterVariableFloat($ident, $name, $profil, $position);
$variableID = $this->GetIDForIdent($ident);
IPS_SetHidden($variableID, true);
return $variableID;
}
private function aktualisiereDiagrammMesswerte(bool $energieAktualisieren = false): void
{
if ($this->findeUnterobjekt('Leistungsdiagramm') === 0
&& $this->findeUnterobjekt('Energiediagramm') === 0
&& $this->findeUnterobjekt('DiagrammHausLeistung') === 0
) {
return;
}
try {
$leistungen = $this->leseMessleistungen();
$pvAnlagen = $this->diagrammPVAnlagen();
$batterien = $this->diagrammBatterien();
$verbraucher = $this->energieflussVerbraucher(
'EnergiediagrammKonfiguration'
);
} catch (Throwable $fehler) {
$this->protokolliere('Energiediagramme', $fehler->getMessage());
return;
}
$separateLeistung = 0.0;
$separateEnergieKWh = 0.0;
foreach ($verbraucher as $eintrag) {
$leistung = max(0.0, (float) $eintrag['Istleistung_W']);
$separateLeistung += $leistung;
$separateEnergieKWh += max(0.0, (float) $eintrag['Energie_Wh']) / 1000.0;
$this->setzeDiagrammMesswert(
'DiagrammVerbraucherLeistung_' . $eintrag['InstanzID'],
$leistung
);
if ($energieAktualisieren) {
$this->setzeDiagrammMesswert(
'DiagrammVerbraucherEnergie_' . $eintrag['InstanzID'],
max(0.0, (float) $eintrag['Energie_Wh']) / 1000.0
);
}
}
$this->setzeDiagrammMesswert(
'DiagrammHausLeistung',
max(0.0, $leistungen['Haus'] - $separateLeistung)
);
if ($energieAktualisieren) {
$this->setzeDiagrammMesswert(
'DiagrammHausEnergie',
max(
0.0,
(float) $this->GetValue('Hausenergie') - $separateEnergieKWh
)
);
}
foreach ($pvAnlagen as $anlage) {
if ($anlage['LeistungVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$anlage['LeistungIdent'],
max(
0.0,
(float) GetValue($anlage['LeistungVariableID'])
* $anlage['Leistungsfaktor']
)
);
}
if ($energieAktualisieren && $anlage['EnergieVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$anlage['EnergieIdent'],
max(
0.0,
(float) GetValue($anlage['EnergieVariableID'])
* $anlage['Energiefaktor']
)
);
}
}
foreach ($batterien as $batterie) {
if ($batterie['LeistungVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$batterie['LeistungIdent'],
(float) GetValue($batterie['LeistungVariableID'])
* $batterie['Leistungsfaktor']
);
}
if ($energieAktualisieren && $batterie['LadeenergieVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$batterie['LadeenergieIdent'],
max(
0.0,
(float) GetValue($batterie['LadeenergieVariableID'])
* $batterie['Energiefaktor']
)
);
}
if ($energieAktualisieren && $batterie['EntladeenergieVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$batterie['EntladeenergieIdent'],
max(
0.0,
(float) GetValue($batterie['EntladeenergieVariableID'])
* $batterie['Energiefaktor']
)
);
}
}
}
private function setzeDiagrammMesswert(string $ident, float $wert): void
{
$variableID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variableID) && $variableID > 0 && IPS_VariableExists($variableID)) {
SetValue($variableID, $wert);
}
}
private function diagrammFarbe(int $index): string
{
$farben = [
'#A85D3C',
'#007C91',
'#6B5B95',
'#2E7D32',
'#C43E1C',
'#3D5A80',
'#8D6E63',
'#00897B',
];
return $farben[$index % count($farben)];
}
/** @param list<array<string, mixed>> $datensaetze */
private function erstelleDiagramm(
string $ident,
@@ -521,7 +1123,8 @@ trait ManagerEnergieTrait
int $position
): int {
$medienID = $this->findeUnterobjekt($ident);
if ($medienID === 0) {
$neu = $medienID === 0;
if ($neu) {
$medienID = IPS_CreateMedia(4);
IPS_SetParent($medienID, $this->InstanceID);
IPS_SetIdent($medienID, $ident);
@@ -530,6 +1133,11 @@ trait ManagerEnergieTrait
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
}
if (!$neu) {
IPS_SetHidden($medienID, false);
return $medienID;
}
IPS_SetName($medienID, $name);
IPS_SetPosition($medienID, $position);
IPS_SetHidden($medienID, false);
@@ -546,16 +1154,17 @@ trait ManagerEnergieTrait
string $ident,
string $titel,
string $farbe,
string $profil
string $profil,
string $typ = 'line'
): array {
return [
'variableID' => $this->GetIDForIdent($ident),
'fillColor' => 'clear',
'fillColor' => $typ === 'bar' ? $farbe : 'clear',
'strokeColor' => $farbe,
'timeOffset' => 0,
'visible' => true,
'title' => $titel,
'type' => 'line',
'type' => $typ,
'side' => 'left',
'profile' => $profil,
];
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Locally approved, time-boxed operation of validated V4 shadow plans on a test plant. */
trait ManagerNetzfahrplanV4AktivtestTrait
{
private const V4_AKTIVTEST_DAUER_SEKUNDEN = 172800;
private const V4_AKTIVTEST_WATCHDOG_VERZICHT = 'explicit_test_plant_watchdog_waiver';
private function registriereV4ManagerAktivtest(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestBeginn', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0);
$this->RegisterVariableBoolean(
'NetzfahrplanV4Aktivtest',
'Netzfahrplansteuerung (Testbetrieb)',
'~Switch',
952
);
$this->EnableAction('NetzfahrplanV4Aktivtest');
}
public function V4ManagerAktivtestSchalten(bool $aktiv): void
{
if (!$aktiv) {
$this->SetValue('NetzfahrplanV4Aktivtest', false);
$this->V4ManagerTestStoppen();
return;
}
if ($this->GetValue('NetzfahrplanV4Aktivtest') && $this->v4ManagerTestSession()) {
return;
}
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) {
throw new RuntimeException('Regelung beschaeftigt.');
}
$armedBattery = null;
try {
$this->v4ManagerTestBedingungen();
if (!$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')) {
throw new RuntimeException('Aktivtest oder Watchdog-Verzicht ist lokal nicht freigegeben.');
}
if ($this->v4ManagerTestSession()) {
throw new RuntimeException('Eine V4-Regelsitzung ist bereits aktiv.');
}
$envelope = json_decode(
$this->ReadAttributeString('NetzfahrplanV4Empfang'),
true,
64,
JSON_THROW_ON_ERROR
);
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
throw new RuntimeException('Kein gueltiger aktueller V4-Plan empfangen.');
}
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR);
if (!is_array($assets) || count($assets) !== 1 || !is_string($assets[0]['ID'] ?? null)) {
throw new RuntimeException('Aktivtest braucht genau eine eindeutig konfigurierte Batterie.');
}
$asset = $assets[0];
$batteryID = $this->v4AktivtestBatterieinstanz($asset);
$now = time();
$start = $this->ReadAttributeInteger('NetzfahrplanV4AktivtestBeginn');
$until = $this->ReadAttributeInteger('NetzfahrplanV4AktivtestEnde');
if ($start === 0 && $until === 0) {
$start = $now;
$until = $now + self::V4_AKTIVTEST_DAUER_SEKUNDEN;
$this->WriteAttributeInteger('NetzfahrplanV4AktivtestBeginn', $start);
$this->WriteAttributeInteger('NetzfahrplanV4AktivtestEnde', $until);
}
if ($start <= 0 || $until <= $now
|| $until - $start > self::V4_AKTIVTEST_DAUER_SEKUNDEN) {
throw new RuntimeException('Die einmalige 48-Stunden-Aktivtestfreigabe ist abgelaufen.');
}
[$maxChargeW, $maxDischargeW] = $this->v4AktivtestLeistungsgrenzen($operation, $asset['ID']);
$session = $this->v4AktivtestUuid();
$s = [
'kind' => 'local_active_test_consent',
'enabled' => true,
'legacyEnabled' => false,
'sessionId' => $session,
'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'managerId' => $this->InstanceID,
'batteryInstanceId' => $batteryID,
'assetId' => $asset['ID'],
'startedAt' => $start,
'expiresAt' => $until,
'maxChargeW' => $maxChargeW,
'maxDischargeW' => $maxDischargeW,
'watchdogWaived' => true,
'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT,
'feedbackConfigHash' => $operation['localFeedback'][$asset['ID']]['configHash'] ?? '',
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($operation)['limits'],
'sequence' => 1,
'lastWallClockAt' => $now,
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now,
];
$response = $this->v4AktivtestAntwort($envelope['response'], $s, $now);
NetzfahrplanV4Regeltest::befehl(
$response,
$operation,
$actual,
$s,
$now,
$envelope['receivedAt']
);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $session);
$armedBattery = $batteryID;
$this->v4ManagerBatterieAktion($batteryID, 'NetzfahrplanV4TestStart', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetTimerInterval('NetzfahrplanV4TestTick', 2000);
$this->SetValue('NetzfahrplanV4Aktivtest', true);
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'active_test_started',
'sessionId' => $session,
'expiresAt' => $until,
'watchdogWaivedForTestPlant' => true,
'physicalResponseVerified' => false,
], JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
$this->SetValue('NetzfahrplanV4Aktivtest', false);
$this->SetBuffer('V4ManagerTrialSession', '{}');
$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
if ($armedBattery !== null) {
$this->v4ManagerBatterieAktion($armedBattery, 'NetzfahrplanV4TestStop', 0);
}
throw $error;
} finally {
IPS_SemaphoreLeave($lock);
}
}
private function v4AktivtestLeistungsgrenzen(array $operation, string $assetId): array
{
foreach ($operation['batteries'] ?? [] as $battery) {
if (($battery['id'] ?? null) !== $assetId) {
continue;
}
return [
NetzfahrplanV4Regeltest::number($battery['maxChargeW'] ?? null, 'Anlagen-Ladegrenze', 1, 1e9),
NetzfahrplanV4Regeltest::number($battery['maxDischargeW'] ?? null, 'Anlagen-Entladegrenze', 1, 1e9),
];
}
throw new RuntimeException('Dynamische Leistungsgrenzen der Testbatterie fehlen.');
}
private function v4AktivtestBatterieinstanz(array $asset): int
{
$matches = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
continue;
}
if (($asset['SOCVariableID'] ?? null) === IPS_GetProperty($id, 'LadezustandVariableID')
&& ($asset['LeistungVariableID'] ?? null) === IPS_GetProperty($id, 'IstleistungVariableID')) {
$matches[] = $id;
}
}
if (count($matches) !== 1) {
throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.');
}
return $matches[0];
}
private function v4AktivtestAntwort(array $response, array $session, int $now): array
{
$plan = $response['plan'] ?? null;
if (!is_array($plan)) {
throw new RuntimeException('V4-Plan fehlt.');
}
$validUntil = min($now + 90, (int) $session['expiresAt']);
$quality = $plan['inputQuality'] ?? [];
$response['controlledTrial'] = [
'version' => 1,
'kind' => 'controlled_trial_authority',
'sourcePlanRemainsShadow' => true,
'installationId' => $session['installationId'],
'sessionId' => $session['sessionId'],
'managerId' => $session['managerId'],
'batteryInstanceId' => $session['batteryInstanceId'],
'assetId' => $session['assetId'],
'sourceShadowPlanId' => $plan['planId'] ?? null,
'revision' => $plan['configRevision'] ?? null,
'family' => $plan['sourceFamily'] ?? null,
'issuedAt' => gmdate('c', (int) $session['startedAt']),
'expiresAt' => gmdate('c', (int) $session['expiresAt']),
'checkedAt' => gmdate('c', $now),
'validUntil' => gmdate('c', $validUntil),
'maxChargeW' => $session['maxChargeW'],
'maxDischargeW' => $session['maxDischargeW'],
'accountingEvidenceId' => $quality['accountingEvidenceId'] ?? null,
'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT,
'acceptEstimatedPeak' => true,
'controlContext' => $plan['controlContext'] ?? null,
];
return $response;
}
private function v4AktivtestUuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4)
. '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
require_once __DIR__ . '/NetzfahrplanV4Datenarchiv.php';
/** Application measurement acquisition + acknowledged durable delivery. No device writes. */
trait ManagerNetzfahrplanV4DatenTrait
{
private function registriereNetzfahrplanV4Daten(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4MessdatenAktiv', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4ArchivAktiv', false);
$this->RegisterPropertyString('NetzfahrplanV4Messkonfiguration', '{}');
$this->RegisterPropertyString('NetzfahrplanV4Datensatz', '');
$this->RegisterAttributeString('NetzfahrplanV4DatenCursor', '{}');
$this->RegisterAttributeString('NetzfahrplanV4LetzteDatenquittung', '{}');
$this->RegisterAttributeString('NetzfahrplanV4ArchivTag', '');
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
$this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'Prognosedaten und Modellversorgung', '', 199);
$this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);");
}
private function konfiguriereNetzfahrplanV4Daten(): void
{
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 0);
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
try {
$this->v4DatenKonfiguration();
if ($this->ReadAttributeInteger('NetzfahrplanV4DatenVersion')<1) {
// One retry after the data-only upgrade, never a cursor reset or a device write.
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersion', 1);
}
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 30000);
} catch (\Throwable $e) {
$this->v4DatenStatus(['status'=>'configuration_error','errorType'=>get_class($e)]);
}
}
private function v4DatenKonfiguration(): array
{
$id=$this->ReadAttributeString('LizenzInstallationID');
$dataset=$this->ReadPropertyString('NetzfahrplanV4Datensatz');
if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/D',$dataset)
|| !preg_match('/^[0-9a-f-]{36}$/D',$id)) throw new \RuntimeException('Dataset/installation not configured');
$c=json_decode($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration'),true,64,JSON_THROW_ON_ERROR);
$c=NetzfahrplanV4Messaufnahme::configuration($c);
if ($c['installationId']!==$id || ($c['managerId']??null)!==$this->InstanceID) throw new \RuntimeException('Measurement mapping belongs to another manager');
return $c;
}
private function v4DatenVerzeichnis(): string
{
$base=rtrim(IPS_GetKernelDir(),'/').'/data/enelix-v4';
$directory=$base.'/'.$this->ReadAttributeString('LizenzInstallationID');
// Fixed application-owned location; no configurable path or arbitrary file access.
foreach ([$base,$directory] as $p) {
if (is_link($p)) throw new \RuntimeException('Measurement directory symlink refused');
if (!is_dir($p) && !mkdir($p,0700,true) && !is_dir($p)) throw new \RuntimeException('Measurement directory unavailable');
if ((fileperms($p)&0007)!==0) throw new \RuntimeException('Measurement directory must remain private');
}
return $directory;
}
private function v4DatenStatus(array $data): void
{
$receipt=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
$data['lastAcknowledgement']=$receipt;
$this->SetValue('NetzfahrplanV4Datenstatus',json_encode($data+['checkedAt'=>gmdate('c'),
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'controlEnabled'=>false],JSON_THROW_ON_ERROR));
}
public function ErfasseNetzfahrplanV4Daten(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
$lock='ENELIX.V4.ApplicationData.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,0)) return;
try {
$c=$this->v4DatenKonfiguration();
$reader=static function(int $id):array {
if (!IPS_VariableExists($id)) throw new \RuntimeException('Missing measurement');
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);
if (!in_array($v['VariableType'],[1,2],true)) throw new \RuntimeException('Numeric source required');
return ['value'=>GetValue($id),'updated'=>(int)$v['VariableUpdated'],'changed'=>(int)$v['VariableChanged'],
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
};
$dir=$this->v4DatenVerzeichnis();
$archiveResult=null;
if ($this->ReadPropertyBoolean('NetzfahrplanV4ArchivAktiv') && $this->ReadAttributeString('NetzfahrplanV4ArchivTag')!==gmdate('Y-m-d')) {
try {
$archiveCursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR);
$archiveResult=NetzfahrplanV4Datenarchiv::run($dir,$archiveCursor,time());
if ($archiveResult['archived']<2 && ($archiveResult['status']??null)!=='writer_busy')$this->WriteAttributeString('NetzfahrplanV4ArchivTag',gmdate('Y-m-d'));
} catch (\Throwable $e) { $archiveResult=['status'=>'archive_error','errorType'=>get_class($e),'originalsRetained'=>true]; }
}
try {
$r=NetzfahrplanV4Messaufnahme::capture($c,$reader,static fn():int=>time());
NetzfahrplanV4Messaufnahme::append($dir,$r);
$result=['status'=>'recorded','capturedAt'=>$r['capturedAt'],'sourceCount'=>count($r['raw']),
'rawIssues'=>count($r['issues']),'measurementBoundaryVerified'=>false];
} catch (\Throwable $e) {
// Storage/capture failure must not prevent retrying the already durable outbox.
$result=['status'=>'capture_error','errorType'=>get_class($e),'dataRetained'=>true];
}
if ($archiveResult!==null)$result['archive']=$archiveResult;
$last=$this->ReadAttributeInteger('NetzfahrplanV4DatenVersuch');
$failures=min(5,$this->ReadAttributeInteger('NetzfahrplanV4DatenFehlerzahl'));
// Planner inputs expire after 120 s. Drain an acknowledged backlog faster, but a
// failed HTTP attempt still backs off to 60 s. Once caught up, normal upload is 60 s.
$lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
$acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??''));
$backlog=$acceptedEpoch===false || time()-$acceptedEpoch>120;
$retry=$failures>0?60:($backlog?15:60);
if (time()>=$last+$retry) {
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch',time());
try {
$result['delivery']=$this->v4DatenUebertragen($dir);
if (($result['delivery']['status']??null)==='acknowledged') {
$this->WriteAttributeString('NetzfahrplanV4LetzteDatenquittung',json_encode($result['delivery']+['receivedAt'=>gmdate('c')],JSON_THROW_ON_ERROR));
}
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',0);
} catch (\Throwable $e) {
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',$failures+1);
$httpCode=(int)$e->getCode();
$result['delivery']=['status'=>'retry_pending','errorType'=>get_class($e),
'httpStatus'=>$httpCode>=100&&$httpCode<=599?$httpCode:null,'dataRetained'=>true,
'nextAttemptAt'=>gmdate('c',time()+60)];
}
} else $result['delivery']=['status'=>'scheduled','nextAttemptAt'=>gmdate('c',$last+$retry)];
$this->v4DatenStatus($result);
} catch (\Throwable $e) {
// Sensor/configuration exceptions never enter the actuator or module-status path.
$this->v4DatenStatus(['status'=>'capture_error','errorType'=>get_class($e)]);
} finally { IPS_SemaphoreLeave($lock); }
}
/** Returns only complete records from the append-only prefix; cursor moves after matching ACK. */
private function v4DatenUebertragen(string $dir): array
{
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new \RuntimeException('Forecast licence missing');
$cursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR);
$day=$cursor['day']??'';$offset=$cursor['offset']??0;
if (!is_string($day) || ($day!==''&&!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)) || !is_int($offset)||$offset<0) throw new \RuntimeException('Invalid data cursor');
$files=glob($dir.'/raw-*.jsonl');
if ($files===false||count($files)>400) throw new \RuntimeException('Measurement journal bounds exceeded');
sort($files,SORT_STRING);
$lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
$acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??''));
if ($acceptedEpoch===false) $acceptedEpoch=null;
$records=[];$candidate=$cursor;$bytes=0;$lastTime=null;$lastEpoch=null;$skippedDuplicateTimestamps=0;$skippedAcknowledgedTimestamps=0;
foreach ($files as $file) {
$name=basename($file);
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||($day!==''&&$name<$day)) continue;
if (is_link($file)||!is_file($file)||realpath($file)!==$file) throw new \RuntimeException('Invalid measurement journal');
$h=fopen($file,'rb');if($h===false)throw new \RuntimeException('Cannot open measurement journal');
try {
$size=fstat($h)['size'];$position=$name===$day?$offset:0;
if ($position>$size||fseek($h,$position)!==0) throw new \RuntimeException('Measurement journal shortened');
while (ftell($h)<$size) {
$line=fgets($h,min(262146,$size-ftell($h)+1));
if ($line===false||!str_ends_with($line,"\n")) break;
if (strlen($line)>262144) throw new \RuntimeException('Oversized measurement');
if (count($records)>=120||$bytes+strlen($line)>900000) break 2;
$r=json_decode($line,false,64,JSON_THROW_ON_ERROR);
if (!is_object($r)||($r->installationId??null)!==$this->ReadAttributeString('LizenzInstallationID')
||($r->kind??null)!=='raw_accounting_capture') throw new \RuntimeException('Journal identity invalid');
$epoch=strtotime((string)($r->capturedAt??''));
if ($epoch===false) throw new \RuntimeException('Journal capture timestamp invalid');
$candidate=['day'=>$name,'offset'=>ftell($h)];
if ($acceptedEpoch!==null && $epoch<=$acceptedEpoch) { $skippedAcknowledgedTimestamps++;continue; }
$bytes+=strlen($line);
if ($lastEpoch!==null && $epoch<$lastEpoch) throw new \RuntimeException('Measurement journal timestamp moved backwards');
if ($lastEpoch!==null && $epoch===$lastEpoch) { $skippedDuplicateTimestamps++;continue; }
$records[]=$r;$lastTime=$r->capturedAt;$lastEpoch=$epoch;
}
} finally { fclose($h); }
}
if ($records===[]) return ['status'=>'up_to_date'];
$id=$this->ReadAttributeString('LizenzInstallationID');$token=$this->ReadAttributeString('PrognoseInstallationsToken');
if ($token==='') throw new \RuntimeException('Existing device token unavailable');
$payload=json_encode(['version'=>1,'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'records'=>$records],JSON_THROW_ON_ERROR);
$h=curl_init('https://license.enelix.ch/api/v1/installations/'.rawurlencode($id).'/prognosis/planner-v4/measurements');
if ($h===false) throw new \RuntimeException('Cannot prepare data transfer');
try {
curl_setopt_array($h,[CURLOPT_POST=>true,CURLOPT_RETURNTRANSFER=>true,CURLOPT_FOLLOWLOCATION=>false,
CURLOPT_CONNECTTIMEOUT=>2,CURLOPT_TIMEOUT=>8,CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2,
CURLOPT_HTTPHEADER=>['Content-Type: application/json','Accept: application/json','Authorization: Bearer '.$token],
CURLOPT_POSTFIELDS=>$payload]);
$answer=curl_exec($h);$code=(int)curl_getinfo($h,CURLINFO_HTTP_CODE);
if ($answer===false||$code!==200||strlen($answer)>65536) throw new \RuntimeException('Measurement delivery not acknowledged',$code);
$ack=json_decode($answer,true,32,JSON_THROW_ON_ERROR);
if (!in_array($ack['status']??null,['stored','duplicate'],true)
||($ack['datasetId']??null)!==$this->ReadPropertyString('NetzfahrplanV4Datensatz')
||($ack['acceptedThrough']??null)!==$lastTime) throw new \RuntimeException('Measurement acknowledgement does not match batch');
$this->WriteAttributeString('NetzfahrplanV4DatenCursor',json_encode($candidate,JSON_THROW_ON_ERROR));
return ['status'=>'acknowledged','records'=>count($records),'acceptedThrough'=>$lastTime,
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),
'journalDuplicateTimestampsSkipped'=>$skippedDuplicateTimestamps,
'journalAcknowledgedTimestampsSkipped'=>$skippedAcknowledgedTimestamps];
} finally { curl_close($h); }
}
}
+272
View File
@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
trait ManagerNetzfahrplanV4EmpfangTrait
{
private function registriereNetzfahrplanV4Empfang(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4EmpfangAktiv', false);
$this->RegisterAttributeString('NetzfahrplanV4Empfang', '{}');
$this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}');
$this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
$this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
$this->RegisterVariableString('NetzfahrplanV4Vorschau', 'Netzfahrplanvorschau (keine Stellfreigabe)', '~HTMLBox', 950);
$this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'Netzfahrplandiagnose', '', 951);
$this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);");
$this->RegisterTimer('NetzfahrplanV4EmpfangAktualisieren', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Empfangen', true);");
}
private function stoppeNetzfahrplanV4Vorschau(): void
{
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', 0);
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', 0);
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', '{"status":"not_received"}');
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
$this->zeigeNetzfahrplanV4Vorschau(['status' => 'not_received', 'reason' => 'Neu initialisiert; keinen alten Sollwert weiterverwenden.']);
}
private function konfiguriereNetzfahrplanV4Vorschau(): void
{
$enabled = $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
&& $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
&& !$this->ReadPropertyBoolean('NetzfahrplanAktiv');
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', $enabled ? 10000 : 0);
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', $enabled ? 10000 : 0);
$this->aktualisiereNetzfahrplanV4Vorschau();
}
/** Called before the next operation upload, so the preceding completed plan can be seen. */
private function empfangeNetzfahrplanV4(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')) return;
$lock = 'ENELIX.V4.Receive.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try {
$now = time();
if ($now < $this->ReadAttributeInteger('NetzfahrplanV4NaechsterAbruf')) return;
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
$interval = ($session['kind'] ?? null) === 'local_active_test_consent' ? 45 : 60;
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', $now + $interval);
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
throw new RuntimeException('Schatten-Sender fehlt oder alter Fahrplan ist eingeschaltet.');
}
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
}
$response = $this->netzfahrplanV4EmpfangHttp('GET', '');
$receipt = time();
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
throw new RuntimeException('Freigabe waehrend des Abrufs geaendert.');
}
// Validate the candidate before replacing the last accepted envelope. During an
// active test a failed refresh may keep the previous envelope, but its original
// receivedAt is never changed, so invalid traffic can never extend its lifetime.
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$checked = NetzfahrplanV4Planpruefung::pruefen($response,
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $receipt);
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
// Only the known, non-secret protocol envelope is cached.
$keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh',
'pending', 'settings', 'plan', 'families', 'lastRun', 'controlledTrial'];
$safe = array_intersect_key($response, array_flip($keys));
$this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode(
['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR));
$state = ['status' => 'received_shadow', 'at' => gmdate('c', $receipt), 'acknowledged' => false];
$ack = ['planId' => $preview['planId'], 'revision' => $preview['revision'], 'step' => $preview['step'], 'status' => 'shadow_seen'];
$last = json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteBestaetigung'), true);
if ($last !== $ack) {
try {
$answer = $this->netzfahrplanV4EmpfangHttp('POST', '/ack', $ack);
if (($answer['status'] ?? null) !== 'recorded') throw new RuntimeException('Empfangsbestaetigung abgelehnt.');
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', json_encode($ack, JSON_THROW_ON_ERROR));
$state['acknowledged'] = true;
} catch (Throwable $error) {
$state['acknowledgementError'] = substr($error->getMessage(), 0, 200);
}
} else {
$state['acknowledged'] = true;
}
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
$this->aktualisiereNetzfahrplanV4Vorschau();
} catch (Throwable $error) {
$keepPrevious = false;
$previous = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true);
if (is_array($previous) && is_int($previous['receivedAt'] ?? null)) {
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
$keepPrevious = (($session['kind'] ?? null) === 'local_active_test_consent')
&& $previous['receivedAt'] <= time()
&& time() - $previous['receivedAt'] <= NetzfahrplanV4Planpruefung::MAX_RECEIPT_AGE;
}
$pendingRetry = is_array($response ?? null)
&& ($response['installationId'] ?? null) === $this->ReadAttributeString('LizenzInstallationID')
&& ($response['pending'] ?? null) !== null;
if ($keepPrevious || $pendingRetry) {
// Also catch a completed plan before the next operation starts another calculation.
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', time() + 15);
}
if (!$keepPrevious) {
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
}
$state = ['status' => 'rejected_or_unavailable', 'at' => gmdate('c'),
'reason' => substr($error->getMessage(), 0, 300), 'previousEnvelopeRetained' => $keepPrevious];
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
$this->zeigeNetzfahrplanV4Vorschau($state);
} finally {
IPS_SemaphoreLeave($lock);
}
}
/** Fixed authenticated read/ack endpoints only; never operation, settings, replan or dispatch. */
private function netzfahrplanV4EmpfangHttp(string $method, string $suffix, ?array $body = null): array
{
if (!(($method === 'GET' && $suffix === '' && $body === null)
|| ($method === 'POST' && $suffix === '/ack' && ($body['status'] ?? null) === 'shadow_seen'))) {
throw new RuntimeException('Unzulaessiger V4-Empfangsaufruf.');
}
$id = $this->ReadAttributeString('LizenzInstallationID');
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $id)
|| !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) {
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
}
$handle = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix);
if ($handle === false) throw new RuntimeException('V4-Empfang konnte nicht vorbereitet werden.');
$response = '';
try {
$options = [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token,
'Accept: application/json', 'Content-Type: application/json', 'Cache-Control: no-cache'],
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
if (strlen($response) + strlen($chunk) > NetzfahrplanV4Planpruefung::MAX_RESPONSE_BYTES) return 0;
$response .= $chunk;return strlen($chunk);
}];
if ($body !== null) $options[CURLOPT_POSTFIELDS] = json_encode($body, JSON_THROW_ON_ERROR);
curl_setopt_array($handle, $options);
$ok = curl_exec($handle);$code = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
if ($ok === false || $code !== 200) throw new RuntimeException('V4-Planempfang HTTP ' . $code . '; kein gueltiger Vorschauwert.');
return NetzfahrplanV4Planpruefung::dekodieren($response);
} finally {
curl_close($handle);
}
}
/** Reads independently of the old control loop. Never manufactures fresh timestamps. */
private function netzfahrplanV4LokaleVorschauwerte(): array
{
$d = json_decode($this->GetNetzfahrplanV4Diagnose(), true, 64, JSON_THROW_ON_ERROR);
if (!is_array($d['operation'] ?? null)) throw new RuntimeException('Lokale Betriebsdaten ungueltig; Vorschau gesperrt.');
$topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR);
$actual = [];$now = time();
foreach ($topology as $asset) {
$controllers=[];
foreach ($this->aktiveVerbraucherIDs() as $controller) {
if (IPS_InstanceExists($controller)
&& IPS_GetInstance($controller)['ModuleInfo']['ModuleID']==='{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}'
&& IPS_GetProperty($controller,'IstleistungVariableID')===($asset['LeistungVariableID']??null)
&& IPS_GetProperty($controller,'LadezustandVariableID')===($asset['SOCVariableID']??null)) $controllers[]=$controller;
}
if (count($controllers)>1) throw new RuntimeException('feedback_controller_ambiguous');
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
if (is_string($configured) && !in_array(trim($configured),['','{}'],true)) {
if (count($topology)!==1) throw new RuntimeException('feedback_multi_asset_adapter_required');
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($controller),true,32,JSON_THROW_ON_ERROR);
$feedbackNow=time();
if (($f['status']??null)==='unavailable') {
$reason=is_string($f['reason']??null)?$f['reason']:'unknown';
throw new RuntimeException('feedback_unavailable:'.substr($reason,0,180));
}
if (($f['installationId']??null)!==$this->ReadAttributeString('LizenzInstallationID')
|| ($f['assetId']??null)!==$asset['ID'] || ($f['managerId']??null)!==$this->InstanceID
|| ($f['batteryInstanceId']??null)!==$controller || !is_numeric($f['batteryW']??null)
|| !is_numeric($f['gridW']??null) || !is_int($f['checkedAt']??null)
|| $feedbackNow-$f['checkedAt']>2 || $f['checkedAt']>$feedbackNow) {
throw new RuntimeException('feedback_invalid_no_legacy_fallback');
}
$actual[$asset['ID']]=$f['batteryW'];$d['operation']['gridW']=$f['gridW'];
$d['operation']['localFeedback'][$asset['ID']]=$f;
continue;
}
}
// Legacy value remains display-only until an explicit physical feedback mapping exists.
$id = (int) ($asset['LeistungVariableID'] ?? 0);
if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.');
$before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id);
$factor = $asset['Leistungsfaktor'] ?? null;
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v)
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor) || $factor == 0
|| $before['VariableUpdated'] !== $after['VariableUpdated']
|| $after['VariableUpdated'] > $now || $now - $after['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
throw new RuntimeException('Batterie-Istleistung fehlt, ist veraltet oder inkonsistent.');
}
$actual[$asset['ID']] = (float) $v * $factor;
}
return [$d['operation'], $actual];
}
public function GetNetzfahrplanV4Vorschau(): string
{
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
throw new RuntimeException('V4-Empfang ausgeschaltet.');
}
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) throw new RuntimeException('Alter Fahrplan ist aktiv; V4-Vorschau gesperrt.');
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
throw new RuntimeException('Noch kein gueltiger V4-Plan empfangen.');
}
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'],
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']);
$out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
$out['localFeedback'] = $operation['localFeedback'] ?? [];
$out['feedbackMeaning'] = isset($operation['localFeedback']) ? 'fresh_physical_or_explicit_partition_model' : 'legacy_display_only_not_for_trial';
$out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true);
} catch (Throwable $error) {
$out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300),
'reception' => json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true)];
}
return json_encode(array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null],
$out, ['canDispatch' => false, 'liveEnabled' => false]), JSON_THROW_ON_ERROR);
}
private function aktualisiereNetzfahrplanV4Vorschau(): void
{
$this->zeigeNetzfahrplanV4Vorschau(json_decode($this->GetNetzfahrplanV4Vorschau(), true, 64, JSON_THROW_ON_ERROR));
}
private function zeigeNetzfahrplanV4Vorschau(array $value): void
{
$value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value,
['canDispatch' => false, 'liveEnabled' => false]);
$escape = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html = '<h3>Netzfahrplanvorschau - keine Stellfreigabe</h3><table>';
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
$item = $value[$key] ?? '-';if (is_float($item)) $item = number_format($item, 1, '.', "'");
$html .= '<tr><th>' . $escape($label) . '</th><td>' . $escape($item) . '</td></tr>';
}
$html .= '</table><p>Batterie + = Laden, - = Entladen. Netz + = Bezug, - = Einspeisung.</p>';
foreach ($value['reasons'] ?? [] as $reason) $html .= '<p>' . $escape($reason) . '</p>';
$html .= '<p>Empfangsbestaetigung ist keine Ausfuehrungsbestaetigung.</p>';
$this->SetValue('NetzfahrplanV4VorschauJSON', json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
$this->SetValue('NetzfahrplanV4Vorschau', $html);
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Local consent + server commissioning authority. NEVER executes a preview alone. */
trait ManagerNetzfahrplanV4TestTrait
{
private function registriereV4ManagerTest(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
$this->RegisterAttributeString('NetzfahrplanV4RegeltestStatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4TestTick', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);");
}
private function v4ManagerTestSession(): array
{
$value = json_decode($this->GetBuffer('V4ManagerTrialSession') ?: '{}', true);
return is_array($value) ? $value : [];
}
private function v4ManagerTestBedingungen(): void
{
if (IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102
|| !$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
|| $this->ReadPropertyBoolean('NetzfahrplanAktiv') || !$this->GetValue('Aktiv')
|| !$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Keine lokale Testberechtigung oder konkurrierender Fahrplan.');
}
}
/** Explicit kernel-local user action only. No network request can call this via the portal. */
public function V4ManagerTestStarten(string $json): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt.');
$armedBattery = null;
try {
$this->v4ManagerTestBedingungen();
if ($this->v4ManagerTestSession()) throw new RuntimeException('Testsitzung bereits aktiv; nicht verlaengern.');
$request = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['confirmation'] ?? null) !== 'START_BOUNDED_V4_CONTROL_TRIAL') {
throw new RuntimeException('Ausdrueckliche lokale Testbestaetigung erforderlich.');
}
$session = NetzfahrplanV4Regeltest::uuid($request['sessionId'] ?? null);
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $session) throw new RuntimeException('Testsitzung bereits benutzt; neue Freigabe erforderlich.');
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
$a = $envelope['response']['controlledTrial'] ?? null;
if (!is_array($a) || ($a['sessionId'] ?? null) !== $session) throw new RuntimeException('Keine passende separate Serverfreigabe empfangen.');
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$batteryID = (int) ($a['batteryInstanceId'] ?? 0);
if (!in_array($batteryID, $this->aktiveVerbraucherIDs(), true)
|| !IPS_InstanceExists($batteryID)
|| IPS_GetInstance($batteryID)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
throw new RuntimeException('Testbatterie ist keine aktive zugeordnete Batterieinstanz.');
}
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR);
if (count($assets) !== 1 || $assets[0]['ID'] !== ($a['assetId'] ?? null)
|| $assets[0]['SOCVariableID'] !== IPS_GetProperty($batteryID, 'LadezustandVariableID')
|| $assets[0]['LeistungVariableID'] !== IPS_GetProperty($batteryID, 'IstleistungVariableID')) {
throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.');
}
$now = time();$until = min(NetzfahrplanV4Planpruefung::zeit($a['expiresAt']),
$now + NetzfahrplanV4Regeltest::integer($request['durationSeconds'] ?? null, 'Lokale Testdauer', 30, 1800));
$s = ['kind' => 'local_control_trial_consent', 'enabled' => true, 'legacyEnabled' => false,
'sessionId' => $session, 'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'managerId' => $this->InstanceID, 'batteryInstanceId' => $batteryID, 'assetId' => $a['assetId'],
'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 1e9),
'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 1e9),
'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'),
'feedbackConfigHash' => $op['localFeedback'][$a['assetId']]['configHash'] ?? '',
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($op)['limits'],
'sequence' => 1, 'lastWallClockAt' => $now,
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now];
// Validate everything BEFORE arming either module. This rejects all normal shadow replies.
NetzfahrplanV4Regeltest::befehl($envelope['response'], $op, $actual, $s, $now, $envelope['receivedAt']);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $session);
$armedBattery = $batteryID;
$this->v4ManagerBatterieAktion($batteryID, 'NetzfahrplanV4TestStart', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetTimerInterval('NetzfahrplanV4TestTick', 2000);
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'armed_local_trial', 'sessionId' => $session, 'expiresAt' => $until,
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
if ($armedBattery !== null) $this->v4ManagerBatterieAktion($armedBattery, 'NetzfahrplanV4TestStop', 0);
throw $e;
} finally { IPS_SemaphoreLeave($lock); }
}
/** Revocation precedes I/O; if the device call fails its own ten-second lease still expires. */
private function v4ManagerTestAbbrechen(string $reason): void
{
$s = $this->v4ManagerTestSession();
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
$this->SetValue('NetzfahrplanV4Aktivtest', false);
if (!$s) return;
$result = ['status' => 'stopped_fallback_pending', 'reason' => $reason,
'sessionId' => $s['sessionId'], 'physicalResponseVerified' => false];
// Reissue the next ordinary target even if numerically equal to a previously cached value.
$this->WriteAttributeString('LetzteSollwerte', '{}');
try {
$this->v4ManagerBatterieAktion($s['batteryInstanceId'], 'NetzfahrplanV4TestStop', 0);
$result['controllerStopAccepted'] = true;
} catch (Throwable $e) {
$result['controllerStopAccepted'] = false;$result['reason'] .= '; Geraete-Abbruch fehlgeschlagen, Watchdog pruefen.';
}
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode($result, JSON_THROW_ON_ERROR));
}
public function V4ManagerTestStoppen(): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt; Stop erneut anfordern.');
try { $this->v4ManagerTestAbbrechen('Lokaler Testabbruch'); }
finally { IPS_SemaphoreLeave($lock); }
$this->regeln(true); // fresh ordinary command; never restore a cached pre-trial target
}
public function GetV4ManagerTestStatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4RegeltestStatus');
}
private function v4ManagerBatterieAktion(int $id, string $ident, $value): void
{
if (IPS_RequestAction($id, $ident, $value) !== true) {
throw new RuntimeException('Batterieaktion nicht bestaetigt: ' . $ident);
}
}
/** Called under the existing regulator's serialization, after allocation but before writes. */
private function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $synchronized): void
{
$s = $this->v4ManagerTestSession();
if (!$s) return;
try {
$this->v4ManagerTestBedingungen();$now = time();
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
// The ordinary commissioning trial still requires a fully synchronized consumer set.
// In the explicitly approved 48h active test, V4 is the sole actuator owner: stale or
// unrelated consumers receive no new Manager target and their physical effect is seen
// through the verified grid feedback on the next V4 cycle.
if ((!$activeTest && !$synchronized) || $now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) {
throw new RuntimeException($activeTest ? 'Testende erreicht.' : 'Testende oder Verbraucher nicht synchronisiert.');
}
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
// Confirmed device reads can cross a wall-clock second. Validate their checkedAt
// against a post-read clock, never against the timestamp captured before I/O.
$now = time();
if ($now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) {
throw new RuntimeException('Testende erreicht.');
}
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
// Other consumers stay under the existing controller. Compensate their predicted
// change once; no double-counting of the battery's already flowing actual power.
// The explicitly approved active test owns its bound battery directly. It must not
// depend on the legacy allocation list as long as the confirmed V4 feedback still
// proves the same battery binding and is usable for the trial.
$otherDelta = 0.0;$found = false;
if ($activeTest) {
$feedback = $op['localFeedback'][$s['assetId']] ?? null;
$found = is_array($feedback)
&& ($feedback['batteryInstanceId'] ?? null) === $s['batteryInstanceId']
&& ($feedback['usableForTrial'] ?? false) === true
&& ($feedback['deviceReadConfirmed'] ?? false) === true;
}
foreach ($consumers as $consumer) {
$id = $consumer['InstanzID'];
if ($id === $s['batteryInstanceId']) { $found = true;continue; }
if (!array_key_exists($id, $allocation['Sollwerte'])) continue;
if ($activeTest) {
unset($allocation['Sollwerte'][$id]);
continue;
}
$d = $consumer['Daten'];
if (($d['Leistungsquelle'] ?? null) !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) throw new RuntimeException('Andere Verbraucherleistung nicht gemessen.');
$otherDelta += NetzfahrplanV4Regeltest::number($allocation['Sollwerte'][$id], 'Anderer Sollwert', -1e9, 1e9)
- NetzfahrplanV4Regeltest::number($d['Istleistung_W'] ?? null, 'Andere Istleistung', -1e9, 1e9);
}
if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.');
$op['gridW'] += $otherDelta;
$op['localPredictedOtherDeltaW']=$otherDelta;
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
throw new RuntimeException('Kein gueltiger empfangener Testkontext.');
}
$response = $envelope['response'];
if (($s['kind'] ?? null) === 'local_active_test_consent') {
if (!$this->GetValue('NetzfahrplanV4Aktivtest')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')) {
throw new RuntimeException('Aktivtestfreigabe entzogen.');
}
$response = $this->v4AktivtestAntwort($response, $s, $now);
}
$command = NetzfahrplanV4Regeltest::befehl($response, $op, $actual, $s, $now, $envelope['receivedAt']);
// A controller-side refusal stops the session; never downgrade it to an ordinary target.
$this->v4ManagerBatterieAktion($s['batteryInstanceId'], 'NetzfahrplanV4TestCommand', json_encode($command, JSON_THROW_ON_ERROR));
$s['sequence']++;$s['lastWallClockAt'] = $now;
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
unset($allocation['Sollwerte'][$s['batteryInstanceId']]); // one owner of this actuator
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'controller_command_sent', 'sessionId' => $s['sessionId'],
'sourceShadowPlanId' => $command['sourceShadowPlanId'], 'command' => $command,
'otherConsumerPredictedDeltaW' => $otherDelta, 'otherConsumersFrozen' => $activeTest,
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->v4ManagerTestAbbrechen($e->getMessage());
// Stop writes zero and next NEW ordinary allocation runs on this same pass.
}
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/ManagerNetzfahrplanV4EmpfangTrait.php';
require_once __DIR__ . '/ManagerNetzfahrplanV4DatenTrait.php';
/** Shadow telemetry only. Does not read/apply a V4 schedule or write actuators. */
trait ManagerNetzfahrplanV4Trait
{
use ManagerNetzfahrplanV4EmpfangTrait;
use ManagerNetzfahrplanV4DatenTrait;
private function registriereNetzfahrplanV4(): void
{
$this->registriereNetzfahrplanV4Empfang();
$this->registriereNetzfahrplanV4Daten();
$this->RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false);
$this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}');
$this->RegisterPropertyInteger('NetzfahrplanV4MessnachweisVariableID', 0);
$this->RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]');
$this->RegisterAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4Senden', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Senden', true);");
}
public function GetNetzfahrplanV4Diagnose(): string
{
try {
$manager = [];
foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout',
'Lastspitzenmodus'] as $key) {
$manager[$key] = $this->ReadPropertyInteger($key);
}
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
$manager[$key] = $this->ReadPropertyFloat($key);
}
$manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified';
$manager['EinspeisebegrenzungAktiv'] = $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv');
$manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt');
$manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR);
$manager['BatterieOptionen'] = json_decode($this->ReadPropertyString('NetzfahrplanV4BatterieOptionen'), true, 512, JSON_THROW_ON_ERROR);
$manager['V4BezugszaehlerQuellen'] = json_decode(
$this->ReadPropertyString('NetzfahrplanV4BezugszaehlerQuellen'), true, 512, JSON_THROW_ON_ERROR
);
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$controllers = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
continue;
}
$c = ['InstanzID' => $id];
foreach (['LadezustandVariableID', 'MaxLadeleistungVariableID', 'MaxEntladeleistungVariableID',
'IstleistungVariableID', 'MindestLadezustand', 'ReserveLadezustand', 'LadezustandHysterese', 'MesswertMaxAlter', 'Batteriemanagement'] as $key) {
$c[$key] = IPS_GetProperty($id, $key);
}
$entry = $cache[(string) $id] ?? [];
$c['EmpfangenAm'] = $entry['EmpfangenAm'] ?? 0;
$c['Verfuegbar'] = $entry['Daten']['Verfuegbar'] ?? false;
if (function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$configured = IPS_GetProperty($id, 'NetzfahrplanV4RueckmeldungKonfiguration');
if (is_string($configured) && !in_array(trim($configured), ['', '{}'], true)) {
try {
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($id), true, 32, JSON_THROW_ON_ERROR);
$checkedAt = $feedback['checkedAt'] ?? null;
if (($feedback['usableForTrial'] ?? false) === true
&& ($feedback['deviceReadConfirmed'] ?? false) === true
&& is_int($checkedAt) && $checkedAt <= time()
&& time() - $checkedAt <= max(2, (int) ($c['MesswertMaxAlter'] ?? 30))) {
$c['EmpfangenAm'] = $checkedAt;
$c['Verfuegbar'] = true;
}
} catch (Throwable) {
// Keep the ordinary cache state; V4 remains fail-safe if direct feedback is unavailable.
}
}
}
foreach (($entry['Daten']['Zustand'] ?? []) as $state) {
if (($state['Kennung'] ?? '') === 'HystereseAktiv') {
$c['HystereseAktiv'] = $state['Wert'];
}
}
$controllers[] = $c;
}
$evidence = [];
$evidenceID = $this->ReadPropertyInteger('NetzfahrplanV4MessnachweisVariableID');
if ($evidenceID > 0) {
if (!IPS_VariableExists($evidenceID) || IPS_GetVariable($evidenceID)['VariableType'] !== 3) {
throw new RuntimeException('Der konfigurierte Messnachweis ist keine JSON-Stringvariable.');
}
$evidence = json_decode(GetValue($evidenceID), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($evidence)) {
throw new RuntimeException('Ungueltiger Messnachweis.');
}
}
$read = static function (int $id): array {
if (!IPS_VariableExists($id)) {
throw new RuntimeException('Messvariable ' . $id . ' fehlt.');
}
$before = IPS_GetVariable($id);
$value = GetValue($id);
$after = IPS_GetVariable($id);
if ($before['VariableUpdated'] !== $after['VariableUpdated']) {
throw new RuntimeException('Messwert hat sich beim Lesen geaendert; naechsten Durchlauf abwarten.');
}
$object = IPS_GetObject($id);
return ['value' => $value, 'updated' => (int) $after['VariableUpdated'],
'ident' => $object['ObjectIdent'], 'parentID' => (int) $object['ParentID']];
};
return json_encode(NetzfahrplanV4Betriebsdaten::erstellen($manager, $assets, $controllers,
$read, $evidence, time()), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (Throwable $error) {
return json_encode(['status' => 'invalid_inputs', 'reason' => substr($error->getMessage(), 0, 300)], JSON_THROW_ON_ERROR);
}
}
public function GetNetzfahrplanV4Sendestatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4Sendestatus');
}
private function sendeNetzfahrplanV4(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
return;
}
// Read the preceding completed plan BEFORE sending a new operation/replan trigger.
$this->empfangeNetzfahrplanV4();
try {
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
}
$snapshot = $this->GetNetzfahrplanV4Diagnose();
$data = json_decode($snapshot, true, 512, JSON_THROW_ON_ERROR);
if (!isset($data['operation'])) {
throw new RuntimeException($data['reason'] ?? 'Betriebsdaten unvollstaendig.');
}
// Decoding as objects preserves empty JSON dictionaries in the API contract.
$payload = json_decode($snapshot, false, 512, JSON_THROW_ON_ERROR);
if (!isset($payload->operation)) {
throw new RuntimeException('Keine konsistenten Betriebsdaten.');
}
$id = $this->ReadAttributeString('LizenzInstallationID');
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
if (!preg_match('/^[0-9a-f-]{36}$/i', $id) || $token === '') {
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
}
$url = 'https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4/operation';
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('V4-Verbindung konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($handle, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_POSTFIELDS => json_encode($payload->operation, JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $token]]);
$answer = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
if ($answer === false || $status !== 200) {
throw new RuntimeException('V4-Schattenanbindung HTTP ' . $status . '; bestehende Regelung unveraendert.');
}
if (strlen($answer) > 65536) {
throw new RuntimeException('V4-Antwort ist zu gross.');
}
$ack = json_decode($answer, true, 512, JSON_THROW_ON_ERROR);
if (!in_array($ack['status'] ?? '', ['stored', 'duplicate', 'archived_older'], true)) {
throw new RuntimeException('Unerwartete V4-Annahmebestaetigung.');
}
} finally {
curl_close($handle);
}
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
'status' => 'sent_shadow', 'capturedAt' => $payload->operation->observedAt,
'acceptance' => $ack['status'], 'warnings' => $data['warnings'], 'liveEnabled' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
// Only this sender's diagnosis changes; never SetStatus, live plan or actuator commands.
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
'status' => 'error', 'reason' => substr($error->getMessage(), 0, 300), 'liveEnabled' => false], JSON_THROW_ON_ERROR));
}
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use LogicException;
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
final class ManagerPrognoseFormular
{
/** Stable identifiers remain for archives and integrations; operation belongs in Forecast. */
public static function laufzeitAnzeige(): array
{
return [
'NetzfahrplanV4Aktivtest' => 'Netzfahrplansteuerung (Testbetrieb)',
'NetzfahrplanV4Vorschau' => 'Netzfahrplanvorschau (keine Stellfreigabe)',
'NetzfahrplanV4VorschauJSON' => 'Netzfahrplandiagnose',
'NetzfahrplanV4Datenstatus' => 'Prognosedaten und Modellversorgung',
];
}
public static function startErlaubt(array $zustand): bool
{
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $feld) {
if (($zustand[$feld] ?? false) !== true) {
return false;
}
}
return ($zustand['legacyAktiv'] ?? true) === false
&& ($zustand['aktivtestAktiv'] ?? true) === false
&& isset($zustand['ende'], $zustand['jetzt'])
&& is_int($zustand['ende']) && is_int($zustand['jetzt'])
&& ($zustand['ende'] === 0 || $zustand['ende'] > $zustand['jetzt']);
}
public static function statustext(array $zustand): string
{
$text = ($zustand['aktivtestAktiv'] ?? false)
? 'Netzfahrplan: befristeter Testbetrieb eingeschaltet.'
: 'Netzfahrplan: Steuerung ausgeschaltet.';
if ($zustand['legacyAktiv'] ?? false) {
$text = 'Bisheriger Netzfahrplan eingeschaltet; neuer Netzfahrplan gesperrt.';
}
if (!($zustand['lizenziert'] ?? false)) {
$text .= ' Netzfahrplanlizenz fehlt.';
}
$ende = $zustand['ende'] ?? 0;
if (is_int($ende) && $ende > 0) {
$text .= ' Bestehendes Testende: ' . gmdate('d.m.Y H:i:s', $ende) . ' UTC.';
if ($ende <= ($zustand['jetzt'] ?? 0)) {
$text .= ' Testfreigabe abgelaufen.';
}
}
return $text . ' Kein freigegebener Dauerbetrieb. Bestehende Laufzeit-, Messwert- und Geraeteschutzpruefungen bleiben wirksam.';
}
public static function integrieren(array $formular, array $zustand): array
{
$gefunden = false;
$v4Konfiguriert = ($zustand['senderAktiv'] ?? false)
|| ($zustand['empfangAktiv'] ?? false) || ($zustand['messdatenAktiv'] ?? false);
foreach ($formular['elements'] as &$element) {
if (($element['name'] ?? null) !== 'PrognoseForecast') {
continue;
}
if ($gefunden) {
throw new LogicException('Prognosebereich ist nicht eindeutig.');
}
$gefunden = true;
foreach ($element['items'] as &$feld) {
if (($feld['name'] ?? null) === 'NetzfahrplanAktiv' && $v4Konfiguriert) {
// An active legacy controller remains visible so it can still be disabled.
$feld['visible'] = (bool) ($zustand['legacyAktiv'] ?? false);
$feld['enabled'] = (bool) ($zustand['legacyAktiv'] ?? false);
$feld['caption'] = 'Bisheriger Netzfahrplan (konkurrierende Steuerung)';
}
}
unset($feld);
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseLizenzVerwalten',
'caption' => 'Prognose und Netzfahrplan im Lizenzportal verwalten', 'link' => true,
'onClick' => "echo 'https://license.enelix.ch';",
];
$element['items'][] = [
'type' => 'CheckBox', 'name' => 'NetzfahrplanV4EmpfangAktiv',
'caption' => 'Aktuellen Netzfahrplan empfangen und anzeigen',
'enabled' => (bool) (($zustand['lizenziert'] ?? false) || ($zustand['empfangAktiv'] ?? false)),
];
$element['items'][] = [
'type' => 'Label', 'name' => 'PrognoseNetzfahrplanStatus',
'caption' => self::statustext($zustand),
];
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStart',
'caption' => 'Netzfahrplan einschalten (Testbetrieb)',
'enabled' => self::startErlaubt($zustand),
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', true);",
];
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStop',
'caption' => 'Netzfahrplan ausschalten', 'enabled' => true,
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', false);",
];
$element['items'][] = [
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanPruefen',
'caption' => 'Plan jetzt pruefen',
'onClick' => "IPS_RequestAction(\$id, 'NetzfahrplanV4Empfangen', true);",
];
}
unset($element);
if (!$gefunden) {
throw new LogicException('Prognosebereich fehlt; Formular nicht erweitert.');
}
return $formular;
}
}
+95 -6
View File
@@ -208,6 +208,7 @@ final class ManagerRegler
'InstanzID' => $eintrag['InstanzID'],
'Prioritaet' => $daten[$prioritaetsfeld],
'Energiegruppe' => (int) round((float) $energie / 2000.0),
'Energie_Wh' => (float) $energie,
'Leistungswerte_W' => $daten['Leistungswerte_W'],
'Festwert' => $festwert,
'Festgehalten' => $festgehalten,
@@ -254,6 +255,7 @@ final class ManagerRegler
}
$rest = $verteilbudget - $nichtSteuerbar - $ausgangssumme;
$gruppen = [];
foreach ($steuerbar as $eintrag) {
if ($eintrag['Festwert'] || $eintrag['Festgehalten']) {
continue;
@@ -262,13 +264,8 @@ final class ManagerRegler
$instanzID = $eintrag['InstanzID'];
$ausgangswert = $sollwerte[$instanzID];
if ($positiveRichtung) {
if ($rest <= 0) {
$gruppen[$eintrag['Prioritaet']][] = $eintrag;
continue;
}
$neuerSollwert = self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'],
$ausgangswert + $rest
);
} else {
if ($rest >= 0) {
continue;
@@ -281,6 +278,9 @@ final class ManagerRegler
$rest -= $neuerSollwert - $ausgangswert;
$sollwerte[$instanzID] = $neuerSollwert;
}
foreach ($gruppen as $gruppe) {
self::verteilePositiveGruppe($gruppe, $sollwerte, $rest);
}
$zugeteilt = $nichtSteuerbar;
foreach ($steuerbar as $eintrag) {
@@ -298,6 +298,95 @@ final class ManagerRegler
];
}
/**
* @param list<array<string, mixed>> $gruppe Verbraucher derselben Prioritaet
* @param array<int, int> $sollwerte
*/
private static function verteilePositiveGruppe(array $gruppe, array &$sollwerte, int &$rest): void
{
while ($rest > 0) {
$kandidaten = self::naechsteLeistungsschritte($gruppe, $sollwerte, $rest);
if ($kandidaten === []) {
return;
}
// Ganze Leistungsniveaus gemeinsam vergeben, ohne Bereiche Watt fuer Watt zu durchlaufen.
$unten = $kandidaten[0]['NaechsteLeistung_W'] - 1;
$oben = $unten;
foreach ($kandidaten as $eintrag) {
$oben = max($oben, $sollwerte[$eintrag['InstanzID']] + $rest);
}
while ($unten < $oben) {
$mitte = $unten + intdiv($oben - $unten + 1, 2);
$bedarf = 0;
foreach ($kandidaten as $eintrag) {
$bisher = $sollwerte[$eintrag['InstanzID']];
$bedarf += max(0, self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'],
$mitte
) - $bisher);
if ($bedarf > $rest) {
break;
}
}
if ($bedarf <= $rest) {
$unten = $mitte;
} else {
$oben = $mitte - 1;
}
}
foreach ($kandidaten as $eintrag) {
$id = $eintrag['InstanzID'];
$neu = max($sollwerte[$id], self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'],
$unten
));
$rest -= $neu - $sollwerte[$id];
$sollwerte[$id] = $neu;
}
// Reicht es nicht fuer das ganze naechste Niveau, entscheidet Leistung, Energie, ID.
$kandidaten = self::naechsteLeistungsschritte($gruppe, $sollwerte, $rest);
if ($kandidaten === []) {
return;
}
$erster = $kandidaten[0];
$id = $erster['InstanzID'];
$rest -= $erster['NaechsteLeistung_W'] - $sollwerte[$id];
$sollwerte[$id] = $erster['NaechsteLeistung_W'];
}
}
/**
* @param list<array<string, mixed>> $gruppe
* @param array<int, int> $sollwerte
* @return list<array<string, mixed>>
*/
private static function naechsteLeistungsschritte(array $gruppe, array $sollwerte, int $rest): array
{
$kandidaten = [];
foreach ($gruppe as $eintrag) {
$bisher = $sollwerte[$eintrag['InstanzID']];
foreach ($eintrag['Leistungswerte_W'] as $angebot) {
$naechste = is_int($angebot) ? $angebot : max($angebot['Von_W'], $bisher + 1);
if ($naechste <= $bisher || (!is_int($angebot) && $naechste > $angebot['Bis_W'])) {
continue;
}
if ($naechste - $bisher <= $rest) {
$eintrag['NaechsteLeistung_W'] = $naechste;
$kandidaten[] = $eintrag;
}
break;
}
}
usort($kandidaten, static function (array $links, array $rechts): int {
return [$links['NaechsteLeistung_W'], $links['Energie_Wh'], $links['InstanzID']]
<=> [$rechts['NaechsteLeistung_W'], $rechts['Energie_Wh'], $rechts['InstanzID']];
});
return $kandidaten;
}
/**
* @param array<string, mixed> $daten
*/
+211
View File
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
trait ManagerSDLTrait
{
private function pruefeSDLKonfiguration(): void
{
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
return;
}
$leistungID = $this->ReadPropertyInteger('SDLLeistungVariableID');
if (!$this->istNumerischeVariable($leistungID)) {
throw new InvalidArgumentException('SDL benoetigt eine numerische Istleistungsvariable.');
}
$this->anlagenMessfaktor($this->ReadPropertyFloat('SDLLeistungsfaktor'), 'SDL');
$socID = $this->ReadPropertyInteger('SDLSOCVariableID');
if ($socID !== 0 && (!$this->istNumerischeVariable($socID) || $socID === $leistungID)) {
throw new InvalidArgumentException('SDL-SOC muss eine separate numerische Variable sein.');
}
$andereQuellen = array_merge($this->pvMessquellen(), $this->batterieMessquellen());
$andereQuellen[] = ['VariableID' => $this->ReadPropertyInteger('NetzleistungVariableID')];
foreach ($andereQuellen as $quelle) {
if ($leistungID === $quelle['VariableID']) {
throw new InvalidArgumentException(
'SDL-Leistung darf nicht zugleich als PV-, Netz- oder Batterieleistung bilanziert werden.'
);
}
}
}
private function synchronisiereSDLMesskonfiguration(): void
{
$neu = json_encode([
$this->ReadPropertyBoolean('SDLAktiv'),
$this->ReadPropertyInteger('SDLLeistungVariableID'),
$this->ReadPropertyFloat('SDLLeistungsfaktor'),
], JSON_THROW_ON_ERROR);
$alt = $this->ReadAttributeString('SDLMesskonfiguration');
if ($neu !== $alt && ($alt !== '' || $this->ReadPropertyBoolean('SDLAktiv'))) {
// Bereits erfasste Energien bleiben erhalten; keine Rueckrechnung mit neuen Quellen.
$this->WriteAttributeInteger('MessungLetzterZeitstempel', 0);
$this->WriteAttributeString('MessungZaehlerstaende', '{}');
$this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', 0);
}
$this->WriteAttributeString('SDLMesskonfiguration', $neu);
}
private function aktuelleSDLLeistung(): float
{
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
return 0.0;
}
$variableID = $this->ReadPropertyInteger('SDLLeistungVariableID');
if (!$this->istNumerischeVariable($variableID)) {
throw new RuntimeException('Die SDL-Istleistung ist nicht verfuegbar.');
}
$wert = GetValue($variableID);
if ((!is_int($wert) && !is_float($wert)) || !is_finite((float) $wert)) {
throw new RuntimeException('Die SDL-Istleistung ist keine endliche Zahl.');
}
$leistung = (float) $wert * $this->ReadPropertyFloat('SDLLeistungsfaktor');
if (!is_finite($leistung)) {
throw new RuntimeException('Die skalierte SDL-Istleistung ist nicht endlich.');
}
if ($this->ReadPropertyInteger('SDLSOCVariableID') === 0 || $this->aktuelleSDLSOC() !== null) {
$this->setzeDiagnosewert('SDLStatus', 'Aktiv');
}
return $leistung;
}
private function aktuelleSDLSOC(): ?float
{
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
return null;
}
$variableID = $this->ReadPropertyInteger('SDLSOCVariableID');
if ($variableID === 0) {
return null;
}
if ($this->istNumerischeVariable($variableID)) {
$wert = (float) GetValue($variableID);
if (is_finite($wert) && $wert >= 0.0 && $wert <= 100.0) {
return $wert;
}
}
$this->setzeDiagnosewert('SDLStatus', 'Fehler: SDL-SOC muss zwischen 0 und 100 Prozent liegen.');
return null;
}
private function sdlAnzeigen(string $property): bool
{
return $this->ReadPropertyBoolean('SDLAktiv') && $this->ReadPropertyBoolean($property);
}
private function aktualisiereSDLStatus(): void
{
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
$this->setzeDiagnosewert('SDLStatus', 'NichtVerwendet');
return;
}
try {
$this->aktuelleSDLLeistung();
$this->aktuelleSDLSOC();
} catch (Throwable $fehler) {
$this->setzeDiagnosewert('SDLStatus', 'Fehler: ' . $fehler->getMessage());
}
}
private function synchronisiereSDLEnergiefluss(int $instanzID, bool $profilAktualisieren = false): void
{
$anzeigen = $this->sdlAnzeigen('SDLEnergieflussAnzeigen');
$variableID = $this->findeUnterobjekt('EnergieflussSDL');
if (!$anzeigen && $variableID === 0) {
return;
}
if ($anzeigen && ($variableID === 0 || $profilAktualisieren)) {
$variableID = $this->energieflussLeistungsvariable(
'EnergieflussSDL', 'Energiefluss SDL', $this->energieflussLeistungsprofil(), 635
);
}
$alt = json_decode(IPS_GetProperty($instanzID, 'Config'), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($alt) || !array_is_list($alt)) {
throw new RuntimeException('Die bestehende Energiefluss-Konfiguration ist keine Liste.');
}
$neu = [];
$gefunden = false;
$socID = $this->aktuelleSDLSOC() !== null
? $this->GetIDForIdent('SDLSOCArchiv') : 1;
foreach ($alt as $knoten) {
if ((int) ($knoten['VariableID'] ?? 0) !== $variableID) {
$neu[] = $knoten;
} elseif ($anzeigen && !$gefunden) {
$knoten['InfoVariableID'] = $socID;
$neu[] = $knoten;
$gefunden = true;
}
}
if ($anzeigen && !$gefunden) {
$neu[] = $this->energieflussKnoten($variableID, 'SDL / Regelenergie', 'storage', false, $socID);
}
if ($neu !== $alt) {
IPS_SetProperty($instanzID, 'Config', json_encode($neu, JSON_THROW_ON_ERROR));
IPS_ApplyChanges($instanzID);
}
}
private function synchronisiereSDLDiagramm(int $medienID, bool $energie): void
{
$idents = $energie
? ['SDLLadenEnergie', 'SDLEntladenEnergie', 'SDLSOCArchiv']
: ['SDLLeistungArchiv', 'SDLSOCArchiv'];
$verwaltet = array_map(fn (string $ident): int => $this->GetIDForIdent($ident), $idents);
$gewuenscht = [];
if ($this->sdlAnzeigen('SDLDiagrammeAnzeigen')) {
if ($energie) {
$gewuenscht[] = $this->chartDatensatz(
'SDLLadenEnergie', 'SDL laden', '#007C91', '~Electricity', 'bar'
);
$gewuenscht[] = $this->chartDatensatz(
'SDLEntladenEnergie', 'SDL entladen', '#C43E1C', '~Electricity', 'bar'
);
} else {
$gewuenscht[] = $this->chartDatensatz(
'SDLLeistungArchiv', 'SDL Istleistung', '#007C91', '~Power'
);
}
if ($this->ReadPropertyInteger('SDLSOCVariableID') > 0) {
$soc = $this->chartDatensatz('SDLSOCArchiv', 'SDL SOC', '#C43E1C', '~Intensity.100');
$soc['side'] = 'right';
$gewuenscht[] = $soc;
}
}
$inhalt = json_decode((string) base64_decode(IPS_GetMediaContent($medienID), true), true);
if (!is_array($inhalt) || !isset($inhalt['datasets']) || !is_array($inhalt['datasets'])) {
if (!$this->sdlAnzeigen('SDLDiagrammeAnzeigen')) {
return;
}
throw new RuntimeException('Das bestehende Diagramm hat keine gueltigen Datensaetze.');
}
$alt = $inhalt['datasets'];
$neu = [];
foreach ($alt as $datensatz) {
$id = (int) ($datensatz['variableID'] ?? 0);
if (!in_array($id, $verwaltet, true)) {
$neu[] = $datensatz;
continue;
}
foreach ($gewuenscht as $index => $ziel) {
if ($id === $ziel['variableID']) {
$neu[] = $datensatz;
unset($gewuenscht[$index]);
break;
}
}
}
$neu = array_merge($neu, array_values($gewuenscht));
if ($neu !== $alt) {
$inhalt['datasets'] = $neu;
IPS_SetMediaContent($medienID, base64_encode(json_encode($inhalt, JSON_THROW_ON_ERROR)));
}
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
require_once __DIR__ . '/NetzfahrplanV4Bezugszaehler.php';
/** Read-only conversion. Meter evidence is never derived from a configured limit. */
final class NetzfahrplanV4Betriebsdaten
{
private static function number($value, string $label, float $min = -1.0e9, float $max = 1.0e9): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value)
|| $value < $min || $value > $max) {
throw new InvalidArgumentException($label . ': ungueltiger Zahlenwert.');
}
return (float) $value;
}
private static function timestamp($value): int
{
if (!is_string($value) || !preg_match('/(?:Z|[+-]\d{2}:\d{2})$/', $value)) {
throw new InvalidArgumentException('Messnachweis benoetigt einen Zeitpunkt mit Zeitzone.');
}
return (new DateTimeImmutable($value))->getTimestamp();
}
/** $read returns ['value' => int|float, 'updated' => int], without changing a device. */
private static function measurement(callable $read, int $id, int $now, int $age, string $label): array
{
if ($id <= 0) {
throw new InvalidArgumentException($label . ': Messquelle fehlt.');
}
$m = $read($id);
if (!isset($m['updated']) || !is_int($m['updated']) || $m['updated'] > $now
|| $now - $m['updated'] > $age) {
throw new InvalidArgumentException($label . ': Messwert fehlt, ist veraltet oder liegt in der Zukunft.');
}
return ['value' => self::number($m['value'] ?? null, $label), 'updated' => $m['updated']];
}
/** No inference of paid peak or elapsed energy from power, reserve or forecast. */
private static function evidence(array $input, int $now, string $meterID, array &$warnings): array
{
$peaks = [];
$past = null;
if ($input === []) {
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
return [(object) [], null];
}
if ($meterID === '' || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== $meterID) {
throw new InvalidArgumentException('Messnachweis: falsche Version oder Bezugszaehler-Zuordnung.');
}
$measured = self::timestamp($input['measuredAt'] ?? null);
if ($measured > $now || $now - $measured > 120) {
throw new InvalidArgumentException('Messnachweis ist nicht aktuell.');
}
$month = (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('Y-m');
foreach (($input['measuredPeaks'] ?? []) as $key => $p) {
if (!is_string($key) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $key) || $key > $month
|| !in_array($p['source'] ?? '', ['meter_month_register', 'verified_month_history', 'verified_new_month'], true)) {
throw new InvalidArgumentException('Messnachweis: Monatspeak ist kein gueltiger Messnachweis.');
}
$peaks[$key] = ['kw' => self::number($p['kw'] ?? null, 'Monatspeak', 0.0), 'source' => $p['source']];
}
if (!array_key_exists($month, $peaks)) {
$warnings[] = 'Vollstaendiger Monatspeak fuer ' . $month . ' fehlt; Managergrenze ist kein Ersatz.';
}
$q = intdiv($now, 900) * 900;
if (isset($input['quarterPast'])) {
$p = $input['quarterPast'];
// Stale quarter evidence remains absent, not extrapolated to the decision time.
if ($measured === $now && self::timestamp($p['start'] ?? null) === $q
&& ($p['measuredSeconds'] ?? null) === $now - $q) {
$past = ['start' => gmdate('c', $q),
'measuredSeconds' => $now - $q,
'importKwh' => self::number($p['importKwh'] ?? null, 'Viertelstundenenergie', 0.0)];
} else {
$warnings[] = 'Viertelstundenenergie passt nicht exakt zum Entscheidungszeitpunkt; nicht verwendet.';
}
}
if ($now !== $q && $past === null) {
$warnings[] = 'Bisherige Energie der laufenden Viertelstunde fehlt; keine Hochrechnung als Messung.';
}
return [(object) $peaks, $past];
}
/** Inputs are explicit non-secret configuration/cache fields, not full instance settings. */
public static function erstellen(array $manager, array $assets, array $controllers, callable $read, array $evidence, int $now): array
{
$warnings = [];
$meter = NetzfahrplanV4Bezugszaehler::lesen(
$manager['V4BezugszaehlerQuellen'] ?? [], $read, $now
);
$grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now,
(int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung');
$gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor');
$mode = $manager['Lastspitzenmodus'] ?? 0;
$import = null;
$months = [];
if ($mode === 1) {
$import = self::number($manager['Lastspitzengrenze'] ?? null, 'Managergrenze', 0.0);
} elseif ($mode === 2) {
foreach (($manager['Monatsgrenzen'] ?? []) as $row) {
$m = $row['MonatIndex'] ?? null;
if (!is_int($m) || $m < 1 || $m > 12 || isset($months[(string) $m])) {
throw new InvalidArgumentException('Monatsgrenzen fehlen oder sind doppelt.');
}
$months[(string) $m] = self::number($row['Grenze_W'] ?? null, 'Monatsgrenze', 0.0);
}
if (count($months) !== 12) {
throw new InvalidArgumentException('Alle zwoelf Monatsgrenzen werden benoetigt.');
}
} elseif ($mode !== 0) {
throw new InvalidArgumentException('Unbekannter Lastspitzenmodus.');
}
$export = !empty($manager['EinspeisebegrenzungAktiv'])
? self::number($manager['Einspeisegrenze'] ?? null, 'Einspeisegrenze', 0.0) : null;
$result = [];
$seen = [];
$used = [];
foreach ($assets as $asset) {
$id = $asset['ID'] ?? '';
if (!is_string($id) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $id) || isset($seen[$id])) {
throw new InvalidArgumentException('Batterie-ID fehlt oder ist doppelt.');
}
$seen[$id] = true;
$socID = (int) ($asset['SOCVariableID'] ?? 0);
$matches = array_values(array_filter($controllers, static function (array $c) use ($socID): bool {
return $socID > 0 && ($c['LadezustandVariableID'] ?? 0) === $socID;
}));
if (count($matches) !== 1) {
throw new InvalidArgumentException($id . ': keine eindeutige aktive Batterieinstanz zur SOC-Quelle.');
}
$c = $matches[0];
$controllerID = $c['InstanzID'];
if (isset($used[$controllerID])) {
throw new InvalidArgumentException('Eine Batterieinstanz darf nicht doppelt bilanziert werden.');
}
$used[$controllerID] = true;
if (($asset['LeistungVariableID'] ?? 0) !== ($c['IstleistungVariableID'] ?? 0)) {
throw new InvalidArgumentException($id . ': Topologie und Batterieinstanz verwenden verschiedene Leistungsmessungen.');
}
$opts = $manager['BatterieOptionen'][$id] ?? [];
$nominal = self::number($asset['Nennkapazitaet_kWh'] ?? null, 'Nennkapazitaet', 0.001);
$usable = self::number($asset['Nutzkapazitaet_kWh'] ?? null, 'Nutzkapazitaet', 0.001, $nominal);
if (abs($nominal - $usable) > 1.0e-6 && !array_key_exists('SOCKapazitaet_kWh', $opts)) {
throw new InvalidArgumentException($id . ': SOC-Kapazitaetsbasis bei verschiedener Nenn-/Nutzkapazitaet bestaetigen.');
}
$capacity = self::number($opts['SOCKapazitaet_kWh'] ?? $nominal, 'SOC-Kapazitaet', 0.001, $nominal);
$age = (int) ($c['MesswertMaxAlter'] ?? 30);
$soc = self::measurement($read, $socID, $now, $age, $id . ' SOC');
$charge = self::measurement($read, (int) $c['MaxLadeleistungVariableID'], $now, $age, $id . ' max. Laden');
$discharge = self::measurement($read, (int) $c['MaxEntladeleistungVariableID'], $now, $age, $id . ' max. Entladen');
$min = max(self::number($c['MindestLadezustand'], 'BMS-Minimum', 0, 100),
self::number($c['ReserveLadezustand'], 'Betriebsreserve', 0, 100));
$max = self::number($opts['MaxSOC_Prozent'] ?? 100.0, 'Maximal-SOC', $min, 100);
$socValue = self::number($soc['value'], 'SOC', 0, 100);
$physicalMin = self::number($c['MindestLadezustand'], 'Technisches Minimum', 0, $min);
if ($socValue < $physicalMin || $socValue > $max) {
throw new InvalidArgumentException($id . ': SOC ausserhalb des Planungsbereichs; kein kuenstliches Anheben.');
}
if (($c['EmpfangenAm'] ?? 0) > $now || $now - ($c['EmpfangenAm'] ?? 0) > (int) ($manager['VerbraucherTimeout'] ?? 60)) {
throw new InvalidArgumentException($id . ': Verbraucher-Rueckmeldung veraltet.');
}
$maxCharge = min(self::number($charge['value'], 'Ladeleistung', 0), 1000 * self::number($asset['MaxLadeleistung_kW'], 'Nennladeleistung', 0));
$maxDischarge = min(self::number($discharge['value'], 'Entladeleistung', 0), 1000 * self::number($asset['MaxEntladeleistung_kW'], 'Nennentladeleistung', 0));
if (($c['Verfuegbar'] ?? false) !== true || ($c['Batteriemanagement'] ?? 0) !== 2) {
$maxCharge = $maxDischarge = 0.0;
$warnings[] = $id . ': nicht fuer Managerregelung verfuegbar; keine Batterieleistung eingeplant.';
}
$rearm = min($max, $min + self::number($c['LadezustandHysterese'] ?? 0.0, 'Entladehysterese', 0, 100));
$blocked = !empty($c['HystereseAktiv']) || $socValue <= $min;
if ($blocked) {
$warnings[] = $id . ': Entladesperre im Modell aktiv bis zur Wiederfreigabe nach vorheriger Ladung.';
}
if ($socValue < $min) {
$warnings[] = $id . ': unter Betriebsreserve; realen SOC behalten und nur zulassige Wiederaufladung planen.';
}
$result[] = ['id' => $id, 'capacityKwh' => $capacity, 'socPercent' => $socValue,
'minSocPercent' => $min, 'maxSocPercent' => $max, 'maxChargeW' => $maxCharge,
'maxDischargeW' => $maxDischarge, 'measuredAt' => gmdate('c', min($soc['updated'], $charge['updated'], $discharge['updated'])),
'gridCharging' => ($manager['NetzladenErlaubt'] ?? false) === true,
'physicalMinSocPercent' => $physicalMin, 'recoveryAllowed' => true,
'dischargeBlocked' => $blocked, 'rearmSocPercent' => $rearm];
}
if ($result === []) {
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
}
[$peaks, $past] = self::evidence($evidence, $now, $meter['meterId'], $warnings);
$payload = ['version' => 1, 'observedAt' => gmdate('c', $now), 'gridW' => $gridW,
'meteringBoundary' => 'common_pcc', 'batteries' => $result,
'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months],
'measuredPeaks' => $peaks, 'quarterPast' => $past];
// A fresh acquisition sample is an operational estimate. It does NOT
// claim a calibrated billing-period boundary or full historical coverage.
$policy = [
'adapterVersion' => 'v4-native-estimated-meter-1',
'sourcePlan' => $manager['V4ControlPlanSource'] ?? 'legacy-unspecified',
'mode' => $mode, 'importW' => $import, 'exportW' => $export,
'months' => $months,
'batteryPolicy' => array_map(static function (array $b): array {
return array_intersect_key($b, array_flip(['id', 'capacityKwh', 'minSocPercent', 'maxSocPercent', 'physicalMinSocPercent', 'rearmSocPercent']));
}, $result),
];
$payload['meterObservation'] = [
'meterId' => $meter['meterId'], 'sampleAt' => gmdate('c', $now),
'powerW' => $gridW, 'totalImportKwh' => $meter['totalKwh'],
'controlPolicyId' => hash('sha256', json_encode($policy, JSON_THROW_ON_ERROR)),
];
$payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'bezugszaehler' => $meter, 'operation' => $payload];
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Read-only active-import counter sum, independent of the legacy energy counter.
* Observation timestamps are NOT proof of a billing-quarter boundary or history.
*/
final class NetzfahrplanV4Bezugszaehler
{
public static function quellen(array $sources): array
{
if ($sources === [] || count($sources) > 8
|| array_keys($sources) !== range(0, count($sources) - 1)) {
throw new InvalidArgumentException('V4 benoetigt 1 bis 8 explizite Wirkenergie-Bezugsquellen.');
}
$result = [];
$parent = null;
foreach ($sources as $source) {
if (!is_array($source) || count($source) !== 5
|| ($source['Messgroesse'] ?? null) !== 'WirkenergieBezug') {
throw new InvalidArgumentException('V4 Bezugszaehler: Messgroesse oder Quellenschema ungueltig.');
}
$id = $source['VariableID'] ?? null;
$pid = $source['ElternID'] ?? null;
$ident = $source['Ident'] ?? null;
$factor = $source['FaktorZuKWh'] ?? null;
if (!is_int($id) || $id <= 0 || !is_int($pid) || $pid <= 0
|| !is_string($ident) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/D', $ident)
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor)
|| $factor <= 0 || $factor > 1.0e6 || isset($result[$id])) {
throw new InvalidArgumentException('V4 Bezugszaehler: ID, Ident, Einheit oder doppelte Quelle ungueltig.');
}
if ($parent !== null && $parent !== $pid) {
throw new InvalidArgumentException('V4 T1/T2 muessen zum selben physischen Bezugszaehler gehoeren.');
}
$parent = $pid;
foreach ($result as $existing) {
if ($existing['Ident'] === $ident) {
throw new InvalidArgumentException('V4 Bezugszaehler: Register doppelt angegeben.');
}
}
$result[$id] = ['VariableID' => $id, 'ElternID' => $pid, 'Ident' => $ident,
'FaktorZuKWh' => (float) $factor, 'Messgroesse' => 'WirkenergieBezug'];
}
ksort($result, SORT_NUMERIC);
return array_values($result);
}
public static function identitaet(array $sources): string
{
// Source/factor changes invalidate old measurement evidence; names do not matter.
return 'symcon-active-import:' . hash('sha256', json_encode(self::quellen($sources),
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
}
private static function probe(array $source, callable $read, int $now, int $maxAge): array
{
$m = $read($source['VariableID']);
if (!is_array($m) || ($m['parentID'] ?? null) !== $source['ElternID']
|| ($m['ident'] ?? null) !== $source['Ident']) {
throw new InvalidArgumentException('V4 Bezugszaehler: Variable passt nicht zum konfigurierten Register.');
}
$value = $m['value'] ?? null;
$updated = $m['updated'] ?? null;
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < 0
|| !is_int($updated) || $updated <= 0 || $updated > $now || $now - $updated > $maxAge) {
throw new InvalidArgumentException('V4 Bezugszaehler: Teilwert fehlt, ist veraltet oder ungueltig.');
}
$kwh = (float) $value * $source['FaktorZuKWh'];
if (!is_finite($kwh) || $kwh > 1.0e12) {
throw new InvalidArgumentException('V4 Bezugszaehler: Energie ausserhalb des Messbereichs.');
}
return ['variableId' => $source['VariableID'], 'rawValue' => (float) $value,
'totalKwh' => $kwh, 'updatedAt' => $updated];
}
/** $read must return only value, updated, ident, parentID. Never uses an archive fallback. */
public static function lesen(array $sources, callable $read, int $now, int $maxAge = 60, int $maxSkew = 2): array
{
if ($now <= 0 || $maxAge < 1 || $maxAge > 300 || $maxSkew < 0 || $maxSkew > 5) {
throw new InvalidArgumentException('V4 Bezugszaehler: Zeitfenster ungueltig.');
}
$sources = self::quellen($sources);
$samples = [];
foreach ($sources as $source) {
$samples[] = self::probe($source, $read, $now, $maxAge);
}
// Reread all channels to reject concurrent updates, including changes in the same second.
foreach ($sources as $i => $source) {
if ($samples[$i] !== self::probe($source, $read, $now, $maxAge)) {
throw new InvalidArgumentException('V4 Bezugszaehler wurde waehrend des Lesens aktualisiert.');
}
}
$times = array_column($samples, 'updatedAt');
if (max($times) - min($times) > $maxSkew) {
throw new InvalidArgumentException('V4 Bezugszaehler: T1/T2-Zeitpunkte liegen zu weit auseinander.');
}
return ['meterId' => self::identitaet($sources), 'quantity' => 'active_import', 'unit' => 'kWh',
'totalKwh' => array_sum(array_column($samples, 'totalKwh')),
'observedAt' => gmdate('c', $now), 'sourceObservationFrom' => gmdate('c', min($times)),
'sourceObservationUntil' => gmdate('c', max($times)), 'components' => $samples,
'billingEvidence' => false, 'historyComplete' => false];
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Pure accounting candidate. No IPS, archive changes, network or control permission.
* Battery-positive = charging. Grid-positive = import. PV-positive = production.
* Physical storage is subtracted ONCE from house load. Virtual allocation is a
* separate ledger, not a second physical meter. Mapping accuracy requires review.
*/
final class NetzfahrplanV4Bilanzierung
{
private static function number($v, string $name, float $min = -1.0e9, float $max = 1.0e9): float
{
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || $v < $min || $v > $max) {
throw new InvalidArgumentException($name . ': ungueltiger Zahlenwert.');
}
return (float) $v;
}
private static function listArray(array $a): bool
{
return $a === [] || array_keys($a) === range(0, count($a)-1);
}
public static function konfigurieren(array $c): array
{
$fields = ['version','sources','maxSkewSeconds','splitToleranceW'];
if (count($c) !== count($fields) || array_diff($fields,array_keys($c)) || $c['version'] !== 1
|| !is_array($c['sources']) || !self::listArray($c['sources']) || count($c['sources']) < 3 || count($c['sources']) > 80
|| !is_int($c['maxSkewSeconds']) || $c['maxSkewSeconds'] < 0 || $c['maxSkewSeconds'] > 60) {
throw new InvalidArgumentException('Bilanzkonfiguration unvollstaendig.');
}
// Preserve a stable numeric representation across JSON property round-trips.
$c['splitToleranceW'] = self::number($c['splitToleranceW'], 'Zuordnungstoleranz W', 0, 500);
$seen=[];$keys=[];$roles=[];
foreach ($c['sources'] as &$s) {
if (!is_array($s) || count($s)!==8 || array_diff(['key','role','variableId','parentId','ident','factorToW','maxAgeSeconds','dependsOn'],array_keys($s))) {
throw new InvalidArgumentException('Explizite Quellenzuordnung erforderlich.');
}
if (!is_string($s['key']) || !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D',$s['key']) || isset($keys[$s['key']])
|| !in_array($s['role'], ['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],true)
|| !is_int($s['variableId']) || $s['variableId']<=0 || isset($seen[$s['variableId']])
|| !is_int($s['parentId']) || $s['parentId']<=0 || !is_string($s['ident']) || strlen($s['ident'])>100
|| !is_int($s['maxAgeSeconds']) || $s['maxAgeSeconds']<1 || $s['maxAgeSeconds']>300) {
throw new InvalidArgumentException('Doppelte oder ungueltige Bilanzquelle.');
}
$s['factorToW']=self::number($s['factorToW'],'Einheiten-/Vorzeichenfaktor',-1e6,1e6);
if ($s['factorToW']==0 || !is_array($s['dependsOn']) || !self::listArray($s['dependsOn']) || count($s['dependsOn'])>40) {
throw new InvalidArgumentException('Abhaengigkeiten oder Faktor fehlen.');
}
foreach ($s['dependsOn'] as $dep) if (!is_string($dep)) throw new InvalidArgumentException('Quellenschluessel erforderlich.');
if (count(array_unique($s['dependsOn']))!==count($s['dependsOn'])) throw new InvalidArgumentException('Doppelte Abhaengigkeit.');
sort($s['dependsOn']);$keys[$s['key']]=true;$seen[$s['variableId']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
}
unset($s);
if (($roles['grid']??0)!==1 || !isset($roles['pv'],$roles['physical_storage'],$roles['controlled_virtual'])) {
throw new InvalidArgumentException('Netz, PV, physische und steuerbare virtuelle Batterie muessen explizit zugeordnet sein.');
}
$graph=[];foreach ($c['sources'] as $s) $graph[$s['key']]=$s['dependsOn'];
$visit=function($key,$stack=[]) use (&$visit,$graph): void {
if (!array_key_exists($key,$graph) || isset($stack[$key])) throw new InvalidArgumentException('Fehlende oder zyklische Ursprungsquelle.');
$stack[$key]=true;foreach ($graph[$key] as $dep) $visit($dep,$stack);
};
foreach (array_keys($graph) as $key) $visit($key);
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));return $c;
}
/** Reader: {value:number, updated:int, parentID:int, ident:string}. */
public static function aufnehmen(array $configuration, callable $read, int $now): array
{
if ($now<=0) throw new InvalidArgumentException('Ungueltige Aufnahmezeit.');
$c=self::konfigurieren($configuration);$samples=[];$sources=[];$issues=[];
$probe=static function(array $s) use ($read): array {
$v=$read($s['variableId']);
if (!is_array($v) || ($v['parentID']??null)!==$s['parentId'] || ($v['ident']??null)!==$s['ident']
|| !is_int($v['updated']??null) || $v['updated']<=0) throw new InvalidArgumentException('Quelle/Originalzeitpunkt ungueltig: '.$s['key']);
return ['raw'=>self::number($v['value']??null,$s['key']), 'updated'=>$v['updated']];
};
foreach ($c['sources'] as $s) {$sources[$s['key']]=$s;$samples[$s['key']]=$probe($s);}
foreach ($c['sources'] as $s) if ($samples[$s['key']]!==$probe($s)) throw new InvalidArgumentException('Aufnahme waehrend des Lesens geaendert: '.$s['key']);
$origin=function($key) use (&$origin,$sources,$samples): int {
$stamp=$samples[$key]['updated'];foreach ($sources[$key]['dependsOn'] as $dep) $stamp=min($stamp,$origin($dep));return $stamp;
};
$totals=array_fill_keys(['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],0.0);
$out=[];$times=[];
foreach ($sources as $key=>$s) {
$t=$origin($key);$w=$samples[$key]['raw']*$s['factorToW'];
if (!is_finite($w) || abs($w)>1e9) throw new InvalidArgumentException('Leistung ausserhalb des Bereichs.');
if ($samples[$key]['updated']>$now || $t>$now) $issues[]='future_source:'.$key;
if ($now-$t>$s['maxAgeSeconds']) $issues[]='stale_origin:'.$key;
if (in_array($s['role'],['pv','flexible_load'],true) && $w<0) $issues[]='negative_unsigned_source:'.$key;
$totals[$s['role']]+=$w;
if ($s['role']!=='reference') $times[]=$t;
$out[$key]=['variableId'=>$s['variableId'],'role'=>$s['role'],'powerW'=>$w,'sourceUpdatedAt'=>$samples[$key]['updated'],'oldestOriginAt'=>$t];
}
$skew=max($times)-min($times);if ($skew>$c['maxSkewSeconds']) $issues[]='source_time_skew';
$load=$totals['grid']+$totals['pv']-$totals['physical_storage'];
$base=$load-$totals['flexible_load'];
// This channel preserves unallocated dynamics instead of hiding them in house load.
$residual=$totals['physical_storage']-$totals['controlled_virtual']-$totals['external_virtual'];
$external=$totals['external_virtual']+$residual;
if ($base<0) $issues[]='negative_base_load';
if (abs($residual)>$c['splitToleranceW']) $issues[]='virtual_physical_allocation_mismatch';
$reconstructed=$base+$totals['flexible_load']+$totals['controlled_virtual']+$external-$totals['pv'];
$identity=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
return ['version'=>1,'status'=>$issues===[]?'consistent_candidate':'quality_hold',
'capturedAt'=>gmdate('c',$now),'sourceObservedFrom'=>gmdate('c',min($times)),'sourceObservedUntil'=>gmdate('c',max($times)),
'sourceSkewSeconds'=>$skew,'mappingId'=>'accounting-map:'.$identity,
'baseLoadCandidateW'=>$base,'consumerLoadCandidateW'=>$load,'pvW'=>$totals['pv'],'gridW'=>$totals['grid'],
'physicalStorageW'=>$totals['physical_storage'],'controlledVirtualW'=>$totals['controlled_virtual'],
'flexibleLoadW'=>$totals['flexible_load'],'reportedExternalW'=>$totals['external_virtual'],
'allocationResidualW'=>$residual,'externalEffectW'=>$external,'reconstructedGridW'=>$reconstructed,
'arithmeticBalanceErrorW'=>$reconstructed-$totals['grid'],'issues'=>$issues,'sources'=>$out,
'controlEligible'=>false,'meteringBoundaryVerified'=>false,'forecastHistoryMigrated'=>false,
'note'=>'Algebraic closure is not independent meter validation. No relabelling of old forecasts, no future SDL assumption.'];
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
/** Lossless archival of fully acknowledged OLD outbox days. No device/API calls. */
final class NetzfahrplanV4Datenarchiv
{
public static function run(string $directory, array $cursor, int $now): array
{
$day=$cursor['day']??'';
if ($day==='') return ['archived'=>0];
if (!is_string($day)||!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)
||!is_int($cursor['offset']??null)||$cursor['offset']<0
||is_link($directory)||realpath($directory)!==$directory) {
throw new \RuntimeException('Invalid archive cursor/directory');
}
$cutoff='raw-'.gmdate('Ymd',$now-7*86400).'.jsonl';
$archive=$directory.'/archive';
if (is_link($archive)) throw new \RuntimeException('Archive symlink refused');
if (!is_dir($archive)&&!mkdir($archive,0700)) throw new \RuntimeException('Archive directory unavailable');
if ((fileperms($archive)&0007)!==0) throw new \RuntimeException('Archive must remain private');
$lockPath=$directory.'/.writer.lock';
if (is_link($lockPath)) throw new \RuntimeException('Writer lock symlink refused');
$lock=fopen($lockPath,'c+b');
if ($lock===false) throw new \RuntimeException('Archive lock unavailable');
$done=[];
try {
if (!flock($lock,LOCK_EX|LOCK_NB)) return ['archived'=>0,'status'=>'writer_busy'];
foreach (glob($directory.'/raw-*.jsonl')?:[] as $file) {
$name=basename($file);
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||$name>=$day||$name>=$cutoff) continue;
if (is_link($file)||realpath($file)!==$file||!is_file($file)) throw new \RuntimeException('Unexpected journal');
$before=stat($file);
if (!$before||$before['size']>67108864) throw new \RuntimeException('Archive input bounds');
$digest=hash_file('sha256',$file);
if (!is_string($digest)) throw new \RuntimeException('Cannot hash journal');
$target=$archive.'/'.$name.'.'.$digest.'.gz';
if (is_link($target)) throw new \RuntimeException('Archive target symlink');
if (!is_file($target)) {
$tmp=tempnam($archive,'.pending-');
if ($tmp===false) throw new \RuntimeException('Cannot create archive');
try {
chmod($tmp,0600);$in=fopen($file,'rb');$gz=gzopen($tmp,'wb6');
if ($in===false||$gz===false) {if(is_resource($in))fclose($in);if(is_resource($gz))gzclose($gz);throw new \RuntimeException('Cannot open archive streams');}
try {
while (!feof($in)) {
$block=fread($in,65536);
if ($block===false) throw new \RuntimeException('Archive read failed');
$offset=0;
while($offset<strlen($block)) {$n=gzwrite($gz,substr($block,$offset));if($n===false||$n===0)throw new \RuntimeException('Archive write failed');$offset+=$n;}
}
} finally {fclose($in);gzclose($gz);}
$sync=fopen($tmp,'r+b');
if ($sync===false) throw new \RuntimeException('Cannot sync archive');
try {if(!fsync($sync))throw new \RuntimeException('Archive sync failed');} finally {fclose($sync);}
if (!rename($tmp,$target)) throw new \RuntimeException('Archive rename failed');
} finally {if(is_file($tmp))unlink($tmp);}
}
$gz=gzopen($target,'rb');if($gz===false)throw new \RuntimeException('Cannot verify archive');
$hash=hash_init('sha256');$bytes=0;
try {while(!gzeof($gz)){$block=gzread($gz,65536);if($block===false)throw new \RuntimeException('Archive verification read failed');$bytes+=strlen($block);if($bytes>$before['size'])throw new \RuntimeException('Oversized decompressed archive');hash_update($hash,$block);}}
finally {gzclose($gz);}
clearstatcache(true,$file);$after=stat($file);
if ($bytes!==$before['size']||hash_final($hash)!==$digest||!$after
||$after['size']!==$before['size']||$after['ino']!==$before['ino']||$after['mtime']!==$before['mtime']
||hash_file('sha256',$file)!==$digest) throw new \RuntimeException('Archive mismatch; original retained');
$directoryHandle=fopen($archive,'r');
if ($directoryHandle===false) throw new \RuntimeException('Cannot sync archive directory');
try {if(!fsync($directoryHandle))throw new \RuntimeException('Archive directory sync failed');} finally {fclose($directoryHandle);}
if (!unlink($file)) throw new \RuntimeException('Verified original could not be retired');
$done[]=$name;
if (count($done)>=2) break;
}
} finally {flock($lock,LOCK_UN);fclose($lock);}
return ['archived'=>count($done),'compressedOriginalsRetained'=>true,'unacknowledgedDaysTouched'=>false];
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
/**
* Binds values to explicitly successful device reads.
*
* Transport calls, object metadata and the clock are injected so the complete
* coordination can be tested without IP-Symcon or real devices.
*/
final class NetzfahrplanV4Geraeteabruf
{
public const MBUS_DEVICE = '{B53BE2E5-892D-4537-94AC-EAC68A469188}';
public const MODBUS_DEVICE = '{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}';
public const MODBUS_ADDRESS = '{CB197E50-273D-4535-8C91-BB35273E3CA5}';
public static function snapshot(
array $config,
callable $moduleId,
callable $refresh,
callable $reader,
callable $clock,
?callable $pause = null
): array {
$config = NetzfahrplanV4Rueckmeldung::configuration($config);
$groups = [];
$sourcesById = [];
foreach ($config['sources'] as $source) {
$sourcesById[$source['variableId']] = $source;
if (in_array($source['role'], ['grid', 'physical'], true)) {
$groups[$source['parentId']][] = $source;
}
}
$snapshot = [];
foreach ($groups as $parentId => $sources) {
$module = $moduleId($parentId);
if (!in_array($module, [self::MBUS_DEVICE, self::MODBUS_DEVICE, self::MODBUS_ADDRESS], true)) {
throw new InvalidArgumentException('feedback_refresh_module_unsupported:' . $sources[0]['key']);
}
$confirmed = false;
for ($attempt = 1; $attempt <= 4; ++$attempt) {
if ($refresh($parentId, $module) === true) {
$confirmed = true;
break;
}
if ($attempt < 4 && $pause !== null) {
$pause(200000);
}
}
if (!$confirmed) {
throw new InvalidArgumentException('feedback_device_read_failed:' . $sources[0]['key']);
}
$confirmedAt = $clock();
if (!is_int($confirmedAt) || $confirmedAt < 1) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
foreach ($sources as $source) {
$record = $reader($source['variableId']);
if (!is_array($record)) {
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
}
$record['confirmedAt'] = $confirmedAt;
$record['confirmedModuleId'] = $module;
$snapshot[$source['variableId']] = $record;
}
}
foreach ($config['sources'] as $source) {
if (!isset($snapshot[$source['variableId']])) {
$record = $reader($source['variableId']);
if (!is_array($record)) {
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
}
$snapshot[$source['variableId']] = $record;
}
}
foreach ($snapshot as $variableId => $record) {
$again = $reader($variableId);
// The variable may legitimately receive a newer process value immediately after
// the explicit device read. Keep the confirmed snapshot, but require the source
// identity to remain stable so a reconfigured/reparented variable is never accepted.
$identityKeys = ['parentID', 'ident'];
if (!is_array($again) || array_intersect_key($again, array_flip($identityKeys))
!== array_intersect_key($record, array_flip($identityKeys))) {
throw new InvalidArgumentException(
'feedback_source_changed_after_confirmed_read:' . $sourcesById[$variableId]['key']
);
}
}
$checkedAt = $clock();
if (!is_int($checkedAt) || $checkedAt < 1) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
foreach ($snapshot as $record) {
if (isset($record['confirmedAt']) && $record['confirmedAt'] > $checkedAt) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
}
return NetzfahrplanV4Rueckmeldung::read(
$config,
static fn(int $variableId): array => $snapshot[$variableId],
$checkedAt
);
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Bilanzierung.php';
/** Passive, versioned RAW capture. Never grants training/control eligibility.
* Reader supplies numeric values only; no configuration, credentials or network.
*/
final class NetzfahrplanV4Messaufnahme
{
public static function configuration(array $c): array
{
if (($c['version'] ?? null) !== 1 || !is_string($c['installationId'] ?? null)
|| !preg_match('/^[0-9a-f-]{36}$/D', $c['installationId'])
|| !is_array($c['extraSources'] ?? null) || count($c['extraSources']) > 60
|| !is_string($c['reportedInventorySha256'] ?? null)
|| !preg_match('/^[0-9a-f]{64}$/D', $c['reportedInventorySha256'])) {
throw new InvalidArgumentException('Invalid capture configuration');
}
$c['accounting'] = NetzfahrplanV4Bilanzierung::konfigurieren($c['accounting'] ?? []);
$keys = []; $ids = [];
foreach ($c['accounting']['sources'] as $s) { $keys[$s['key']] = true; $ids[$s['variableId']] = true; }
foreach ($c['extraSources'] as $s) {
if (count($s) !== 5 || !is_string($s['key'] ?? null)
|| !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D', $s['key']) || isset($keys[$s['key']])
|| !is_int($s['variableId'] ?? null) || $s['variableId'] < 1 || isset($ids[$s['variableId']])
|| !is_int($s['parentId'] ?? null) || $s['parentId'] < 1
|| !is_string($s['ident'] ?? null) || strlen($s['ident']) > 100
|| !in_array($s['unit'] ?? null, ['W','kWh','percent','raw_power','scale'], true)) {
throw new InvalidArgumentException('Invalid or duplicate raw source');
}
$keys[$s['key']] = true; $ids[$s['variableId']] = true;
}
usort($c['extraSources'], static fn(array $a, array $b): int => strcmp($a['key'], $b['key']));
return $c;
}
private static function readSource(array $s, callable $read): array
{
try {
$r = $read($s['variableId']);
if (!is_array($r)) throw new RuntimeException('Missing reading');
} catch (Throwable $e) {
return ['value' => null, 'sourceUpdatedAt' => null, 'sourceChangedAt' => null, 'issues' => ['read_failed']];
}
$issues = [];
$v = $r['value'] ?? null;
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || abs((float)$v) > 1.0e12) {
$v = null; $issues[] = 'invalid_numeric_value';
}
$at = $r['updated'] ?? null;
if (!is_int($at) || $at < 1) { $at = null; $issues[] = 'missing_original_timestamp'; }
$changed = $r['changed'] ?? null;
if (!is_int($changed) || $changed < 0) $changed = null;
if (($r['parentID'] ?? null) !== $s['parentId'] || ($r['ident'] ?? null) !== $s['ident']) {
// Do not record a numeric value from an object re-used for a different purpose.
$v = null; $issues[] = 'source_identity_mismatch';
}
return ['value' => $v, 'sourceUpdatedAt' => $at, 'sourceChangedAt' => $changed, 'issues' => $issues];
}
public static function capture(array $config, callable $read, callable $clock): array
{
$c = self::configuration($config);
$start = $clock();
if (!is_int($start) || $start <= 0) throw new InvalidArgumentException('Integer UTC epoch required');
$sources = array_merge($c['accounting']['sources'], $c['extraSources']);
$first = []; $second = []; $raw = []; $issues = [];
foreach ($sources as $s) $first[$s['key']] = self::readSource($s, $read);
foreach ($sources as $s) $second[$s['key']] = self::readSource($s, $read);
$end = $clock();
if (!is_int($end) || $end < $start) throw new RuntimeException('Acquisition clock moved backwards');
foreach ($sources as $s) {
$key = $s['key']; $r = $first[$key];
if ($r !== $second[$key]) {
$r['issues'][] = 'changed_during_capture';
$r['secondValue'] = $second[$key]['value'];
$r['secondUpdatedAt'] = $second[$key]['sourceUpdatedAt'];
}
if ($r['sourceUpdatedAt'] !== null && $r['sourceUpdatedAt'] > $end) $r['issues'][] = 'future_source';
$r['sourceAgeSeconds'] = $r['sourceUpdatedAt'] === null ? null : $end - $r['sourceUpdatedAt'];
$raw[$key] = ['variableId' => $s['variableId']] + $r;
foreach ($r['issues'] as $issue) $issues[] = $key . ':' . $issue;
}
$assessment = null; $accountingUsable = true; $samples = [];
foreach ($c['accounting']['sources'] as $s) {
$r = $raw[$s['key']];
if ($r['issues'] !== []) $accountingUsable = false;
$samples[$s['variableId']] = ['value' => $r['value'], 'updated' => $r['sourceUpdatedAt'], 'parentID' => $s['parentId'], 'ident' => $s['ident']];
}
if ($accountingUsable) {
try { $assessment = NetzfahrplanV4Bilanzierung::aufnehmen($c['accounting'], static fn(int $id): array => $samples[$id], $end); }
catch (Throwable $e) { $issues[] = 'accounting_input_invalid'; }
} else { $issues[] = 'accounting_snapshot_unusable'; }
if ($end - $start > 5) $issues[] = 'long_acquisition';
return ['schemaVersion' => 1, 'kind' => 'raw_accounting_capture', 'installationId' => $c['installationId'],
'captureStartedAt' => gmdate('c', $start), 'capturedAt' => gmdate('c', $end),
'mappingSha256' => hash('sha256', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)),
'reportedInventorySha256' => $c['reportedInventorySha256'],
'timestampMeaning' => 'Symcon VariableUpdated; not proof of an independent device timestamp',
'raw' => $raw, 'issues' => $issues, 'assessment' => $assessment,
'trainingEligible' => false, 'controlEligible' => false, 'measurementBoundaryVerified' => false,
'futureSdl' => 'unknown_no_assumption', 'historicalDataRewritten' => false];
}
/** Append-only daily JSONL with exclusive lock, bounds and crash-tail detection.
* No retention deletion; quota stops capture visibly, never deletes old histories.
*/
public static function append(string $directory, array $record, int $quota = 536870912): string
{
if (!is_dir($directory) || is_link($directory) || realpath($directory) !== $directory || $quota < 1) {
throw new RuntimeException('Invalid dedicated data directory');
}
$json = json_encode($record, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . "\n";
if (strlen($json) > 262144) throw new RuntimeException('Capture exceeds record limit');
$epoch = strtotime($record['capturedAt'] ?? '');
if ($epoch === false) throw new RuntimeException('Capture timestamp missing');
$file = $directory . '/raw-' . gmdate('Ymd', $epoch) . '.jsonl';
$lockPath = $directory . '/.writer.lock';
if (is_link($lockPath) || is_link($file)) throw new RuntimeException('Symbolic link refused');
$lock = fopen($lockPath, 'c+b');
if ($lock === false) throw new RuntimeException('Capture lock unavailable');
try {
if (!flock($lock, LOCK_EX | LOCK_NB)) throw new RuntimeException('Capture writer busy');
clearstatcache(); $used = 0; $files = glob($directory . '/raw-*.jsonl');
if ($files === false || count($files) > 400) throw new RuntimeException('Data directory bounds exceeded');
foreach ($files as $p) {
if (is_link($p)) throw new RuntimeException('Symbolic journal refused');
$size = filesize($p); if ($size === false) throw new RuntimeException('Cannot size journal');
$used += $size;
}
if ($used + strlen($json) > $quota) throw new RuntimeException('Capture quota reached; no data deleted');
$free = disk_free_space($directory);
if ($free === false || $free < 104857600 + strlen($json)) throw new RuntimeException('Insufficient free disk; capture paused');
$h = fopen($file, 'c+b');
if ($h === false) throw new RuntimeException('Cannot open capture journal');
try {
fseek($h, 0, SEEK_END); $old = ftell($h);
if ($old === false || $old + strlen($json) > 67108864) throw new RuntimeException('Daily capture bounds exceeded');
if ($old > 0) {
fseek($h, -1, SEEK_END);
if (fread($h, 1) !== "\n") throw new RuntimeException('Incomplete journal tail; preserve for review');
}
fseek($h, 0, SEEK_END); $n = 0;
try {
while ($n < strlen($json)) {
$written = fwrite($h, substr($json, $n));
if ($written === false || $written === 0) throw new RuntimeException('Capture write incomplete');
$n += $written;
}
if (!fflush($h)) throw new RuntimeException('Capture flush failed');
} catch (Throwable $e) { ftruncate($h, $old); fflush($h); throw $e; }
if (!chmod($file, 0640)) throw new RuntimeException('Cannot restrict journal permissions');
} finally { fclose($h); }
return $file;
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
use InvalidArgumentException;
/** Independent physical-load and virtual-allocation views. Observation ONLY.
* The unfiltered partition is an ACCOUNTING MODEL, not two measured powers.
* No IPS calls, device writes, SOC integration, training or dispatch permission.
*/
final class NetzfahrplanV4Messsicht
{
private static function numeric($v): bool
{
return (is_int($v) || is_float($v)) && is_finite((float)$v) && abs($v) <= 1e12;
}
public static function policy(array $p, array $c): array
{
$expected = ['version','evRequestKey','sdlRequestKey','extraMaxAgeSeconds','trackingToleranceW','solarReference'];
if (count($p) !== count($expected) || array_diff($expected,array_keys($p)) || $p['version'] !== 1
|| !is_int($p['extraMaxAgeSeconds']) || $p['extraMaxAgeSeconds'] < 1 || $p['extraMaxAgeSeconds'] > 300
|| !self::numeric($p['trackingToleranceW']) || $p['trackingToleranceW'] < 0 || $p['trackingToleranceW'] > 1000) {
throw new InvalidArgumentException('Invalid measurement-view policy');
}
$extra = array_column($c['extraSources'], null, 'key');
foreach (['evRequestKey','sdlRequestKey'] as $k) {
if (!is_string($p[$k]) || !isset($extra[$p[$k]]) || $extra[$p[$k]]['unit'] !== 'W') {
throw new InvalidArgumentException('Explicit separate request channels required');
}
}
if ($p['evRequestKey'] === $p['sdlRequestKey']) throw new InvalidArgumentException('Duplicate request channel');
if ($p['solarReference'] !== null) {
$s=$p['solarReference']; $keys=['pvKey','batteryKey','rawKey','scaleKey'];
if (!is_array($s) || count($s)!==4 || array_diff($keys,array_keys($s))) throw new InvalidArgumentException('Invalid solar-origin policy');
foreach ($keys as $k) if (!is_string($s[$k])) throw new InvalidArgumentException('Invalid solar-origin key');
$primary=array_column($c['accounting']['sources'],null,'key');
if (($primary[$s['pvKey']]['role']??null)!=='pv' || ($primary[$s['batteryKey']]['role']??null)!=='physical_storage'
|| ($extra[$s['rawKey']]['unit']??null)!=='raw_power' || ($extra[$s['scaleKey']]['unit']??null)!=='scale') {
throw new InvalidArgumentException('Solar references must be explicit original sources');
}
}
return $p;
}
public static function calculate(array $r, array $config, array $policy): array
{
$c=NetzfahrplanV4Messaufnahme::configuration($config);$p=self::policy($policy,$c);
$mapping=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
if (($r['kind']??null)!=='raw_accounting_capture' || ($r['schemaVersion']??null)!==1
|| ($r['installationId']??null)!==$c['installationId'] || ($r['mappingSha256']??null)!==$mapping
|| ($r['reportedInventorySha256']??null)!==$c['reportedInventorySha256']) throw new InvalidArgumentException('Wrong capture identity');
foreach (['capturedAt','captureStartedAt'] as $k) {
if (!is_string($r[$k]??null) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$r[$k])) throw new InvalidArgumentException('Explicit UTC capture time required');
$date=new \DateTimeImmutable($r[$k]);$err=\DateTimeImmutable::getLastErrors();
if ($err!==false && ($err['warning_count']||$err['error_count'])) throw new InvalidArgumentException('Invalid capture time');
$time[$k]=$date->getTimestamp();
}
$now=$time['capturedAt'];$duration=$now-$time['captureStartedAt'];
if ($duration<0) throw new InvalidArgumentException('Reversed acquisition clock');
$sources=array_column(array_merge($c['accounting']['sources'],$c['extraSources']),null,'key');
$v=[];
foreach ($sources as $key=>$s) {
$raw=$r['raw'][$key]??[]; $issues=[]; $value=$raw['value']??null; $updated=$raw['sourceUpdatedAt']??null;
if (($raw['variableId']??null)!==$s['variableId']) {$issues[]='identity';$value=null;}
if (!self::numeric($value)) {$issues[]='not_numeric';$value=null;}
if (!is_int($updated) || $updated<=0) {$issues[]='timestamp';$updated=null;}
if (($raw['issues']??null)!==[]) $issues[]='capture_quality'; // never copy arbitrary source text
if ($updated!==null && ($updated>$now || $now-$updated>($s['maxAgeSeconds']??$p['extraMaxAgeSeconds']))) $issues[]='source_age';
$scaled=$value===null?null:(float)$value*($s['factorToW']??1.0);
if ($scaled!==null && (!is_finite($scaled)||abs($scaled)>1e12)) {$scaled=null;$issues[]='converted_value';}
$changed=$raw['sourceChangedAt']??null;
if (!is_int($changed)||$changed<0||$updated===null||$changed>$updated) $changed=null;
$v[$key]=['value'=>$scaled,'updated'=>$updated,'changed'=>$changed,'issues'=>$issues];
}
$closure=function(array $keys)use(&$closure,$sources):array {
$out=[];foreach($keys as $key){if(!isset($sources[$key]))throw new InvalidArgumentException('Missing origin');$out[]=$key;
foreach($closure($sources[$key]['dependsOn']??[]) as $dep)$out[]=$dep;}
return array_values(array_unique($out));
};
$quality=function(array $keys)use($closure,$v,$c,$duration):array {
$keys=$closure($keys);$issues=[];$ts=[];
foreach($keys as $key){foreach($v[$key]['issues'] as $i)$issues[]=$key.':'.$i;if($v[$key]['updated']!==null)$ts[]=$v[$key]['updated'];}
$skew=$ts===[]?null:max($ts)-min($ts);
if($duration>5)$issues[]='long_acquisition';
if($skew!==null && $skew>$c['accounting']['maxSkewSeconds'])$issues[]='source_time_skew';
return ['status'=>$issues===[]?'consistent_candidate':'quality_hold','issues'=>$issues,'oldestSourceUpdatedAt'=>$ts===[]?null:min($ts),'sourceSkewSeconds'=>$skew];
};
$byRole=[];foreach($c['accounting']['sources'] as $s)$byRole[$s['role']][]=$s['key'];
$physical=$byRole['physical_storage'];$pv=$byRole['pv'];$grid=$byRole['grid'];$flex=$byRole['flexible_load']??[];
$sum=function(array $keys)use($v):?float { $total=0.0;foreach($keys as $key){if($v[$key]['value']===null)return null;$total+=$v[$key]['value'];}return $total;};
$storage=$quality($physical);$storage['physicalStorageCandidateW']=$sum($physical);
$solar=['status'=>'not_configured','acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null,'issues'=>['origin_not_configured']];
$loadKeys=array_merge($grid,$pv,$physical,$flex);$sr=$p['solarReference'];
if($sr!==null){
$origin=[$sr['rawKey'],$sr['scaleKey'],$sr['batteryKey']];$loadKeys=array_merge($loadKeys,$origin);
$solar=$quality($origin);$raw=$v[$sr['rawKey']]['value'];$sf=$v[$sr['scaleKey']]['value'];
$solar+=['acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null];
// int16 invalid sentinel excluded, including scale sentinel; no unsigned guessing.
if($raw===null||$sf===null||floor($raw)!==$raw||$raw<=-32768||$raw>32767||floor($sf)!==$sf||$sf < -6||$sf>6){
$solar['status']='quality_hold';$solar['issues'][]='invalid_raw_or_scale';
}else{
$ac=$raw*(10**(int)$sf);
if(abs($ac)>1e9){$solar['status']='quality_hold';$solar['issues'][]='scaled_power_range';}
else{$solar['acTerminalCandidateW']=$ac;$b=$v[$sr['batteryKey']]['value'];
if($b!==null)$solar['reconstructedPvCandidateW']=$ac+$b;
$reported=$v[$sr['pvKey']]['value'];
if($reported!==null&&$solar['reconstructedPvCandidateW']!==null)$solar['derivedPvDifferenceW']=$reported-max(0.0,$solar['reconstructedPvCandidateW']);}
}
}
$load=$quality($loadKeys);$net=$sum($grid);$pvSum=$sum($pv);$phys=$sum($physical);$fl=$sum($flex);
$candidate=($net===null||$pvSum===null||$phys===null||$fl===null)?null:$net+$pvSum-$phys-$fl;
if($candidate!==null&&$candidate<0){$load['status']='quality_hold';$load['issues'][]='negative_load';}
foreach(array_merge($pv,$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$load['status']='quality_hold';$load['issues'][]='negative_unsigned:'.$key;}
if($sr!==null&&$solar['status']!=='consistent_candidate'){$load['status']='quality_hold';$load['issues'][]='solar_origin_unverified_value';}
$load+=['loadCandidateW'=>$candidate,'gridW'=>$net,'pvCandidateW'=>$pvSum,'physicalStorageCandidateW'=>$phys,'flexibleLoadW'=>$fl,
'virtualAllocationUsed'=>false,'measurementBoundaryVerified'=>false];
// AC terminal alternative eliminates SolarEdge battery/PV cancellation and zero-clamp artefact.
$alternative=null;$altQuality=null;
if($sr!==null){
$altKeys=array_values(array_diff(array_merge($grid,$pv,$physical,$flex),[$sr['pvKey'],$sr['batteryKey']]));
$altKeys=array_merge($altKeys,[$sr['rawKey'],$sr['scaleKey']]);$altQuality=$quality($altKeys);
$pvRest=$sum(array_values(array_diff($pv,[$sr['pvKey']])));$bRest=$sum(array_values(array_diff($physical,[$sr['batteryKey']])));
if($net!==null&&$pvRest!==null&&$bRest!==null&&$fl!==null&&$solar['acTerminalCandidateW']!==null){
$alternative=$net+$pvRest-$bRest+$solar['acTerminalCandidateW']-$fl;
if($alternative<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_load';}
}else{$altQuality['status']='quality_hold';$altQuality['issues'][]='solar_ac_unavailable';}
foreach(array_merge(array_values(array_diff($pv,[$sr['pvKey']])),$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_unsigned:'.$key;}
$altQuality+=['loadCandidateW'=>$alternative,'measurementBoundaryVerified'=>false,'solarBatterySubtractedAgain'=>false];
}
$requestKeys=[$p['evRequestKey'],$p['sdlRequestKey']];$alloc=$quality(array_merge($physical,$requestKeys));
$ev=$v[$p['evRequestKey']]['value'];$sdl=$v[$p['sdlRequestKey']]['value'];$evModel=null;$sdlModel=null;$unallocated=$phys;$delta=null;
foreach($requestKeys as $key){
if($v[$key]['changed']===null){$alloc['issues'][]='request_change_time_unknown:'.$key;$alloc['status']='quality_hold';}
elseif($storage['oldestSourceUpdatedAt']!==null && $v[$key]['changed']>$storage['oldestSourceUpdatedAt']){$alloc['issues'][]='request_newer_than_feedback:'.$key;$alloc['status']='quality_hold';}
}
if($phys!==null&&$ev!==null&&$sdl!==null){
$abs=abs($ev)+abs($sdl);$delta=$phys-$ev-$sdl;
if($abs>=1.0){$evModel=$ev+$delta*abs($ev)/$abs;$sdlModel=$phys-$evModel;$unallocated=0.0;
if(abs($delta)>$p['trackingToleranceW']){$alloc['issues'][]='physical_request_tracking_error';$alloc['status']='quality_hold';}
}else{$alloc['issues'][]='idle_power_unallocated';$alloc['status']='quality_hold';}
}
$reportedEv=$sum($byRole['controlled_virtual']);$reportedSdl=$sum($byRole['external_virtual']??[]);
$reportedResidual=($phys===null||$reportedEv===null||$reportedSdl===null)?null:$phys-$reportedEv-$reportedSdl;
$alloc+=['physicalStorageCandidateW'=>$phys,'requestedEvW'=>$ev,'requestedSdlW'=>$sdl,'unfilteredModelEvW'=>$evModel,
'unfilteredModelSdlW'=>$sdlModel,'unallocatedPhysicalW'=>$unallocated,'physicalRequestErrorW'=>$delta,
'reportedEvW'=>$reportedEv,'reportedSdlW'=>$reportedSdl,'reportedAllocationResidualW'=>$reportedResidual,
'reportedVirtualQuality'=>$quality(array_merge($byRole['controlled_virtual'],$byRole['external_virtual']??[])),
'underlyingTimesMeaning'=>'Symcon VariableUpdated; not independently measured EV/SDL channel times',
'reportedAllocationWithinTolerance'=>$reportedResidual!==null&&abs($reportedResidual)<=$c['accounting']['splitToleranceW'],
'separatelyMeasured'=>false,'method'=>'request_weighted_residual_unfiltered_not_unique_measurement'];
return ['schemaVersion'=>1,'kind'=>'separated_measurement_views','installationId'=>$c['installationId'],'capturedAt'=>$r['capturedAt'],
'mappingSha256'=>$mapping,'policySha256'=>hash('sha256',json_encode($p,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION)),
'physicalLoad'=>$load,'storageFeedback'=>$storage,'allocationModel'=>$alloc,'solarOrigin'=>$solar,'solarAcAlternative'=>$altQuality,
'trainingEligible'=>false,'canDispatch'=>false,'controlEnabled'=>false,'socAccountsChanged'=>false,'futureSdl'=>'unknown_no_assumption',
'note'=>'Observation only. Source times are Symcon update times. Algebraic partition is not separate EV/SDL measurement. GoodWe and combined AC/DC boundaries remain unverified.'];
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
/** Pure observation-only validator. No IPS calls, transport, timers or actuator writes. */
final class NetzfahrplanV4Planpruefung
{
public const MAX_RESPONSE_BYTES = 2000000;
public const MAX_PLAN_AGE = 900;
public const MAX_RECEIPT_AGE = 90;
private static function liste(array $value): bool
{
return $value === [] || array_keys($value) === range(0, count($value) - 1);
}
public static function zeit($value): int
{
if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $value)) {
throw new InvalidArgumentException('Zeitpunkt ohne eindeutige Zeitzone.');
}
$time = new DateTimeImmutable($value);
$errors = DateTimeImmutable::getLastErrors();
if ($errors !== false && ($errors['warning_count'] || $errors['error_count'])) {
throw new InvalidArgumentException('Ungueltiger Kalenderzeitpunkt.');
}
return $time->getTimestamp();
}
private static function zahl($value, string $name, float $low = -1.0e9, float $high = 1.0e9): float
{
if ((!is_float($value) && !is_int($value)) || !is_finite((float) $value) || $value < $low || $value > $high) {
throw new InvalidArgumentException($name . ': endlicher Zahlenwert erforderlich.');
}
return (float) $value;
}
private static function uuid($value): string
{
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
throw new InvalidArgumentException('Ungueltige UUID.');
}
return $value;
}
public static function dekodieren(string $raw): array
{
if (strlen($raw) > self::MAX_RESPONSE_BYTES) {
throw new InvalidArgumentException('V4-Antwort zu gross.');
}
$result = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
if (!is_array($result) || self::liste($result)) {
throw new InvalidArgumentException('V4-Antwort ist kein Objekt.');
}
return $result;
}
/** Stable local planning policy. Volatile SOC/availability are checked separately. */
public static function kontext(array $operation): array
{
$assets = [];
foreach ($operation['batteries'] ?? [] as $b) {
if (!is_array($b) || !is_string($b['id'] ?? null) || isset($assets[$b['id']])) {
throw new InvalidArgumentException('Batteriezuordnung ungueltig.');
}
$assets[$b['id']] = [
'capacityKwh' => self::zahl($b['capacityKwh'] ?? null, 'Kapazitaet', .001),
'minSocPercent' => self::zahl($b['minSocPercent'] ?? null, 'Reserve', 0, 100),
'maxSocPercent' => self::zahl($b['maxSocPercent'] ?? null, 'Maximal-SOC', 0, 100),
'physicalMinSocPercent' => self::zahl($b['physicalMinSocPercent'] ?? 0.0, 'Minimum', 0, 100),
'rearmSocPercent' => self::zahl($b['rearmSocPercent'] ?? $b['minSocPercent'], 'Hysterese', 0, 100),
'gridCharging' => $b['gridCharging'] ?? null,
];
if (!is_bool($assets[$b['id']]['gridCharging'])) {
throw new InvalidArgumentException('Netzladefreigabe fehlt.');
}
}
if (!$assets || count($assets) > 20 || !is_array($operation['limits'] ?? null)) {
throw new InvalidArgumentException('Batterien oder lokale Grenzen fehlen.');
}
ksort($assets);
$limits = $operation['limits'];
$expected = ['importW', 'exportW', 'managerMonthLimitsW'];
if (count($limits) !== 3 || array_diff($expected, array_keys($limits)) || !is_array($limits['managerMonthLimitsW'])) {
throw new InvalidArgumentException('Unvollstaendige lokale Grenzen.');
}
foreach (['importW', 'exportW'] as $key) {
if ($limits[$key] !== null) $limits[$key] = self::zahl($limits[$key], 'Grenze', 0);
}
foreach ($limits['managerMonthLimitsW'] as $month => $limit) {
if ((string) (int) $month !== (string) $month || (int) $month < 1 || (int) $month > 12) {
throw new InvalidArgumentException('Ungueltige Monatsgrenze.');
}
$limits['managerMonthLimitsW'][$month] = self::zahl($limit, 'Monatsgrenze', 0);
}
ksort($limits['managerMonthLimitsW']);
return ['batteries' => $assets, 'limits' => $limits];
}
/** Authenticated endpoint must echo installation identity; old protocol is refused. */
public static function pruefen(array $response, string $installation, array $operation, int $now, int $receivedAt): array
{
self::uuid($installation);
if (($response['receiverProtocolVersion'] ?? null) !== 1
|| ($response['installationId'] ?? null) !== $installation
|| ($response['liveEnabled'] ?? null) !== false) {
throw new InvalidArgumentException('V4-Protokoll/Anlagenzuordnung oder Schattenmodus ungueltig.');
}
if ($receivedAt > $now || $now - $receivedAt > self::MAX_RECEIPT_AGE) {
throw new InvalidArgumentException('Empfang veraltet; Vorschau verworfen.');
}
$serverAt = self::zeit($response['checkedAt'] ?? null);
if ($serverAt > $now + 5 || $now - $serverAt > self::MAX_RECEIPT_AGE) {
throw new InvalidArgumentException('Serverantwort nicht aktuell.');
}
if (($response['fresh'] ?? null) !== true || ($response['pending'] ?? null) !== null) {
throw new InvalidArgumentException('Server hat noch keinen aktuellen freigegebenen Schattenplan.');
}
$settings = $response['settings'] ?? [];
$p = $response['plan'] ?? [];
if (!is_array($p) || !is_int($settings['revision'] ?? null) || $settings['revision'] < 0
|| ($p['schemaVersion'] ?? null) !== 2 || ($p['installationId'] ?? null) !== $installation
|| ($p['configRevision'] ?? null) !== $settings['revision']
|| ($settings['runMode'] ?? null) !== 'shadow'
|| ($p['runMode'] ?? null) !== 'shadow' || ($p['liveEnabled'] ?? null) !== false
|| ($p['executable'] ?? null) !== true
|| !in_array($p['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
throw new InvalidArgumentException('Planrevision, Status oder Schattenmodus ungueltig.');
}
self::uuid($p['planId'] ?? null);
if (($response['lastRun']['detail']['planId'] ?? null) !== $p['planId']
|| !in_array($response['lastRun']['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
throw new InvalidArgumentException('Letzte Rechnung passt nicht zum empfangenen Plan.');
}
$families = array_column($response['families'] ?? [], 'key');
if (!is_string($p['sourceFamily'] ?? null) || !in_array($p['sourceFamily'], $families, true)
|| (($settings['family'] ?? null) !== 'auto' && $settings['family'] !== $p['sourceFamily'])) {
throw new InvalidArgumentException('Modellfamilie unbekannt oder nicht ausgewaehlt.');
}
$generated = self::zeit($p['generatedAt'] ?? null);
$start = self::zeit($p['validFrom'] ?? null);
$until = self::zeit($p['validUntil'] ?? null);
if ($generated > $now || $now - $generated > self::MAX_PLAN_AGE || $start > $now || $until <= $now
|| $start !== $generated || $until - $start > 48 * 3600
|| self::zeit($p['pricesKnownUntil'] ?? null) < $until) {
throw new InvalidArgumentException('Plan abgelaufen, zukuenftig oder ohne bekannten Preishorizont.');
}
$opAt = self::zeit($operation['observedAt'] ?? null);
if ($opAt > $now || $now - $opAt > 60) {
throw new InvalidArgumentException('Lokale Betriebsdaten veraltet.');
}
$context = self::kontext($operation);
$pc = $p['controlContext'] ?? null;
if (!is_array($pc) || count($pc) !== 2 || !is_array($pc['batteries'] ?? null) || !is_array($pc['limits'] ?? null)) {
throw new InvalidArgumentException('Planungskontext fehlt.');
}
$policyAssets = [];
foreach ($pc['batteries'] as $id => $policy) {
if (!is_array($policy) || count($policy) !== 6) {
throw new InvalidArgumentException('Unbekannter Batteriekontext.');
}
$policyAssets[] = ['id' => $id] + $policy;
}
// Normalize explicitly typed numeric values; strings/bools are not numbers.
$plannedContext = self::kontext(['batteries' => $policyAssets, 'limits' => $pc['limits']]);
if ($plannedContext != $context) {
throw new InvalidArgumentException('Lokale Batterie-/Grenzkonfiguration passt nicht zum Plan.');
}
if (!is_array($p['inputRefs'] ?? null) || !is_string($p['inputRefs']['operation'] ?? null)
|| $p['inputRefs']['operation'] === '') {
throw new InvalidArgumentException('Plan ohne nachvollziehbare Eingangsdaten.');
}
self::zahl($settings['roundtripEfficiency'] ?? null, 'Wirkungsgrad', .01, 1);
$points = $p['points'] ?? null;
if (!is_array($points) || !self::liste($points) || count($points) < 1 || count($points) > 576) {
throw new InvalidArgumentException('Ungueltige Zeitschrittliste.');
}
$current = null;
$last = $start;
$ids = array_keys($context['batteries']);
foreach ($points as $point) {
$a = self::zeit($point['time'] ?? null);
$b = self::zeit($point['validUntil'] ?? null);
if ($a !== $last || $b <= $a || $b - $a > 300 || $b % 300 !== 0) {
throw new InvalidArgumentException('Luecke/Ueberlappung oder falsches Fuenfminutenraster.');
}
$last = $b;
$grid = self::zahl($point['gridTargetW'] ?? null, 'Netzziel');
$bat = self::zahl($point['batteryTargetW'] ?? null, 'Batterieziel');
$base = self::zahl($point['baselineGridW'] ?? null, 'Basisnetz');
$curtail = self::zahl($point['pvCurtailmentW'] ?? null, 'PV-Abregelung', 0);
if (abs($base + $bat + $curtail - $grid) > 1.0) {
throw new InvalidArgumentException('Leistungsbilanz des Plans verletzt.');
}
if (!in_array($point['intent'] ?? '', ['hold', 'gridCharge', 'pvCharge', 'discharge', 'export'], true)) {
throw new InvalidArgumentException('Unbekannte Planabsicht.');
}
$targets = $point['assetTargetsW'] ?? null;
$socs = $point['socEndPercent'] ?? null;
if (!is_array($targets) || !is_array($socs)) {
throw new InvalidArgumentException('Batterieziele fehlen.');
}
$keys = array_keys($targets);sort($keys);$socKeys = array_keys($socs);sort($socKeys);
if ($keys !== $ids || $socKeys !== $ids) {
throw new InvalidArgumentException('Plan enthaelt falsche Batteriezuordnung.');
}
$sum = 0.0;
foreach ($targets as $id => $w) {
$sum += self::zahl($w, 'Einzelziel');
self::zahl($socs[$id], 'Plan-SOC', 0, 100);
}
if (abs($sum - $bat) > 1.0) {
throw new InvalidArgumentException('Summe der Batterieziele passt nicht.');
}
foreach (['importLimitW', 'exportLimitW'] as $limit) {
if (!array_key_exists($limit, $point)) {
throw new InvalidArgumentException('Plangrenze fehlt.');
}
if ($point[$limit] !== null) {
$v = self::zahl($point[$limit], 'Plangrenze', 0);
if (($limit === 'importLimitW' ? $grid : -$grid) > $v + 1) {
throw new InvalidArgumentException('Netzgrenze im Plan verletzt.');
}
}
}
if ($a <= $now && $now < $b) {
$current = $point;
}
}
if ($last !== $until || $current === null) {
throw new InvalidArgumentException('Kein derzeit gueltiger Planabschnitt.');
}
return ['plan' => $p, 'point' => $current, 'efficiency' => (float) $settings['roundtripEfficiency']];
}
/** Proposed single-battery correction only. Never use this result as a dispatch permit. */
public static function vorschau(array $checked, array $operation, array $actualBatteryW, int $now): array
{
$point = $checked['point'];$p = $checked['plan'];
if (count($operation['batteries']) !== 1) {
throw new InvalidArgumentException('Lokale Vorschau fuer mehrere Batterien noch nicht freigegeben.');
}
$b = $operation['batteries'][0];$id = $b['id'];
$age = $now - self::zeit($b['measuredAt'] ?? null);
if ($age < 0 || $age > 60) {
throw new InvalidArgumentException('Batteriemessung veraltet.');
}
$grid = self::zahl($operation['gridW'], 'Netzleistung');
$battery = self::zahl($actualBatteryW[$id] ?? null, 'Batterie-Istleistung');
$soc = self::zahl($b['socPercent'], 'Aktueller SOC', $b['physicalMinSocPercent'] ?? 0, $b['maxSocPercent']);
$remainingHours = (self::zeit($point['validUntil']) - $now) / 3600;
if ($remainingHours <= 0) {
throw new InvalidArgumentException('Planabschnitt abgelaufen.');
}
$eta = sqrt($checked['efficiency']);$capacity = self::zahl($b['capacityKwh'], 'Kapazitaet', .001);
$charge = min(self::zahl($b['maxChargeW'], 'Ladeleistung', 0),
max(0., $b['maxSocPercent'] - $soc) * $capacity / 100 / $eta / $remainingHours * 1000);
$discharge = min(self::zahl($b['maxDischargeW'], 'Entladeleistung', 0),
max(0., $soc - $b['minSocPercent']) * $capacity / 100 * $eta / $remainingHours * 1000);
$reasons = [];
if (($b['dischargeBlocked'] ?? false) === true || $soc <= $b['minSocPercent']) {
$discharge = 0.0;$reasons[] = 'Lokale Entladesperre/Reserve';
}
$residual = $grid - $battery;
if ($b['gridCharging'] !== true) {
$charge = min($charge, max(0., -$residual));$reasons[] = 'Netzladen lokal gesperrt';
}
$target = (float) $point['gridTargetW'];$limits = $operation['limits'];
$import = $limits['importW'];$export = $limits['exportW'];
$month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n');
if (isset($limits['managerMonthLimitsW'][(string) $month])) {
$monthly = self::zahl($limits['managerMonthLimitsW'][(string) $month], 'Monatsgrenze', 0);
$import = $import === null ? $monthly : min($import, $monthly);
}
if ($import !== null) $target = min($target, self::zahl($import, 'Bezugsgrenze', 0));
if ($export !== null) $target = max($target, -self::zahl($export, 'Einspeisegrenze', 0));
$raw = $battery + $target - $grid;
$wanted = $raw;
if (abs($target) > 1.0) {
$wanted = match ($point['intent']) {
'gridCharge' => max(0., $wanted),
'pvCharge' => max(0., min(max(0., -$residual), $wanted)),
'discharge', 'export' => min(0., $wanted),
'hold' => 0.,
};
}
// Hard connection limits override economic intent, never BMS/availability.
if ($import !== null) $wanted = min($wanted, $import - $residual);
if ($export !== null) $wanted = max($wanted, -$export - $residual);
$clipped = min($charge, max(-$discharge, $wanted));
if (abs($clipped - $raw) > 1) $reasons[] = 'Leistung, SOC, Planabsicht oder Netzgrenze begrenzt';
$expectedGrid = $residual + $clipped;
$violation = max(0., $import === null ? 0. : $expectedGrid - $import, $export === null ? 0. : -$export - $expectedGrid);
if ($violation > 1) $reasons[] = 'Netzgrenze durch Batterie allein nicht erreichbar; lokale Schutzregelung erforderlich';
return ['status' => 'preview_only', 'liveEnabled' => false, 'canDispatch' => false,
'installationId' => $p['installationId'], 'planId' => $p['planId'], 'revision' => $p['configRevision'],
'family' => $p['sourceFamily'], 'checkedAt' => gmdate('c', $now), 'step' => $point['time'],
'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'],
'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped,
'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid,
'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [],
'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.'];
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
/** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */
final class NetzfahrplanV4Regeltest
{
public const MAX_TEST_SECONDS = 1800;
public const MAX_ACTIVE_TEST_SECONDS = 172800;
public const COMMAND_TTL_SECONDS = 10;
public static function number($value, string $name, float $min, float $max): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < $min || $value > $max) {
throw new InvalidArgumentException($name . ': ungueltiger Wert.');
}
return (float) $value;
}
public static function uuid($value): string
{
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
throw new InvalidArgumentException('Eindeutige Testkennung erforderlich.');
}
return $value;
}
public static function integer($value, string $name, int $min, int $max): int
{
if (!is_int($value) || $value < $min || $value > $max) {
throw new InvalidArgumentException($name . ': Ganzzahl erforderlich.');
}
return $value;
}
/** Validated server authorization plus independent LOCAL consent and physical mapping. */
public static function befehl(array $response, array $operation, array $actual, array $local, int $now, int $receivedAt): array
{
if (($local['enabled'] ?? null) !== true || ($local['legacyEnabled'] ?? null) !== false) {
throw new InvalidArgumentException('Keine lokale Testfreigabe oder konkurrierender Fahrplan.');
}
$installation = self::uuid($local['installationId'] ?? null);
$session = self::uuid($local['sessionId'] ?? null);
$manager = self::integer($local['managerId'] ?? null, 'Manager', 1, 99999);
$controller = self::integer($local['batteryInstanceId'] ?? null, 'Batterieinstanz', 1, 99999);
$activeTest = ($local['kind'] ?? null) === 'local_active_test_consent';
$maxSeconds = $activeTest ? self::MAX_ACTIVE_TEST_SECONDS : self::MAX_TEST_SECONDS;
$end = self::integer($local['expiresAt'] ?? null, 'Lokales Testende', $now + 1, $now + $maxSeconds);
if (($local['lastWallClockAt'] ?? $now) > $now) throw new InvalidArgumentException('Systemzeit rueckwaerts; Test abbrechen.');
$checked = NetzfahrplanV4Planpruefung::pruefen($response, $installation, $operation, $now, $receivedAt);
$plan = $checked['plan'];$a = $response['controlledTrial'] ?? null;
if (!is_array($a) || ($a['version'] ?? null) !== 1 || ($a['kind'] ?? null) !== 'controlled_trial_authority'
|| ($a['sourcePlanRemainsShadow'] ?? null) !== true || ($a['installationId'] ?? null) !== $installation
|| ($a['sessionId'] ?? null) !== $session || ($a['managerId'] ?? null) !== $manager
|| ($a['batteryInstanceId'] ?? null) !== $controller || ($a['sourceShadowPlanId'] ?? null) !== $plan['planId']
|| ($a['revision'] ?? null) !== $plan['configRevision'] || ($a['family'] ?? null) !== $plan['sourceFamily']) {
throw new InvalidArgumentException('Keine passende separate Server-Testfreigabe.');
}
$issued = NetzfahrplanV4Planpruefung::zeit($a['issuedAt'] ?? null);
$expires = NetzfahrplanV4Planpruefung::zeit($a['expiresAt'] ?? null);
$authorityAt = NetzfahrplanV4Planpruefung::zeit($a['checkedAt'] ?? null);
$authorityUntil = NetzfahrplanV4Planpruefung::zeit($a['validUntil'] ?? null);
if ($issued > $now || $expires <= $now || $expires - $issued > $maxSeconds
|| $end > $expires || $authorityAt > $now + 2 || $authorityUntil <= $now
|| $authorityUntil - $authorityAt > 90 || $authorityUntil > $expires) {
throw new InvalidArgumentException('Testfreigabe abgelaufen oder ungueltig.');
}
$quality = $plan['inputQuality'] ?? [];
$evidence = $quality['accountingEvidenceId'] ?? null;
$watchdogBound = is_string($a['actuatorWatchdogEvidenceId'] ?? null)
&& strlen($a['actuatorWatchdogEvidenceId']) >= 8
&& $a['actuatorWatchdogEvidenceId'] === ($local['actuatorWatchdogEvidenceId'] ?? null);
$watchdogWaived = $activeTest && ($local['watchdogWaived'] ?? null) === true
&& ($a['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
if (($quality['loadBasis'] ?? null) !== 'base_load' || !is_string($evidence) || strlen($evidence) < 8
|| $evidence !== ($a['accountingEvidenceId'] ?? null)
|| (!$watchdogBound && !$watchdogWaived)) {
throw new InvalidArgumentException('Anlagenbilanz oder Geraete-Watchdog nicht nachgewiesen.');
}
if (($plan['peakCostIsEstimate'] ?? true) && ($a['acceptEstimatedPeak'] ?? null) !== true) {
throw new InvalidArgumentException('Geschaetzte Peakbasis nicht fuer Test akzeptiert.');
}
if (count($operation['batteries']) !== 1 || $operation['batteries'][0]['id'] !== ($a['assetId'] ?? null)
|| ($local['assetId'] ?? null) !== $a['assetId']) {
throw new InvalidArgumentException('Test braucht eindeutige Batteriezuordnung.');
}
if (($a['controlContext'] ?? null) != $plan['controlContext']) {
throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.');
}
$feedback = $operation['localFeedback'][$a['assetId']] ?? [];
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $local, $now);
if (abs(self::number($actual[$a['assetId']] ?? null,'Batterie Ist',-1e9,1e9) - self::number($feedback['batteryW'],'Rueckmeldung',-1e9,1e9)) > 0.000001) throw new InvalidArgumentException('feedback_power_not_from_verified_snapshot');
$otherDelta=self::number($operation['localPredictedOtherDeltaW']??0.0,'Andere Verbraucherabweichung',-1e9,1e9);
if (abs($operation['gridW']-$feedback['gridW']-$otherDelta)>0.001) throw new InvalidArgumentException('feedback_grid_not_from_same_snapshot');
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
$battery = $operation['batteries'][0];
$charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, 1e9),
self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, 1e9),
self::number($battery['maxChargeW'] ?? null, 'Anlagen-Ladegrenze', 1, 1e9));
$discharge = min(self::number($a['maxDischargeW'] ?? null, 'Server-Entladegrenze', 1, 1e9),
self::number($local['maxDischargeW'] ?? null, 'Lokale Entladegrenze', 1, 1e9),
self::number($battery['maxDischargeW'] ?? null, 'Anlagen-Entladegrenze', 1, 1e9));
$watts = (int) (max(-$discharge, min($charge, $preview['previewBatteryW']))); // truncate towards zero
$residual = $preview['actualGridW'] - $preview['actualBatteryW'];
$grid = $residual + $watts;
$p = $checked['point'];
if (($p['importLimitW'] !== null && $grid > $p['importLimitW'] + 1)
|| ($p['exportLimitW'] !== null && -$grid > $p['exportLimitW'] + 1)) {
throw new InvalidArgumentException('Testleistungsgrenze reicht fuer Netzgrenze nicht; lokale Regelung hat Vorrang.');
}
return ['kind' => 'controlled_trial_command', 'version' => 1,
'sessionId' => $session, 'installationId' => $installation, 'assetId' => $a['assetId'],
'managerId' => $manager, 'batteryInstanceId' => $controller,
'sequence' => self::integer($local['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX),
'issuedAt' => $now, 'expiresAt' => min($now + self::COMMAND_TTL_SECONDS, $authorityUntil, $end,
NetzfahrplanV4Planpruefung::zeit($p['validUntil'])),
'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge,
'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'],
'revision' => $plan['configRevision'], 'expectedGridW' => $grid,
'feedbackConfigHash' => $feedback['configHash'], 'feedbackEstimated' => $feedback['estimated'],
'feedbackSourceOldestAt' => $feedback['sourceOldestAt'],
'importLimitW' => $p['importLimitW'], 'exportLimitW' => $p['exportLimitW'],
'executionMeaning' => $activeTest
? 'explicit_time_boxed_test_plant_operation'
: 'separate_local_control_trial_not_shadow_plan_execution'];
}
/** Independently checked by the battery before any physical register call. */
public static function pruefeBatteriebefehl(array $command, array $session, int $now, float $monotonicNow): void
{
if (($command['kind'] ?? null) !== 'controlled_trial_command' || ($command['version'] ?? null) !== 1) {
throw new InvalidArgumentException('Vorschau ist kein Stellbefehl.');
}
foreach (['sessionId', 'installationId', 'assetId', 'managerId', 'batteryInstanceId'] as $key) {
if (!array_key_exists($key, $session) || ($command[$key] ?? null) !== $session[$key]) {
throw new InvalidArgumentException('Befehl gehoert nicht zur lokal angemeldeten Testsitzung.');
}
}
if (($session['active'] ?? null) !== true || $now >= ($session['expiresAt'] ?? 0)
|| $monotonicNow >= ($session['monotonicDeadline'] ?? 0)
|| $now < ($session['lastWallClockAt'] ?? $now)) {
throw new InvalidArgumentException('Lokale Testsitzung abgelaufen/abgebrochen.');
}
$sent = self::integer($command['issuedAt'] ?? null, 'Befehlszeit', $now - self::COMMAND_TTL_SECONDS, $now);
$end = self::integer($command['expiresAt'] ?? null, 'Befehlsende', $now + 1, $sent + self::COMMAND_TTL_SECONDS);
if ($end > $session['expiresAt']) throw new InvalidArgumentException('Befehl verlaengert Test.');
$seq = self::integer($command['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX);
if ($seq <= ($session['lastSequence'] ?? 0)) throw new InvalidArgumentException('Alter/doppelter Befehl verworfen.');
$sessionCharge = self::number($session['maxChargeW'] ?? null, 'Sitzungs-Ladegrenze', 1, 1e9);
$sessionDischarge = self::number($session['maxDischargeW'] ?? null, 'Sitzungs-Entladegrenze', 1, 1e9);
$w = self::integer($command['watts'] ?? null, 'Sollwert W', -(int) $sessionDischarge, (int) $sessionCharge);
$c = self::number($command['maxChargeW'] ?? null, 'Befehl-Ladegrenze', 1, $sessionCharge);
$d = self::number($command['maxDischargeW'] ?? null, 'Befehl-Entladegrenze', 1, $sessionDischarge);
if ($w > $c || $w < -$d) {
throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.');
}
if (!is_string($command['feedbackConfigHash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $command['feedbackConfigHash'])
|| $command['feedbackConfigHash'] !== ($session['feedbackConfigHash'] ?? null)) throw new InvalidArgumentException('feedback_command_binding_invalid');
foreach (['importLimitW','exportLimitW'] as $limit) {
if (!array_key_exists($limit,$command)) throw new InvalidArgumentException('feedback_command_limits_missing');
if ($command[$limit] !== null) self::number($command[$limit],$limit,0,1e9);
}
$l=$session['gridLimits'] ?? null;
if (!is_array($l) || !array_key_exists('importW',$l) || !array_key_exists('exportW',$l) || !is_array($l['managerMonthLimitsW']??null)) throw new InvalidArgumentException('session_grid_limits_missing');
$month=(int)(new \DateTimeImmutable('@'.$now))->setTimezone(new \DateTimeZone('Europe/Zurich'))->format('n');
$import=$l['importW'];$monthly=$l['managerMonthLimitsW'][$month]??null;
if ($monthly!==null) $import=$import===null?$monthly:min($import,$monthly);
foreach (['importLimitW'=>$import,'exportLimitW'=>$l['exportW']] as $key=>$limit) {
if ($limit!==null) {
self::number($limit,$key,0,1e9);
if ($command[$key]===null || $command[$key]>$limit) throw new InvalidArgumentException('command_relaxes_local_grid_limit');
}
}
self::uuid($command['sourceShadowPlanId'] ?? null);
}
/** Re-evaluated on every battery update; no reliance on the manager's earlier clipping. */
public static function batterieZiel(array $command, array $session, array $m, array $policy, int $now, float $monotonicNow): int
{
if ($now >= $command['expiresAt'] || $now < $session['lastWallClockAt']
|| $monotonicNow >= $session['commandMonotonicDeadline'] || $monotonicNow >= $session['monotonicDeadline']) {
throw new InvalidArgumentException('Befehls-Watchdog abgelaufen.');
}
if (($m['Gueltig'] ?? null) !== true || ($policy['available'] ?? null) !== true) {
throw new InvalidArgumentException('Batterie nicht verfuegbar oder Messwerte ungueltig.');
}
$soc = self::number($m['Ladezustand'] ?? null, 'SOC', 0, 100);
$reserve = self::number($policy['reserve'] ?? null, 'Reserve', 0, 100);
$minimum = self::number($policy['minimum'] ?? null, 'Minimum', 0, $reserve);
if ($soc < $minimum) throw new InvalidArgumentException('SOC unter technischer Untergrenze.');
$charge = min($session['maxChargeW'], $command['maxChargeW'], self::number($m['MaxLaden'] ?? null, 'Max Laden', 0, 1e9));
$discharge = min($session['maxDischargeW'], $command['maxDischargeW'], self::number($m['MaxEntladen'] ?? null, 'Max Entladen', 0, 1e9));
if ($soc >= 99) $charge = 0; // existing battery hardware policy is more conservative than planner max 100
if ($soc <= $reserve || ($policy['blocked'] ?? true)) $discharge = 0;
if (($soc <= $reserve || ($policy['blocked'] ?? true)) && $policy['rechargeLimitW'] !== null) {
$charge = min($charge, self::number($policy['rechargeLimitW'], 'Nachladegrenze', 0, 1e9));
}
return (int) max(-$discharge, min($charge, $command['watts']));
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Fresh physical feedback. Pure reader: no writes, history reconstruction or actuator authority. */
final class NetzfahrplanV4Rueckmeldung
{
private static function number($v, float $lo, float $hi): float
{
if ((!is_int($v) && !is_float($v)) || !is_finite((float)$v) || $v<$lo || $v>$hi) {
throw new InvalidArgumentException('feedback_numeric_invalid');
}
return (float)$v;
}
private static function ordered(array $v): array
{
if (!array_is_list($v)) ksort($v, SORT_STRING);
foreach ($v as &$x) if (is_array($x)) $x=self::ordered($x);
unset($x);return $v;
}
public static function configuration(array $c): array
{
$keys=['version','installationId','assetId','managerId','batteryInstanceId','mode','allowEstimatedForTrial',
'maxSkewSeconds','maxAgeSeconds','idleToleranceW','trackingToleranceW','sources'];
if (count($c)!==count($keys) || array_diff($keys,array_keys($c)) || $c['version']!==1
|| !is_string($c['installationId']) || !preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/D',$c['installationId'])
|| !is_string($c['assetId']) || $c['assetId']==='' || strlen($c['assetId'])>100
|| !in_array($c['mode'],['physical_sum','virtual_split'],true) || !is_bool($c['allowEstimatedForTrial'])) {
throw new InvalidArgumentException('feedback_configuration_invalid');
}
foreach (['managerId','batteryInstanceId'] as $k) if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>99999) throw new InvalidArgumentException('feedback_binding_invalid');
if ($c['managerId']===$c['batteryInstanceId']) throw new InvalidArgumentException('feedback_binding_invalid');
foreach (['maxSkewSeconds'=>30,'maxAgeSeconds'=>60] as $k=>$max) {
if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>$max) throw new InvalidArgumentException('feedback_freshness_invalid');
}
$c['idleToleranceW']=self::number($c['idleToleranceW'],0,100);
$c['trackingToleranceW']=self::number($c['trackingToleranceW'],0,500);
if (!is_array($c['sources']) || !array_is_list($c['sources']) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid');
$ids=[];$names=[];$roles=[];
foreach ($c['sources'] as &$s) {
$fields=['key','role','variableId','parentId','ident','factorToW'];
if (!is_array($s) || count($s)!==count($fields) || array_diff($fields,array_keys($s))
|| !is_string($s['key']) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D',$s['key'])
|| isset($names[$s['key']]) || !in_array($s['role'],['grid','physical','ev_request','sdl_request','gateway_active'],true)
|| !is_int($s['variableId']) || $s['variableId']<1 || $s['variableId']>99999 || isset($ids[$s['variableId']])
|| !is_int($s['parentId']) || $s['parentId']<1 || !is_string($s['ident']) || strlen($s['ident'])>100) throw new InvalidArgumentException('feedback_source_identity_invalid');
$s['factorToW']=self::number($s['factorToW'],-1e6,1e6);
if ($s['factorToW']==0 || ($s['role']==='gateway_active' && $s['factorToW']!==1.0)) throw new InvalidArgumentException('feedback_factor_invalid');
$ids[$s['variableId']]=true;$names[$s['key']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
}
unset($s);
if (($roles['grid']??0)!==1 || ($roles['physical']??0)<1) throw new InvalidArgumentException('feedback_measurements_required');
foreach (['ev_request','sdl_request','gateway_active'] as $role) {
if (($roles[$role]??0)!==($c['mode']==='virtual_split'?1:0)) throw new InvalidArgumentException('feedback_partition_sources_required');
}
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));
return self::ordered($c);
}
public static function fingerprint(array $c): string
{
return hash('sha256',json_encode(self::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
}
/** Reader supplies value, original updated/changed, parentID, ident. Never trusts collector timestamp alone. */
public static function read(array $config, callable $reader, int $now): array
{
$c=self::configuration($config);
if ($now<1) throw new InvalidArgumentException('feedback_time_invalid');
$first=[];$values=[];$times=[];$physicalTimes=[];$variableTimes=[];$changed=[];
foreach ($c['sources'] as $s) {
$r=$reader($s['variableId']);
if (!is_array($r) || ($r['parentID']??null)!==$s['parentId'] || ($r['ident']??null)!==$s['ident']
|| !is_int($r['updated']??null) || $r['updated']<1 || $r['updated']>$now
|| !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']
|| (in_array($s['role'],['grid','physical'],true)
&& (!is_int($r['confirmedAt']??null) || $r['confirmedAt']<1 || $r['confirmedAt']>$now
|| $r['updated']>$r['confirmedAt']))) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident','confirmedAt']));
if ($s['role']==='gateway_active') {
if (($r['value']??null)!==true) throw new InvalidArgumentException('feedback_gateway_inactive');
} else {
$w=self::number($r['value']??null,-1e9,1e9)*$s['factorToW'];
$values[$s['role']]=($values[$s['role']]??0)+self::number($w,-1e9,1e9);
}
// VariableUpdated is retained as provenance. Freshness comes only from a successful device response.
if (in_array($s['role'],['grid','physical'],true)) {
if ($now-$r['confirmedAt']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
$times[]=$r['confirmedAt'];$variableTimes[]=$r['updated'];
if ($s['role']==='physical') $physicalTimes[]=$r['confirmedAt'];
} else $changed[$s['role']]=$r['changed'];
}
foreach ($c['sources'] as $s) {
$second=$reader($s['variableId']);
if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident','confirmedAt']))!==$first[$s['key']]) {
throw new InvalidArgumentException('feedback_changed_during_read:'.$s['key']);
}
}
if (max($times)-min($times)>$c['maxSkewSeconds']) throw new InvalidArgumentException('feedback_source_skew');
$physical=(float)$values['physical'];$power=$physical;$external=0.0;$estimated=$c['mode']==='virtual_split';$reasons=[];$tracking=null;
if ($estimated) {
if ($changed['gateway_active']>min($physicalTimes) || $changed['sdl_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_external_transition_not_observed');
$ev=(float)$values['ev_request'];$sdl=(float)$values['sdl_request'];$tracking=$physical-$ev-$sdl;
if ($sdl===0.0) {
// No external commanded contribution. Physical response may lag a NEW EV command.
$power=$physical;
if ($ev===0.0 && abs($physical)>$c['idleToleranceW']) throw new InvalidArgumentException('feedback_unassigned_idle_power');
} else {
if ($changed['ev_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_ev_transition_not_observed');
if (abs($tracking)>$c['trackingToleranceW']) throw new InvalidArgumentException('feedback_partition_not_tracking');
$power=$ev+$tracking*abs($ev)/(abs($ev)+abs($sdl));$external=$physical-$power;
if ($ev*$sdl<0) $reasons[]='opposing_virtual_flows_not_identifiable';
}
if (!$c['allowEstimatedForTrial']) $reasons[]='modelled_feedback_not_accepted_for_trial';
}
return ['version'=>1,'kind'=>'local_physical_feedback','installationId'=>$c['installationId'],'assetId'=>$c['assetId'],
'managerId'=>$c['managerId'],'batteryInstanceId'=>$c['batteryInstanceId'],'checkedAt'=>$now,
'sourceOldestAt'=>min($times),'sourceNewestAt'=>max($times),'physicalOldestAt'=>min($physicalTimes),
'sourceSkewSeconds'=>max($times)-min($times),'sourceVariableOldestAt'=>min($variableTimes),
'sourceVariableNewestAt'=>max($variableTimes),'deviceReadConfirmed'=>true,'maxAgeSeconds'=>$c['maxAgeSeconds'],
'configHash'=>self::fingerprint($c),'gridW'=>(float)$values['grid'],'physicalStorageW'=>$physical,
'batteryW'=>$power,'externalModelW'=>$external,'trackingErrorW'=>$tracking,
'estimated'=>$estimated,'separatelyMeasured'=>!$estimated,'usableForTrial'=>$reasons===[],
'reasons'=>$reasons,'method'=>$estimated?'physical_request_partition_unfiltered':'physical_sum',
'canDispatch'=>false,'gatewayWatchdogVerified'=>false,'heldFilterUsed'=>false];
}
public static function requireTrial(array $f, array $binding, int $now): void
{
foreach (['installationId','assetId','managerId','batteryInstanceId'] as $key) {
if (!array_key_exists($key,$binding) || ($f[$key]??null)!==$binding[$key]) throw new InvalidArgumentException('feedback_wrong_binding');
}
if (($f['kind']??null)!=='local_physical_feedback' || ($f['version']??null)!==1 || ($f['usableForTrial']??null)!==true
|| ($f['deviceReadConfirmed']??null)!==true
|| !is_int($f['checkedAt']??null) || $f['checkedAt']>$now || $now-$f['checkedAt']>2
|| !is_int($f['sourceOldestAt']??null) || !is_int($f['maxAgeSeconds']??null)
|| $f['maxAgeSeconds']<1 || $f['maxAgeSeconds']>60 || $f['sourceOldestAt']>$now || $now-$f['sourceOldestAt']>$f['maxAgeSeconds']
|| !is_string($f['configHash']??null) || !preg_match('/^[a-f0-9]{64}$/D',$f['configHash'])) throw new InvalidArgumentException('feedback_not_usable_for_trial');
if (isset($binding['feedbackConfigHash']) && $binding['feedbackConfigHash']!==$f['configHash']) throw new InvalidArgumentException('feedback_configuration_changed');
self::number($f['batteryW']??null,-1e9,1e9);self::number($f['gridW']??null,-1e9,1e9);
}
}
+19 -1
View File
@@ -5,13 +5,31 @@ declare(strict_types=1);
namespace Belevo\EnelixEMS;
/**
* Registriert die gemeinsamen Properties und Variablen aller EMS-Verbraucher.
* Gemeinsame Registrierung und Sollwert-Bereinigung aller EMS-Verbraucher.
*
* Das Trait ist noch keine vollstaendige Steuerungsimplementierung. Die
* konkreten Module ergaenzen Geraeteanbindung, Leistungsangebot und Zustand.
*/
trait VerbraucherBasisTrait
{
protected function verwerfeSollwert(): void
{
// Lokale Ersatzleistungen nicht bei jedem weiteren Timeout kurz auf 0 setzen.
if (!(bool) $this->leseZustand('SollwertGueltig')) {
return;
}
$this->setzeZustand('SollwertGueltig', false);
$this->setzeZustand('Sollleistung', 0);
}
protected function bereinigeUngueltigenSollwert(): void
{
// Auch vor dem Update gespeicherte, bereits ungueltige Vorgaben bereinigen.
if (!(bool) $this->leseZustand('SollwertGueltig')) {
$this->setzeZustand('Sollleistung', 0);
}
}
protected function registriereVerbraucherBasis(): void
{
$this->RegisterPropertyInteger('PrioritaetPV', 0);
@@ -0,0 +1,17 @@
# Prognose im bestehenden Portal
Versionierte statische Assets fuer den Prognosebereich von license.enelix.ch.
Der neue Planner teilt die gewaehlte Anlage und Sitzung des Portals. Die
bestehende historische Anzeige bleibt separat verfuegbar. Es gibt keine
Aktivierungsanfrage an Geraete oder Trial-Endpunkte.
`deploy.py` prueft ohne Argumente die Ausgangs- und Zielhashes. `--apply` sichert
die vorhandenen Assets und tauscht nur die sechs aufgefuehrten Dateien aus.
Ein abweichender Ausgangsstand fuehrt zum Abbruch, nicht zum Ueberschreiben.
Nach erfolgter Installation ist erneuter Aufruf absichtlich nicht erforderlich.
Neuere Branding-Assets und deren Einbindung bleiben erhalten.
Pruefungen: `node --test portal-integration.test.mjs`; weitere Chart- und
Browserpruefungen liegen unter `services/netplan-v4/gui`.
Der Releasebericht unter `docs/FORECAST_SDL_RELEASE_20261006.md` benennt den
noch ausstehenden Backend-Neustand und die Betriebsgrenzen der Testanlage.
@@ -0,0 +1,62 @@
"""Deploy static assets only. Abort on drift; keep a private rollback snapshot."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import time
parser = argparse.ArgumentParser()
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
package = Path(__file__).resolve().parent
root = Path('/home/agent/services/license')
manifest = json.loads((package / 'manifest.json').read_text())
sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None
for entry in manifest:
relative = Path(entry['path'])
if relative.is_absolute() or '..' in relative.parts or relative.parts[0] != 'public':
raise SystemExit('Unsafe manifest path')
target = root / relative
if target.is_symlink() or sha(target) != entry['before']:
raise SystemExit('Runtime drift: ' + str(relative))
if sha(package / relative) != entry['after']:
raise SystemExit('Release checksum mismatch: ' + str(relative))
if not args.apply:
print(json.dumps({'preflight': 'ok', 'files': len(manifest)}))
raise SystemExit(0)
backup = Path('/srv/agent/forecast-completion-20261006') / ('portal-backup-' + str(time.time_ns()))
backup.mkdir(mode=0o700)
for entry in manifest:
source = root / entry['path']
if source.exists():
target = backup / entry['path']
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
shutil.copyfile(package / 'manifest.json', backup / 'manifest.json')
written = []
try:
# New imported dependencies first; the HTML entrypoint is switched last.
order = sorted(manifest, key=lambda e: (e['path'].endswith('index.html'), e['path'].endswith('app.js')))
for entry in order:
target = root / entry['path']
if sha(target) != entry['before']:
raise RuntimeError('Concurrent change: ' + entry['path'])
temp = target.with_name(target.name + '.forecast-new')
with temp.open('xb') as stream:
stream.write((package / entry['path']).read_bytes())
os.chmod(temp, target.stat().st_mode & 0o777 if target.exists() else 0o644)
os.replace(temp, target)
written.append(entry)
if any(sha(root / entry['path']) != entry['after'] for entry in manifest):
raise RuntimeError('Post-deployment checksum mismatch')
except Exception:
for entry in reversed(written):
target = root / entry['path']
if sha(target) != entry['after']:
continue
if entry['before'] is not None:
shutil.copy2(backup / entry['path'], target)
raise
print(json.dumps({'deployed': True, 'files': len(written), 'backup': str(backup)}))
@@ -0,0 +1,32 @@
[
{
"path": "public/app.js",
"before": "b4828a1bd58a5cc29a3970475c8eebadf89a3fda788c269d6951572603fa5dd9",
"after": "95a5a0b0958ad0bd7b444225b90598c2a8c2e92b54631265211bd26b520cdd56"
},
{
"path": "public/index.html",
"before": "7010e36746b6739d50c13b91cd2ad29de1ced2bc4ca42b49b3f43cc66f9c1ec2",
"after": "26ed3c78ce3deff774f5016357928120d764e434fbe844374656953791d021b0"
},
{
"path": "public/netplan-v4.js",
"before": "245af626f3322dcaec5afbbc9772707ee6de00f07fd939e59709e7349f2d12d9",
"after": "9c17da0bc6014c1dc09a84bb6015a6af160c59a07c8acda096da9f4e72b3b55d"
},
{
"path": "public/netplan-v4.css",
"before": "f7e55386157aa64db5be62319b9f37e27052f0f001fb28f8233d886d3f9b40b5",
"after": "1189b8d9b7a092bbdff5ec77a99207d1f0fecab56357a44b856ed161ab80cabe"
},
{
"path": "public/netplan-v4.html",
"before": "5ee72a589f12cb30ae14cc484ac406788affe530db4a25dfec3ffb3c79beb924",
"after": "df2414f0854743595ebf2eaaf1f49d546e90cb18b71dc86b16b5c1f193cdf960"
},
{
"path": "public/forecast-chart.js",
"before": null,
"after": "2d51aabd27156c581c31eaffe138a987208b86490f22df2ef98b37c1c018ff4b"
}
]
@@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
const source = await readFile(new URL('./public/app.js', import.meta.url), 'utf8');
const html = await readFile(new URL('./public/index.html', import.meta.url), 'utf8');
const plannerSource = await readFile(new URL('./public/netplan-v4.js', import.meta.url), 'utf8');
function section(start, end) {
const first = source.indexOf(start), last = source.indexOf(end, first + start.length);
assert.ok(first >= 0 && last > first, `integration source boundaries: ${start}`);
return source.slice(first, last);
}
const mountSource = section(' var integratedPlanner = null;', ' function faultDate(');
const viewSource = section(' function showView(name)', ' async function loadPortalHealth(');
const authSource = section(' function showAuth()', ' function workspaceOptions(');
const importPattern = /import\(("\/netplan-v4\.js[^"\n]*")\)/;
assert.match(mountSource, importPattern);
const executableSource = mountSource.replace(importPattern, 'importPlanner($1)') + '\n' + viewSource + '\n' + authSource;
function deferred() {
let resolve, reject;
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
const settle = () => new Promise(resolve => setImmediate(resolve));
function node(id, active = false) {
const classes = new Set(active ? ['active'] : []);
return {
id, textContent: '',
replaceChildren() { this.textContent = ''; },
classList: {
contains(name) { return classes.has(name); },
add(name) { classes.add(name); },
remove(name) { classes.delete(name); },
toggle(name, enabled) { if (enabled) classes.add(name); else classes.delete(name); }
}
};
}
function harness(options = {}) {
const nodes = new Map();
const byId = id => {
if (!nodes.has(id)) nodes.set(id, node(id, id === 'view-forecast' && options.active !== false));
return nodes.get(id);
};
const calls = { imports: [], api: [], mounted: [], disposed: [], rendered: [], legacyStatus: [], configuration: [] };
let plant = { id: 'A', installation_id: 'installation-A', manager_variant: 'manager_standard' };
const plannerModule = {
mountPlanner(host, config) {
calls.mounted.push(config.plantId); host.textContent = `mounted:${config.plantId}`;
return { ready: options.ready ? options.ready(config.plantId) : Promise.resolve(), dispose() { calls.disposed.push(config.plantId); host.replaceChildren(); } };
}
};
const state = { session: { csrfToken: 'test-only' }, forecastRequest: 0, forecastHistoryDays: 1 };
const context = vm.createContext({
state, byId, encodeURIComponent,
document: { querySelectorAll(selector) { return selector === '.view' ? [byId('view-forecast'), byId('view-overview')] : []; } },
history: { replaceState() {} }, location: { pathname: '/', search: '' }, window: { scrollTo() {} },
selectedPlant() { return plant; }, managerLabel(value) { return value; },
resetForecastChart() {}, renderForecastLicenseWarning() {},
setForecastState(message) { calls.legacyStatus.push(message); },
renderForecastResult(result) { calls.rendered.push(result); },
loadForecastConfiguration(selected, requestId) { calls.configuration.push({ plant: selected.id, requestId }); return Promise.resolve(); },
loadInvitations() {}, loadOrders() {}, updateFaults() {},
api(url) { calls.api.push(url); return options.api ? options.api(url) : Promise.resolve({ status: 'available', source: url }); },
importPlanner(url) { calls.imports.push(url); return options.importPlanner ? options.importPlanner(url, plannerModule) : Promise.resolve(plannerModule); }
});
vm.runInContext(executableSource + '\nthis.integration = {updateForecast, showView, showAuth, currentPlanner: () => integratedPlanner};', context);
return { state, calls, byId, module: plannerModule, ...context.integration, select(id, linked = true) { plant = id ? { id, installation_id: linked ? `installation-${id}` : null, manager_variant: 'manager_standard' } : null; } };
}
test('release assets expose the host, stylesheet and relative chart dependency', async () => {
assert.equal((html.match(/id="forecastPlanner"/g) || []).length, 1);
assert.match(html, /href="\/netplan-v4\.css\?[^"\s]+"/);
assert.equal(html.match(/src="\/app\.js[^"\s]*"/)?.[0], 'src="/app.js?v=20261006-forecast"', 'entrypoint version must invalidate the pre-integration cached app');
assert.ok(html.indexOf('id="forecastPlanner"') > html.indexOf('id="view-forecast"'));
assert.match(plannerSource, /export function mountPlanner\(/);
const dependency = plannerSource.match(/from ['"](\.\/forecast-chart\.js)['"]/);
assert.ok(dependency, 'chart import remains relative to the deployed planner module');
assert.ok((await readFile(new URL('./public/' + dependency[1], import.meta.url), 'utf8')).includes('export function renderForecastChart'));
});
test('inactive views and unlinked plants do not mount the planner', async () => {
const hidden = harness({ active: false }); await hidden.updateForecast(); await settle();
assert.equal(hidden.calls.imports.length, 0); assert.equal(hidden.calls.api.length, 0);
const unlinked = harness(); unlinked.select('A', false); await unlinked.updateForecast(); await settle();
assert.equal(unlinked.calls.mounted.length, 0); assert.equal(unlinked.calls.api.length, 1);
});
test('changing plants invalidates stale imports and legacy responses', async () => {
const imports = [deferred(), deferred()], results = [deferred(), deferred()];
let importIndex = 0, resultIndex = 0;
const app = harness({ importPlanner: () => imports[importIndex++].promise, api: () => results[resultIndex++].promise });
const first = app.updateForecast(); app.select('B'); const second = app.updateForecast();
imports[0].resolve(app.module); await settle(); assert.deepEqual(app.calls.mounted, []);
imports[1].resolve(app.module); results[1].resolve({ status: 'available', plant: 'B' });
results[0].resolve({ status: 'available', plant: 'A' }); await Promise.all([first, second]); await settle();
assert.deepEqual(app.calls.mounted, ['B']);
assert.deepEqual(app.calls.rendered.map(result => result.plant), ['B']);
assert.deepEqual(app.calls.configuration.map(entry => entry.plant), ['B']);
});
test('leaving the forecast view disposes the mounted planner and invalidates pending imports', async () => {
const app = harness(); await app.updateForecast(); await settle();
const revision = app.state.forecastRequest; app.showView('overview');
assert.equal(app.state.forecastRequest, revision + 1); assert.deepEqual(app.calls.disposed, ['A']);
assert.equal(app.currentPlanner(), null);
const imported = deferred(), pending = harness({ importPlanner: () => imported.promise });
await pending.updateForecast(); pending.showView('overview'); imported.resolve(pending.module); await settle();
assert.deepEqual(pending.calls.mounted, []);
});
test('returning to authentication disposes the planner and invalidates pending responses', async () => {
const ready = deferred(), app = harness({ ready: () => ready.promise });
await app.updateForecast(); await settle();
const revision = app.state.forecastRequest; app.showAuth();
assert.equal(app.state.forecastRequest, revision + 1);
assert.deepEqual(app.calls.disposed, ['A']); assert.equal(app.currentPlanner(), null);
assert.equal(app.byId('portalApp').classList.contains('hidden'), true);
ready.reject(new Error('late post-logout failure')); await settle();
assert.equal(app.byId('forecastPlanner').textContent, '');
const imported = deferred(), result = deferred();
const pending = harness({ importPlanner: () => imported.promise, api: () => result.promise });
const update = pending.updateForecast(); pending.showAuth();
imported.resolve(pending.module); result.resolve({ status: 'available', plant: 'A' });
await update; await settle();
assert.deepEqual(pending.calls.mounted, []);
assert.deepEqual(pending.calls.rendered, []);
assert.deepEqual(pending.calls.configuration, []);
});
test('legacy API failures do not clear or prevent the independent planner', async () => {
const app = harness({ api: () => Promise.reject(new Error('legacy unavailable')) });
await app.updateForecast(); await settle();
assert.deepEqual(app.calls.mounted, ['A']);
assert.equal(app.byId('forecastPlanner').textContent, 'mounted:A');
assert.equal(app.calls.legacyStatus.at(-1), 'legacy unavailable');
});
test('planner import failures remain inside the planner host', async () => {
const app = harness({ importPlanner: () => Promise.reject(new Error('planner unavailable')) });
await app.updateForecast(); await settle();
assert.equal(app.calls.rendered.length, 1);
assert.match(app.byId('forecastPlanner').textContent, /planner unavailable/);
assert.ok(!app.calls.legacyStatus.includes('planner unavailable'));
});
test('late planner errors cannot overwrite the next plant after disposal', async () => {
const ready = deferred(), app = harness({ ready: id => id === 'A' ? ready.promise : Promise.resolve() });
await app.updateForecast(); await settle(); app.select('B'); await app.updateForecast(); await settle();
ready.reject(new Error('late old failure')); await settle();
assert.deepEqual(app.calls.disposed, ['A']);
assert.deepEqual(app.calls.mounted, ['A', 'B']);
assert.equal(app.byId('forecastPlanner').textContent, 'mounted:B');
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
const SVG_NS = 'http://www.w3.org/2000/svg';
const COLORS = ['#4e8181', '#757b12', '#ac4f68', '#386b91', '#737c7e', '#9c6335'];
let sequence = 0;
export const finiteValue = value => typeof value === 'number' && Number.isFinite(value);
export const formatNumber = (value, digits = 2) => finiteValue(value)
? value.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'nicht vorhanden';
export function forecastSeries(plan) {
const fullHorizon = Array.isArray(plan.forecastPoints) && plan.forecastPoints.length > 0;
return { fullHorizon, points: fullHorizon ? plan.forecastPoints : Array.isArray(plan.points) ? plan.points : [] };
}
export function formatTime(value) {
const stamp = Date.parse(value);
return Number.isFinite(stamp) ? new Date(stamp).toLocaleString('de-CH', {
timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'
}) : 'nicht vorhanden';
}
export function chartDomain(points, series) {
let low = 0, high = 0, start = Infinity, end = -Infinity, valid = false;
for (const point of points) {
const at = Date.parse(point.time), until = Date.parse(point.validUntil || point.time);
if (!Number.isFinite(at) || !Number.isFinite(until) || until < at) continue;
start = Math.min(start, at); end = Math.max(end, until);
for (const item of series) {
const value = item.value(point);
if (!finiteValue(value)) continue;
low = Math.min(low, value); high = Math.max(high, value); valid = true;
}
}
if (!Number.isFinite(start)) return null;
return { low, high: high === low ? low + 1 : high, start, end: Math.max(start + 1, end), valid };
}
// Keep missing values and non-contiguous intervals as visible gaps, never interpolated data.
export function seriesSegments(points, series, x, y) {
const segments = [];
let section = [], previousEnd;
const flush = () => { if (section.length) segments.push(section); section = []; };
for (const point of points) {
const value = series.value(point), at = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
if (!finiteValue(value) || !Number.isFinite(at) || !Number.isFinite(end) || end < at) {
flush(); previousEnd = undefined; continue;
}
if (previousEnd !== undefined && (at > previousEnd || at < previousEnd)) flush();
if (series.position === 'end') section.push([x(end), y(value)]);
else {
section.push([x(at), y(value)]);
if (end > at) section.push([x(end), y(value)]);
}
previousEnd = end;
}
flush(); return segments;
}
export function pointIndexAt(points, stamp) {
let selected = -1, distance = Infinity;
points.forEach((point, index) => {
const start = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return;
const next = stamp < start ? start - stamp : stamp >= end ? stamp - end : 0;
if (next <= distance) { distance = next; selected = index; }
});
return selected;
}
const element = (tag, text, className) => {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
};
const svgElement = (tag, attributes, text) => {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
if (text !== undefined) node.textContent = text;
return node;
};
export function renderForecastChart(container, { title, points = [], series = [], unit = '', caption = '' }) {
const id = 'forecast-chart-' + ++sequence;
const root = element('section', undefined, 'forecast-chart');
const heading = element('h3', title); heading.id = id;
root.setAttribute('aria-labelledby', id); root.append(heading); container.append(root);
const records = points.filter(point => Number.isFinite(Date.parse(point.time)) && Number.isFinite(Date.parse(point.validUntil || point.time)) && Date.parse(point.validUntil || point.time) >= Date.parse(point.time));
if (!records.length || !chartDomain(records, series)?.valid) {
root.append(element('p', 'Keine Prognosewerte vorhanden.', 'forecast-chart-empty'));
if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
return { element: root, destroy() { root.remove(); } };
}
const sources = series.map((source, index) => ({ ...source, color: source.color || COLORS[index % COLORS.length], visible: source.visible !== false }));
const legend = element('fieldset', undefined, 'forecast-chart-legend');
legend.append(element('legend', 'Datenreihen', 'forecast-chart-sr'));
const plot = element('div', undefined, 'forecast-chart-plot');
const svg = svgElement('svg', { role: 'img', 'aria-label': `${title}, ${unit}. Prognosewerte pro Zeitintervall.` });
plot.append(svg);
const inspector = element('div', undefined, 'forecast-chart-inspector');
const timestamp = element('p', undefined, 'forecast-chart-time');
const readout = element('dl'); inspector.append(timestamp, readout);
const sliderLabel = element('label', 'Prognosezeitpunkt', 'forecast-chart-slider');
const slider = element('input'); slider.type = 'range'; slider.min = '0'; slider.max = String(records.length - 1); slider.step = '1'; slider.value = '0';
slider.disabled = records.length === 1; sliderLabel.append(slider);
root.append(legend, plot, sliderLabel, inspector);
if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
let index = 0, domain, width = 900, marker;
const left = 68, right = 16, top = 18, bottom = 238, height = 284;
const x = value => left + (width - left - right) * (value - domain.start) / (domain.end - domain.start);
const y = value => bottom - (bottom - top) * (value - domain.low) / (domain.high - domain.low);
function inspect(next) {
index = Math.max(0, Math.min(records.length - 1, next)); slider.value = String(index);
const point = records[index];
timestamp.textContent = `${formatTime(point.time)} bis ${formatTime(point.validUntil || point.time)}`;
readout.replaceChildren();
const descriptions = [];
for (const source of sources.filter(item => item.visible)) {
const row = element('div'), name = element('dt', source.label), value = source.value(point);
name.style.setProperty('--series-color', source.color);
const text = finiteValue(value) ? `${formatNumber(value, source.digits ?? 2)} ${unit}` : 'nicht vorhanden';
row.append(name, element('dd', text)); readout.append(row); descriptions.push(`${source.label}: ${text}`);
}
if (!descriptions.length) readout.append(element('div', 'Keine Datenreihe ausgewaehlt.'));
slider.setAttribute('aria-valuetext', `${timestamp.textContent}. ${descriptions.join('; ')}`);
if (marker) {
const active = sources.filter(item => item.visible);
const endPosition = active.length && active.every(item => item.position === 'end');
const at = x(Date.parse(endPosition ? point.validUntil || point.time : point.time)); marker.setAttribute('x1', at); marker.setAttribute('x2', at);
}
}
function draw() {
width = Math.max(300, Math.round(plot.getBoundingClientRect().width || 900));
const active = sources.filter(source => source.visible);
domain = chartDomain(records, active.length ? active : sources);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.replaceChildren();
for (let tick = 0; tick <= 4; tick++) {
const value = domain.low + (domain.high - domain.low) * tick / 4, at = y(value);
svg.append(svgElement('line', { x1: left, x2: width - right, y1: at, y2: at, class: 'forecast-chart-grid' }));
svg.append(svgElement('text', { x: left - 8, y: at + 4, 'text-anchor': 'end', class: 'forecast-chart-axis' }, formatNumber(value, Math.max(1, ...active.map(source => source.digits ?? 1)))));
}
svg.append(svgElement('text', { x: left, y: 12, class: 'forecast-chart-axis' }, unit));
const tickCount = width < 550 ? 2 : 4;
for (let tick = 0; tick <= tickCount; tick++) {
const stamp = domain.start + (domain.end - domain.start) * tick / tickCount;
const anchor = tick === 0 ? 'start' : tick === tickCount ? 'end' : 'middle';
const at = new Date(stamp);
svg.append(svgElement('text', { x: x(stamp), y: bottom + 20, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleTimeString('de-CH', { timeZone: 'Europe/Zurich', hour: '2-digit', minute: '2-digit' })));
svg.append(svgElement('text', { x: x(stamp), y: bottom + 36, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleDateString('de-CH', { timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit' })));
}
for (const source of active) {
for (const section of seriesSegments(records, source, x, y)) {
const attributes = { fill: 'none', stroke: source.color, 'stroke-width': 2, 'vector-effect': 'non-scaling-stroke', 'stroke-linecap': 'round' };
if (source.dash) attributes['stroke-dasharray'] = source.dash;
if (section.length === 1) svg.append(svgElement('circle', { cx: section[0][0], cy: section[0][1], r: 3, fill: source.color }));
else svg.append(svgElement('polyline', { ...attributes, points: section.map(pair => pair.join(',')).join(' ') }));
}
}
marker = svgElement('line', { y1: top, y2: bottom, class: 'forecast-chart-marker' }); svg.append(marker);
inspect(index);
}
sources.forEach(source => {
const label = element('label'), input = element('input'), swatch = element('span', undefined, 'forecast-chart-swatch');
input.type = 'checkbox'; input.checked = source.visible; swatch.style.background = source.color;
if (source.dash) swatch.style.background = `repeating-linear-gradient(90deg, ${source.color} 0 5px, transparent 5px 8px)`;
label.append(input, swatch, document.createTextNode(source.label)); legend.append(label);
input.addEventListener('change', () => { source.visible = input.checked; draw(); });
});
slider.addEventListener('input', () => inspect(Number(slider.value)));
const pointer = event => {
const bounds = svg.getBoundingClientRect();
if (!bounds.width) return;
const position = Math.max(left, Math.min(width - right, (event.clientX - bounds.left) * width / bounds.width));
const stamp = domain.start + (position - left) / (width - left - right) * (domain.end - domain.start);
inspect(pointIndexAt(records, stamp));
};
svg.addEventListener('pointermove', event => { if (event.pointerType !== 'touch') pointer(event); });
svg.addEventListener('pointerdown', pointer);
draw();
const resize = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(draw) : null;
resize?.observe(plot);
return { element: root, destroy() { resize?.disconnect(); root.remove(); } };
}
@@ -0,0 +1,382 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Enelix EMS Portal für Systeme, Lizenzen und Prognosemanager">
<title>Enelix EMS Portal</title>
<meta name="theme-color" content="#4e8181">
<link rel="icon" type="image/svg+xml" href="/branding/enelix-positive.svg">
<link rel="apple-touch-icon" href="/branding/enelix-touch.png">
<link rel="stylesheet" href="/styles.css?v=20261006-brand">
<link rel="stylesheet" href="/docs-portal.css?v=20261006">
<link rel="stylesheet" href="/branding/enelix-20261006.css">
<link rel="stylesheet" href="/netplan-v4.css?v=20261006-integrated">
</head>
<body>
<div id="toast" class="toast" role="status"></div>
<main id="authScreen" class="auth-screen">
<section class="auth-brand">
<div class="brand-lockup"><img class="brand-logo" src="/branding/enelix-negative.svg" alt="enelix" width="252" height="72"><small class="brand-context">EMS Portal</small></div>
<div class="auth-message"><p class="eyebrow">Energiemanagement</p><h1>Systeme planen.<br>Lizenzen verbinden.</h1><p>Ein Zugang für Anlagen, Prognosemanager und Lizenzierung.</p></div>
<div class="energy-lines" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</section>
<section class="auth-workspace">
<div class="auth-box">
<nav class="public-doc-links" aria-label="Öffentliche Informationen"><a href="/docs/index.html">Dokumentation</a><a href="/docs/faq.html">FAQ</a><a href="/docs/preise.html">Preise</a></nav>
<div class="auth-tabs" id="authTabs" role="tablist" aria-label="Anmeldung">
<button class="auth-tab active" id="loginTab" type="button">Anmelden</button>
<button class="auth-tab" id="registerTab" type="button">Konto erstellen</button>
</div>
<p class="auth-notice hidden" id="authNotice" role="status"></p>
<form id="loginForm" class="auth-form">
<div><p class="eyebrow">Willkommen zurück</p><h2>Am Portal anmelden</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<p class="form-error" id="loginError" role="alert"></p>
<button class="button primary wide" type="submit">Anmelden</button>
<button class="auth-link" id="forgotPasswordButton" type="button">Passwort vergessen?</button>
<button class="auth-link" id="resendVerificationButton" type="button">Bestätigungs-E-Mail erneut senden</button>
</form>
<form id="registerForm" class="auth-form hidden">
<div><p class="eyebrow">Offene Registrierung</p><h2>Neues Konto erstellen</h2></div>
<label>Name<input name="name" autocomplete="name" required minlength="2"></label>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label>
<p class="form-error" id="registerError" role="alert"></p>
<button class="button primary wide" type="submit">Konto erstellen</button>
</form>
<form id="forgotForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Passwort zurücksetzen</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" required></label>
<p class="form-error" id="forgotError" role="alert"></p>
<button class="button primary wide" type="submit">Link anfordern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
<form id="resetForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Neues Passwort</h2></div>
<label>Neues Passwort<input name="password" type="password" required minlength="10"></label>
<label>Passwort wiederholen<input name="password_repeat" type="password" required minlength="10"></label>
<p class="form-error" id="resetError" role="alert"></p>
<button class="button primary wide" type="submit">Passwort speichern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
</div>
</section>
</main>
<div id="portalApp" class="portal hidden">
<header class="topbar">
<a class="brand" href="#overview" aria-label="Enelix EMS Portal"><img class="brand-logo" src="/branding/enelix-positive.svg" alt="enelix" width="148" height="43"><small class="brand-context">EMS Portal</small></a>
<div class="top-actions"><nav class="header-links" aria-label="Dokumentation und Preise"><a href="/docs/index.html">Dokumentation</a><a href="/docs/preise.html">Preise</a></nav><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
</header>
<div class="app-shell">
<aside class="sidebar">
<nav>
<button class="nav-item active" data-view="overview">Lizenzen</button>
<button class="nav-item" data-view="forecast">Prognose</button>
<button class="nav-item" data-view="faults">Störüberwachung</button>
<button class="nav-item" data-view="account">Konto</button>
</nav>
<div class="workspace-switcher"><label>Organisation<select id="workspaceSelect"></select></label><small id="workspaceRole">Eigentümer</small></div>
</aside>
<main class="content">
<section class="view active" id="view-overview">
<div class="page-head"><div><p class="eyebrow" id="overviewOrg">Arbeitsbereich</p><h1>Lizenzübersicht</h1><p>Gekaufte Lizenzen, Verwendung und Managerbindung auf einen Blick.</p></div><button class="button primary" type="button" data-open-new-license>Neue Lizenz</button></div>
<div class="metrics"><div><span id="metricPlants">0</span><small>Lizenzpakete</small></div><div><span id="metricManagers">0</span><small>Bezahlte Lizenzen</small></div><div><span id="metricConnected">0</span><small>Verknüpfte Manager</small></div></div>
<section class="work-section"><div class="section-head"><div><h2>Lizenzen und Manager</h2><p>Status, Verwendung und verfügbare Erweiterungen.</p></div></div><div id="overviewPlants"></div></section>
</section>
<section class="view" id="view-licenses">
<div class="page-head"><div><p class="eyebrow">Kauf · CHF netto</p><h1>Lizenz erweitern</h1><p>Vorhandene Lizenzen prüfen, zusätzliche Module ergänzen und Peak Shaving bei Bedarf upgraden.</p></div><div class="page-actions"><button class="button secondary" type="button" data-configure-current>System konfigurieren</button><span class="status" id="stripeStatus">Stripe Sandbox</span></div></div>
<div id="moduleNoPlant" class="empty-state hidden"><strong>Noch keine Lizenz vorhanden</strong><p>Lege ein Lizenzpaket direkt an oder ermittle es mit dem Systemkonfigurator.</p><button class="button primary" data-open-new-license>Neue Lizenz</button></div>
<div id="moduleConfigurator" class="config-layout">
<section class="module-panel">
<label class="select-line">System<select id="orderPlant"></select></label>
<div class="manager-summary"><div><span class="field-label">Manager</span><strong id="managerCurrentName">Noch keine bezahlte Manager-Lizenz</strong><small id="ownedManager">Der Manager wird beim Erstkauf einmalig berechnet.</small></div><label id="peakUpgradeOption" class="upgrade-option hidden"><input id="peakUpgrade" type="checkbox"><span><strong>Auf Peak Shaving upgraden</strong><small>Einmalig nur die Preisdifferenz bezahlen.</small></span></label><span><b id="managerPrice">CHF 0.00</b><small id="managerSetup">Kein weiterer Managerkauf</small></span></div>
<div class="module-list">
<div class="module-header"><span>Verbrauchermodul</span><span>Lizenz</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="battery" data-name="Batteriespeicher" data-price="41.95" data-setup="54.55"><span><input type="checkbox" class="module-check"><strong>Batteriespeicher</strong><small>Batterie</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 41.95</span><span>CHF 54.55</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="buffer_storage" data-name="Pufferspeicher" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Pufferspeicher</strong><small>VerbraucherPufferspeicher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="ev_charger" data-name="EV-Ladestation" data-price="33.65" data-setup="43.75"><span><input type="checkbox" class="module-check"><strong>EV-Ladestation</strong><small>Stand-Alone oder Gateway</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 33.65</span><span>CHF 43.75</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>Ein schaltbarer Verbraucher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="heat_pump" data-name="Wärmepumpe" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Wärmepumpe</strong><small>Sperre / Erhöhung oder SG Ready</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="boiler_multi" data-name="Boiler mehrstufig" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Boiler mehrstufig</strong><small>Warmwassererwärmer</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<div class="module-header"><span>Prognoselizenz</span><span>Jahrespreis</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="forecast_pv" data-name="PV-Ertragsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>PV-Ertragsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="forecast_load" data-name="Verbrauchsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Verbrauchsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="grid_schedule" data-name="Intelligenter Netzfahrplan" data-price="50" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Intelligenter Netzfahrplan</strong><small>Jahreslizenz · Manager mit Peak Shaving erforderlich</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 50.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
</div>
</section>
<aside class="order-panel"><p class="eyebrow">Bestellung</p><div id="orderItems" class="order-items"></div><dl><div><dt>Lizenzen netto</dt><dd id="moduleTotal">CHF 0.00</dd></div><div id="setupTotalRow" class="hidden"><dt>Generator / Ersteinrichtung</dt><dd id="setupTotal">CHF 0.00</dd></div><div><dt>Gesamt netto</dt><dd id="netTotal">CHF 0.00</dd></div><div><dt id="vatLabel">MwSt. 8.1 %</dt><dd id="vatTotal">CHF 0.00</dd></div><div class="grand-total"><dt>Gesamt inkl. MwSt.</dt><dd id="grandTotal">CHF 0.00</dd></div></dl><button class="button primary wide" id="checkoutButton" type="button">Mit Stripe bezahlen</button><p class="form-error" id="checkoutError"></p></aside>
</div>
</section>
<section class="view" id="view-configurator">
<div class="page-head"><div><p class="eyebrow">Planung</p><h1>System konfigurieren</h1><p>Geräte erfassen und passende Lizenzen ermitteln.</p></div><span class="status available">Enelix EMS</span></div>
<form id="systemForm" class="system-configurator">
<section class="config-form wizard">
<header class="wizard-header">
<div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 10</p><h2 id="wizardStepTitle">Anlage</h2></div>
<ol class="wizard-progress" aria-label="Fortschritt">
<li class="active" data-wizard-marker="0">Anlage</li><li data-wizard-marker="1">Energiequellen</li>
<li data-wizard-marker="2">Batterie</li><li data-wizard-marker="3">Pufferspeicher</li>
<li data-wizard-marker="4">Ladestation</li><li data-wizard-marker="5">Verbraucher</li>
<li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Wärmepumpe</li>
<li data-wizard-marker="8">Abrechnung</li><li data-wizard-marker="9">Prüfen</li>
</ol>
</header>
<section class="wizard-step active" data-wizard-step="0">
<p class="wizard-question">Wie heisst die Anlage und wo befindet sie sich?</p>
<div class="field-grid"><label>Systemname<input name="name" required minlength="2" placeholder="z. B. Werk Zürich"></label><label>Standort<input name="location" required minlength="2" placeholder="Ort, Schweiz"></label><label>Anlagentyp<select name="siteType"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div>
</section>
<section class="wizard-step" data-wizard-step="1">
<p class="wizard-question">Wie soll der Manager arbeiten und über welche Schnittstellen werden Energiedaten gelesen?</p>
<label class="setup-option"><input name="peakShaving" type="checkbox"><span><strong>Peak Shaving erforderlich</strong><small>Wählt die Manager-Variante mit Lastspitzenregelung.</small></span></label>
<div class="configuration-band">
<h3>Netzmessung</h3>
<div class="field-grid">
<label>Geräteprofil<select name="gridProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="gridHost" maxlength="120" placeholder="z. B. 192.168.1.40"></label>
<label>Port<input name="gridPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="gridUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="grid">Bestehende Leistungsvariable<input name="gridSourceVariableId" type="number" min="0" value="0"></label>
<label>Hinweis<textarea name="gridNotes" rows="3" maxlength="300"></textarea></label>
</div>
</div>
<label class="setup-option"><input name="pvEnabled" type="checkbox"><span><strong>PV-Anlage einlesen</strong><small>Herstellerprofil und lokale Schnittstelle für die Erzeugungsmessung verwenden.</small></span></label>
<div class="configuration-band">
<h3>PV-Wechselrichter</h3>
<div class="field-grid">
<label>Bezeichnung<input name="pvName" maxlength="80" value="PV-Wechselrichter"></label>
<label>AC-Nennleistung (kW)<input name="pvAcPowerKw" type="number" min="0" step="0.1" value="0"></label>
<label>Geräteprofil<select name="pvProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="pvHost" maxlength="120"></label>
<label>Port<input name="pvPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="pvUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="pv">Bestehende Leistungsvariable<input name="pvSourceVariableId" type="number" min="0" value="0"></label>
</div>
</div>
<div class="configuration-band" data-pv-topology>
<div class="structured-editor-head"><div><h3>PV-Flächen</h3><p>Dachflächen und Ausrichtung ohne JSON erfassen.</p></div></div>
<div class="quantity-line"><label>Anzahl PV-Flächen<input name="pvSurfaceQuantity" data-topology-quantity="pvSurfaces" type="number" min="0" max="50" value="0"></label><small>Bei aktivierter PV wird mindestens eine Fläche angelegt.</small></div>
<div class="device-editor" data-topology-editor="pvSurfaces"></div>
</div>
<details class="config-details"><summary>Optionale Manager-Funktionen</summary><p>Nur betriebliche Optionen. Technische Zuordnungen, Prognoseanschlüsse, Archivierung und Aufbewahrung werden automatisch gesetzt.</p><div id="managerConfigurationFields" class="schema-groups"></div></details>
</section>
<section class="wizard-step" data-wizard-step="2">
<p class="wizard-question">Welche Batteriespeicher soll der Manager steuern?</p>
<div class="quantity-line"><label>Batteriespeicher<input name="batteryQuantity" data-device-quantity="battery" type="number" min="0" max="50" value="0"></label><small>Geräteprofil, Leistungsdaten und Grenzen genügen; Typ und Variablen werden automatisch zugeordnet.</small></div>
<div class="device-editor" data-device-editor="battery"></div>
</section>
<section class="wizard-step" data-wizard-step="3">
<p class="wizard-question">Welche Pufferspeicher sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Pufferspeicher<input name="bufferQuantity" data-device-quantity="buffer_storage" type="number" min="0" max="50" value="0"></label><small>Fühler, Heizkurve und Leistungsstufen werden je Pufferspeicher erfasst.</small></div>
<div class="device-editor" data-device-editor="buffer_storage"></div>
</section>
<section class="wizard-step" data-wizard-step="4">
<p class="wizard-question">Welche EV-Ladestationen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="evQuantity" data-device-quantity="ev_charger" type="number" min="0" max="50" value="0"></label><small>Je Station kann zwischen direkter Anbindung und Easee Gateway gewählt werden.</small></div>
<div class="device-editor" data-device-editor="ev_charger"></div>
</section>
<section class="wizard-step" data-wizard-step="5">
<p class="wizard-question">Welche einstufigen Verbraucher sollen berücksichtigt werden?</p>
<div class="quantity-line"><label>Anzahl<input name="consumerQuantity" data-device-quantity="consumer_single" type="number" min="0" max="50" value="0"></label><small>Bei Shelly-Profilen werden Schaltkontakt und Rückmeldung automatisch verbunden.</small></div>
<div class="device-editor" data-device-editor="consumer_single"></div>
</section>
<section class="wizard-step" data-wizard-step="6">
<p class="wizard-question">Gibt es mehrstufige Boiler oder Warmwassererwärmer?</p>
<div class="quantity-line"><label>Anzahl<input name="boilerQuantity" data-device-quantity="boiler_multi" type="number" min="0" max="50" value="0"></label><small>Leistungsstufen, Zeitplan und Temperaturfühler werden als Listen konfiguriert.</small></div>
<div class="device-editor" data-device-editor="boiler_multi"></div>
</section>
<section class="wizard-step" data-wizard-step="7">
<p class="wizard-question">Welche Wärmepumpen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="heatPumpQuantity" data-device-quantity="heat_pump" type="number" min="0" max="50" value="0"></label></div>
<div class="device-editor" data-device-editor="heat_pump"></div>
</section>
<section class="wizard-step" data-wizard-step="8">
<p class="wizard-question">Soll im selben Symcon-System ein Verbrauchskostenreport eingerichtet werden?</p>
<label class="setup-option"><input name="costReportEnabled" type="checkbox"><span><strong>Verbrauchskostenreport hinzufügen</strong><small>Wird als eigene Instanz eingerichtet und verwendet einen separaten Lizenzcode.</small></span></label>
<div class="field-grid"><label>Stromzähler<input name="costPowerMeters" type="number" min="0" max="999" value="0"></label><label>Wärme- oder Wasserzähler<input name="costAuxMeters" type="number" min="0" max="999" value="0"></label></div>
</section>
<section class="wizard-step" data-wizard-step="9">
<p class="wizard-question">Bitte prüfe die Angaben vor dem Speichern.</p>
<div id="wizardReview" class="wizard-review"></div>
</section>
<p class="form-error" id="systemError"></p>
<footer class="wizard-actions"><button class="button secondary" id="wizardBack" type="button">Zurück</button><button class="button primary" id="wizardNext" type="button">Weiter</button><button class="button primary hidden" id="wizardSubmit" type="submit">Konfiguration abschliessen</button></footer>
</section>
<aside class="recommendation-panel"><p class="eyebrow">Aktueller Vorschlag</p><h2 id="recommendManager">Manager ohne Peak Shaving</h2><div id="recommendModules" class="recommend-list"></div><p>Der Vorschlag enthält den Manager und alle gewählten Verbrauchermodule.</p><div id="packageResult" class="package-result hidden"><strong>Einrichtungspaket bereit</strong><a class="button secondary wide" id="packageDownload" href="#">Symcon-Paket herunterladen</a><button class="button primary wide" id="orderRecommendation" type="button">Lizenzen bestellen</button></div></aside>
</form>
</section>
<section class="view" id="view-forecast">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Prognose</h1><p>Messwerte und berechnete Verläufe des lizenzierten Prognosemanagers.</p></div><span class="status available">Serverplanung</span></div>
<div id="forecastEmpty" class="empty-state hidden"><strong>Kein System verfügbar</strong></div>
<div id="forecastContent">
<section class="forecast-toolbar">
<label>System<select id="forecastPlant"></select></label>
<div class="connection-state"><span class="signal"></span><div><strong id="forecastConnection">Noch nicht verknüpft</strong><p id="forecastApiStatus">Status wird geprüft</p></div></div>
</section>
<section id="forecastPlanner" class="forecast-planner-section" aria-label="Aktueller Netzfahrplan"></section>
<div class="forecast-metrics">
<div><span id="forecastLastUpdated">–</span><small>Aktualisiert</small></div>
<div><span id="forecastSeriesCount">0</span><small>Datenreihen</small></div>
<div><span id="forecastWindowValue">72 h</span><small>Zeitfenster</small></div>
</div>
<section class="forecast-chart-section">
<div class="section-head"><div><h2>Leistung und Prognose</h2><p>Messwerte und Prognosen gemeinsam vergleichen. Reihen können einzeln ein- und ausgeblendet werden.</p></div></div>
<div class="forecast-series-panel">
<strong>Kurven auswählen</strong>
<div id="forecastSeriesControls" class="forecast-series-controls"></div>
</div>
<div class="forecast-chart-controls">
<label>Historie
<select id="forecastHistoryDays">
<option value="1" selected>1 Tag</option>
<option value="7">7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
<option value="90">90 Tage</option>
<option value="180">180 Tage</option>
</select>
</label>
<label>Von<input id="forecastRangeStart" type="datetime-local"></label>
<label>Bis<input id="forecastRangeEnd" type="datetime-local"></label>
<button id="forecastRangeReset" class="button secondary compact" type="button">Gesamtes Zeitfenster</button>
</div>
<div id="forecastState" class="forecast-state" role="status">Prognosedaten werden geladen.</div>
<div id="forecastLegend" class="forecast-chart-legend hidden" aria-label="Legende der angezeigten Kurven"></div>
<div class="forecast-chart-wrap">
<svg id="forecastChart" class="forecast-chart hidden" viewBox="0 0 960 320" preserveAspectRatio="none" role="img" aria-label="Zeitverlauf der Prognosedaten"></svg>
<div id="forecastTooltip" class="forecast-tooltip hidden"></div>
</div>
</section>
<section class="forecast-quality-section">
<div class="section-head"><div><h2>Prognosequalität</h2><p>Vergleich der Modellprognosen mit den tatsächlich gemessenen Werten.</p></div><span id="forecastQualityStatus" class="status review">Aufbauphase</span></div>
<div id="forecastQualityState" class="forecast-quality-state" role="status">Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.</div>
<div id="forecastQualityTable" class="table-wrap hidden">
<table>
<thead><tr><th>Modell</th><th>Ziel</th><th>R²</th><th>MAE</th><th>RMSE</th><th>Vergleiche</th></tr></thead>
<tbody id="forecastQualityBody"></tbody>
</table>
</div>
</section>
<section id="forecastConfigSection" class="forecast-config-section hidden">
<div class="section-head">
<div><h2>Prognosekonfiguration</h2><p>Anlagenparameter für Berechnung und Tarife.</p></div>
<span id="forecastConfigStatus" class="status review">Nicht gespeichert</span>
</div>
<form id="forecastConfigForm">
<div class="forecast-config-grid">
<label>Standort<input name="standort" maxlength="120" required></label>
<label>Kanton<select name="kanton"><option>AG</option><option>AI</option><option>AR</option><option>BE</option><option>BL</option><option>BS</option><option>FR</option><option>GE</option><option>GL</option><option>GR</option><option>JU</option><option>LU</option><option>NE</option><option>NW</option><option>OW</option><option>SG</option><option>SH</option><option>SO</option><option>SZ</option><option>TG</option><option>TI</option><option>UR</option><option>VD</option><option>VS</option><option>ZG</option><option>ZH</option></select></label>
<label>PV AC-Leistung kW<input name="ac_leistung" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batteriekapazität kWh<input name="batt_capacity_kwh" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batterieleistung kW<input name="batt_power_kw" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Peakpreis CHF/kW<input name="tarif_peak_fest" type="number" min="0" max="100000" step="0.01" required></label>
<label>Bezug Tarifart<select name="tarif_bezug_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Bezugstarif<select name="tarif_bezug"></select></label>
<label data-tariff-custom="import">Bezugspreis CHF/kWh<input name="tarif_bezug_fest" type="number" min="0" max="100" step="0.001" required></label>
<label>Einspeisung Tarifart<select name="tarif_einspeisung_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Einspeisetarif<select name="tarif_einspeisung"></select></label>
<label data-tariff-custom="export">Einspeisevergütung CHF/kWh<input name="tarif_einspeisung_fest" type="number" min="0" max="100" step="0.001" required></label>
</div>
<div id="forecastTopologyBlock" class="forecast-config-block hidden">
<div class="config-block-head"><div><h3>Technische Anlagendaten</h3><p>Schreibgeschützt aus dem verbundenen Enelix Manager.</p></div><span class="status available">Manager</span></div>
<div id="forecastTopology" class="forecast-topology"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>PV-Dächer</h3><p>Leistung, Neigung und Azimut je Dachfläche.</p></div><button id="forecastAddRoof" class="button secondary compact" type="button">Dach hinzufügen</button></div>
<div id="forecastRoofs" class="forecast-roofs"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>Prognoseumfang</h3><p>Funktionen für diese Anlage. Der Netzfahrplan schliesst PV- und Verbrauchsprognose ein.</p></div></div>
<div class="forecast-variants">
<label><input type="checkbox" name="forecast_pv">PV-Ertragsprognose</label>
<label><input type="checkbox" name="forecast_load">Verbrauchsprognose</label>
<label><input type="checkbox" name="grid_schedule">Intelligenter Netzfahrplan</label>
</div>
</div>
<p id="forecastConfigError" class="form-error" role="alert"></p>
<div class="forecast-config-actions"><button class="button primary" type="submit">Konfiguration speichern</button></div>
</form>
</section>
<div class="forecast-layout">
<section class="manager-detail"><p class="eyebrow">Lizenzierte Variante</p><h2 id="forecastVariant">Manager</h2><div class="license-validity"><span>Lizenzdauer</span><strong>Unbeschränkt gültig</strong></div><div id="forecastOfflineWarning" class="offline-license-warning hidden" role="status"><strong id="forecastOfflineTitle">Keine Verbindung zum Lizenzserver</strong><p id="forecastOfflineText"></p></div></section>
<section class="forecast-scope"><p class="eyebrow">Datenumfang</p><h2>Live-Prognose</h2><dl class="detail-list"><div><dt>Modus</dt><dd>Konfiguration und Anzeige</dd></div><div><dt>Simulation</dt><dd>Nicht enthalten</dd></div><div><dt>Zugriff</dt><dd>Lizenzgebunden</dd></div></dl></section>
</div>
</div>
</section>
<section class="view" id="view-faults">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Störüberwachung</h1><p>Aktive und behobene Störungen der verbundenen Enelix Manager und Geräte.</p></div><span class="status neutral" id="faultConnectionStatus">Status wird geprüft</span></div>
<div id="faultEmpty" class="empty-state hidden"><strong>Kein Manager-System verfügbar</strong></div>
<div id="faultContent">
<section class="fault-toolbar">
<label>System<select id="faultPlant"></select></label>
<div class="connection-state"><span class="signal" id="faultSignal"></span><div><strong id="faultConnection">Noch nicht verknüpft</strong><p id="faultApiStatus">Status wird geprüft</p></div></div>
</section>
<div class="fault-metrics">
<div><span id="faultActiveCount">0</span><small>Aktive Störungen</small></div>
<div><span id="faultUrgentCount">0</span><small>Kritisch oder Fehler</small></div>
<div><span id="faultLastUpdated">–</span><small>Letzter Empfang</small></div>
</div>
<section class="fault-section">
<div class="section-head"><div><h2>Aktive Störungen</h2><p>Aktueller Vollsnapshot des Managers.</p></div></div>
<div id="faultState" class="fault-state" role="status">Störungsdaten werden geladen.</div>
<div id="faultActiveTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Seit</th></tr></thead><tbody id="faultActiveBody"></tbody></table></div>
</section>
<section class="fault-section">
<div class="section-head"><div><h2>Behobene Störungen</h2><p>Die letzten 100 abgeschlossenen Ereignisse.</p></div></div>
<div id="faultHistoryState" class="fault-state">Noch keine behobenen Störungen.</div>
<div id="faultHistoryTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Behoben</th></tr></thead><tbody id="faultHistoryBody"></tbody></table></div>
</section>
<section class="fault-section fault-notification-section">
<div class="section-head"><div><h2>E-Mail-Benachrichtigungen</h2><p>Empfänger für neue und behobene Störungen dieser Anlage.</p></div></div>
<form id="faultRecipientForm" class="fault-recipient-form">
<label>E-Mail-Adresse<input id="faultRecipientEmail" name="email" type="email" maxlength="254" autocomplete="email" placeholder="technik@beispiel.ch" required></label>
<button class="button secondary" type="submit">Hinzufügen</button>
</form>
<p id="faultRecipientError" class="form-error" role="alert"></p>
<div id="faultRecipientList" class="fault-recipient-list"></div>
<p id="faultRecipientStatus" class="fault-recipient-status">Empfänger werden geladen.</p>
</section>
</div>
</section>
<section class="view" id="view-account">
<div class="page-head"><div><p class="eyebrow">Einstellungen</p><h1>Konto und Abrechnung</h1><p>Zugang, Organisation, Personen und Zahlungsverlauf.</p></div><button class="button secondary" id="logoutButton" type="button">Abmelden</button></div>
<div class="account-layout">
<section><h2>Benutzerkonto</h2><dl class="detail-list"><div><dt>Name</dt><dd id="accountName">–</dd></div><div><dt>E-Mail</dt><dd id="accountEmail">–</dd></div><div><dt>Arbeitsbereich</dt><dd id="accountOrg">–</dd></div></dl><label id="accountWorkspaceLabel">Organisation<select id="accountWorkspaceSelect"></select></label></section>
<section id="organizationCreateSection"><h2>Organisation anlegen</h2><form id="organizationForm" class="compact-form"><label>Name<input name="name" required minlength="2"></label><p class="form-error" id="organizationError"></p><button class="button secondary" type="submit">Organisation erstellen</button></form></section>
<section id="invitationSection" class="hidden"><h2>Person einladen</h2><form id="invitationForm" class="compact-form"><label>E-Mail-Adresse<input name="email" type="email" required></label><label>Rolle<select name="role"><option value="operator">Operator</option><option value="admin">Administrator</option><option value="billing">Abrechnung</option><option value="viewer">Lesend</option></select></label><p class="form-error" id="invitationError"></p><button class="button secondary" type="submit">Einladung senden</button></form><div id="pendingInvitations" class="pending-list"></div></section>
<section><h2>Passwort ändern</h2><form id="passwordForm" class="compact-form"><label>Aktuelles Passwort<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>Neues Passwort<input name="newPassword" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label><label>Neues Passwort wiederholen<input name="newPasswordRepeat" type="password" autocomplete="new-password" required minlength="10"></label><p class="form-error" id="passwordError"></p><button class="button secondary" type="submit">Passwort speichern</button></form></section>
</div>
<section class="work-section billing-orders"><div class="section-head"><div><h2>Abrechnung</h2><p>Zahlungen, Lizenzcodes und PDF-Belege der aktuellen Organisation.</p></div><div class="billing-summary"><span class="status review" id="billingStripeStatus">Stripe Sandbox</span><span id="billingOrg">–</span><span id="stripeProviderState">Prüfung</span></div></div><div class="table-wrap"><table><thead><tr><th>Datum</th><th>System</th><th>Lizenzen</th><th>Gesamt inkl. MwSt.</th><th>Status</th><th>Lizenzcode</th><th>Beleg</th></tr></thead><tbody id="ordersBody"></tbody></table></div></section>
</section>
</main>
</div>
</div>
<dialog id="newLicenseDialog" class="decision-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Neue Lizenz</p><h2>Wie möchtest du starten?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="purchase-paths"><button type="button" data-open-plant><span><strong>Lizenz direkt kaufen</strong><small>Lizenzpaket anlegen und benötigte Module auswählen.</small></span><b>Weiter</b></button><button type="button" data-goto="configurator"><span><strong>System mit Manager konfigurieren</strong><small>Geräte erfassen und passende Lizenzen ermitteln.</small></span><b>Konfigurieren</b></button><button type="button" data-open-billing><span><strong>Abrechnungslizenz lösen</strong><small>Abrechnungsmanager und benötigte Zähler lizenzieren.</small></span><b>Auswählen</b></button></div></div></dialog>
<dialog id="plantDialog"><form id="plantForm"><div class="dialog-head"><div><p class="eyebrow">Direkter Kauf</p><h2>Neue Lizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Werk Zürich"></label><label>Typ<select name="type"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label><fieldset><legend>Prognosemanager</legend><label class="choice-row"><input type="radio" name="manager" value="manager_standard" checked><span><strong>Ohne Peak Shaving</strong><small id="plantManagerStandardPrice">CHF 308.15 netto</small></span></label><label class="choice-row"><input type="radio" name="manager" value="manager_peak"><span><strong>Mit Peak Shaving</strong><small id="plantManagerPeakPrice">CHF 329.05 netto</small></span></label></fieldset><p class="form-error" id="plantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zur Lizenzwahl</button></div></form></dialog>
<dialog id="billingDialog"><form id="billingForm"><div class="dialog-head"><div><p class="eyebrow">Abrechnung</p><h2>Abrechnungslizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div id="billingIdentityFields"><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Abrechnung Werk Zürich"></label><label>Typ<input name="type" value="Abrechnung" readonly></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label></div><fieldset><legend>Zählerlizenzen</legend><div class="field-pair"><label>Stromzähler<input name="powerMeters" type="number" min="0" max="999" value="0" required><small id="billingPowerPrice">CHF 5.00 netto pro Zähler</small></label><label>Wärme- oder Wasserzähler<input name="auxMeters" type="number" min="0" max="999" value="0" required><small id="billingAuxPrice">CHF 3.00 netto pro Zähler</small></label></div></fieldset><p class="billing-base-price">Grundlizenz: <strong id="billingBasePrice">CHF 245.15 netto</strong>. Zuzüglich MwSt.</p><p class="form-error" id="billingError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zu Stripe</button></div></form></dialog>
<dialog id="replaceCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode neu generieren</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Achtung: Der aktuelle Lizenzcode wird ungültig.</strong><p>Für <b id="replaceCodeSystem">dieses System</b> wird ein neuer Code erstellt und ausschließlich als PDF an die Konto-E-Mail-Adresse gesendet.</p><p id="replaceCodeBinding" class="hidden">Die bisherige Installationsbindung wird gelöst. Der Ersatzcode kann anschließend einmalig mit einer neuen Symcon-Installation verbunden werden.</p></div><p class="form-error" id="replaceCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmReplaceCode" type="button">Alten Code ersetzen</button></div></div></dialog>
<dialog id="deleteCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Sind Sie sicher?</strong><p>Der aktuelle Lizenzcode für <b id="deleteCodeSystem">dieses System</b> wird sofort und unwiderruflich ungültig.</p><p>Die bezahlte Lizenz und eine bestehende Installationsbindung bleiben erhalten. Ein neuer Code kann später wieder per E-Mail erzeugt werden.</p></div><p class="form-error" id="deleteCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeleteCode" type="button">Lizenzcode löschen</button></div></div></dialog>
<dialog id="deletePlantDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Anlage löschen</p><h2>Sind Sie sicher?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Die Anlage wird dauerhaft entfernt.</strong><p>Möchten Sie <b id="deletePlantSystem">diese Anlage</b> wirklich löschen? Konfigurationen und noch nicht bezahlte Bestellungen dieser Anlage werden ebenfalls entfernt.</p></div><p class="form-error" id="deletePlantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePlant" type="button">Anlage löschen</button></div></div></dialog>
<dialog id="deletePaidDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Zweite Bestätigung</p><h2>Auch Lizenz löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning critical-warning"><strong>Achtung: Für diese Anlage besteht eine bezahlte Lizenz.</strong><p>Sollen <b id="deletePaidSystem">die Anlage</b> und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p></div><p class="form-error" id="deletePaidError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePaid" type="button">Anlage und Lizenz löschen</button></div></div></dialog>
<script src="/app.js?v=20261006-forecast"></script>
</body>
</html>
@@ -0,0 +1,49 @@
.forecast-page { margin: 0; background: #f2f5f4; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
.forecast-page main { max-width: 1100px; margin: 2rem auto; padding: 1.5rem; background: #fff; }
.forecast-page a { color: #305b5b; }
.forecast-planner { min-width: 0; color: #172124; font-family: Figtree, sans-serif; letter-spacing: 0; }
.forecast-planner * { box-sizing: border-box; }
.forecast-planner h3 { margin: 1.6rem 0 .8rem; font-size: 1.05rem; line-height: 1.35; }
.forecast-planner p { line-height: 1.5; overflow-wrap: anywhere; }
.forecast-planner form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin: 1.5rem 0; }
.forecast-planner form label { display: grid; gap: .4rem; align-content: start; font-size: .85rem; font-weight: 600; }
.forecast-planner select, .forecast-planner input:not([type=checkbox]):not([type=range]), .forecast-planner button { width: 100%; min-height: 42px; margin: 0; padding: .65rem .75rem; border: 1px solid #bacaca; border-radius: 4px; background: #fff; color: #172124; font: inherit; }
.forecast-planner button { align-self: end; background: #4e8181; color: #fff; cursor: pointer; }
.forecast-planner button[type=submit] { background: #dce169; color: #172124; border-color: #dce169; }
.forecast-planner button:disabled { opacity: .5; cursor: wait; }
.forecast-planner button:focus-visible, .forecast-planner input:focus-visible, .forecast-planner select:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
.forecast-planner-notice { padding: .85rem 1rem; border-left: 4px solid #dce169; background: #f2f6e7; }
.forecast-planner table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: .85rem; }
.forecast-planner th, .forecast-planner td { padding: .65rem .5rem; border-bottom: 1px solid #d8e0de; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
.forecast-planner th { width: 40%; font-weight: 600; }
.forecast-planner-settings { margin-top: 1.5rem; border-top: 1px solid #d8e0de; padding-top: 1rem; }
.forecast-planner-settings > summary { padding: .5rem 0; cursor: pointer; font-weight: 600; }
.forecast-planner .forecast-chart { min-width: 0; height: auto; padding: 1.2rem 0; border-top: 1px solid #d8e0de; font-family: Figtree, sans-serif; letter-spacing: 0; }
.forecast-planner .forecast-chart h3 { margin: 0 0 .75rem; color: #172124; font-size: 1.05rem; line-height: 1.35; }
.forecast-planner .forecast-chart-legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; min-width: 0; margin: 0 0 .75rem; padding: 0; border: 0; }
.forecast-planner .forecast-chart-legend label { display: inline-flex; align-items: center; gap: .4rem; min-height: 32px; margin: 0; color: #172124; font-size: .8rem; font-weight: 500; cursor: pointer; }
.forecast-planner .forecast-chart-legend input { width: 16px; height: 16px; margin: 0; accent-color: #4e8181; }
.forecast-planner .forecast-chart-swatch { display: inline-block; width: 20px; height: 3px; flex: 0 0 20px; }
.forecast-planner .forecast-chart-plot { width: 100%; min-width: 0; min-height: 284px; }
.forecast-planner .forecast-chart svg { display: block; width: 100%; height: 284px; overflow: visible; }
.forecast-planner .forecast-chart-grid { stroke: #e3e9e7; stroke-width: 1; }
.forecast-planner .forecast-chart-axis { fill: #576467; font-size: 11px; font-family: Figtree, sans-serif; }
.forecast-planner .forecast-chart-marker { stroke: #172124; stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; }
.forecast-planner .forecast-chart-slider { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: .75rem; margin: .2rem 0 .75rem; color: #576467; font-size: .75rem; font-weight: 500; }
.forecast-planner .forecast-chart-slider input[type=range] { width: 100%; height: 28px; margin: 0; padding: 0; border: 0; box-shadow: none; accent-color: #4e8181; }
.forecast-planner .forecast-chart input:focus-visible { outline: 2px solid #305b5b; outline-offset: 3px; }
.forecast-planner .forecast-chart-inspector { padding: .75rem 0; border-top: 1px solid #d8e0de; }
.forecast-planner .forecast-chart-time { margin: 0 0 .5rem; color: #172124; font-size: .8rem; font-weight: 600; }
.forecast-planner .forecast-chart-inspector dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .6rem 1.25rem; margin: 0; }
.forecast-planner .forecast-chart-inspector dl > div { min-width: 0; }
.forecast-planner .forecast-chart-inspector dt { padding-left: .5rem; border-left: 3px solid var(--series-color); color: #576467; font-size: .75rem; font-weight: 500; overflow-wrap: anywhere; }
.forecast-planner .forecast-chart-inspector dd { margin: .25rem 0 0 .7rem; color: #172124; font-size: 1rem; font-variant-numeric: tabular-nums; }
.forecast-planner .forecast-chart-caption, .forecast-planner .forecast-chart-empty { margin: .5rem 0 0; color: #576467; font-size: .8rem; line-height: 1.5; }
.forecast-planner .forecast-chart-sr { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 600px) {
.forecast-page main { margin: 0; padding: 1rem; }
.forecast-planner form { grid-template-columns: minmax(0, 1fr); }
.forecast-planner th { width: 43%; }
.forecast-planner .forecast-chart-slider { grid-template-columns: minmax(0, 1fr); gap: 0; }
.forecast-planner .forecast-chart-inspector dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@@ -0,0 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ENELIX Prognose und Netzfahrplan</title><link rel="stylesheet" href="/netplan-v4.css"></head><body class="forecast-page"><main class="forecast-planner" data-standalone-planner><a href="/">Zurueck zum Portal</a><h1>Prognose und Netzfahrplan</h1><label>Anlage <select id="plants"></select></label><p id="message" role="status"></p><section id="panel"></section></main><script type="module" src="/netplan-v4.js"></script></body></html>
@@ -0,0 +1,138 @@
import { forecastSeries, renderForecastChart } from './forecast-chart.js';
export function createPlanner({ message, panel, plants, session }) {
let generation = 0;
const charts = [];
const element = (tag, text) => { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; return e; };
const time = value => value ? new Date(value).toLocaleString('de-CH', { timeZone: 'Europe/Zurich' }) : 'nicht vorhanden';
const num = (v, digits = 2) => typeof v === 'number' && Number.isFinite(v) ? v.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'unbekannt';
async function request(url, method = 'GET', body) {
const headers = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (method !== 'GET') headers['X-CSRF-Token'] = session.csrfToken;
const response = await fetch(url, { method, headers, credentials: 'same-origin', body: body === undefined ? undefined : JSON.stringify(body) });
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.detail || 'Anfrage fehlgeschlagen');
return data;
}
function select(label, choices, selected) {
const wrap = element('label', label + ' '), field = element('select');
for (const [value, caption] of choices) { const option = element('option', caption); option.value = value; field.append(option); }
field.value = selected; wrap.append(field); return { wrap, field };
}
function table(title, rows) {
panel.append(element('h3', title)); const grid = element('table');
for (const [name, value] of rows) { const row = element('tr'); row.append(element('th', name), element('td', String(value))); grid.append(row); }
panel.append(grid);
}
function clearCharts() { for (const chart of charts.splice(0)) chart.destroy(); }
function curve(title, points, series, unit, caption) {
charts.push(renderForecastChart(panel, { title, points, series, unit, caption }));
}
async function render() {
const ticket = ++generation, id = plants.value; if (!id) return;
const base = `/api/plants/${encodeURIComponent(id)}/prognosis/planner-v4`;
message.textContent = 'Lade Planungsstand ...';
clearCharts(); panel.replaceChildren();
try {
const state = await request(base); if (ticket !== generation) return; clearCharts(); panel.replaceChildren();
const safety = element('p', 'SCHATTENBETRIEB der Serverplanung: Die Serverprognose allein schaltet keine Geraete. Eine lokale Nutzung durch den Forecast Switch braucht eine gesonderte Freigabe. Der tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt.');
safety.className = 'forecast-planner-notice'; panel.append(safety);
table('Freigabe und Datenstand', [['Serverseitige Probebetriebsfreigabe', state.controlledTrialAuthorized === true ? 'Vorhanden; lokale Schutzpruefung bleibt erforderlich' : state.controlledTrialAuthorized === false ? 'Nicht vorhanden' : 'Nicht gemeldet'], ['Lokale Ausfuehrung', 'In dieser Ansicht nicht nachgewiesen'], ['Status geprueft', time(state.checkedAt)], ['Letzte Planquittierung', state.acknowledgement?.status || 'Nicht vorhanden']]);
const settingsStart = panel.childNodes.length;
const form = element('form');
const family = select('Modellfamilie', [['auto', 'Automatisch – historischer Kostenvergleich'], ...state.families.map(f => [f.key, f.label])], state.settings.family);
const measurement = select('Messdatenbasis', [['verified_only', 'Nur verifizierte Peak-/Viertelstundenwerte'], ['allow_estimates', 'Gekennzeichnete Schaetzungen fuer die Planung zulassen']], state.settings.measurementPolicy || 'verified_only');
const outlook = select('Peakbewertung', [['full_incremental', 'Voller zusaetzlicher Monatstarif'], ['empirical_if_available', 'Restmonats-Szenarien bei ausreichender Historie']], state.settings.peakOutlookPolicy || 'full_incremental');
const dataSource = select('Verbrauchsprognose', [['legacy', 'Bisherige Datenquelle'], ['corrected_profile', 'Bereinigte physische Lastprofile']], state.settings.forecastSource || 'legacy');
const datasets = state.dataPipeline?.datasets || [];
const dataset = select('Messdatensatz', [['', 'Datensatz auswaehlen'], ...datasets.map(d => [d.datasetId, d.datasetId + ' (' + d.status + ')'])], state.settings.measurementDataset || '');
const cadence = select('Nachtraining', [['daily', 'Taeglich'], ['weekly', 'Woechentlich']], state.settings.trainingCadence);
const relianceWrap = element('label', 'Gewicht der Restmonatsprognose (0 bis 100 %) '), reliance = element('input');
reliance.type = 'number'; reliance.min = '0'; reliance.max = '100'; reliance.step = '1'; reliance.value = String(100 * (state.settings.peakOutlookReliance ?? .5)); relianceWrap.append(reliance);
const save = element('button', 'Speichern und neu berechnen'), replan = element('button', 'Neu berechnen'), refresh = element('button', 'Status aktualisieren');
save.type = 'submit'; replan.type = refresh.type = 'button';
form.append(family.wrap, dataSource.wrap, dataset.wrap, measurement.wrap, outlook.wrap, relianceWrap, cadence.wrap, save, replan, refresh); panel.append(form);
let revision = state.settings.revision;
form.addEventListener('submit', async event => {
event.preventDefault(); save.disabled = true;
try {
const r = await request(base + '/settings', 'PUT', { expectedRevision: revision, changes: { family: family.field.value, forecastSource: dataSource.field.value, measurementDataset: dataset.field.value, measurementPolicy: measurement.field.value, peakOutlookPolicy: outlook.field.value, peakOutlookReliance: Number(reliance.value) / 100, trainingCadence: cadence.field.value } });
if (ticket !== generation) return;
revision = r.revision; message.textContent = `Revision ${revision} gespeichert. Neuberechnung angefordert, noch nicht abgeschlossen.`;
} catch (error) { if (ticket === generation) message.textContent = error.message; } finally { save.disabled = false; }
});
replan.addEventListener('click', async () => { replan.disabled = true; try { await request(base + '/replan', 'POST', {}); if (ticket === generation) message.textContent = 'Neuberechnung angefordert.'; } catch (error) { if (ticket === generation) message.textContent = error.message; } finally { replan.disabled = false; } });
refresh.addEventListener('click', render);
panel.append(element('p', 'Die Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe. Ohne ausreichende vergleichbare Historie gilt der volle zusaetzliche Monatstarif. Technische und konfigurierte Managergrenzen bleiben verbindlich.'));
panel.append(element('p', 'Fuer bereinigte Lastprofile steuert die Trainingsauswahl den automatischen Modellaufbau. Historische Prognosen bleiben unveraendert. Die wirtschaftliche Modellautomatik erstellt vergleichbare Kosten-Replays aus eingefrorenen Tagesplaenen und spaeter eingegangenen Messwerten. Ohne ausreichende Vergleichstage bleibt die bisherige Familie aktiv.'));
for (const d of datasets) table('Daten und Training: ' + d.datasetId, [['Zustand', d.status], ['Angenommene Aufnahmen', d.records], ['Letzte Aufnahme', time(d.lastCapture)], ['Letzter Serverempfang', time(d.lastReceived)], ['Nutzbare 5-Minutenwerte', d.detail?.usableWindows ?? 0], ['Datenbasis in Stunden', num(d.detail?.usableEquivalentHours)], ['Modellstand', d.detail?.modelId || 'noch in Datensammlung'], ['Letztes Training', time(d.detail?.trainedAt)], ['Validierung', d.detail?.validation?.status || 'noch ausstehend'], ['Messgrundlage', 'Konfigurierte physische Schaetzung; kein unabhaengiger Messnachweis']] );
if (state.economicComparison?.connected) {
table('Wirtschaftlicher Modellvergleich', [['Gespeicherte Tagesvergleiche', state.economicComparison.cohorts], ['Abgeschlossene Vergleiche', state.economicComparison.completedComparisons.length], ['Methode', 'Eingefrorener Tagesplan mit simulierter Netzregelung'], ['Grenze dieses Vergleichs', 'Eine netzladefaehige Batterie; keine vollstaendige Nachbildung laufender Neuoptimierungen'], ['Bewertung', 'Energiekosten + zusaetzlicher Peak + Verluste + gemeinsame Restenergiebewertung']]);
panel.append(element('p', 'Der SDL-Anteil der historischen Bilanz ist eine gekennzeichnete Schaetzung aus dem damaligen Auftrag. Die Vergleichskosten sind keine nachgewiesenen Einsparungen auf einer Stromrechnung.'));
for (const item of state.economicComparison.completedComparisons) table('Vergleich ' + item.day, Object.entries(item.results).map(([family, result]) => ['Familie ' + family, num(result.costChf) + ' CHF; Datenabdeckung ' + num(100 * result.coverage, 1) + ' %']));
}
if (state.maintenance) table('Datenaufbewahrung', [['Archivierung', state.maintenance.status], ['Originale', 'Nach verifizierter Kompression weiterhin wiederherstellbar; externe Sicherung separat betreiben']]);
panel.append(element('p', state.lastRun ? `Rechenstatus: ${state.lastRun.status} ${state.lastRun.detail?.reason || ''}` : 'Noch kein Rechenlauf.'));
if (state.pending) panel.append(element('p', 'Neuberechnung vorgemerkt oder aktiv.'));
for (const [month, basis] of Object.entries(state.peakPlanningBases || {})) table(`Vorlaeufige Peakbasis ${month}`, [['Wert', num(basis.kw) + ' kW'], ['Qualitaet', basis.quality], ['Quelle', basis.source], ['Beobachtet', time(basis.observedAt)], ['Hinweis', basis.notes || 'Kein Abrechnungsnachweis']]);
const settingsDetails = element('details');
settingsDetails.className = 'forecast-planner-settings';
settingsDetails.append(element('summary', 'Planungseinstellungen, Daten und Training'), ...Array.from(panel.childNodes).slice(settingsStart));
const plan = state.plan;
if (plan) {
if (!state.fresh) panel.append(element('p', 'ACHTUNG: Plan veraltet oder Neuberechnung ausstehend.'));
const quality = plan.inputQuality || {};
const loadBasis = quality.loadBasis === 'base_load' ? 'Bereinigte Basislast' : quality.loadBasis === 'house_total' ? 'Aggregierte Hauslast; SDL-Trennung nicht nachgewiesen' : quality.loadBasis || 'Nicht angegeben';
table('Plan und Quellen', [['Plan-ID', plan.planId], ['Berechnet', time(plan.generatedAt)], ['Rechenstatus', plan.status || 'Nicht vorhanden'], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Lastgrundlage', loadBasis], ['Datenquelle der Lastprognose', quality.dataPipeline ? 'Bereinigte physische Lastprofile' : 'Eingegangene Prognose der angegebenen Modellfamilie'], ['Planungsmodus des Servers', plan.runMode || 'Nicht angegeben'], ['Optimierter Horizont bis', time(plan.validUntil)], ['Prognoseeingang bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)]]);
table('Kostenprognose', [['Energiekosten', num(plan.energyCostChf) + ' CHF'], ['Zusaetzliche Peakkosten zum vollen Tarif', num(plan.additionalPeakCostChf) + ' CHF'], ['Peakbewertung unter Restmonats-Szenarien', num(plan.planningPeakCostChf) + ' CHF'], ['Planung mit geschaetzter Peakbasis', plan.peakCostIsEstimate ? 'Ja – kein Abrechnungsnachweis' : 'Nein']]);
for (const warning of plan.warnings || []) panel.append(element('p', warning));
for (const [month, basis] of Object.entries(plan.peakBasis || {})) table(`Peak ${month}`, [['Ausgangsbasis', num(basis.kw) + ' kW (' + basis.quality + ')'], ['Geplant', num(plan.plannedPeaksKw?.[month]) + ' kW'], ['Bewertung', plan.peakOutlook]]);
const points = plan.points || [];
const forecast = forecastSeries(plan);
const forecastScope = forecast.fullHorizon ? 'Vollstaendige, fuer diesen Plan gespeicherte Eingangsprognose, auch ueber das Ende der bekannten Preise hinaus. Netz- und Batterieplan sowie Kosten bleiben auf den optimierten Zeitraum begrenzt.' : 'Aelterer Plan ohne separat gespeicherte Gesamtprognose: nur Eingangswerte im optimierten Zeitraum verfuegbar.';
curve('PV, Last und externe Leistung', forecast.points, [{ label: 'PV-Prognose', value: p => p.pvW, color: '#757b12' }, { label: quality.loadBasis === 'base_load' ? 'Basislast-Prognose' : 'Last-Prognose', value: p => p.loadW, color: '#4e8181' }, { label: 'Externe Leistung / SDL-Szenario', value: p => quality.loadBasis === 'base_load' ? p.externalW : undefined, color: '#ac4f68', dash: '6 3' }], 'W', forecastScope + ' Externe Leistung ist nur bei nachgewiesener Basislast separat bilanziert; ein fortgeschriebener SDL-Auftrag ist kein veroeffentlichter SDL-Fahrplan.');
curve('Netzleistung', points, [{ label: 'Ohne Optimierung', value: p => p.baselineGridW, color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.gridTargetW, color: '#4e8181' }], 'W', 'Positiv: Netzbezug. Negativ: Einspeisung.');
curve('Batterieleistung', points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }], 'W', 'Positiv: Laden. Negativ: Entladen.');
const assets = new Set(points.flatMap(point => Object.keys(point.socEndPercent || {})));
for (const asset of assets) curve(`Ladezustand ${asset}`, points, [{ label: 'Geplanter SOC am Intervallende', value: p => p.socEndPercent?.[asset], position: 'end' }], '%');
curve('Energiepreise', points, [{ label: 'Bezugspreis', value: p => p.importPriceChfKwh, digits: 4 }, { label: 'Einspeisepreis', value: p => p.exportPriceChfKwh, digits: 4, dash: '6 3' }], 'CHF/kWh', 'Preise im optimierten Horizont, keine Fortschreibung ueber den bekannten Preiszeitraum hinaus.');
curve('Kumulierte Kosten inklusive vollem Peakpreis', points, [{ label: 'Ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf, position: 'end', color: '#737c7e', dash: '6 3' }, { label: 'Optimiert', value: p => p.cumulativeCashCostChf, position: 'end', color: '#4e8181' }], 'CHF', 'Jeweils am Intervallende. Prognose von Energie- und zusaetzlichen vollen Monatspeakkosten, keine Rechnung und kein Nachweis realisierter Einsparungen.');
panel.append(element('p', 'Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese. Der angezeigte Kostenverlauf enthaelt diese nicht; auch Durchsatzkosten sind darin nicht enthalten.'));
}
panel.append(settingsDetails);
if (!plan) settingsDetails.open = true;
message.textContent = '';
} catch (error) { if (ticket === generation) { clearCharts(); panel.replaceChildren(); message.textContent = error.message; } }
}
return { reload: render, dispose() { ++generation; clearCharts(); panel.replaceChildren(); } };
}
export function mountPlanner(container, { plantId, session }) {
container.classList.add('forecast-planner');
const message = document.createElement('p'), panel = document.createElement('div');
message.setAttribute('role', 'status'); container.replaceChildren(message, panel);
const planner = createPlanner({ message, panel, plants: { value: plantId }, session });
return { ...planner, ready: planner.reload() };
}
const standalone = document.querySelector('[data-standalone-planner]');
if (standalone) {
const message = standalone.querySelector('#message'), panel = standalone.querySelector('#panel'), plants = standalone.querySelector('#plants');
try {
const response = await fetch('/api/session', { credentials: 'same-origin' });
const session = await response.json();
if (!response.ok || !session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.');
const plantsResponse = await fetch('/api/plants', { credentials: 'same-origin' });
const data = await plantsResponse.json();
if (!plantsResponse.ok) throw new Error(data.error || 'Anlagen konnten nicht geladen werden.');
for (const plant of data.plants || []) {
if (!plant.installation_id) continue;
const option = document.createElement('option'); option.textContent = plant.name; option.value = plant.id; plants.append(option);
}
if (!plants.options.length) throw new Error('Keine verknuepfte Anlage vorhanden.');
const planner = createPlanner({ message, panel, plants, session });
plants.addEventListener('change', planner.reload); await planner.reload();
} catch (error) { message.textContent = error.message; }
}
@@ -0,0 +1,86 @@
# Portal: Waermepumpe und Header
Stand: 6. Oktober 2026. Entwickelt und getestet, noch nicht live veroeffentlicht.
## Aenderung / Pull-Request-Text
Der bestehende Katalogeintrag `heat_pump` wird im Lizenzkauf beruecksichtigt.
Die bestehenden Preise bleiben unveraendert: CHF 20.95 Lizenz und CHF 27.25
Generator / Ersteinrichtung, jeweils netto. Die Bereitstellung aktiviert nur die
Verfuegbarkeit des Produkts; sie kauft keine Lizenz und vergibt keine kostenlose
Berechtigung an eine Anlage.
Der Wizard hat einen eigenen Waermepumpenschritt mit SG Ready oder Sperre/Erhoehung,
zwei Kontakten, Betriebsrueckmeldung und Schutzzeiten. Gespeicherte Konfigurationen,
Lizenzvorschlag und generiertes Symcon-Paket verwenden das Modul
`{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Freie Bestandskontingente von
`consumer_single` werden wie im Manager angerechnet, ohne Doppelbelegung.
Technische Variablenkennungen duerfen zur Planung noch 0 sein. Das Bootstrap-Skript
bricht vor Objektveraenderungen ab, wenn die Waermepumpen-Zuordnung unvollstaendig
ist, das Modul fehlt oder die Rueckmeldung einen unpassenden Typ hat.
Dokumentation und Preise stehen als Textlinks rechts im Portalheader und nicht
mehr in der Seitennavigation. Oeffentliche Links auf der Anmeldeseite bleiben.
## Voraussetzungen und Konfliktschutz
- Ziel: `/home/agent/services/license` auf `enelix-services`.
- `portal.patch` zeigt ausschliesslich Aenderungen gegen den gelesenen Live-Stand.
- `manifest.json` enthaelt SHA-256 vor und nach jeder Quellaenderung.
- Die weiterentwickelte, nicht veroeffentlichte Wizard-Fassung auf `ubuntu` wird
nicht durch diesen Live-Patch ersetzt.
- Keine Aenderung an Symcon-Anlagen, Docker-Compose, Datenbankschema, Geheimnissen,
Prognosediensten oder fremden Git-Aenderungen. Daniel Haefliger hat Commit und
Push auf `develop` sowie den Portalneustart ausdruecklich freigegeben.
## Tests
- 32 Node-Tests: vorhandene Portalregressionen plus acht neue Waermepumpentests.
- Browserpruefung mit isolierten API-Fixtures: Header bei 1440, 768, 390 und
320 Pixeln; Wizard konfigurieren, speichern, Lizenzvorschlag und erneut oeffnen.
- Keine Browserfehler. Kein echter Kauf, keine Live-Lizenzvergabe und keine
Schaltbefehle an eine Waermepumpe.
- JavaScript-Syntax und Anwendbarkeit des Patches geprueft. PHP-Syntaxpruefung
und Symcon-Laufzeittest konnten nicht ausgefuehrt werden: keine native PHP-CLI
und kein Zugriff auf die Docker-Sockets in dieser Agent-Ausfuehrung.
- `tests/` enthaelt neue bzw. angepasste Tests. Im Testcheckout in dessen
vorhandenes `tests/` uebernehmen und `node --test tests/*.test.mjs` ausfuehren.
- Browserpruefung: `node tests/browser-smoke.mjs` mit installiertem Playwright und
Chromium; optional `BROWSER_PACKAGE` und `BROWSER_EXECUTABLE` fuer vorhandene
Testwerkzeuge setzen. API-Antworten stammen ausschliesslich aus Test-Fixtures.
## Bereitstellung
Der Portalneustart ist vom Auftraggeber freigegeben; dem Serverkonto `agent`
fehlt jedoch der technische Zugriff auf den Docker-Socket. Ein berechtigter
Administrator prueft dieses Paket und fuehrt auf dem Diensteserver aus:
```sh
python3 /PFAD/ZUM/PAKET/deploy.py
```
Das Skript verlangt Root, prueft alle Ausgangshashes und Container-Mounts und
bricht bei Abweichungen ohne Quellaenderung ab. Es sichert die betroffenen Dateien
und den Katalog, stoppt nur `enelix_license_portal`, schreibt die Quelldateien
unter Erhalt der Datei-Bind-Mounts und setzt ausschliesslich die Produktfreigabe
`items.heat_pump.available` auf `true`. Anschliessend startet es den Portalcontainer
und prueft Healthcheck, Wizardkatalog und ausgelieferte Oberflaeche. Es nimmt keine
Bestellung vor und laesst alle Produktpreise unveraendert.
Das Stoppen/Starten verursacht eine kurze Portalunterbrechung. Backoffice und
Anlagensteuerungen werden nicht neu gestartet. Vor Ausfuehrung parallele
Katalogbearbeitungen im Backoffice fuer das Wartungsfenster aussetzen.
## Ruecksetzen und Nachkontrolle
Bei Fehlern stellt das Skript die gesicherten Quellinhalte wieder her und startet
den Portalcontainer erneut. Ein nachtraeglich anderweitig veraenderter Katalog
wird dabei nicht blind ueberschrieben. Sicherungen liegen unter
`/home/agent/services/license/change-backups/heat-pump-header-<Zeitpunkt>`.
Nach erfolgreicher Bereitstellung angemeldet Headerlinks, neue Lizenzzeile,
Waermepumpenschritt, Speichern und Wiedereroeffnen kontrollieren. Zahlungen nur
mit einem ausdruecklich freigegebenen Sandbox-Test abschliessen. Das erzeugte
Symcon-Paket zuerst in einer isolierten IP-Symcon-8-Installation pruefen; das
Ausfuehren des Pakets kann Kontakte schalten und ist kein Bestandteil dieser
Portalbereitstellung.
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Apply the reviewed portal patch as an authorized server administrator."""
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timezone
from urllib.request import urlopen
APP = Path('/home/agent/services/license')
PACKAGE = Path(__file__).resolve().parent
CONTAINER = 'enelix_license_portal'
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
def docker(*args):
return subprocess.run(['docker', *args], check=True, capture_output=True, text=True).stdout
def write_in_place(path, content):
# Keep existing file bind mounts attached to the same inode.
with path.open('wb') as target:
target.write(content)
target.flush()
os.fsync(target.fileno())
def verify_runtime():
with urlopen('https://license.enelix.ch/healthz', timeout=5) as response:
if response.status != 200:
raise RuntimeError('Portal health check failed')
with urlopen('https://license.enelix.ch/api/wizard-catalog', timeout=5) as response:
catalog = json.load(response)
if 'heat_pump' not in catalog.get('consumerFields', {}):
raise RuntimeError('Heat-pump wizard is not served')
with urlopen('https://license.enelix.ch/', timeout=5) as response:
html = response.read().decode()
if 'class="header-links"' not in html or 'data-device-quantity="heat_pump"' not in html:
raise RuntimeError('New portal frontend is not served')
def write_catalog(path, content):
metadata = path.stat()
descriptor, temporary = tempfile.mkstemp(prefix='.catalog-release-', dir=path.parent)
try:
with os.fdopen(descriptor, 'wb') as target:
target.write(content)
target.flush()
os.fsync(target.fileno())
os.chmod(temporary, metadata.st_mode & 0o777)
os.chown(temporary, metadata.st_uid, metadata.st_gid)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def main():
if os.geteuid() != 0:
raise RuntimeError('Run this reviewed deployment as an authorized administrator (root).')
manifest = json.loads((PACKAGE / 'manifest.json').read_text())
for name, hashes in manifest['files'].items():
if Path(name).is_absolute() or '..' in Path(name).parts:
raise RuntimeError('Invalid file path')
if digest(APP / name) != hashes['before']:
raise RuntimeError('Live file changed; stop for conflict review: ' + name)
if digest(PACKAGE / 'files' / name) != hashes['after']:
raise RuntimeError('Package checksum mismatch: ' + name)
state = json.loads(docker('inspect', '--format', '{{json .State}}', CONTAINER))
if not state.get('Running'):
raise RuntimeError('Portal is not running; resolve its state before deployment.')
mounts = json.loads(docker('inspect', '--format', '{{json .Mounts}}', CONTAINER))
for name in ['server.mjs', 'stripe.mjs', 'portal-domain.mjs', 'symcon-package.mjs', 'public', 'data']:
if not any(m['Source'] == str(APP / name) and m['Destination'] == '/app/' + name for m in mounts):
raise RuntimeError('Unexpected container mount: ' + name)
catalog_path = APP / 'data/catalog.json'
catalog_bytes = catalog_path.read_bytes()
catalog = json.loads(catalog_bytes)
if catalog.get('items', {}).get('heat_pump', {}).get('sku') != 'ENX-HEAT-PUMP':
raise RuntimeError('Expected heat-pump catalog entry is missing.')
backup = APP / 'change-backups' / ('heat-pump-header-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
for name in manifest['files']:
destination = backup / name
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(APP / name, destination)
(backup / 'catalog.json').write_bytes(catalog_bytes)
(backup / 'catalog.json').chmod(0o600)
# Refuse concurrent source edits before stopping the portal.
for name, hashes in manifest['files'].items():
if digest(APP / name) != hashes['before']:
raise RuntimeError('Concurrent source change: ' + name)
if catalog_path.read_bytes() != catalog_bytes:
raise RuntimeError('Catalog changed during preflight; retry after review.')
written_catalog = None
docker('stop', '--time', '20', CONTAINER)
try:
if catalog_path.read_bytes() != catalog_bytes:
raise RuntimeError('Catalog changed during stop; no catalog migration applied.')
for name in manifest['files']:
write_in_place(APP / name, (PACKAGE / 'files' / name).read_bytes())
catalog['items']['heat_pump']['available'] = True
catalog['updatedAt'] = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
written_catalog = (json.dumps(catalog, ensure_ascii=False, indent=2) + '\n').encode()
write_catalog(catalog_path, written_catalog)
docker('start', CONTAINER)
for attempt in range(30):
try:
verify_runtime()
print('Portal deployed and verified. Backup: ' + str(backup))
return
except Exception:
if attempt == 29:
raise
time.sleep(1)
except Exception:
docker('stop', '--time', '20', CONTAINER)
for name in manifest['files']:
write_in_place(APP / name, (backup / name).read_bytes())
# Do not overwrite a concurrent backoffice catalog update.
current_catalog = catalog_path.read_bytes()
if written_catalog is not None and current_catalog == written_catalog:
write_catalog(catalog_path, catalog_bytes)
docker('start', CONTAINER)
print('Deployment failed; original application files restored. Backup: ' + str(backup), file=sys.stderr)
raise
if __name__ == '__main__':
main()
@@ -0,0 +1,665 @@
import { randomBytes } from "node:crypto";
export const systemDeviceTypes = Object.freeze({
battery: { name: "Batteriespeicher", catalogKey: "battery", moduleId: "{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}" },
buffer_storage: { name: "Pufferspeicher", catalogKey: "buffer_storage", moduleId: "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}" },
ev_charger: { name: "EV-Ladestation", catalogKey: "ev_charger", moduleId: "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}", gatewayModuleId: "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}" },
consumer_single: { name: "Verbraucher einstufig", catalogKey: "consumer_single", moduleId: "{15879A4E-D0C2-4495-83DE-46E1E462591E}" },
boiler_multi: { name: "Boiler mehrstufig", catalogKey: "boiler_multi", moduleId: "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" },
heat_pump: { name: "Wärmepumpe", catalogKey: "heat_pump", moduleId: "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}" }
});
export const integrationProfiles = Object.freeze({
energy: [
{ id: "manual", name: "Bestehende Symcon-Variablen", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresHost: false },
{ id: "fronius_solar_api_v1", name: "Fronius Solar API v1", manufacturer: "Fronius", protocol: "HTTP Solar API v1", defaultPort: 80, setup: "automatic", requiresHost: true },
{ id: "huawei_sun2000_modbus", name: "SUN2000 / Smart Dongle", manufacturer: "Huawei", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "sma_sunspec_modbus", name: "Sunny Boy / Tripower SunSpec", manufacturer: "SMA", protocol: "SunSpec Modbus TCP", defaultPort: 502, setup: "sunspec", requiresHost: true },
{ id: "goodwe_et_modbus", name: "ET / BT / EH Serie", manufacturer: "GoodWe", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "solaredge_sunspec_modbus", name: "SetApp SunSpec", manufacturer: "SolarEdge", protocol: "SunSpec Modbus TCP", defaultPort: 1502, setup: "sunspec", requiresHost: true }
],
storage: [
{ id: "manual", name: "Bestehende Symcon-Variablen", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresHost: false },
{ id: "fronius_hybrid_storage", name: "BYD über Fronius Hybridwechselrichter", manufacturer: "Fronius", protocol: "HTTP Solar API v1", defaultPort: 80, setup: "automatic", requiresHost: true },
{ id: "huawei_luna2000", name: "LUNA2000 über SUN2000", manufacturer: "Huawei", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "sma_home_storage", name: "SMA Home Storage", manufacturer: "SMA", protocol: "SunSpec Modbus TCP", defaultPort: 502, setup: "sunspec", requiresHost: true },
{ id: "goodwe_lynx", name: "Lynx Home über Hybridwechselrichter", manufacturer: "GoodWe", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "solaredge_home_battery", name: "SolarEdge Home Battery", manufacturer: "SolarEdge", protocol: "SunSpec Modbus TCP", defaultPort: 1502, setup: "sunspec", requiresHost: true },
{ id: "sigenergy_sigenstor", name: "SigenStor / Energy Controller", manufacturer: "Sigenergy", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true }
],
sensor: [
{ id: "manual", name: "Bestehender Temperaturfühler", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresTopic: false },
{ id: "shelly_addon_temperature", name: "Shelly Plus Add-on Temperaturfühler", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", defaultPort: 0, setup: "automatic", requiresTopic: true }
],
actuator: [
{ id: "manual", name: "Bestehender Symcon-Schaltkontakt", manufacturer: "Allgemein", protocol: "Variablenzuordnung", outputs: 1, setup: "manual", requiresTopic: false },
{ id: "shelly_plus_1pm", name: "Shelly Plus 1PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true },
{ id: "shelly_plus_2pm", name: "Shelly Plus 2PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 2, setup: "automatic", requiresTopic: true },
{ id: "shelly_pro_1pm", name: "Shelly Pro 1PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true },
{ id: "shelly_pro_2pm", name: "Shelly Pro 2PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 2, setup: "automatic", requiresTopic: true },
{ id: "shelly_pro_4pm", name: "Shelly Pro 4PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 4, setup: "automatic", requiresTopic: true },
{ id: "shelly_plug_s_gen3", name: "Shelly Plug S Gen3", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true }
]
});
const monthLimits = ["Januar", "Februar", "Maerz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
.map((Monat, index) => ({ Monat, Grenze_W: 0, MonatIndex: index + 1 }));
const f = (key, label, type, defaultValue, section, extra = {}) => ({ key, label, type, default: defaultValue, section, ...extra });
export const managerFields = Object.freeze([
f("Rolle", "Rolle", "select", 0, "Grundeinstellungen", { options: [{ value: 0, label: "Alleine" }, { value: 1, label: "Hauptmanager" }, { value: 2, label: "Untermanager" }] }),
f("NetzleistungVariableID", "Netzleistung Variable-ID", "integer", 0, "Grundeinstellungen", { min: 0 }),
f("Netzleistungsfaktor", "Netzleistungsfaktor", "number", 1, "Grundeinstellungen"),
f("MesswertMaxAlter", "Maximales Messwertalter (s)", "integer", 60, "Grundeinstellungen", { min: 1 }),
f("VerbraucherZuordnung", "Manuelle Verbraucherzuordnung (JSON)", "json", "[]", "Grundeinstellungen"),
f("AutomatischeVerbraucherZuordnung", "Automatische Verbraucherzuordnung (JSON)", "json", "[]", "Grundeinstellungen"),
f("AutomatischeSuche", "Verbraucher automatisch suchen", "boolean", true, "Grundeinstellungen"),
f("SuchbereichID", "Suchbereich Objekt-ID", "integer", 0, "Grundeinstellungen", { min: 0 }),
f("KeepAlive", "Keep-Alive (s)", "integer", 60, "Grundeinstellungen", { min: 1 }),
f("VerbraucherTimeout", "Verbraucher-Timeout (s)", "integer", 60, "Grundeinstellungen", { min: 1 }),
f("Lastspitzenmodus", "Lastspitzenmodus", "select", 0, "Regelung", { options: [{ value: 0, label: "Aus" }, { value: 1, label: "Konstant" }, { value: 2, label: "Monatlich" }] }),
f("Lastspitzengrenze", "Lastspitzengrenze (W)", "number", 0, "Regelung", { min: 0 }),
f("Monatsgrenzen", "Monatliche Grenzen (JSON)", "json", JSON.stringify(monthLimits), "Regelung"),
f("SollwertSolarladen", "Sollwert Solarladen (W)", "number", 0, "Regelung"),
f("Umschaltdifferenz", "Umschaltdifferenz (W)", "number", 5, "Regelung", { min: 0 }),
f("EinspeisebegrenzungAktiv", "Einspeisebegrenzung aktiv", "boolean", false, "Regelung"),
f("Einspeisegrenze", "Maximale Einspeisung (W)", "number", 0, "Regelung", { min: 0 }),
f("PrognoseAktiv", "Prognose aktiv", "boolean", false, "Prognose und Dienste"),
f("NetzfahrplanAktiv", "Netzfahrplan aktiv", "boolean", false, "Prognose und Dienste"),
f("PrognosePVVariableID", "PV-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }),
f("PrognoseHausverbrauchVariableID", "Hausverbrauch-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }),
f("PrognoseSOCVariableID", "SOC-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }),
f("PrognoseSendeintervall", "Prognose-Sendeintervall (s)", "integer", 60, "Prognose und Dienste", { min: 1 }),
f("PrognoseAnschluss", "Prognose-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("SDLAnschluss", "SDL-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("LizenzAnschluss", "Lizenz-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("StoermeldeAnschluss", "Störmelde-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("StoerueberwachungAktiv", "Störüberwachung aktiv", "boolean", false, "Prognose und Dienste"),
f("StoerungsSendeintervall", "Störungs-Sendeintervall (s)", "integer", 300, "Prognose und Dienste", { min: 10 }),
f("AnlagenWechselrichter", "Wechselrichter (JSON)", "json", "[]", "Anlagentopologie"),
f("AnlagenPVFlaechen", "PV-Flächen (JSON)", "json", "[]", "Anlagentopologie"),
f("AnlagenBatterien", "Batterien (JSON)", "json", "[]", "Anlagentopologie"),
f("NetzbezugEnergieVariableID", "Netzbezug Energie Variable-ID", "integer", 0, "Anlagentopologie", { min: 0 }),
f("NetzbezugEnergiefaktor", "Netzbezug Energiefaktor", "number", 1, "Anlagentopologie"),
f("NetzeinspeisungEnergieVariableID", "Netzeinspeisung Energie Variable-ID", "integer", 0, "Anlagentopologie", { min: 0 }),
f("NetzeinspeisungEnergiefaktor", "Netzeinspeisung Energiefaktor", "number", 1, "Anlagentopologie"),
f("EnergieaufzeichnungAktiv", "Energieaufzeichnung aktiv", "boolean", true, "Messung und Darstellung"),
f("MessungPVLeistungVariableID", "PV-Leistung Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("MessungHausverbrauchLeistungVariableID", "Hausverbrauch Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("MessungBatterieleistungVariableID", "Batterieleistung Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("MessungPVLeistungsfaktor", "PV-Leistungsfaktor", "number", 1, "Messung und Darstellung"),
f("MessungHausverbrauchLeistungsfaktor", "Hausverbrauch-Leistungsfaktor", "number", 1, "Messung und Darstellung"),
f("MessungBatterieleistungsfaktor", "Batterie-Leistungsfaktor", "number", 1, "Messung und Darstellung"),
f("MesswerteAnzeigen", "Messwerte anzeigen", "boolean", false, "Messung und Darstellung"),
f("LeistungsaufzeichnungMinuten", "Leistungsaufzeichnung (min)", "integer", 1, "Messung und Darstellung", { min: 1 }),
f("LeistungLoeschenMonate", "Leistungsdaten löschen (Monate)", "integer", 12, "Messung und Darstellung", { min: 0 }),
f("EnergieaufzeichnungMinuten", "Energieaufzeichnung (min)", "integer", 5, "Messung und Darstellung", { min: 1 }),
f("EnergieVerdichtenMonate", "Energiedaten verdichten (Monate)", "integer", 12, "Messung und Darstellung", { min: 0 }),
f("EnergieLoeschenMonate", "Energiedaten löschen (Monate)", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("EnergyPieAnzeigen", "Energie-Kreisdiagramm anzeigen", "boolean", true, "Messung und Darstellung"),
f("EnergiediagrammeAnzeigen", "Energiediagramme anzeigen", "boolean", true, "Messung und Darstellung"),
f("FunFactsAnzeigen", "Fun Facts anzeigen", "boolean", true, "Messung und Darstellung"),
f("EnergieflussAnzeigen", "Energiefluss anzeigen", "boolean", true, "Messung und Darstellung"),
f("DiagnosevariablenAnzeigen", "Diagnosevariablen anzeigen", "boolean", false, "Diagnose"),
f("LoggingEin", "Diagnoseprotokoll aktivieren", "boolean", false, "Diagnose")
]);
const managerWizardFieldKeys = new Set([
"Lastspitzengrenze",
"SollwertSolarladen",
"EinspeisebegrenzungAktiv",
"Einspeisegrenze",
"PrognoseAktiv",
"NetzfahrplanAktiv",
"StoerueberwachungAktiv",
"EnergyPieAnzeigen",
"EnergiediagrammeAnzeigen",
"FunFactsAnzeigen",
"EnergieflussAnzeigen"
]);
export const managerWizardFields = Object.freeze(
managerFields
.filter(field => managerWizardFieldKeys.has(field.key))
.map(field => ({
...field,
section: field.section === "Messung und Darstellung" ? "Darstellung" : field.section
}))
);
export const topologyFields = Object.freeze({
pvSurface: [
f("Name", "Bezeichnung", "text", "PV-Fläche", "PV-Fläche"),
f("DCLeistung_kWp", "DC-Leistung (kWp)", "number", 0, "PV-Fläche", { min: 0 }),
f("Neigung_Grad", "Neigung (Grad)", "number", 30, "Ausrichtung", { min: 0, max: 90 }),
f("Azimut_Grad", "Azimut (Grad)", "number", 0, "Ausrichtung", { min: -180, max: 180 }),
f("MPPT", "MPPT / Eingang", "text", "", "Zuordnung"),
f("Modulanzahl", "Anzahl Module", "integer", 0, "Module", { min: 0 }),
f("Modulleistung_Wp", "Modulleistung (Wp)", "number", 0, "Module", { min: 0 })
],
battery: [
f("Nennkapazitaet_kWh", "Nennkapazität (kWh)", "number", 0, "Anlagentopologie", { min: 0 }),
f("Nutzkapazitaet_kWh", "Nutzkapazität (kWh)", "number", 0, "Anlagentopologie", { min: 0 }),
f("MaxLadeleistung_kW", "Maximale Ladeleistung (kW)", "number", 0, "Anlagentopologie", { min: 0 }),
f("MaxEntladeleistung_kW", "Maximale Entladeleistung (kW)", "number", 0, "Anlagentopologie", { min: 0 }),
f("Kopplung", "Kopplung", "choice", "ac", "Anlagentopologie", { options: [
{ value: "ac", label: "AC-gekoppelt" },
{ value: "dc", label: "DC-gekoppelt" },
{ value: "hybrid", label: "Hybrid" }
] })
]
});
const commonConsumerFields = [
f("PrioritaetPV", "Priorität PV", "integer", 0, "Manager und Zeitverhalten", { min: 0 }),
f("PrioritaetPeak", "Priorität Peak", "integer", 0, "Manager und Zeitverhalten", { min: 0 }),
f("Meldeintervall", "Meldeintervall (s)", "integer", 10, "Manager und Zeitverhalten", { min: 1 }),
f("VorgabeTimeout", "Vorgabe-Timeout (s)", "integer", 120, "Manager und Zeitverhalten", { min: 1 }),
f("EinstellungenInVisu", "Einstellungen in Visualisierung", "boolean", true, "Darstellung und Diagnose"),
f("DiagnosevariablenAnzeigen", "Diagnosevariablen anzeigen", "boolean", false, "Darstellung und Diagnose"),
f("LoggingEin", "Diagnoseprotokoll aktivieren", "boolean", false, "Darstellung und Diagnose")
];
export const consumerFields = Object.freeze({
battery: [...commonConsumerFields,
f("Batterietyp", "Batterietyp", "select", 0, "Batterie", { options: [{ value: 0, label: "Unkonfiguriert" }, { value: 1, label: "Herstellerunabhängig" }, { value: 2, label: "GoodWe" }, { value: 3, label: "SolarEdge" }, { value: 4, label: "Sigenergy" }] }),
f("Batteriemanagement", "Steuerung", "select", 1, "Batterie", { options: [{ value: 1, label: "Wechselrichter" }, { value: 2, label: "Enelix Manager" }] }),
...["MaxLadeleistungVariableID", "MaxEntladeleistungVariableID", "LadezustandVariableID", "NetzleistungVariableID", "IstleistungVariableID", "ManagementRegisterVariableID", "ModusRegisterVariableID", "LeistungsRegisterVariableID", "LadeleistungRegisterVariableID", "EntladeleistungRegisterVariableID"].map(key => f(key, key.replace(/VariableID$/, " Variable-ID"), "integer", 0, "Variablenzuordnung", { min: 0 })),
f("ReserveLadezustand", "Reserve für Peak Shaving (%)", "number", 20, "Batterie", { min: 0, max: 100 }),
f("MindestLadezustand", "Minimaler Ladezustand (%)", "number", 10, "Batterie", { min: 0, max: 100 }),
f("LadezustandHysterese", "Reserve-Hysterese (%)", "number", 2, "Batterie", { min: 0, max: 100 }),
f("MesswertMaxAlter", "Maximales Messwertalter (s)", "integer", 60, "Batterie", { min: 1 }),
f("Aenderungssperre", "Sperrzeit nach Leistungsänderung (s)", "integer", 4, "Batterie", { min: 0 })
],
buffer_storage: [...commonConsumerFields,
f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Pufferspeicher"),
f("PufferfuehlerVariableID", "Puffertemperatur Variable-ID", "integer", 0, "Pufferspeicher", { min: 0 }),
f("AussentemperaturVariableID", "Außentemperatur Variable-ID", "integer", 0, "Pufferspeicher", { min: 0 }),
f("WaermepumpenSolltemperaturVariableID", "Wärmepumpen-Solltemperatur Variable-ID", "integer", 0, "Heizkurve", { min: 0 }),
f("FusspunktVorlauftemperatur", "Fußpunkt Vorlauftemperatur (°C)", "number", 35, "Heizkurve"),
f("HeizkurvenSteigung", "Heizkurvensteigung", "number", 1, "Heizkurve", { min: 0 }),
f("HeizkurveMinimaltemperatur", "Minimale Solltemperatur (°C)", "number", 20, "Heizkurve"),
f("HeizkurveMaximaltemperatur", "Maximale Solltemperatur (°C)", "number", 80, "Heizkurve"),
f("Hysterese", "Hysterese (K)", "number", 5, "Heizkurve", { min: 0.1 }),
f("MindesttemperaturModus", "Mindesttemperatur", "select", 0, "Heizkurve", { options: [{ value: 0, label: "Aus" }, { value: 1, label: "Statisch" }, { value: 2, label: "Differenz zur Solltemperatur" }] }),
f("Mindesttemperatur", "Statische Mindesttemperatur (°C)", "number", 20, "Heizkurve"),
f("MindesttemperaturDifferenz", "Differenz zur Solltemperatur (K)", "number", 5, "Heizkurve", { min: 0 }),
f("LastwechselSperrzeit", "Lastwechsel-Sperrzeit (s)", "integer", 5, "Pufferspeicher", { min: 0 }),
f("TemperaturMaxAlter", "Maximales Temperaturalter (s)", "integer", 120, "Pufferspeicher", { min: 1 }),
f("PuffertemperaturGlaetten", "Puffertemperatur glätten", "boolean", false, "Pufferspeicher"),
f("ZeitKonstante", "PT1-Zeitkonstante (s)", "integer", 120, "Pufferspeicher", { min: 1 })
],
ev_charger: [...commonConsumerFields,
f("Geraetetyp", "Gerätetyp", "select", 0, "Ladestation", { options: [{ value: 0, label: "Nicht konfiguriert" }, { value: 1, label: "go-e alte API" }, { value: 2, label: "go-e Gemini" }, { value: 3, label: "smart-me Pico" }] }),
f("Geraeteadresse", "IP-Adresse oder Hostname", "text", "", "Ladestation"),
f("GeraeteID", "Geräte-ID", "text", "", "Ladestation"),
f("Seriennummer", "Seriennummer", "text", "", "Ladestation"),
f("Benutzername", "Benutzername", "text", "", "Ladestation"),
f("MaximalerLadestrom", "Maximaler Ladestrom (A)", "integer", 16, "Ladestation", { min: 6, max: 32 }),
f("Abfrageintervall", "Statusabfrage (s)", "integer", 5, "Ladestation", { min: 1 }),
f("Ladefreigabe", "Ladefreigabe beim Start", "boolean", true, "Ladestation"),
f("Solarladen", "Solarladen beim Start", "boolean", true, "Ladestation")
],
consumer_single: [...commonConsumerFields,
f("Nennleistung", "Nennleistung (W)", "integer", 0, "Verbraucher", { min: 0 }),
f("SchaltkontaktVariableID", "Schaltkontakt Variable-ID", "integer", 0, "Verbraucher", { min: 0 }),
f("SchaltkontaktInvertiert", "Schaltkontakt invertieren", "boolean", false, "Verbraucher"),
f("RueckmeldungVariableID", "Rückmeldung Variable-ID", "integer", 0, "Verbraucher", { min: 0 }),
f("Mindestlaufzeit", "Tägliche Mindestlaufzeit (s)", "integer", 0, "Verbraucher", { min: 0, max: 86400 }),
f("PeakSperreBeiMindestlaufzeitAnbieten", "Bei Peak-Sperre während Mindestlaufzeit anbieten", "boolean", true, "Verbraucher"),
f("Mindesteinschaltdauer", "Mindest-Einschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 }),
f("Mindestausschaltdauer", "Mindest-Ausschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 })
],
heat_pump: [...commonConsumerFields,
f("Kontaktart", "Kontaktart", "select", 0, "Kontakte", { min: 0, max: 1, options: [{ value: 0, label: "Sperre und Erhöhung" }, { value: 1, label: "SG Ready" }] }),
f("Kontakt1VariableID", "Kontakt 1: Sperre / SG Ready 1 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }),
f("Kontakt2VariableID", "Kontakt 2: Erhöhung / SG Ready 2 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }),
f("Kontakt1Invertiert", "Kontakt 1 invertieren", "boolean", false, "Kontakte"),
f("Kontakt2Invertiert", "Kontakt 2 invertieren", "boolean", false, "Kontakte"),
f("Nennleistung", "Elektrische Nennleistung (W)", "integer", 0, "Wärmepumpe", { min: 0 }),
f("Rueckmeldungsart", "Rückmeldungsart", "select", 0, "Betriebsrückmeldung", { min: 0, max: 1, options: [{ value: 0, label: "Gemessene Leistung" }, { value: 1, label: "Boolean-Betriebsstatus" }] }),
f("IstleistungVariableID", "Leistung oder Betriebsstatus (Variable-ID)", "integer", 0, "Betriebsrückmeldung", { min: 0 }),
f("Laufschwelle", "Laufschwelle bei Leistungsmessung (W)", "number", 100, "Betriebsrückmeldung", { min: 0 }),
f("Anlaufwartezeit", "Anlaufwartezeit (s)", "integer", 30, "Schutzzeiten", { min: 0 }),
f("Wiederholsperre", "Wiederholsperre nach erfolglosem Anlauf (s)", "integer", 300, "Schutzzeiten", { min: 0 }),
f("Mindestlaufzeit", "Mindestlaufzeit nach bestätigtem Start (s)", "integer", 1200, "Schutzzeiten", { min: 0 }),
f("Mindestsperrzeit", "Mindest-Sperrzeit (s)", "integer", 1200, "Schutzzeiten", { min: 0 }),
f("MaximaleSperrzeit", "Maximale Sperrzeit am Stück (s; 0 = aus)", "integer", 7200, "Schutzzeiten", { min: 0 }),
f("MaximaleSperrzeit24h", "Maximale Sperrzeit in 24 Stunden (s; 0 = aus)", "integer", 21600, "Schutzzeiten", { min: 0, max: 86400 })
],
boiler_multi: [...commonConsumerFields,
f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Warmwassererwärmer"),
f("Boilerfuehler_PT1", "Boilerfühler Variable-ID", "integer", 0, "Warmwassererwärmer", { min: 0 }),
f("LegionellenfunktionAktiv", "Legionellenschaltung aktiv", "boolean", true, "Warmwassererwärmer"),
f("LegionellenMinimalintervallTage", "Frühestens nach Tagen", "integer", 4, "Warmwassererwärmer", { min: 1 }),
f("LegionellenMaximalintervallTage", "Spätestens nach Tagen", "integer", 7, "Warmwassererwärmer", { min: 1 }),
f("Boilervolumen", "Boilervolumen (l)", "integer", 300, "Warmwassererwärmer", { min: 1 }),
f("Hysterese", "Temperaturhysterese (K)", "number", 5, "Warmwassererwärmer", { min: 0.1 }),
f("Zeitplan", "Solltemperatur-Zeitplan (JSON)", "json", "[]", "Warmwassererwärmer"),
f("LastwechselSperrzeit", "Mindestzeit zwischen Lastwechseln (s)", "integer", 5, "Warmwassererwärmer", { min: 1 }),
f("TemperaturMaxAlter", "Maximales Messwertalter (s)", "integer", 30, "Warmwassererwärmer", { min: 1 }),
f("BoilertemperaturGlaetten", "Boilertemperatur glätten", "boolean", false, "Warmwassererwärmer"),
f("ZeitKonstante", "PT1-Zeitkonstante (s)", "integer", 120, "Warmwassererwärmer", { min: 1 })
]
});
export const evGatewayFields = Object.freeze([
...commonConsumerFields,
f("Betriebsmodus", "Betriebsart", "select", 0, "Gateway-Ladestation", { options: [
{ value: 0, label: "Easee" },
{ value: 1, label: "Easee · nur Solarladen" }
] }),
f("Ladestationskennung", "Easee Seriennummer", "text", "", "Gateway-Ladestation"),
f("MaximalerLadestrom", "Maximaler Ladestrom (A)", "integer", 16, "Gateway-Ladestation", { min: 6, max: 32 }),
f("Ladefreigabe", "Ladefreigabe beim Start", "boolean", true, "Gateway-Ladestation"),
f("Solarladen", "Solarladen beim Start", "boolean", true, "Gateway-Ladestation")
]);
const consumerWizardFieldKeys = Object.freeze({
battery: new Set(["Batteriemanagement", "ReserveLadezustand", "MindestLadezustand"]),
buffer_storage: new Set([
"FusspunktVorlauftemperatur", "HeizkurvenSteigung", "HeizkurveMinimaltemperatur",
"HeizkurveMaximaltemperatur", "Hysterese", "MindesttemperaturModus",
"Mindesttemperatur", "MindesttemperaturDifferenz"
]),
ev_charger: new Set([
"Geraetetyp", "Geraeteadresse", "GeraeteID", "Seriennummer", "Benutzername",
"MaximalerLadestrom", "Ladefreigabe", "Solarladen"
]),
consumer_single: new Set([
"Nennleistung", "SchaltkontaktInvertiert", "Mindestlaufzeit",
"Mindesteinschaltdauer", "Mindestausschaltdauer"
]),
heat_pump: new Set([
"Kontaktart", "Kontakt1VariableID", "Kontakt2VariableID", "Kontakt1Invertiert",
"Kontakt2Invertiert", "Nennleistung", "Rueckmeldungsart", "IstleistungVariableID",
"Laufschwelle", "Anlaufwartezeit", "Wiederholsperre", "Mindestlaufzeit",
"Mindestsperrzeit", "MaximaleSperrzeit", "MaximaleSperrzeit24h"
]),
boiler_multi: new Set([
"Boilervolumen", "Hysterese", "LegionellenfunktionAktiv",
"LegionellenMinimalintervallTage", "LegionellenMaximalintervallTage"
])
});
export const consumerWizardFields = Object.freeze(Object.fromEntries(
Object.entries(consumerFields).map(([key, fields]) => [
key,
fields.filter(field => consumerWizardFieldKeys[key].has(field.key))
])
));
export const evGatewayWizardFields = Object.freeze(
evGatewayFields.filter(field => [
"Betriebsmodus", "Ladestationskennung", "MaximalerLadestrom", "Ladefreigabe", "Solarladen"
].includes(field.key))
);
export const deviceListFields = Object.freeze({
powerLevel: [
f("Stufe", "Stufe", "integer", 1, "Leistungsstufe", { min: 1 }),
f("Leistung", "Leistung (W)", "integer", 0, "Leistungsstufe", { min: 1 })
],
schedule: [
f("Uhrzeit", "Uhrzeit", "text", "00:00", "Zeitplan"),
f("Solltemperatur", "Solltemperatur (°C)", "number", 50, "Zeitplan", { min: 0, max: 100 })
]
});
function text(value, maxLength) {
return String(value || "").trim().replace(/\s+/g, " ").slice(0, maxLength);
}
function count(value, maximum = 50) {
const parsed = Number(value || 0);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > maximum) {
throw Object.assign(new Error("invalid_quantity"), { status: 400, publicMessage: "Mengen müssen zwischen 0 und " + maximum + " liegen." });
}
return parsed;
}
function bool(value) {
return value === true || value === 1 || value === "1" || value === "true" || value === "on";
}
function finite(value, fallback, minimum = -Number.MAX_VALUE, maximum = Number.MAX_VALUE, integer = false) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
const bounded = Math.max(minimum, Math.min(maximum, parsed));
return integer ? Math.round(bounded) : bounded;
}
function jsonText(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
try {
return JSON.stringify(typeof value === "string" ? JSON.parse(value) : value);
} catch {
throw Object.assign(new Error("invalid_configuration_json"), { status: 400, publicMessage: "Ein JSON-Konfigurationsfeld ist ungültig." });
}
}
function normalizeProperties(input, fields) {
const source = input && typeof input === "object" ? input : {};
return Object.fromEntries(fields.map(field => {
const raw = Object.prototype.hasOwnProperty.call(source, field.key) ? source[field.key] : field.default;
if (field.type === "boolean") return [field.key, bool(raw)];
if (field.type === "integer" || field.type === "select") return [field.key, finite(raw, field.default, field.min, field.max, true)];
if (field.type === "number") return [field.key, finite(raw, field.default, field.min, field.max, false)];
if (field.type === "json") return [field.key, jsonText(raw, field.default)];
return [field.key, text(raw, 240)];
}));
}
function parseList(value) {
if (Array.isArray(value)) return value;
if (typeof value !== "string" || value.trim() === "") return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function normalizeTopologyValues(input, fields) {
const source = input && typeof input === "object" ? input : {};
return Object.fromEntries(fields.map(field => {
const raw = Object.prototype.hasOwnProperty.call(source, field.key) ? source[field.key] : field.default;
if (field.type === "integer") return [field.key, finite(raw, field.default, field.min, field.max, true)];
if (field.type === "number") return [field.key, finite(raw, field.default, field.min, field.max, false)];
if (field.type === "choice") {
const allowed = (field.options || []).map(option => option.value);
return [field.key, allowed.includes(raw) ? raw : field.default];
}
return [field.key, text(raw, 120)];
}));
}
function normalizePvSurfaces(input) {
return (Array.isArray(input) ? input : []).slice(0, 50).map((item, index) => ({
ID: text(item?.ID || "pv-" + (index + 1), 60),
...normalizeTopologyValues(item, topologyFields.pvSurface),
WechselrichterID: "wr-1"
}));
}
function normalizeBatteryTopology(input) {
return normalizeTopologyValues(input, topologyFields.battery);
}
function normalizePowerLevels(input) {
return parseList(input).slice(0, 12).map((item, index) => ({
...normalizeTopologyValues({ ...item, Stufe: item?.Stufe ?? index + 1 }, deviceListFields.powerLevel),
integration: normalizeIntegration(item?.integration || { profile: "manual", sourceVariableId: item?.Schaltkontakt_Stufe || 0 }, "actuator")
}));
}
function normalizeSchedule(input) {
return parseList(input).slice(0, 24).map(item => {
const value = normalizeTopologyValues(item, deviceListFields.schedule);
return {
Uhrzeit: /^([01]\d|2[0-3]):[0-5]\d$/.test(value.Uhrzeit) ? value.Uhrzeit : "00:00",
Solltemperatur: value.Solltemperatur
};
});
}
function batteryTypeForProfile(profileId) {
if (profileId === "goodwe_lynx") return 2;
if (profileId === "solaredge_home_battery") return 3;
if (profileId === "sigenergy_sigenstor") return 4;
return profileId === "manual" ? 0 : 1;
}
function profile(kind, value) {
const profiles = integrationProfiles[kind];
return profiles.find(item => item.id === value) || profiles[0];
}
function normalizeIntegration(input, kind) {
const source = input && typeof input === "object" ? input : {};
const selected = profile(kind, text(source.profile, 60));
const result = {
profile: selected.id,
manufacturer: selected.manufacturer,
protocol: selected.protocol,
setup: selected.setup,
host: text(source.host, 120),
port: finite(source.port, selected.defaultPort || 0, 0, 65535, true),
unitId: finite(source.unitId, 1, 0, 247, true),
topicPrefix: text(source.topicPrefix, 180),
channel: finite(source.channel, 0, 0, Math.max(0, (selected.outputs || 1) - 1), true),
sourceVariableId: finite(source.sourceVariableId, 0, 0, 2147483647, true),
feedbackVariableId: finite(source.feedbackVariableId, 0, 0, 2147483647, true),
notes: text(source.notes, 300)
};
if (selected.requiresHost && result.host === "") {
throw Object.assign(new Error("integration_host_missing"), { status: 400, publicMessage: "Bitte für " + selected.name + " eine IP-Adresse oder einen Hostnamen angeben." });
}
if (selected.requiresTopic && result.topicPrefix === "") {
throw Object.assign(new Error("integration_topic_missing"), { status: 400, publicMessage: "Bitte für " + selected.name + " das MQTT-Topic-Präfix angeben." });
}
return result;
}
function legacyItem(key, source, index) {
const integration = {};
if (key === "battery") {
integration.profile = source.profile || "manual";
integration.host = source.address || source.host || "";
} else if (["buffer_storage", "consumer_single", "boiler_multi"].includes(key)) {
integration.profile = source.hardware === "shelly_1pm" ? "shelly_plus_1pm" : "manual";
integration.topicPrefix = source.topicPrefix || "";
}
const properties = {};
if (key === "consumer_single" && source.ratedPower) properties.Nennleistung = source.ratedPower;
if (key === "boiler_multi" && source.volume) properties.Boilervolumen = source.volume;
if (key === "ev_charger") {
const types = { goe_legacy: 1, goe_gemini: 2, smartme_pico: 3 };
properties.Geraetetyp = types[source.model] || 0;
properties.Geraeteadresse = source.address || "";
}
return {
name: source.name || systemDeviceTypes[key].name + " " + (index + 1),
variant: key === "ev_charger" ? "standalone" : undefined,
integration,
properties,
topology: key === "battery" ? normalizeBatteryTopology(source.topology) : undefined,
powerLevels: ["buffer_storage", "boiler_multi"].includes(key) ? [] : undefined,
schedule: key === "boiler_multi" ? [] : undefined,
sensor: ["buffer_storage", "boiler_multi"].includes(key) ? { profile: "manual", sourceVariableId: 0 } : undefined
};
}
function normalizeDevice(key, input) {
const source = input && typeof input === "object" ? input : {};
const requested = count(source.quantity);
const rawItems = Array.isArray(source.items) ? source.items.slice(0, requested) : [];
while (rawItems.length < requested) rawItems.push(legacyItem(key, source, rawItems.length));
const items = rawItems.map((item, index) => {
const variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone";
const kind = key === "battery" ? "storage" : (["buffer_storage", "consumer_single", "boiler_multi", "heat_pump"].includes(key) ? "actuator" : null);
const integration = kind ? normalizeIntegration(key === "heat_pump" ? { profile: "manual" } : item.integration, kind) : {
profile: text(item.integration?.profile || "device_api", 60),
host: text(item.integration?.host, 120),
port: finite(item.integration?.port, 0, 0, 65535, true),
unitId: finite(item.integration?.unitId, 1, 0, 247, true),
notes: text(item.integration?.notes, 300)
};
const propertyFields = key === "ev_charger" && variant === "gateway" ? evGatewayFields : consumerFields[key];
const properties = normalizeProperties(item.properties, propertyFields);
if (key === "battery") properties.Batterietyp = batteryTypeForProfile(integration.profile);
const result = {
name: text(item.name || systemDeviceTypes[key].name + " " + (index + 1), 80),
variant,
integration,
properties
};
if (key === "battery") result.topology = normalizeBatteryTopology(item.topology);
if (["buffer_storage", "boiler_multi"].includes(key)) {
result.powerLevels = normalizePowerLevels(item.powerLevels ?? item.properties?.LeistungsStufen);
result.sensor = normalizeIntegration(item.sensor || {
profile: "manual",
sourceVariableId: key === "boiler_multi"
? item.properties?.Boilerfuehler_PT1
: item.properties?.PufferfuehlerVariableID
}, "sensor");
result.properties.LeistungsStufen = JSON.stringify(result.powerLevels.map(level => ({
Stufe: level.Stufe,
Leistung: level.Leistung,
Schaltkontakt_Stufe: level.integration.sourceVariableId || 0
})));
}
if (key === "boiler_multi") {
result.schedule = normalizeSchedule(item.schedule ?? item.properties?.Zeitplan);
result.properties.Zeitplan = JSON.stringify(result.schedule);
}
return result;
});
return { quantity: requested, items };
}
export function normalizeSystemConfiguration(input = {}) {
const inputDevices = input.devices || {};
const hasSeparatedStorage = Object.prototype.hasOwnProperty.call(inputDevices, "buffer_storage");
const devices = {};
for (const key of Object.keys(systemDeviceTypes)) {
let source = inputDevices[key] || {};
if (!hasSeparatedStorage && key === "buffer_storage") source = inputDevices.battery || {};
if (!hasSeparatedStorage && key === "battery") source = {};
devices[key] = normalizeDevice(key, source);
}
const legacyGridProfile = input.gridMeasurement?.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual";
const legacyPvProfile = input.pvSystem?.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual";
const legacyInverter = parseList(input.manager?.properties?.AnlagenWechselrichter)[0] || {};
const legacyPvSurfaces = parseList(input.manager?.properties?.AnlagenPVFlaechen);
const pvEnabled = bool(input.pvSystem?.enabled);
const pvSystem = {
enabled: pvEnabled,
id: "wr-1",
name: text(input.pvSystem?.name || legacyInverter.Name || "PV-Wechselrichter", 80),
acPowerKw: finite(input.pvSystem?.acPowerKw ?? legacyInverter.ACNennleistung_kW, 0, 0),
...normalizeIntegration({
...input.pvSystem,
profile: pvEnabled ? (input.pvSystem?.profile || legacyPvProfile) : "manual"
}, "energy")
};
const topology = {
pvSurfaces: normalizePvSurfaces(input.topology?.pvSurfaces ?? legacyPvSurfaces)
};
const result = {
schemaVersion: 4,
name: text(input.name, 100),
location: text(input.location, 120),
siteType: ["Privat", "Gewerbe", "Industrie"].includes(input.siteType) ? input.siteType : "Gewerbe",
peakShaving: bool(input.peakShaving),
manager: { properties: normalizeProperties(input.manager?.properties, managerFields) },
gridMeasurement: normalizeIntegration({ ...input.gridMeasurement, profile: input.gridMeasurement?.profile || legacyGridProfile }, "energy"),
pvSystem,
topology,
costReport: {
enabled: bool(input.costReport?.enabled),
powerMeters: count(input.costReport?.powerMeters, 999),
auxiliaryMeters: count(input.costReport?.auxiliaryMeters, 999)
},
devices
};
result.manager.properties.Lastspitzenmodus = result.peakShaving
? Math.max(1, result.manager.properties.Lastspitzenmodus)
: 0;
if (result.pvSystem.enabled && result.topology.pvSurfaces.length === 0) {
result.topology.pvSurfaces = normalizePvSurfaces([{ Name: "PV-Fläche 1" }]);
}
if (!result.pvSystem.enabled) {
result.pvSystem = { ...result.pvSystem, profile: "manual", host: "", port: 0 };
result.topology.pvSurfaces = [];
}
const inverters = result.pvSystem.enabled ? [{
ID: "wr-1",
Name: result.pvSystem.name,
Typ: "pv",
ACNennleistung_kW: result.pvSystem.acPowerKw,
IstleistungVariableID: result.pvSystem.sourceVariableId,
Istleistungsfaktor: 1,
ErzeugungsenergieVariableID: 0,
Erzeugungsenergiefaktor: 1,
BegrenzungVariableID: 0,
Begrenzungsart: "percent"
}] : [];
const batteries = result.devices.battery.items.map((item, index) => ({
ID: "bat-" + (index + 1),
Name: item.name,
...item.topology,
WechselrichterID: item.topology.Kopplung === "ac" || !result.pvSystem.enabled ? "" : "wr-1",
LeistungVariableID: item.integration.sourceVariableId || item.properties.IstleistungVariableID || 0,
Leistungsfaktor: 1,
SOCVariableID: item.properties.LadezustandVariableID || 0,
LadeenergieVariableID: 0,
EntladeenergieVariableID: 0,
Energiefaktor: 1
}));
result.manager.properties.AnlagenWechselrichter = JSON.stringify(inverters);
result.manager.properties.AnlagenPVFlaechen = JSON.stringify(result.topology.pvSurfaces);
result.manager.properties.AnlagenBatterien = JSON.stringify(batteries);
if (result.name.length < 2 || result.location.length < 2) {
throw Object.assign(new Error("invalid_system_configuration"), { status: 400, publicMessage: "Bitte Systemname und Standort angeben." });
}
return result;
}
export function licenseCoverage(requested = {}, owned = {}) {
const coverage = { ...owned };
const spareConsumers = Math.max(0, Number(owned.consumer_single || 0) - Number(requested.consumer_single || 0));
coverage.heat_pump = Number(owned.heat_pump || 0) + Math.min(spareConsumers,
Math.max(0, Number(requested.heat_pump || 0) - Number(owned.heat_pump || 0)));
return coverage;
}
export function recommendLicenses(configuration) {
const modules = {};
for (const key of Object.keys(systemDeviceTypes)) modules[key] = configuration.devices[key].quantity;
return {
managerVariant: configuration.peakShaving ? "manager_peak" : "manager_standard",
modules,
costReport: configuration.costReport
};
}
export function publicWizardCatalog() {
return {
schemaVersion: 4,
integrations: integrationProfiles,
managerFields: managerWizardFields,
topologyFields,
consumerFields: consumerWizardFields,
evGatewayFields: evGatewayWizardFields,
deviceListFields,
evVariants: [
{ value: "standalone", label: "Direkt / Stand-alone" },
{ value: "gateway", label: "Easee über Gateway" }
],
deviceTypes: Object.fromEntries(Object.entries(systemDeviceTypes).map(([key, item]) => [key, { name: item.name }]))
};
}
const activationAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function createActivationCode() {
const bytes = randomBytes(16);
let body = "";
for (let index = 0; index < 16; index += 1) body += activationAlphabet[bytes[index] % activationAlphabet.length];
return "ENX-" + body.slice(0, 4) + "-" + body.slice(4, 8) + "-" + body.slice(8, 12) + "-" + body.slice(12, 16);
}
export function normalizeActivationCode(value) {
return String(value || "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
export function activationCodeValid(value) {
return /^ENX[A-HJ-NP-Z2-9]{16}$/.test(normalizeActivationCode(value));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Enelix EMS Portal für Systeme, Lizenzen und Prognosemanager">
<title>Enelix EMS Portal</title>
<link rel="stylesheet" href="/styles.css?v=20261006-heat-pump">
<link rel="stylesheet" href="/docs-portal.css?v=20261006">
</head>
<body>
<div id="toast" class="toast" role="status"></div>
<main id="authScreen" class="auth-screen">
<section class="auth-brand">
<div class="brand-lockup"><span class="brand-mark">E</span><span><strong>enelix</strong><small>EMS Portal</small></span></div>
<div class="auth-message"><p class="eyebrow">Energiemanagement</p><h1>Systeme planen.<br>Lizenzen verbinden.</h1><p>Ein Zugang für Anlagen, Prognosemanager und Lizenzierung.</p></div>
<div class="energy-lines" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</section>
<section class="auth-workspace">
<div class="auth-box">
<nav class="public-doc-links" aria-label="Öffentliche Informationen"><a href="/docs/index.html">Dokumentation</a><a href="/docs/faq.html">FAQ</a><a href="/docs/preise.html">Preise</a></nav>
<div class="auth-tabs" id="authTabs" role="tablist" aria-label="Anmeldung">
<button class="auth-tab active" id="loginTab" type="button">Anmelden</button>
<button class="auth-tab" id="registerTab" type="button">Konto erstellen</button>
</div>
<p class="auth-notice hidden" id="authNotice" role="status"></p>
<form id="loginForm" class="auth-form">
<div><p class="eyebrow">Willkommen zurück</p><h2>Am Portal anmelden</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<p class="form-error" id="loginError" role="alert"></p>
<button class="button primary wide" type="submit">Anmelden</button>
<button class="auth-link" id="forgotPasswordButton" type="button">Passwort vergessen?</button>
<button class="auth-link" id="resendVerificationButton" type="button">Bestätigungs-E-Mail erneut senden</button>
</form>
<form id="registerForm" class="auth-form hidden">
<div><p class="eyebrow">Offene Registrierung</p><h2>Neues Konto erstellen</h2></div>
<label>Name<input name="name" autocomplete="name" required minlength="2"></label>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label>
<p class="form-error" id="registerError" role="alert"></p>
<button class="button primary wide" type="submit">Konto erstellen</button>
</form>
<form id="forgotForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Passwort zurücksetzen</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" required></label>
<p class="form-error" id="forgotError" role="alert"></p>
<button class="button primary wide" type="submit">Link anfordern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
<form id="resetForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Neues Passwort</h2></div>
<label>Neues Passwort<input name="password" type="password" required minlength="10"></label>
<label>Passwort wiederholen<input name="password_repeat" type="password" required minlength="10"></label>
<p class="form-error" id="resetError" role="alert"></p>
<button class="button primary wide" type="submit">Passwort speichern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
</div>
</section>
</main>
<div id="portalApp" class="portal hidden">
<header class="topbar">
<a class="brand" href="#overview"><span class="brand-mark">E</span><span><strong>enelix</strong><small>EMS Portal</small></span></a>
<div class="top-actions"><nav class="header-links" aria-label="Dokumentation und Preise"><a href="/docs/index.html">Dokumentation</a><a href="/docs/preise.html">Preise</a></nav><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
</header>
<div class="app-shell">
<aside class="sidebar">
<nav>
<button class="nav-item active" data-view="overview">Lizenzen</button>
<button class="nav-item" data-view="forecast">Prognose</button>
<button class="nav-item" data-view="faults">Störüberwachung</button>
<button class="nav-item" data-view="account">Konto</button>
</nav>
<div class="workspace-switcher"><label>Organisation<select id="workspaceSelect"></select></label><small id="workspaceRole">Eigentümer</small></div>
</aside>
<main class="content">
<section class="view active" id="view-overview">
<div class="page-head"><div><p class="eyebrow" id="overviewOrg">Arbeitsbereich</p><h1>Lizenzübersicht</h1><p>Gekaufte Lizenzen, Verwendung und Managerbindung auf einen Blick.</p></div><button class="button primary" type="button" data-open-new-license>Neue Lizenz</button></div>
<div class="metrics"><div><span id="metricPlants">0</span><small>Lizenzpakete</small></div><div><span id="metricManagers">0</span><small>Bezahlte Lizenzen</small></div><div><span id="metricConnected">0</span><small>Verknüpfte Manager</small></div></div>
<section class="work-section"><div class="section-head"><div><h2>Lizenzen und Manager</h2><p>Status, Verwendung und verfügbare Erweiterungen.</p></div></div><div id="overviewPlants"></div></section>
</section>
<section class="view" id="view-licenses">
<div class="page-head"><div><p class="eyebrow">Kauf · CHF netto</p><h1>Lizenz erweitern</h1><p>Vorhandene Lizenzen prüfen, zusätzliche Module ergänzen und Peak Shaving bei Bedarf upgraden.</p></div><div class="page-actions"><button class="button secondary" type="button" data-configure-current>System konfigurieren</button><span class="status" id="stripeStatus">Stripe Sandbox</span></div></div>
<div id="moduleNoPlant" class="empty-state hidden"><strong>Noch keine Lizenz vorhanden</strong><p>Lege ein Lizenzpaket direkt an oder ermittle es mit dem Systemkonfigurator.</p><button class="button primary" data-open-new-license>Neue Lizenz</button></div>
<div id="moduleConfigurator" class="config-layout">
<section class="module-panel">
<label class="select-line">System<select id="orderPlant"></select></label>
<div class="manager-summary"><div><span class="field-label">Manager</span><strong id="managerCurrentName">Noch keine bezahlte Manager-Lizenz</strong><small id="ownedManager">Der Manager wird beim Erstkauf einmalig berechnet.</small></div><label id="peakUpgradeOption" class="upgrade-option hidden"><input id="peakUpgrade" type="checkbox"><span><strong>Auf Peak Shaving upgraden</strong><small>Einmalig nur die Preisdifferenz bezahlen.</small></span></label><span><b id="managerPrice">CHF 0.00</b><small id="managerSetup">Kein weiterer Managerkauf</small></span></div>
<div class="module-list">
<div class="module-header"><span>Verbrauchermodul</span><span>Lizenz</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="battery" data-name="Batteriespeicher" data-price="41.95" data-setup="54.55"><span><input type="checkbox" class="module-check"><strong>Batteriespeicher</strong><small>Batterie</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 41.95</span><span>CHF 54.55</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="buffer_storage" data-name="Pufferspeicher" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Pufferspeicher</strong><small>VerbraucherPufferspeicher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="ev_charger" data-name="EV-Ladestation" data-price="33.65" data-setup="43.75"><span><input type="checkbox" class="module-check"><strong>EV-Ladestation</strong><small>Stand-Alone oder Gateway</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 33.65</span><span>CHF 43.75</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>Ein schaltbarer Verbraucher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="heat_pump" data-name="Wärmepumpe" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Wärmepumpe</strong><small>Sperre / Erhöhung oder SG Ready</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="boiler_multi" data-name="Boiler mehrstufig" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Boiler mehrstufig</strong><small>Warmwassererwärmer</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<div class="module-header"><span>Prognoselizenz</span><span>Jahrespreis</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="forecast_pv" data-name="PV-Ertragsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>PV-Ertragsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="forecast_load" data-name="Verbrauchsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Verbrauchsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="grid_schedule" data-name="Intelligenter Netzfahrplan" data-price="50" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Intelligenter Netzfahrplan</strong><small>Jahreslizenz · Manager mit Peak Shaving erforderlich</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 50.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
</div>
</section>
<aside class="order-panel"><p class="eyebrow">Bestellung</p><div id="orderItems" class="order-items"></div><dl><div><dt>Lizenzen netto</dt><dd id="moduleTotal">CHF 0.00</dd></div><div id="setupTotalRow" class="hidden"><dt>Generator / Ersteinrichtung</dt><dd id="setupTotal">CHF 0.00</dd></div><div><dt>Gesamt netto</dt><dd id="netTotal">CHF 0.00</dd></div><div><dt id="vatLabel">MwSt. 8.1 %</dt><dd id="vatTotal">CHF 0.00</dd></div><div class="grand-total"><dt>Gesamt inkl. MwSt.</dt><dd id="grandTotal">CHF 0.00</dd></div></dl><button class="button primary wide" id="checkoutButton" type="button">Mit Stripe bezahlen</button><p class="form-error" id="checkoutError"></p></aside>
</div>
</section>
<section class="view" id="view-configurator">
<div class="page-head"><div><p class="eyebrow">Planung</p><h1>System konfigurieren</h1><p>Geräte erfassen und passende Lizenzen ermitteln.</p></div><span class="status available">Enelix EMS</span></div>
<form id="systemForm" class="system-configurator">
<section class="config-form wizard">
<header class="wizard-header">
<div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 10</p><h2 id="wizardStepTitle">Anlage</h2></div>
<ol class="wizard-progress" aria-label="Fortschritt">
<li class="active" data-wizard-marker="0">Anlage</li><li data-wizard-marker="1">Energiequellen</li>
<li data-wizard-marker="2">Batterie</li><li data-wizard-marker="3">Pufferspeicher</li>
<li data-wizard-marker="4">Ladestation</li><li data-wizard-marker="5">Verbraucher</li>
<li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Wärmepumpe</li>
<li data-wizard-marker="8">Abrechnung</li><li data-wizard-marker="9">Prüfen</li>
</ol>
</header>
<section class="wizard-step active" data-wizard-step="0">
<p class="wizard-question">Wie heisst die Anlage und wo befindet sie sich?</p>
<div class="field-grid"><label>Systemname<input name="name" required minlength="2" placeholder="z. B. Werk Zürich"></label><label>Standort<input name="location" required minlength="2" placeholder="Ort, Schweiz"></label><label>Anlagentyp<select name="siteType"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div>
</section>
<section class="wizard-step" data-wizard-step="1">
<p class="wizard-question">Wie soll der Manager arbeiten und über welche Schnittstellen werden Energiedaten gelesen?</p>
<label class="setup-option"><input name="peakShaving" type="checkbox"><span><strong>Peak Shaving erforderlich</strong><small>Wählt die Manager-Variante mit Lastspitzenregelung.</small></span></label>
<div class="configuration-band">
<h3>Netzmessung</h3>
<div class="field-grid">
<label>Geräteprofil<select name="gridProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="gridHost" maxlength="120" placeholder="z. B. 192.168.1.40"></label>
<label>Port<input name="gridPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="gridUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="grid">Bestehende Leistungsvariable<input name="gridSourceVariableId" type="number" min="0" value="0"></label>
<label>Hinweis<textarea name="gridNotes" rows="3" maxlength="300"></textarea></label>
</div>
</div>
<label class="setup-option"><input name="pvEnabled" type="checkbox"><span><strong>PV-Anlage einlesen</strong><small>Herstellerprofil und lokale Schnittstelle für die Erzeugungsmessung verwenden.</small></span></label>
<div class="configuration-band">
<h3>PV-Wechselrichter</h3>
<div class="field-grid">
<label>Bezeichnung<input name="pvName" maxlength="80" value="PV-Wechselrichter"></label>
<label>AC-Nennleistung (kW)<input name="pvAcPowerKw" type="number" min="0" step="0.1" value="0"></label>
<label>Geräteprofil<select name="pvProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="pvHost" maxlength="120"></label>
<label>Port<input name="pvPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="pvUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="pv">Bestehende Leistungsvariable<input name="pvSourceVariableId" type="number" min="0" value="0"></label>
</div>
</div>
<div class="configuration-band" data-pv-topology>
<div class="structured-editor-head"><div><h3>PV-Flächen</h3><p>Dachflächen und Ausrichtung ohne JSON erfassen.</p></div></div>
<div class="quantity-line"><label>Anzahl PV-Flächen<input name="pvSurfaceQuantity" data-topology-quantity="pvSurfaces" type="number" min="0" max="50" value="0"></label><small>Bei aktivierter PV wird mindestens eine Fläche angelegt.</small></div>
<div class="device-editor" data-topology-editor="pvSurfaces"></div>
</div>
<details class="config-details"><summary>Optionale Manager-Funktionen</summary><p>Nur betriebliche Optionen. Technische Zuordnungen, Prognoseanschlüsse, Archivierung und Aufbewahrung werden automatisch gesetzt.</p><div id="managerConfigurationFields" class="schema-groups"></div></details>
</section>
<section class="wizard-step" data-wizard-step="2">
<p class="wizard-question">Welche Batteriespeicher soll der Manager steuern?</p>
<div class="quantity-line"><label>Batteriespeicher<input name="batteryQuantity" data-device-quantity="battery" type="number" min="0" max="50" value="0"></label><small>Geräteprofil, Leistungsdaten und Grenzen genügen; Typ und Variablen werden automatisch zugeordnet.</small></div>
<div class="device-editor" data-device-editor="battery"></div>
</section>
<section class="wizard-step" data-wizard-step="3">
<p class="wizard-question">Welche Pufferspeicher sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Pufferspeicher<input name="bufferQuantity" data-device-quantity="buffer_storage" type="number" min="0" max="50" value="0"></label><small>Fühler, Heizkurve und Leistungsstufen werden je Pufferspeicher erfasst.</small></div>
<div class="device-editor" data-device-editor="buffer_storage"></div>
</section>
<section class="wizard-step" data-wizard-step="4">
<p class="wizard-question">Welche EV-Ladestationen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="evQuantity" data-device-quantity="ev_charger" type="number" min="0" max="50" value="0"></label><small>Je Station kann zwischen direkter Anbindung und Easee Gateway gewählt werden.</small></div>
<div class="device-editor" data-device-editor="ev_charger"></div>
</section>
<section class="wizard-step" data-wizard-step="5">
<p class="wizard-question">Welche einstufigen Verbraucher sollen berücksichtigt werden?</p>
<div class="quantity-line"><label>Anzahl<input name="consumerQuantity" data-device-quantity="consumer_single" type="number" min="0" max="50" value="0"></label><small>Bei Shelly-Profilen werden Schaltkontakt und Rückmeldung automatisch verbunden.</small></div>
<div class="device-editor" data-device-editor="consumer_single"></div>
</section>
<section class="wizard-step" data-wizard-step="6">
<p class="wizard-question">Gibt es mehrstufige Boiler oder Warmwassererwärmer?</p>
<div class="quantity-line"><label>Anzahl<input name="boilerQuantity" data-device-quantity="boiler_multi" type="number" min="0" max="50" value="0"></label><small>Leistungsstufen, Zeitplan und Temperaturfühler werden als Listen konfiguriert.</small></div>
<div class="device-editor" data-device-editor="boiler_multi"></div>
</section>
<section class="wizard-step" data-wizard-step="7">
<p class="wizard-question">Welche Wärmepumpen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="heatPumpQuantity" data-device-quantity="heat_pump" type="number" min="0" max="50" value="0"></label></div>
<div class="device-editor" data-device-editor="heat_pump"></div>
</section>
<section class="wizard-step" data-wizard-step="8">
<p class="wizard-question">Soll im selben Symcon-System ein Verbrauchskostenreport eingerichtet werden?</p>
<label class="setup-option"><input name="costReportEnabled" type="checkbox"><span><strong>Verbrauchskostenreport hinzufügen</strong><small>Wird als eigene Instanz eingerichtet und verwendet einen separaten Lizenzcode.</small></span></label>
<div class="field-grid"><label>Stromzähler<input name="costPowerMeters" type="number" min="0" max="999" value="0"></label><label>Wärme- oder Wasserzähler<input name="costAuxMeters" type="number" min="0" max="999" value="0"></label></div>
</section>
<section class="wizard-step" data-wizard-step="9">
<p class="wizard-question">Bitte prüfe die Angaben vor dem Speichern.</p>
<div id="wizardReview" class="wizard-review"></div>
</section>
<p class="form-error" id="systemError"></p>
<footer class="wizard-actions"><button class="button secondary" id="wizardBack" type="button">Zurück</button><button class="button primary" id="wizardNext" type="button">Weiter</button><button class="button primary hidden" id="wizardSubmit" type="submit">Konfiguration abschliessen</button></footer>
</section>
<aside class="recommendation-panel"><p class="eyebrow">Aktueller Vorschlag</p><h2 id="recommendManager">Manager ohne Peak Shaving</h2><div id="recommendModules" class="recommend-list"></div><p>Der Vorschlag enthält den Manager und alle gewählten Verbrauchermodule.</p><div id="packageResult" class="package-result hidden"><strong>Einrichtungspaket bereit</strong><a class="button secondary wide" id="packageDownload" href="#">Symcon-Paket herunterladen</a><button class="button primary wide" id="orderRecommendation" type="button">Lizenzen bestellen</button></div></aside>
</form>
</section>
<section class="view" id="view-forecast">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Prognose</h1><p>Messwerte und berechnete Verläufe des lizenzierten Prognosemanagers.</p></div><span class="status available">Nur Anzeige</span></div>
<div id="forecastEmpty" class="empty-state hidden"><strong>Kein System verfügbar</strong></div>
<div id="forecastContent">
<section class="forecast-toolbar">
<label>System<select id="forecastPlant"></select></label>
<div class="connection-state"><span class="signal"></span><div><strong id="forecastConnection">Noch nicht verknüpft</strong><p id="forecastApiStatus">Status wird geprüft</p></div></div>
</section>
<div class="forecast-metrics">
<div><span id="forecastLastUpdated">–</span><small>Aktualisiert</small></div>
<div><span id="forecastSeriesCount">0</span><small>Datenreihen</small></div>
<div><span id="forecastWindowValue">72 h</span><small>Zeitfenster</small></div>
</div>
<section class="forecast-chart-section">
<div class="section-head"><div><h2>Leistung und Prognose</h2><p>Messwerte und Prognosen gemeinsam vergleichen. Reihen können einzeln ein- und ausgeblendet werden.</p></div></div>
<div class="forecast-series-panel">
<strong>Kurven auswählen</strong>
<div id="forecastSeriesControls" class="forecast-series-controls"></div>
</div>
<div class="forecast-chart-controls">
<label>Historie
<select id="forecastHistoryDays">
<option value="1" selected>1 Tag</option>
<option value="7">7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
<option value="90">90 Tage</option>
<option value="180">180 Tage</option>
</select>
</label>
<label>Von<input id="forecastRangeStart" type="datetime-local"></label>
<label>Bis<input id="forecastRangeEnd" type="datetime-local"></label>
<button id="forecastRangeReset" class="button secondary compact" type="button">Gesamtes Zeitfenster</button>
</div>
<div id="forecastState" class="forecast-state" role="status">Prognosedaten werden geladen.</div>
<div id="forecastLegend" class="forecast-chart-legend hidden" aria-label="Legende der angezeigten Kurven"></div>
<div class="forecast-chart-wrap">
<svg id="forecastChart" class="forecast-chart hidden" viewBox="0 0 960 320" preserveAspectRatio="none" role="img" aria-label="Zeitverlauf der Prognosedaten"></svg>
<div id="forecastTooltip" class="forecast-tooltip hidden"></div>
</div>
</section>
<section class="forecast-quality-section">
<div class="section-head"><div><h2>Prognosequalität</h2><p>Vergleich der Modellprognosen mit den tatsächlich gemessenen Werten.</p></div><span id="forecastQualityStatus" class="status review">Aufbauphase</span></div>
<div id="forecastQualityState" class="forecast-quality-state" role="status">Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.</div>
<div id="forecastQualityTable" class="table-wrap hidden">
<table>
<thead><tr><th>Modell</th><th>Ziel</th><th>R²</th><th>MAE</th><th>RMSE</th><th>Vergleiche</th></tr></thead>
<tbody id="forecastQualityBody"></tbody>
</table>
</div>
</section>
<section id="forecastConfigSection" class="forecast-config-section hidden">
<div class="section-head">
<div><h2>Prognosekonfiguration</h2><p>Anlagenparameter für Berechnung und Tarife.</p></div>
<span id="forecastConfigStatus" class="status review">Nicht gespeichert</span>
</div>
<form id="forecastConfigForm">
<div class="forecast-config-grid">
<label>Standort<input name="standort" maxlength="120" required></label>
<label>Kanton<select name="kanton"><option>AG</option><option>AI</option><option>AR</option><option>BE</option><option>BL</option><option>BS</option><option>FR</option><option>GE</option><option>GL</option><option>GR</option><option>JU</option><option>LU</option><option>NE</option><option>NW</option><option>OW</option><option>SG</option><option>SH</option><option>SO</option><option>SZ</option><option>TG</option><option>TI</option><option>UR</option><option>VD</option><option>VS</option><option>ZG</option><option>ZH</option></select></label>
<label>PV AC-Leistung kW<input name="ac_leistung" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batteriekapazität kWh<input name="batt_capacity_kwh" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batterieleistung kW<input name="batt_power_kw" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Peakpreis CHF/kW<input name="tarif_peak_fest" type="number" min="0" max="100000" step="0.01" required></label>
<label>Bezug Tarifart<select name="tarif_bezug_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Bezugstarif<select name="tarif_bezug"></select></label>
<label data-tariff-custom="import">Bezugspreis CHF/kWh<input name="tarif_bezug_fest" type="number" min="0" max="100" step="0.001" required></label>
<label>Einspeisung Tarifart<select name="tarif_einspeisung_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Einspeisetarif<select name="tarif_einspeisung"></select></label>
<label data-tariff-custom="export">Einspeisevergütung CHF/kWh<input name="tarif_einspeisung_fest" type="number" min="0" max="100" step="0.001" required></label>
</div>
<div id="forecastTopologyBlock" class="forecast-config-block hidden">
<div class="config-block-head"><div><h3>Technische Anlagendaten</h3><p>Schreibgeschützt aus dem verbundenen Enelix Manager.</p></div><span class="status available">Manager</span></div>
<div id="forecastTopology" class="forecast-topology"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>PV-Dächer</h3><p>Leistung, Neigung und Azimut je Dachfläche.</p></div><button id="forecastAddRoof" class="button secondary compact" type="button">Dach hinzufügen</button></div>
<div id="forecastRoofs" class="forecast-roofs"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>Prognoseumfang</h3><p>Funktionen für diese Anlage. Der Netzfahrplan schliesst PV- und Verbrauchsprognose ein.</p></div></div>
<div class="forecast-variants">
<label><input type="checkbox" name="forecast_pv">PV-Ertragsprognose</label>
<label><input type="checkbox" name="forecast_load">Verbrauchsprognose</label>
<label><input type="checkbox" name="grid_schedule">Intelligenter Netzfahrplan</label>
</div>
</div>
<p id="forecastConfigError" class="form-error" role="alert"></p>
<div class="forecast-config-actions"><button class="button primary" type="submit">Konfiguration speichern</button></div>
</form>
</section>
<div class="forecast-layout">
<section class="manager-detail"><p class="eyebrow">Lizenzierte Variante</p><h2 id="forecastVariant">Manager</h2><div class="license-validity"><span>Lizenzdauer</span><strong>Unbeschränkt gültig</strong></div><div id="forecastOfflineWarning" class="offline-license-warning hidden" role="status"><strong id="forecastOfflineTitle">Keine Verbindung zum Lizenzserver</strong><p id="forecastOfflineText"></p></div></section>
<section class="forecast-scope"><p class="eyebrow">Datenumfang</p><h2>Live-Prognose</h2><dl class="detail-list"><div><dt>Modus</dt><dd>Konfiguration und Anzeige</dd></div><div><dt>Simulation</dt><dd>Nicht enthalten</dd></div><div><dt>Zugriff</dt><dd>Lizenzgebunden</dd></div></dl></section>
</div>
</div>
</section>
<section class="view" id="view-faults">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Störüberwachung</h1><p>Aktive und behobene Störungen der verbundenen Enelix Manager und Geräte.</p></div><span class="status neutral" id="faultConnectionStatus">Status wird geprüft</span></div>
<div id="faultEmpty" class="empty-state hidden"><strong>Kein Manager-System verfügbar</strong></div>
<div id="faultContent">
<section class="fault-toolbar">
<label>System<select id="faultPlant"></select></label>
<div class="connection-state"><span class="signal" id="faultSignal"></span><div><strong id="faultConnection">Noch nicht verknüpft</strong><p id="faultApiStatus">Status wird geprüft</p></div></div>
</section>
<div class="fault-metrics">
<div><span id="faultActiveCount">0</span><small>Aktive Störungen</small></div>
<div><span id="faultUrgentCount">0</span><small>Kritisch oder Fehler</small></div>
<div><span id="faultLastUpdated">–</span><small>Letzter Empfang</small></div>
</div>
<section class="fault-section">
<div class="section-head"><div><h2>Aktive Störungen</h2><p>Aktueller Vollsnapshot des Managers.</p></div></div>
<div id="faultState" class="fault-state" role="status">Störungsdaten werden geladen.</div>
<div id="faultActiveTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Seit</th></tr></thead><tbody id="faultActiveBody"></tbody></table></div>
</section>
<section class="fault-section">
<div class="section-head"><div><h2>Behobene Störungen</h2><p>Die letzten 100 abgeschlossenen Ereignisse.</p></div></div>
<div id="faultHistoryState" class="fault-state">Noch keine behobenen Störungen.</div>
<div id="faultHistoryTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Behoben</th></tr></thead><tbody id="faultHistoryBody"></tbody></table></div>
</section>
<section class="fault-section fault-notification-section">
<div class="section-head"><div><h2>E-Mail-Benachrichtigungen</h2><p>Empfänger für neue und behobene Störungen dieser Anlage.</p></div></div>
<form id="faultRecipientForm" class="fault-recipient-form">
<label>E-Mail-Adresse<input id="faultRecipientEmail" name="email" type="email" maxlength="254" autocomplete="email" placeholder="technik@beispiel.ch" required></label>
<button class="button secondary" type="submit">Hinzufügen</button>
</form>
<p id="faultRecipientError" class="form-error" role="alert"></p>
<div id="faultRecipientList" class="fault-recipient-list"></div>
<p id="faultRecipientStatus" class="fault-recipient-status">Empfänger werden geladen.</p>
</section>
</div>
</section>
<section class="view" id="view-account">
<div class="page-head"><div><p class="eyebrow">Einstellungen</p><h1>Konto und Abrechnung</h1><p>Zugang, Organisation, Personen und Zahlungsverlauf.</p></div><button class="button secondary" id="logoutButton" type="button">Abmelden</button></div>
<div class="account-layout">
<section><h2>Benutzerkonto</h2><dl class="detail-list"><div><dt>Name</dt><dd id="accountName">–</dd></div><div><dt>E-Mail</dt><dd id="accountEmail">–</dd></div><div><dt>Arbeitsbereich</dt><dd id="accountOrg">–</dd></div></dl><label id="accountWorkspaceLabel">Organisation<select id="accountWorkspaceSelect"></select></label></section>
<section id="organizationCreateSection"><h2>Organisation anlegen</h2><form id="organizationForm" class="compact-form"><label>Name<input name="name" required minlength="2"></label><p class="form-error" id="organizationError"></p><button class="button secondary" type="submit">Organisation erstellen</button></form></section>
<section id="invitationSection" class="hidden"><h2>Person einladen</h2><form id="invitationForm" class="compact-form"><label>E-Mail-Adresse<input name="email" type="email" required></label><label>Rolle<select name="role"><option value="operator">Operator</option><option value="admin">Administrator</option><option value="billing">Abrechnung</option><option value="viewer">Lesend</option></select></label><p class="form-error" id="invitationError"></p><button class="button secondary" type="submit">Einladung senden</button></form><div id="pendingInvitations" class="pending-list"></div></section>
<section><h2>Passwort ändern</h2><form id="passwordForm" class="compact-form"><label>Aktuelles Passwort<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>Neues Passwort<input name="newPassword" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label><label>Neues Passwort wiederholen<input name="newPasswordRepeat" type="password" autocomplete="new-password" required minlength="10"></label><p class="form-error" id="passwordError"></p><button class="button secondary" type="submit">Passwort speichern</button></form></section>
</div>
<section class="work-section billing-orders"><div class="section-head"><div><h2>Abrechnung</h2><p>Zahlungen, Lizenzcodes und PDF-Belege der aktuellen Organisation.</p></div><div class="billing-summary"><span class="status review" id="billingStripeStatus">Stripe Sandbox</span><span id="billingOrg">–</span><span id="stripeProviderState">Prüfung</span></div></div><div class="table-wrap"><table><thead><tr><th>Datum</th><th>System</th><th>Lizenzen</th><th>Gesamt inkl. MwSt.</th><th>Status</th><th>Lizenzcode</th><th>Beleg</th></tr></thead><tbody id="ordersBody"></tbody></table></div></section>
</section>
</main>
</div>
</div>
<dialog id="newLicenseDialog" class="decision-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Neue Lizenz</p><h2>Wie möchtest du starten?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="purchase-paths"><button type="button" data-open-plant><span><strong>Lizenz direkt kaufen</strong><small>Lizenzpaket anlegen und benötigte Module auswählen.</small></span><b>Weiter</b></button><button type="button" data-goto="configurator"><span><strong>System mit Manager konfigurieren</strong><small>Geräte erfassen und passende Lizenzen ermitteln.</small></span><b>Konfigurieren</b></button><button type="button" data-open-billing><span><strong>Abrechnungslizenz lösen</strong><small>Abrechnungsmanager und benötigte Zähler lizenzieren.</small></span><b>Auswählen</b></button></div></div></dialog>
<dialog id="plantDialog"><form id="plantForm"><div class="dialog-head"><div><p class="eyebrow">Direkter Kauf</p><h2>Neue Lizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Werk Zürich"></label><label>Typ<select name="type"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label><fieldset><legend>Prognosemanager</legend><label class="choice-row"><input type="radio" name="manager" value="manager_standard" checked><span><strong>Ohne Peak Shaving</strong><small id="plantManagerStandardPrice">CHF 308.15 netto</small></span></label><label class="choice-row"><input type="radio" name="manager" value="manager_peak"><span><strong>Mit Peak Shaving</strong><small id="plantManagerPeakPrice">CHF 329.05 netto</small></span></label></fieldset><p class="form-error" id="plantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zur Lizenzwahl</button></div></form></dialog>
<dialog id="billingDialog"><form id="billingForm"><div class="dialog-head"><div><p class="eyebrow">Abrechnung</p><h2>Abrechnungslizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div id="billingIdentityFields"><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Abrechnung Werk Zürich"></label><label>Typ<input name="type" value="Abrechnung" readonly></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label></div><fieldset><legend>Zählerlizenzen</legend><div class="field-pair"><label>Stromzähler<input name="powerMeters" type="number" min="0" max="999" value="0" required><small id="billingPowerPrice">CHF 5.00 netto pro Zähler</small></label><label>Wärme- oder Wasserzähler<input name="auxMeters" type="number" min="0" max="999" value="0" required><small id="billingAuxPrice">CHF 3.00 netto pro Zähler</small></label></div></fieldset><p class="billing-base-price">Grundlizenz: <strong id="billingBasePrice">CHF 245.15 netto</strong>. Zuzüglich MwSt.</p><p class="form-error" id="billingError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zu Stripe</button></div></form></dialog>
<dialog id="replaceCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode neu generieren</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Achtung: Der aktuelle Lizenzcode wird ungültig.</strong><p>Für <b id="replaceCodeSystem">dieses System</b> wird ein neuer Code erstellt und ausschließlich als PDF an die Konto-E-Mail-Adresse gesendet.</p><p id="replaceCodeBinding" class="hidden">Die bisherige Installationsbindung wird gelöst. Der Ersatzcode kann anschließend einmalig mit einer neuen Symcon-Installation verbunden werden.</p></div><p class="form-error" id="replaceCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmReplaceCode" type="button">Alten Code ersetzen</button></div></div></dialog>
<dialog id="deleteCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Sind Sie sicher?</strong><p>Der aktuelle Lizenzcode für <b id="deleteCodeSystem">dieses System</b> wird sofort und unwiderruflich ungültig.</p><p>Die bezahlte Lizenz und eine bestehende Installationsbindung bleiben erhalten. Ein neuer Code kann später wieder per E-Mail erzeugt werden.</p></div><p class="form-error" id="deleteCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeleteCode" type="button">Lizenzcode löschen</button></div></div></dialog>
<dialog id="deletePlantDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Anlage löschen</p><h2>Sind Sie sicher?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Die Anlage wird dauerhaft entfernt.</strong><p>Möchten Sie <b id="deletePlantSystem">diese Anlage</b> wirklich löschen? Konfigurationen und noch nicht bezahlte Bestellungen dieser Anlage werden ebenfalls entfernt.</p></div><p class="form-error" id="deletePlantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePlant" type="button">Anlage löschen</button></div></div></dialog>
<dialog id="deletePaidDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Zweite Bestätigung</p><h2>Auch Lizenz löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning critical-warning"><strong>Achtung: Für diese Anlage besteht eine bezahlte Lizenz.</strong><p>Sollen <b id="deletePaidSystem">die Anlage</b> und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p></div><p class="form-error" id="deletePaidError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePaid" type="button">Anlage und Lizenz löschen</button></div></div></dialog>
<script src="/app.js?v=20261006-heat-pump"></script>
</body>
</html>
@@ -0,0 +1,853 @@
:root {
--ink: #172124;
--muted: #667579;
--line: #d8e0de;
--surface: #ffffff;
--canvas: #f2f5f4;
--petrol: #2f6b66;
--petrol-dark: #1f4d49;
--lime: #d5df38;
--blue: #386b91;
--amber: #8d6518;
--danger: #9a3939;
--radius: 6px;
color: var(--ink);
background: var(--canvas);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--canvas); }
body { margin: 0; min-height: 100vh; background: var(--canvas); }
button, input, select, textarea { font: inherit; letter-spacing: 0; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 7px; font-size: clamp(1.8rem, 3vw, 2.65rem); line-height: 1.06; letter-spacing: 0; }
h2 { margin-bottom: 6px; font-size: 1.08rem; letter-spacing: 0; }
p { color: var(--muted); line-height: 1.55; }
.hidden { display: none !important; }
.brand { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
.brand-mark { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 4px; background: var(--lime); color: #203031; font-weight: 850; font-size: 1.25rem; }
.brand > span:last-child { display: grid; line-height: 1; }
.brand strong { font-size: 1.28rem; font-weight: 800; }
.brand small { margin-top: 5px; color: var(--muted); font-size: .68rem; text-transform: uppercase; letter-spacing: .09em; }
.brand-light { color: #fff; }
.brand-light small { color: #b7ccca; }
.auth-screen { display: grid; min-height: 100svh; grid-template-columns: minmax(360px, .92fr) minmax(480px, 1.08fr); background: var(--surface); }
.auth-brand { position: relative; display: flex; flex-direction: column; justify-content: space-between; min-height: 100svh; overflow: hidden; padding: 44px clamp(34px, 5vw, 76px); background: #173f3c; color: #fff; }
.auth-brand::after { position: absolute; inset: auto -15% -25% 25%; height: 55%; border: 1px solid rgba(213, 223, 56, .24); transform: skewY(-12deg); content: ""; }
.auth-message { position: relative; z-index: 1; max-width: 560px; padding: 70px 0; }
.auth-message h1 { max-width: 520px; font-size: clamp(2.25rem, 4.5vw, 4.6rem); line-height: .98; }
.auth-message > p:last-child { max-width: 510px; color: #c2d2d0; font-size: 1.04rem; }
.eyebrow { margin-bottom: 9px; color: var(--petrol); font-size: .7rem; font-weight: 800; text-transform: uppercase; letter-spacing: .11em; }
.auth-brand .eyebrow { color: var(--lime); }
.energy-lines { position: absolute; inset: auto 0 10% 0; display: grid; gap: 18px; opacity: .38; transform: rotate(-8deg); }
.energy-lines i { display: block; width: 76%; height: 1px; background: rgba(213,223,56,.78); }
.energy-lines i:nth-child(2) { width: 91%; transform: translateX(8%); }
.energy-lines i:nth-child(3) { width: 68%; transform: translateX(-12%); }
.energy-lines i:nth-child(4) { width: 82%; transform: translateX(18%); }
.auth-workspace { display: grid; min-height: 100svh; place-items: center; padding: 36px; }
.auth-box { width: min(480px, 100%); }
.auth-tabs { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 38px; border-bottom: 1px solid var(--line); }
.auth-tab { padding: 13px 8px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: var(--muted); font-weight: 750; }
.auth-tab.active { border-color: var(--petrol); color: var(--ink); }
.auth-form { display: grid; gap: 18px; }
.auth-form h2 { font-size: 1.65rem; }
.field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
label { display: grid; gap: 7px; color: #3e4b4e; font-size: .82rem; font-weight: 700; }
label small { color: var(--muted); font-weight: 450; }
input, select, textarea { width: 100%; min-width: 0; border: 1px solid #c9d3d1; border-radius: 5px; outline: 0; background: #fff; color: var(--ink); padding: 0 12px; }
input, select { height: 43px; }
textarea { min-height: 72px; resize: vertical; padding-top: 10px; }
input:focus, select:focus, textarea:focus { border-color: var(--petrol); box-shadow: 0 0 0 3px rgba(47, 107, 102, .12); }
.form-error { min-height: 20px; margin: -5px 0 0; color: var(--danger); font-size: .82rem; }
.legal-copy { margin: 0; font-size: .76rem; }
.auth-link { justify-self: center; border: 0; background: transparent; color: var(--petrol); padding: 0; font-size: .79rem; font-weight: 750; }
.auth-notice { border-left: 3px solid var(--lime); background: #eef5f3; color: #274a47; margin: 0 0 20px; padding: 12px 14px; font-size: .82rem; }
.auth-notice.error { border-left-color: var(--danger); background: #fff0f0; color: var(--danger); }
.button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 5px; padding: 9px 15px; font-size: .84rem; font-weight: 760; transition: background .16s ease, border-color .16s ease, transform .16s ease; }
.button:hover { transform: translateY(-1px); }
.button.primary { background: var(--petrol); color: #fff; }
.button.primary:hover { background: var(--petrol-dark); }
.button.secondary { border-color: #cbd6d3; background: #fff; color: var(--ink); }
.button.danger { background: var(--danger); color: #fff; }
.button.danger:hover { background: #7f2f2f; }
.button.wide { width: 100%; }
.button:disabled { cursor: not-allowed; opacity: .45; transform: none; }
.portal { min-height: 100vh; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; height: 68px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); padding: 0 28px; }
.top-actions { display: flex; align-items: center; gap: 13px; }
.header-links { display: flex; align-items: center; gap: 24px; margin-right: 16px; }
.header-links a { color: var(--petrol); font-size: .88rem; font-weight: 700; text-decoration: none; padding: 10px 0; white-space: nowrap; }
.header-links a:hover { text-decoration: underline; text-underline-offset: 5px; }
.header-links a:focus-visible { outline: 2px solid var(--petrol); outline-offset: 4px; }
.environment, .status { display: inline-flex; align-items: center; border-radius: 999px; white-space: nowrap; font-size: .7rem; font-weight: 800; }
.environment { background: #edf2f1; color: #566568; padding: 5px 9px; text-transform: uppercase; letter-spacing: .07em; }
.account-button { display: flex; align-items: center; gap: 8px; border: 0; background: transparent; color: var(--ink); }
.account-button span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 50%; background: var(--petrol); color: #fff; font-size: .72rem; }
.account-button strong { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .82rem; }
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-height: calc(100vh - 68px); }
.sidebar { display: flex; flex-direction: column; justify-content: space-between; border-right: 1px solid var(--line); background: #e9eeec; padding: 24px 14px; }
.sidebar nav { display: grid; gap: 3px; }
.nav-item { min-height: 42px; border: 0; border-left: 3px solid transparent; border-radius: 3px; background: transparent; color: #47565a; padding: 0 15px; text-align: left; font-size: .84rem; font-weight: 680; }
.nav-item:hover { background: #dde6e3; }
.nav-item.active { border-left-color: var(--lime); background: #fff; color: var(--ink); }
.workspace-switcher { display: grid; gap: 7px; padding: 16px; border-top: 1px solid #cfd9d6; }
.workspace-switcher label { gap: 5px; font-size: .68rem; text-transform: uppercase; }
.workspace-switcher select { height: 38px; background: #fff; font-size: .76rem; text-transform: none; }
.workspace-switcher small { color: var(--muted); font-size: .7rem; }
.content { min-width: 0; padding: 38px clamp(22px, 4vw, 64px) 68px; }
.view { display: none; max-width: 1320px; margin: 0 auto; animation: enter .2s ease both; }
.view.active { display: block; }
@keyframes enter { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
.page-head { display: flex; min-height: 92px; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
.page-head p { margin-bottom: 0; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 34px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--surface); }
.metrics > div { display: grid; gap: 4px; min-width: 0; padding: 22px 24px; border-right: 1px solid var(--line); }
.metrics > div:last-child { border-right: 0; }
.metrics span { overflow-wrap: anywhere; font-size: 1.38rem; font-weight: 800; }
.metrics small { color: var(--muted); font-size: .72rem; }
.work-section { margin-bottom: 34px; }
.section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 13px; }
.section-head p { margin: 0; font-size: .84rem; }
.text-action { border: 0; background: transparent; color: var(--petrol); padding: 4px; font-weight: 750; }
.plant-lines { border-top: 1px solid var(--line); }
.plant-line { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(140px, .8fr) minmax(150px, .8fr) auto; align-items: center; gap: 18px; min-height: 76px; border-bottom: 1px solid var(--line); background: #fff; padding: 13px 17px; }
.plant-line > span { display: grid; gap: 4px; min-width: 0; }
.plant-line small { color: var(--muted); font-size: .74rem; }
.plant-line strong { overflow-wrap: anywhere; }
.connection-band { display: grid; grid-template-columns: minmax(260px, .85fr) minmax(330px, 1.15fr); gap: 50px; align-items: center; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 33px 0; }
.connection-band p { margin-bottom: 0; }
.flow-track { display: flex; align-items: center; }
.flow-track span { flex: 0 0 auto; border: 1px solid #bdcac7; background: #fff; padding: 12px 15px; font-size: .78rem; font-weight: 800; }
.flow-track i { min-width: 22px; flex: 1; height: 1px; background: var(--petrol); }
.table-wrap { overflow-x: auto; border-top: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; background: #fff; }
th { background: #edf1f0; color: #5d6c6e; text-align: left; text-transform: uppercase; letter-spacing: .06em; font-size: .68rem; }
th, td { border-bottom: 1px solid var(--line); padding: 14px 16px; vertical-align: middle; }
td { font-size: .83rem; }
td > strong, td > small { display: block; }
td > small { margin-top: 4px; color: var(--muted); }
.status { padding: 5px 9px; }
.status.available { background: #e5f2e8; color: #31643d; }
.status.review { background: #fff1d8; color: var(--amber); }
.status.neutral { border: 1px solid #cdd7d5; background: #fff; color: #59676a; }
.empty-state { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fff; padding: 42px 24px; text-align: center; }
.empty-state strong { display: block; font-size: 1.05rem; }
.empty-state p { margin: 7px auto 18px; }
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 26px; align-items: start; }
.select-line { max-width: 430px; margin-bottom: 22px; }
.manager-summary { margin-bottom: 25px; border-left: 4px solid var(--lime); background: #e8efed; padding: 20px 22px; }
.manager-summary { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.manager-summary > div, .manager-summary > span { display: grid; gap: 5px; }
.manager-summary > .upgrade-option { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 10px; align-items: center; min-width: 280px; color: var(--petrol); }
.upgrade-option input { width: 18px; height: 18px; accent-color: var(--petrol); }
.upgrade-option span { display: grid; gap: 3px; }
.field-label { color: var(--muted); font-size: .72rem; font-weight: 800; text-transform: uppercase; }
.manager-summary > span:last-child { text-align: right; }
.manager-summary small { color: var(--muted); font-size: .75rem; }
.module-row .owned-count { grid-column: 2; color: var(--petrol); font-size: .68rem; }
.module-row .owned-count b { font-size: inherit; }
.module-list { border-top: 1px solid var(--line); }
.module-header, .module-row { display: grid; grid-template-columns: minmax(230px, 1.4fr) 105px 105px 72px; gap: 14px; align-items: center; }
.module-header { min-height: 42px; background: #e8eeec; color: #5d6c6e; padding: 0 14px; font-size: .67rem; font-weight: 800; text-transform: uppercase; }
.module-row { min-height: 72px; border-bottom: 1px solid var(--line); background: #fff; padding: 10px 14px; font-size: .8rem; }
.module-row > span:first-child { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 2px 9px; align-items: center; }
.module-row input[type="checkbox"] { grid-row: 1 / 4; width: 17px; height: 17px; accent-color: var(--petrol); }
.module-row strong, .module-row small { min-width: 0; overflow-wrap: anywhere; }
.module-row small { color: var(--muted); font-weight: 450; }
.module-row .qty { height: 36px; padding: 0 8px; text-align: center; }
.catalog-note { margin: 13px 0 0; font-size: .76rem; }
.order-panel { position: sticky; top: 92px; border: 1px solid #cad5d2; background: #fff; padding: 21px; }
.order-items { display: grid; gap: 10px; margin: 18px 0; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.order-item { display: flex; justify-content: space-between; gap: 14px; font-size: .78rem; }
.order-item span { color: var(--muted); }
.order-panel dl, .connection-panel dl, .detail-list { display: grid; gap: 0; margin: 0 0 20px; }
.order-panel dl > div, .connection-panel dl > div, .detail-list > div { display: flex; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); padding: 10px 0; }
dt { color: var(--muted); font-size: .76rem; }
dd { margin: 0; text-align: right; font-size: .8rem; font-weight: 750; overflow-wrap: anywhere; }
.grand-total { margin-top: 5px; border-top: 2px solid var(--ink); }
.grand-total dd { font-size: 1rem; }
.order-panel > small { display: block; margin-top: 11px; color: var(--muted); line-height: 1.45; }
.forecast-layout, .billing-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.account-layout { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px; }
.forecast-layout > section, .manager-detail, .billing-layout > section, .account-layout > section { border-top: 3px solid var(--petrol); background: #fff; padding: 24px; }
.connection-state { display: flex; align-items: center; gap: 13px; margin: 25px 0 17px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 18px 0; }
.connection-state p { margin: 4px 0 0; font-size: .78rem; }
.signal { width: 11px; height: 11px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 5px rgba(213,223,56,.2); }
.manager-detail { display: flex; flex-direction: column; justify-content: center; min-height: 290px; }
.license-validity { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-top: 18px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 14px 0; }
.license-validity span { color: var(--muted); font-size: .76rem; }
.license-validity strong { color: #31643d; font-size: .84rem; }
.offline-license-warning { margin-top: 16px; border-left: 4px solid var(--amber); background: #fff8ea; padding: 13px 15px; }
.offline-license-warning strong { color: var(--amber); font-size: .82rem; }
.offline-license-warning p { margin: 5px 0 0; color: var(--ink); font-size: .76rem; line-height: 1.5; }
.provider-row { display: grid; grid-template-columns: 1fr 1.4fr auto; gap: 16px; align-items: center; margin: 20px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 17px 0; }
.provider-row span { color: var(--muted); font-size: .8rem; }
.provider-row em { color: var(--amber); font-size: .72rem; font-style: normal; font-weight: 800; }
.billing-orders { margin-top: 30px; }
.compact-form { display: grid; gap: 13px; margin-top: 18px; }
.compact-form .button { justify-self: start; }
.pending-list { display: grid; margin-top: 18px; border-top: 1px solid var(--line); }
.pending-list > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding: 10px 0; }
.pending-list span { display: grid; gap: 3px; }
.pending-list small { color: var(--muted); font-size: .72rem; }
dialog { width: min(610px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 0; border-radius: 7px; padding: 0; color: var(--ink); box-shadow: 0 26px 80px rgba(15, 31, 32, .28); }
dialog::backdrop { background: rgba(11, 27, 29, .64); }
dialog form { display: grid; gap: 17px; padding: 25px; }
.dialog-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.dialog-head h2 { font-size: 1.35rem; }
.icon-button { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: #fff; color: var(--ink); font-size: 1.3rem; }
fieldset { display: grid; gap: 9px; margin: 0; border: 1px solid var(--line); padding: 13px; }
legend { padding: 0 6px; color: #3e4b4e; font-size: .82rem; font-weight: 750; }
.choice-row { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 10px; align-items: center; border: 1px solid var(--line); padding: 11px; }
.choice-row:has(input:checked) { border-color: var(--petrol); background: #eef5f3; }
.choice-row input { width: 17px; height: 17px; accent-color: var(--petrol); }
.choice-row span { display: grid; gap: 3px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; padding-top: 5px; }
.integration-warning { border-left: 3px solid var(--amber); background: #fff8ea; padding: 13px 15px; }
.integration-warning p { margin: 5px 0 0; font-size: .8rem; }
.toast { position: fixed; z-index: 100; right: 20px; bottom: 20px; max-width: min(400px, calc(100vw - 40px)); border-left: 4px solid var(--lime); background: #173f3c; color: #fff; padding: 13px 17px; opacity: 0; pointer-events: none; transform: translateY(12px); transition: .2s ease; }
.toast.show { opacity: 1; transform: none; }
.inline-actions { display: flex; justify-content: center; gap: 10px; }
.mode-switch { display: inline-grid; grid-template-columns: 1fr 1fr; margin-bottom: 24px; border: 1px solid var(--line); background: #fff; }
.mode-switch button { min-height: 42px; border: 0; border-right: 1px solid var(--line); background: transparent; padding: 0 16px; color: var(--muted); font-weight: 750; }
.mode-switch button:last-child { border-right: 0; }
.mode-switch button.active { background: var(--petrol); color: #fff; }
.setup-option { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 11px; align-items: center; margin-top: 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fff; padding: 15px 13px; }
.setup-option input { width: 18px; height: 18px; accent-color: var(--petrol); }
.setup-option span { display: grid; gap: 3px; }
.button.compact { min-height: 32px; padding: 5px 10px; border-color: var(--petrol); background: #fff; color: var(--petrol); font-size: .74rem; }
.code-cell { min-width: 150px; }
.code-cell small, .code-hint { display: block; margin-top: 4px; color: var(--muted); font-size: .68rem; }
.system-configurator { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 28px; align-items: start; }
.config-form { display: grid; gap: 28px; }
.form-section { border-top: 2px solid var(--petrol); background: #fff; padding: 24px; }
.wizard { min-height: 520px; align-content: start; border-top: 3px solid var(--petrol); background: #fff; padding: 24px; }
.wizard-header { display: grid; gap: 20px; border-bottom: 1px solid var(--line); padding-bottom: 20px; }
.wizard-header h2 { font-size: 1.45rem; }
.wizard-progress { display: grid; grid-template-columns: repeat(10, minmax(max-content, 1fr)); overflow-x: auto; margin: 0; padding: 0; list-style: none; }
.wizard-progress li { min-width: 72px; border-top: 3px solid var(--line); padding: 8px 8px 0; color: var(--muted); font-size: .7rem; font-weight: 750; text-align: center; white-space: nowrap; }
.wizard-progress li.active { border-color: var(--lime); color: var(--ink); }
.wizard-progress li.done { border-color: var(--petrol); color: var(--petrol); }
.wizard-step { display: none; min-height: 250px; padding: 28px 0 8px; }
.wizard-step.active { display: block; }
.wizard-question { max-width: 720px; margin: 0 0 24px; color: var(--ink); font-size: 1.15rem; font-weight: 760; }
.wizard-device { display: grid; grid-template-columns: minmax(100px, .35fr) 1fr 1fr; gap: 16px; align-items: end; }
.wizard-review { display: grid; border-top: 1px solid var(--line); }
.wizard-review > div { display: flex; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); padding: 12px 0; }
.wizard-review span { color: var(--muted); }
.wizard-review strong { text-align: right; }
.wizard-actions { display: flex; justify-content: space-between; gap: 10px; border-top: 1px solid var(--line); padding-top: 18px; }
.wizard-actions .primary { margin-left: auto; }
.wizard-actions button { min-width: 112px; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 20px 0; }
.configuration-band { margin-top: 22px; border-top: 1px solid var(--line); padding-top: 18px; }
.configuration-band h3 { margin: 0; font-size: .95rem; }
.config-details { margin-top: 24px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 0; }
.config-details > summary { cursor: pointer; padding: 16px 0; color: var(--petrol); font-weight: 760; }
.config-details > p { margin: 0 0 16px; color: var(--muted); font-size: .78rem; }
.schema-groups { display: grid; gap: 18px; padding-bottom: 20px; }
.schema-group { border-top: 1px solid var(--line); padding-top: 14px; }
.schema-group h4 { margin: 0 0 10px; font-size: .84rem; }
.schema-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
.schema-grid label { min-width: 0; margin: 0; }
.schema-grid label.full-width { grid-column: 1 / -1; }
.schema-grid input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--petrol); }
.schema-toggle { display: grid; grid-template-columns: 18px 1fr; gap: 9px; align-items: center; min-height: 43px; border-bottom: 1px solid var(--line); padding: 4px 0; }
.quantity-line { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 20px; align-items: end; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 16px; }
.quantity-line small { align-self: center; color: var(--muted); }
.device-editor { display: grid; gap: 14px; margin: 16px 0 28px; }
.device-instance { border: 1px solid var(--line); border-radius: 4px; background: #fbfcfc; }
.device-instance > summary { cursor: pointer; padding: 14px 16px; color: var(--ink); font-weight: 760; }
.device-instance-body { display: grid; gap: 18px; border-top: 1px solid var(--line); padding: 16px; }
.structured-editor { border-top: 1px solid var(--line); padding-top: 16px; }
.structured-editor-head, .structured-row-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.structured-editor-head h3, .structured-row-head strong { margin: 0; font-size: .9rem; }
.structured-editor-head p { margin: 4px 0 0; color: var(--muted); font-size: .78rem; }
.structured-row { margin-top: 12px; border-left: 3px solid var(--petrol); background: #f6f9f8; padding: 14px 16px; }
.structured-row .configuration-band { margin-top: 12px; }
.structured-row .schema-groups { padding-bottom: 0; }
.compact-row { padding-bottom: 4px; }
.sensor-band { border-top-color: var(--petrol); }
.integration-status { margin: -4px 0 0; color: var(--muted); font-size: .72rem; }
.device-grid { display: grid; border-top: 1px solid var(--line); }
.device-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) 90px minmax(150px, .8fr) minmax(150px, .8fr); gap: 13px; align-items: end; border-bottom: 1px solid var(--line); padding: 15px 0; }
.device-row > div { display: grid; gap: 4px; align-self: center; }
.device-row small { color: var(--muted); font-size: .72rem; }
.recommendation-panel { position: sticky; top: 92px; border-top: 3px solid var(--lime); background: #173f3c; color: #fff; padding: 23px; }
.recommendation-panel .eyebrow { color: var(--lime); }
.recommendation-panel p { color: #bcd0ce; font-size: .78rem; }
.recommend-list { display: grid; margin: 20px 0; border-top: 1px solid #3b625f; }
.recommend-list > div { display: flex; justify-content: space-between; gap: 15px; border-bottom: 1px solid #3b625f; padding: 11px 0; font-size: .8rem; }
.recommend-list > div > span { display: grid; gap: 3px; }
.recommend-list small { font-size: .68rem; }
.recommend-list .license-covered { color: #cbdc7c; }
.recommend-list .license-missing { color: #ffd291; font-weight: 750; }
.package-result { display: grid; gap: 10px; margin-top: 20px; border-top: 1px solid #5c7b78; padding-top: 18px; }
.package-result .button.secondary { border-color: #78938f; background: transparent; color: #fff; }
#activationDialog { padding: 25px; }
#activationDialog > p { margin: 12px 0 20px; }
.activation-code { display: block; margin-bottom: 16px; border: 1px solid var(--line); background: #edf2f1; padding: 18px 12px; text-align: center; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1.2rem; font-weight: 800; overflow-wrap: anywhere; }
@media (max-width: 940px) {
.auth-screen { grid-template-columns: 1fr; }
.auth-brand { min-height: 290px; padding: 28px; }
.auth-message { padding: 42px 0 10px; }
.auth-message h1 { font-size: clamp(2rem, 8vw, 3.4rem); }
.auth-workspace { min-height: auto; padding: 42px 24px; }
.app-shell { grid-template-columns: 1fr; }
.sidebar { position: sticky; z-index: 15; top: 68px; display: block; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); padding: 7px 12px; }
.sidebar nav { display: flex; width: max-content; }
.nav-item { min-height: 39px; border-left: 0; border-bottom: 3px solid transparent; padding: 0 13px; }
.nav-item.active { border-bottom-color: var(--lime); }
.workspace-switcher { display: none; }
.config-layout { grid-template-columns: 1fr; }
.order-panel { position: static; }
.connection-band { grid-template-columns: 1fr; gap: 24px; }
.system-configurator { grid-template-columns: 1fr; }
.recommendation-panel { position: static; }
.account-layout { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 680px) {
.topbar { height: 62px; padding: 0 15px; }
.top-actions { gap: 10px; }
.header-links { gap: 12px; margin-right: 0; }
.header-links a { font-size: .75rem; }
.brand strong { font-size: 1.05rem; }
.brand-mark { width: 32px; height: 32px; }
.environment, .account-button strong { display: none; }
.sidebar { top: 62px; }
.content { padding: 26px 15px 55px; }
.page-head { display: grid; min-height: 0; margin-bottom: 22px; }
.page-head .button { width: 100%; }
.metrics { grid-template-columns: 1fr 1fr; }
.metrics > div:nth-child(2) { border-right: 0; }
.metrics > div:last-child { grid-column: 1 / -1; border-top: 1px solid var(--line); }
.metrics > div { padding: 17px; }
.plant-line { grid-template-columns: 1fr auto; gap: 11px; }
.plant-line > span:nth-child(2), .plant-line > span:nth-child(3) { grid-column: 1 / -1; }
.plant-line .text-action { grid-column: 2; grid-row: 1; }
.flow-track { overflow-x: auto; }
.manager-summary { display: grid; }
.manager-summary > label, .manager-summary > label select { min-width: 0; width: 100%; }
.manager-summary > span:last-child { text-align: left; }
.module-header { display: none; }
.module-row { grid-template-columns: minmax(0, 1fr) 76px; gap: 8px 12px; padding: 14px 12px; }
.module-row > span:first-child { grid-row: 1 / 4; }
.module-row > span:nth-child(2), .module-row > span:nth-child(3) { font-size: .73rem; text-align: right; }
.module-row .qty { grid-column: 2; }
.forecast-layout, .billing-layout, .account-layout { grid-template-columns: 1fr; }
.field-pair { grid-template-columns: 1fr; }
.field-grid, .schema-grid { grid-template-columns: 1fr; }
.schema-grid label.full-width { grid-column: auto; }
.quantity-line { grid-template-columns: 1fr; gap: 8px; }
.device-instance-body { padding: 14px 12px; }
.device-row { grid-template-columns: 1fr 1fr; align-items: start; }
.device-row > div { grid-column: 1 / -1; }
.wizard { min-height: 480px; padding: 18px; }
.wizard-device { grid-template-columns: 1fr; }
.wizard-review > div { display: grid; gap: 5px; }
.wizard-review strong { text-align: left; }
.wizard-actions button { min-width: 0; flex: 1; }
.mode-switch { display: grid; width: 100%; }
.auth-brand { min-height: 260px; }
.auth-workspace { padding: 34px 18px; }
.provider-row { grid-template-columns: 1fr auto; }
.provider-row span { grid-column: 1 / -1; grid-row: 2; }
.dialog-actions { display: grid; grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
.page-actions { display: flex; align-items: center; gap: 10px; }
.license-list { border-top: 1px solid var(--line); }
.license-row {
display: grid;
grid-template-columns: minmax(170px, 1.2fr) minmax(170px, 1fr) 80px 120px minmax(130px, .8fr) auto;
gap: 18px;
align-items: center;
min-height: 82px;
border-bottom: 1px solid var(--line);
background: #fff;
padding: 14px 16px;
}
.license-row > span { display: grid; gap: 5px; min-width: 0; }
.license-row strong { overflow-wrap: anywhere; font-size: .8rem; }
.license-row small { color: var(--muted); font-size: .68rem; }
.license-main strong { font-size: .92rem; }
.license-row .status { width: max-content; }
.license-actions { display: grid; gap: 7px; min-width: 190px; }
.license-actions .button { width: 100%; }
.license-actions .code-replacement { border-color: #d9b4b4; color: var(--danger); }
.license-actions .destructive-action { border-color: #d9b4b4; color: var(--danger); background: #fff; }
.destructive-link { color: var(--danger); justify-self: center; }
.code-cell { display: grid; gap: 5px; min-width: 170px; }
.code-cell .destructive-link { justify-self: start; }
.replacement-warning { border-left: 4px solid var(--danger); background: #fff1f1; padding: 16px 18px; }
.replacement-warning strong { color: var(--danger); }
.replacement-warning p { margin: 8px 0 0; font-size: .84rem; line-height: 1.55; }
.critical-warning { background: #ffe5e5; border-left-width: 6px; }
.billing-summary { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .75rem; }
.account-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dialog-body { display: grid; gap: 18px; padding: 25px; }
.purchase-paths { display: grid; gap: 10px; margin-top: 4px; }
.purchase-paths > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
min-height: 86px;
border: 1px solid var(--line);
border-radius: 5px;
background: #fff;
color: var(--ink);
padding: 16px;
text-align: left;
transition: border-color .16s ease, background .16s ease, transform .16s ease;
}
.purchase-paths > button:hover { border-color: var(--petrol); background: #eef5f3; transform: translateY(-1px); }
.purchase-paths span { display: grid; gap: 5px; }
.purchase-paths small { color: var(--muted); font-weight: 450; line-height: 1.4; }
.purchase-paths b { color: var(--petrol); font-size: .76rem; }
dialog[open] { animation: dialog-enter .16s ease both; }
@keyframes dialog-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@media (max-width: 1100px) {
.license-row { grid-template-columns: minmax(180px, 1.2fr) 1fr 80px 120px auto; }
.license-row > span:nth-child(5) { grid-column: 2 / 5; }
}
@media (max-width: 680px) {
.page-actions { display: grid; width: 100%; }
.license-row { grid-template-columns: 1fr 1fr; gap: 14px; padding: 17px 14px; }
.license-row .license-main { grid-column: 1 / -1; }
.license-row > span:nth-child(5), .license-actions { grid-column: 1 / -1; }
.license-row .button { width: 100%; }
.billing-summary { align-items: flex-end; flex-direction: column; }
.account-layout { grid-template-columns: 1fr; }
.purchase-paths > button { grid-template-columns: 1fr; gap: 8px; }
}
/* Read-only prognosis workspace */
.forecast-toolbar {
display: grid;
grid-template-columns: minmax(260px, .8fr) minmax(320px, 1.2fr);
gap: 32px;
align-items: end;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 22px 24px;
}
.forecast-toolbar label { margin: 0; }
.forecast-toolbar select { min-width: 0; }
.forecast-toolbar .connection-state { margin: 0; }
.forecast-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
background: #f7faf9;
}
.forecast-metrics > div {
display: grid;
gap: 3px;
min-width: 0;
border-right: 1px solid var(--line);
padding: 18px 24px;
}
.forecast-metrics > div:last-child { border-right: 0; }
.forecast-metrics span {
overflow-wrap: anywhere;
color: var(--ink);
font-size: 1.25rem;
font-weight: 750;
}
.forecast-metrics small { color: var(--muted); }
.forecast-chart-section {
margin-top: 28px;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 24px;
}
.forecast-chart-section .section-head { align-items: flex-start; }
.forecast-state {
display: grid;
min-height: 260px;
place-items: center;
border: 1px dashed var(--line);
color: var(--muted);
padding: 32px;
text-align: center;
}
.forecast-chart {
display: block;
width: 100%;
height: 320px;
overflow: visible;
}
.forecast-series-panel {
display: grid;
gap: 8px;
margin-top: 14px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 12px 0;
}
.forecast-series-panel > strong {
font-size: .86rem;
}
.forecast-series-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 4px 18px;
}
.forecast-series-option {
display: inline-grid;
grid-template-columns: 16px 18px minmax(0, 1fr);
align-items: center;
gap: 6px;
min-height: 30px;
margin: 0;
color: var(--muted);
cursor: pointer;
font-size: .78rem;
}
.forecast-series-option input {
width: 16px;
height: 16px;
margin: 0;
}
.forecast-series-option i,
.forecast-chart-legend-item i {
width: 18px;
height: 3px;
flex: 0 0 auto;
background: var(--series-color);
}
.forecast-series-option i.prediction,
.forecast-chart-legend-item i.prediction {
background: repeating-linear-gradient(
90deg,
var(--series-color) 0 7px,
transparent 7px 11px
);
}
.forecast-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin: 0 0 10px;
border-bottom: 1px solid var(--line);
padding: 0 0 10px;
}
.forecast-chart-legend-item {
display: inline-flex;
align-items: center;
gap: 7px;
color: #455350;
font-size: .78rem;
white-space: nowrap;
}
.forecast-chart-controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
margin: 14px 0 18px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.forecast-chart-controls label {
min-width: 190px;
margin: 0;
}
.forecast-chart-controls input,
.forecast-chart-controls select {
min-height: 40px;
}
.forecast-chart-controls .button {
min-height: 40px;
}
.forecast-chart-wrap { position: relative; }
.forecast-tooltip { position: absolute; z-index: 4; display: grid; gap: 7px; min-width: 220px; max-width: 340px; border: 1px solid #cfdad7; border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgba(24,39,36,.15); padding: 10px 12px; pointer-events: none; font-size: .8rem; }
.forecast-tooltip > strong { margin-bottom: 2px; }
.forecast-tooltip span { display: grid; grid-template-columns: 10px 1fr auto; gap: 7px; align-items: center; }
.forecast-tooltip span i { width: 8px; height: 8px; border-radius: 50%; background: var(--series-color); }
.forecast-tooltip span b { font-weight: 600; }
.forecast-tooltip span em { color: var(--muted); font-style: normal; font-variant-numeric: tabular-nums; }
.forecast-hover-line { stroke: #596461; stroke-width: 1; stroke-dasharray: 3 3; vector-effect: non-scaling-stroke; pointer-events: none; }
.forecast-grid-line {
stroke: #dce5e3;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.forecast-series-line {
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2.25;
}
.forecast-series-prediction {
stroke-dasharray: 8 5;
}
.forecast-now-line {
stroke: #222c2a;
stroke-dasharray: 5 5;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.forecast-axis-label, .forecast-now-label {
fill: #687572;
font-family: inherit;
font-size: 12px;
}
.forecast-now-label { fill: #222c2a; font-weight: 700; }
.forecast-quality-section {
margin-top: 28px;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 24px;
}
.forecast-quality-state {
margin-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
padding: 22px 0 4px;
}
.forecast-quality-section .table-wrap { margin-top: 18px; }
.forecast-quality-section table { min-width: 760px; }
.forecast-layout { margin-top: 28px; }
.forecast-scope { min-height: 290px; }
@media (max-width: 760px) {
.forecast-toolbar { grid-template-columns: 1fr; gap: 20px; padding: 18px; }
.forecast-metrics { grid-template-columns: 1fr 1fr; }
.forecast-metrics > div { padding: 14px 16px; }
.forecast-metrics > div:nth-child(2) { border-right: 0; }
.forecast-metrics > div:last-child {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
}
.forecast-chart-section { padding: 18px 12px 14px; }
.forecast-chart-section .section-head { display: grid; gap: 14px; }
.forecast-quality-section { padding: 18px 12px; }
.forecast-quality-section .section-head { display: grid; gap: 14px; }
.forecast-series-controls { grid-template-columns: 1fr; }
.forecast-chart-legend { justify-content: flex-start; }
.forecast-chart-controls { display: grid; grid-template-columns: 1fr; }
.forecast-chart-controls label { min-width: 0; }
.forecast-chart-controls .button { width: 100%; }
.forecast-chart { height: 270px; }
}
.forecast-config-section {
margin-top: 28px;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 24px;
}
.forecast-config-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 18px;
}
.forecast-config-grid label { margin: 0; }
.forecast-config-block {
margin-top: 28px;
border-top: 1px solid var(--line);
padding-top: 22px;
}
.config-block-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 16px;
}
.config-block-head h3 { margin: 0 0 4px; font-size: 1rem; }
.config-block-head p { margin: 0; color: var(--muted); }
.button.compact { min-height: 38px; padding: 8px 13px; }
.forecast-roofs { display: grid; gap: 10px; }
.forecast-roof-row {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr)) 42px;
gap: 12px;
align-items: end;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
}
.forecast-roof-row label { margin: 0; }
.forecast-roof-row .icon-button {
width: 42px;
height: 42px;
margin-bottom: 1px;
}
.forecast-variants {
display: grid;
grid-template-columns: repeat(3, minmax(140px, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
}
.forecast-variants label {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
margin: 0;
background: #f7faf9;
padding: 10px 14px;
}
.forecast-variants input { width: auto; }
.forecast-config-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.forecast-topology { display: grid; gap: 22px; }
.forecast-topology-summary {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.forecast-topology-summary > div {
display: grid;
gap: 4px;
padding: 14px 16px;
border-right: 1px solid var(--line);
}
.forecast-topology-summary > div:last-child { border-right: 0; }
.forecast-topology-summary span,
.forecast-topology-group span { color: var(--muted); font-size: 0.83rem; }
.forecast-topology-groups {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 24px;
}
.forecast-topology-group h4 { margin: 0 0 10px; font-size: 0.9rem; }
.forecast-topology-group > div {
display: grid;
gap: 3px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.forecast-topology-group p { margin: 0; color: var(--muted); }
.forecast-config-section input:disabled,
.forecast-config-section select:disabled {
color: #34413f;
background: #edf2f1;
opacity: 1;
}
@media (max-width: 980px) {
.forecast-config-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
.forecast-topology-summary { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.forecast-topology-groups { grid-template-columns: 1fr; }
.forecast-variants { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
}
@media (max-width: 680px) {
.forecast-config-section { padding: 18px; }
.forecast-config-grid { grid-template-columns: 1fr; }
.forecast-topology-summary { grid-template-columns: repeat(2, minmax(110px, 1fr)); }
.forecast-topology-summary > div { border-bottom: 1px solid var(--line); }
.config-block-head { display: grid; }
.forecast-roof-row { grid-template-columns: 1fr 1fr; }
.forecast-roof-row label:first-child { grid-column: 1 / -1; }
.forecast-roof-row .icon-button { justify-self: end; }
.forecast-variants { grid-template-columns: 1fr; }
.forecast-config-actions .button { width: 100%; }
}
.fault-toolbar {
display: grid;
grid-template-columns: minmax(220px, 360px) minmax(300px, 1fr);
gap: 32px;
align-items: end;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 22px 24px;
}
.fault-toolbar .connection-state { margin: 0; }
.fault-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 26px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #fff;
}
.fault-metrics > div { display: grid; gap: 4px; padding: 20px 24px; border-right: 1px solid var(--line); }
.fault-metrics > div:last-child { border-right: 0; }
.fault-metrics span { overflow-wrap: anywhere; font-size: 1.28rem; font-weight: 800; }
.fault-metrics small { color: var(--muted); font-size: .72rem; }
.fault-section { margin-top: 26px; border-top: 3px solid var(--petrol); background: #fff; padding: 23px 24px 0; }
.fault-section .table-wrap { margin: 0 -24px; }
.fault-section table { min-width: 760px; }
.fault-state { margin: 0 -24px; border-top: 1px solid var(--line); padding: 28px 24px; color: var(--muted); }
.severity { display: inline-block; min-width: 72px; padding: 5px 8px; text-align: center; font-size: .7rem; font-weight: 800; }
.severity-critical { background: #702634; color: #fff; }
.severity-error { background: #f4d9de; color: #7b2635; }
.severity-warning { background: #fff1d8; color: #865b14; }
.severity-info { background: #dcecf2; color: #245b70; }
.signal.offline { background: #9aa5a3; box-shadow: 0 0 0 5px rgba(154, 165, 163, .18); }
.fault-notification-section { padding-bottom: 22px; }
.fault-recipient-form {
display: grid;
grid-template-columns: minmax(240px, 1fr) auto;
gap: 12px;
align-items: end;
border-top: 1px solid var(--line);
margin: 18px -24px 0;
padding: 20px 24px;
}
.fault-recipient-form label { margin: 0; }
.fault-recipient-form .button { min-height: 46px; }
.fault-recipient-list { border-top: 1px solid var(--line); }
.fault-recipient-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
border-bottom: 1px solid var(--line);
padding: 10px 0;
}
.fault-recipient-row span { display: grid; gap: 3px; overflow-wrap: anywhere; }
.fault-recipient-row small, .fault-recipient-status, .fault-recipient-empty { color: var(--muted); }
.fault-recipient-row .icon-button { flex: 0 0 40px; width: 40px; height: 40px; }
.fault-recipient-empty { padding: 19px 0; }
.fault-recipient-status { margin: 16px 0 0; font-size: .82rem; }
@media (max-width: 760px) {
.fault-toolbar { grid-template-columns: 1fr; gap: 18px; padding: 18px; }
.fault-metrics { grid-template-columns: 1fr; }
.fault-metrics > div { border-right: 0; border-bottom: 1px solid var(--line); }
.fault-metrics > div:last-child { border-bottom: 0; }
.fault-section { padding: 19px 18px 0; }
.fault-section .table-wrap, .fault-state { margin-left: -18px; margin-right: -18px; }
.fault-notification-section { padding-bottom: 18px; }
.fault-recipient-form { grid-template-columns: 1fr; margin-left: -18px; margin-right: -18px; padding: 18px; }
.fault-recipient-form .button { width: 100%; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,253 @@
import { createHmac, timingSafeEqual } from "node:crypto";
const stripeSecretKey = String(process.env.STRIPE_SECRET_KEY || "").trim();
const stripeWebhookSecret = String(process.env.STRIPE_WEBHOOK_SECRET || "").trim();
const stripeApiBase = String(process.env.STRIPE_API_BASE || "https://api.stripe.com").replace(/\/$/, "");
export const catalog = Object.freeze({
manager_standard: { sku: "ENX-MANAGER", name: "Manager ohne Peak Shaving", unitAmount: 30815, setupAmount: 11460, entitlement: true },
manager_peak: { sku: "ENX-MANAGER-PEAK", name: "Manager mit Peak Shaving", unitAmount: 32905, setupAmount: 14180, entitlement: true },
manager_peak_upgrade: { sku: "ENX-MANAGER-PEAK-UPGRADE", name: "Upgrade auf Peak Shaving", unitAmount: 2090, setupAmount: 0, entitlement: true },
battery: { sku: "ENX-BATTERY", name: "Batteriespeicher", unitAmount: 4195, setupAmount: 5455, entitlement: true },
buffer_storage: { sku: "ENX-BUFFER", name: "Pufferspeicher", unitAmount: 2515, setupAmount: 3270, entitlement: true },
ev_charger: { sku: "ENX-EV", name: "EV-Ladestation", unitAmount: 3365, setupAmount: 4375, entitlement: true },
consumer_single: { sku: "ENX-CONSUMER-1", name: "Verbraucher einstufig", unitAmount: 2095, setupAmount: 2725, entitlement: true },
boiler_multi: { sku: "ENX-BOILER-MULTI", name: "Boiler mehrstufig", unitAmount: 2515, setupAmount: 3270, entitlement: true },
heat_pump: { sku: "ENX-HEAT-PUMP", name: "Wärmepumpe", unitAmount: 2095, setupAmount: 2725, entitlement: true },
forecast_pv: { sku: "ENX-FORECAST-PV", name: "PV-Ertragsprognose", unitAmount: 0, setupAmount: 0, entitlement: true, termMonths: 12 },
forecast_load: { sku: "ENX-FORECAST-LOAD", name: "Verbrauchsprognose", unitAmount: 0, setupAmount: 0, entitlement: true, termMonths: 12 },
grid_schedule: { sku: "ENX-GRID-SCHEDULE", name: "Intelligenter Netzfahrplan", unitAmount: 5000, setupAmount: 0, entitlement: true, termMonths: 12 },
cost_report: { sku: "ENX-BILLING", name: "Abrechnungsmanager", unitAmount: 24515, setupAmount: 3270, entitlement: true },
cost_meter_power: { sku: "ENX-BILLING-POWER", name: "Stromzähler Abrechnung", unitAmount: 500, setupAmount: 0, entitlement: true },
cost_meter_aux: { sku: "ENX-BILLING-AUX", name: "Wärme- oder Wasserzähler Abrechnung", unitAmount: 300, setupAmount: 0, entitlement: true }
});
const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi", "heat_pump"];
const forecastKeys = ["forecast_pv", "forecast_load", "grid_schedule"];
const licenseKeys = [...moduleKeys, ...forecastKeys];
export function publicCatalog(priceCatalog = catalog) {
return Object.fromEntries(Object.entries(priceCatalog).map(([key, item]) => [key, {
sku: item.sku,
name: item.name,
unitAmount: item.unitAmount,
setupAmount: item.setupAmount || 0,
termMonths: item.termMonths || null
}]));
}
function quantity(value) {
const number = Number(value || 0);
if (!Number.isInteger(number) || number < 0 || number > 999) throw Object.assign(new Error("invalid_quantity"), { status: 400 });
return number;
}
export function buildOrderLines(
managerVariant,
requestedModules = {},
setupRequest = {},
ownedManagerVariant = null,
priceCatalog = catalog,
vatRate = 0
) {
const manager = priceCatalog[managerVariant];
if (!manager || !["manager_standard", "manager_peak"].includes(managerVariant)) {
throw Object.assign(new Error("invalid_manager"), { status: 400, publicMessage: "Ungültige Manager-Variante." });
}
if (ownedManagerVariant && !["manager_standard", "manager_peak"].includes(ownedManagerVariant)) {
throw Object.assign(new Error("invalid_owned_manager"), { status: 400 });
}
const lines = [];
if (!ownedManagerVariant) {
lines.push({ ...manager, catalogKey: managerVariant, quantity: 1, lineType: "license" });
} else if (ownedManagerVariant === "manager_standard" && managerVariant === "manager_peak") {
lines.push({ ...priceCatalog.manager_peak_upgrade, catalogKey: "manager_peak", quantity: 1, lineType: "license" });
}
if (setupRequest.manager === true && manager.setupAmount > 0) {
lines.push({
sku: `${manager.sku}-SETUP`,
name: `Generator / Ersteinrichtung ${manager.name}`,
unitAmount: manager.setupAmount,
catalogKey: managerVariant,
quantity: 1,
lineType: "setup",
entitlement: false
});
}
for (const key of licenseKeys) {
const count = quantity(requestedModules[key]);
if (!count) continue;
const item = priceCatalog[key];
if (!item || item.available === false) {
throw Object.assign(new Error("catalog_item_unavailable"), { status: 400, publicMessage: "Die gewählte Lizenz ist derzeit nicht verfügbar." });
}
if (key === "grid_schedule" && managerVariant !== "manager_peak") {
throw Object.assign(new Error("grid_schedule_requires_peak"), { status: 400, publicMessage: "Der intelligente Netzfahrplan benötigt den Manager mit Peak Shaving." });
}
lines.push({ ...item, catalogKey: key, quantity: count, lineType: "license" });
}
const setupModules = setupRequest.modules || {};
for (const key of moduleKeys) {
const count = quantity(setupModules[key]);
if (!count) continue;
const item = priceCatalog[key];
lines.push({
sku: `${item.sku}-SETUP`,
name: `Generator / Ersteinrichtung ${item.name}`,
unitAmount: item.setupAmount,
catalogKey: key,
quantity: count,
lineType: "setup",
entitlement: false
});
}
if (!lines.length) {
throw Object.assign(new Error("nothing_to_purchase"), { status: 400, publicMessage: "Für diese Auswahl ist keine Lizenzerweiterung oder Ersteinrichtung erforderlich." });
}
const normalizedVatRate = Number(vatRate);
if (!Number.isFinite(normalizedVatRate) || normalizedVatRate < 0 || normalizedVatRate > 100) {
throw Object.assign(new Error("invalid_vat_rate"), { status: 500 });
}
const netAmount = lines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0);
const vatAmount = Math.round(netAmount * normalizedVatRate / 100);
if (vatAmount > 0) {
lines.push({
sku: "ENX-VAT",
name: `MwSt. ${normalizedVatRate.toFixed(1)} %`,
unitAmount: vatAmount,
catalogKey: "vat",
quantity: 1,
lineType: "tax",
entitlement: false
});
}
return lines;
}
export function buildBillingOrderLines(
requestedMeters = {},
ownedQuantities = {},
setupRequested = false,
priceCatalog = catalog,
vatRate = 0
) {
const lines = [];
const base = priceCatalog.cost_report;
if (quantity(ownedQuantities.cost_report) < 1) {
lines.push({ ...base, catalogKey: "cost_report", quantity: 1, lineType: "license" });
}
for (const key of ["cost_meter_power", "cost_meter_aux"]) {
const count = quantity(requestedMeters[key]);
if (!count) continue;
lines.push({ ...priceCatalog[key], catalogKey: key, quantity: count, lineType: "license" });
}
if (setupRequested && base.setupAmount > 0) {
lines.push({
sku: `${base.sku}-SETUP`,
name: `Generator / Ersteinrichtung ${base.name}`,
unitAmount: base.setupAmount,
catalogKey: "cost_report",
quantity: 1,
lineType: "setup",
entitlement: false
});
}
if (!lines.length) {
throw Object.assign(new Error("nothing_to_purchase"), { status: 400, publicMessage: "Für diese Abrechnungslizenz ist keine Erweiterung ausgewählt." });
}
const normalizedVatRate = Number(vatRate);
if (!Number.isFinite(normalizedVatRate) || normalizedVatRate < 0 || normalizedVatRate > 100) {
throw Object.assign(new Error("invalid_vat_rate"), { status: 500 });
}
const netAmount = lines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0);
const vatAmount = Math.round(netAmount * normalizedVatRate / 100);
if (vatAmount > 0) {
lines.push({
sku: "ENX-VAT",
name: `MwSt. ${normalizedVatRate.toFixed(1)} %`,
unitAmount: vatAmount,
catalogKey: "vat",
quantity: 1,
lineType: "tax",
entitlement: false
});
}
return lines;
}
export function stripeConfigured() {
return /^(sk|rk)_test_/.test(stripeSecretKey) && stripeWebhookSecret.startsWith("whsec_");
}
export async function createCheckoutSession({ orderId, userId, email, lines, successUrl, cancelUrl }) {
if (!stripeConfigured()) throw Object.assign(new Error("stripe_not_configured"), { status: 503 });
const params = new URLSearchParams();
params.set("mode", "payment");
params.set("customer_email", email);
params.set("client_reference_id", orderId);
params.set("metadata[order_id]", orderId);
params.set("metadata[user_id]", String(userId));
params.set("success_url", successUrl);
params.set("cancel_url", cancelUrl);
lines.forEach((line, index) => {
const root = `line_items[${index}]`;
params.set(`${root}[quantity]`, String(line.quantity));
params.set(`${root}[price_data][currency]`, "chf");
params.set(`${root}[price_data][unit_amount]`, String(line.unitAmount));
params.set(`${root}[price_data][product_data][name]`, line.name);
params.set(`${root}[price_data][product_data][metadata][sku]`, line.sku);
});
const response = await fetch(`${stripeApiBase}/v1/checkout/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${stripeSecretKey}`,
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const providerError = payload.error || {};
const error = Object.assign(new Error("stripe_checkout_failed"), {
status: 502,
publicMessage: providerError.message || "Stripe Checkout konnte nicht geöffnet werden.",
diagnostic: {
status: response.status,
type: providerError.type || "unknown",
code: providerError.code || "unknown",
param: providerError.param || null,
requestId: response.headers.get("request-id") || null
}
});
throw error;
}
if (!payload.id || !payload.url) {
throw Object.assign(new Error("stripe_checkout_invalid_response"), {
status: 502,
publicMessage: "Stripe Checkout hat keine gültige Sitzung zurückgegeben."
});
}
return { id: payload.id, url: payload.url, expiresAt: payload.expires_at || null };
}
export function verifyStripeSignature(rawBody, signatureHeader) {
if (!stripeWebhookSecret || !signatureHeader) return false;
const parts = String(signatureHeader).split(",").map((part) => part.split("="));
const timestamp = parts.find(([key]) => key === "t")?.[1];
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value);
if (!/^\d+$/.test(timestamp || "") || !signatures.length) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", stripeWebhookSecret)
.update(`${timestamp}.${rawBody.toString("utf8")}`)
.digest("hex");
return signatures.some((signature) => {
if (!/^[0-9a-f]{64}$/i.test(signature)) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
});
}
@@ -0,0 +1,485 @@
import { normalizeSystemConfiguration, recommendLicenses, systemDeviceTypes } from "./portal-domain.mjs";
const managerModuleId = "{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}";
const shellyModuleId = "{21D82EDB-AC8E-4F3D-9FEE-D20BCAFC1C33}";
const costReportModuleId = "{9DAC29C7-F381-F2A4-7CE2-F391C4123F38}";
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear());
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()
};
}
function zipStore(entries) {
const localParts = [];
const centralParts = [];
let offset = 0;
const stamp = dosDateTime();
for (const entry of entries) {
const name = Buffer.from(entry.name, "utf8");
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, "utf8");
const checksum = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(0, 8); local.writeUInt16LE(stamp.time, 10); local.writeUInt16LE(stamp.date, 12);
local.writeUInt32LE(checksum, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28);
localParts.push(local, name, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(stamp.time, 12);
central.writeUInt16LE(stamp.date, 14); central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32); central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38); central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.length + name.length + data.length;
}
const centralDirectory = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDirectory.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, centralDirectory, end]);
}
function provisioningScript(configuration) {
const encoded = Buffer.from(JSON.stringify(configuration), "utf8").toString("base64");
const modules = Object.fromEntries(Object.entries(systemDeviceTypes).map(([key, item]) => [key, item.moduleId]));
return `<?php
declare(strict_types=1);
$config = json_decode(base64_decode('${encoded}'), true, 512, JSON_THROW_ON_ERROR);
$moduleIDs = json_decode('${JSON.stringify(modules)}', true, 512, JSON_THROW_ON_ERROR);
$managerModuleID = '${managerModuleId}';
$shellyModuleID = '${shellyModuleId}';
$costReportModuleID = '${costReportModuleId}';
$energyPieModuleID = '{F73B8F8D-5E36-4B23-B379-4632A517A1D6}';
$energyFlowModuleID = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}';
$evGatewayModuleID = '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}';
$easeeGatewayModuleID = '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}';
if (version_compare(IPS_GetKernelVersion(), '8.0', '<') || version_compare(IPS_GetKernelVersion(), '9.0', '>=')) {
throw new RuntimeException('Dieses Einrichtungspaket benoetigt IP-Symcon 8.x.');
}
function enxFind(int $parent, string $ident): int {
$id = @IPS_GetObjectIDByIdent($ident, $parent);
return $id === false ? 0 : (int) $id;
}
function enxCategory(int $parent, string $ident, string $name): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateCategory(); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
return $id;
}
function enxInstance(int $parent, string $ident, string $name, string $moduleID): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateInstance($moduleID); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
if (IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== $moduleID) throw new RuntimeException('Modulkonflikt bei ' . $ident);
IPS_SetName($id, $name);
return $id;
}
function enxVariable(int $parent, string $ident, string $name, string $profile = '~Watt'): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateVariable(2); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
if ($profile !== '' && IPS_VariableProfileExists($profile)) IPS_SetVariableCustomProfile($id, $profile);
return $id;
}
function enxScript(int $parent, string $ident, string $name): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateScript(0); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
return $id;
}
function enxLink(int $parent, string $ident, string $name, int $target): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateLink(); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
IPS_SetLinkTargetID($id, $target);
return $id;
}
function enxShellyBinding(
int $parent,
string $ident,
string $name,
string $topic,
int $channel,
int $target,
string $role,
int $levelIndex,
string $shellyModuleID
): void {
if ($topic === '') throw new RuntimeException('MQTT-Topic fuer ' . $name . ' fehlt.');
$hardware = enxInstance($parent, 'SHELLY_' . $ident, 'Shelly ' . $name, $shellyModuleID);
IPS_SetProperty($hardware, 'UseDeviceTopicFilter', true);
IPS_SetProperty($hardware, 'DeviceTopicPrefix', $topic);
IPS_SetProperty($hardware, 'CreateSwitches', $role !== 'sensor');
IPS_SetProperty($hardware, 'CreatePower', $role !== 'sensor');
IPS_SetProperty($hardware, 'CreateEnergy', $role !== 'sensor');
IPS_SetProperty($hardware, 'CreateTemperature', $role === 'sensor');
IPS_ApplyChanges($hardware);
$binding = base64_encode(json_encode([
'target' => $target,
'hardware' => $hardware,
'topic' => $topic,
'channel' => $channel,
'role' => $role,
'levelIndex' => $levelIndex,
], JSON_THROW_ON_ERROR));
$finalizer = enxScript($parent, 'SHELLY_LINK_' . $ident, 'Shelly-Zuordnung ' . $name);
$script = '<?php' . "\n"
. '$binding = json_decode(base64_decode(' . var_export($binding, true) . '), true, 512, JSON_THROW_ON_ERROR);' . "\n"
. '$key = trim(preg_replace("/[^a-z0-9_]+/", "_", strtolower($binding["topic"])), "_");' . "\n"
. '$key = substr($key === "" ? "device" : $key, 0, 48) . "_" . substr(hash("sha256", $binding["topic"]), 0, 8);' . "\n"
. '$folder = @IPS_GetObjectIDByIdent("folder_" . $key, $binding["hardware"]);' . "\n"
. 'if ($folder === false) return;' . "\n"
. '$variable = false;' . "\n"
. 'if ($binding["role"] === "sensor") {' . "\n"
. ' foreach (IPS_GetChildrenIDs((int) $folder) as $candidate) {' . "\n"
. ' if (!IPS_VariableExists($candidate)) continue;' . "\n"
. ' $meta = IPS_GetVariable($candidate);' . "\n"
. ' if (($meta["VariableCustomProfile"] ?? $meta["VariableProfile"] ?? "") === "~Temperature") { $variable = $candidate; break; }' . "\n"
. ' }' . "\n"
. '} else {' . "\n"
. ' $variable = @IPS_GetObjectIDByIdent($key . "_output_" . $binding["channel"], (int) $folder);' . "\n"
. '}' . "\n"
. 'if ($variable === false) return;' . "\n"
. 'if ($binding["role"] === "consumer") {' . "\n"
. ' IPS_SetProperty($binding["target"], "SchaltkontaktVariableID", (int) $variable);' . "\n"
. ' IPS_SetProperty($binding["target"], "RueckmeldungVariableID", (int) $variable);' . "\n"
. '} elseif ($binding["role"] === "level") {' . "\n"
. ' $levels = json_decode((string) IPS_GetProperty($binding["target"], "LeistungsStufen"), true);' . "\n"
. ' if (!is_array($levels) || !isset($levels[$binding["levelIndex"]])) return;' . "\n"
. ' $levels[$binding["levelIndex"]]["Schaltkontakt_Stufe"] = (int) $variable;' . "\n"
. ' IPS_SetProperty($binding["target"], "LeistungsStufen", json_encode($levels, JSON_THROW_ON_ERROR));' . "\n"
. '} elseif ($binding["role"] === "sensor") {' . "\n"
. ' $property = IPS_GetInstance($binding["target"])["ModuleInfo"]["ModuleID"] === "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" ? "Boilerfuehler_PT1" : "PufferfuehlerVariableID";' . "\n"
. ' IPS_SetProperty($binding["target"], $property, (int) $variable);' . "\n"
. '}' . "\n"
. 'IPS_ApplyChanges($binding["target"]);' . "\n"
. 'IPS_SetScriptTimer($_IPS["SELF"], 0);';
IPS_SetScriptContent($finalizer, $script);
IPS_SetScriptTimer($finalizer, 15);
IPS_RunScript($finalizer);
}
$required = [$managerModuleID];
foreach ($config['devices'] as $key => $device) {
foreach (($device['items'] ?? []) as $item) {
if ($key === 'ev_charger' && ($item['variant'] ?? 'standalone') === 'gateway') {
$required[] = $evGatewayModuleID;
$required[] = $easeeGatewayModuleID;
} else {
$required[] = $moduleIDs[$key];
}
$integrations = [$item['integration'] ?? [], $item['sensor'] ?? []];
foreach (($item['powerLevels'] ?? []) as $level) $integrations[] = $level['integration'] ?? [];
foreach ($integrations as $integration) {
if (str_starts_with((string) ($integration['profile'] ?? ''), 'shelly_')) $required[] = $shellyModuleID;
}
}
}
if (($config['costReport']['enabled'] ?? false) === true) $required[] = $costReportModuleID;
if (($config['manager']['properties']['EnergyPieAnzeigen'] ?? false) === true) $required[] = $energyPieModuleID;
if (($config['manager']['properties']['EnergieflussAnzeigen'] ?? false) === true) $required[] = $energyFlowModuleID;
foreach (array_unique($required) as $moduleID) {
if (!in_array($moduleID, IPS_GetModuleList(), true)) throw new RuntimeException('Erforderliches Modul fehlt: ' . $moduleID);
}
// Validate heat-pump bindings before creating or changing any system objects.
foreach (($config['devices']['heat_pump']['items'] ?? []) as $item) {
if (!in_array($moduleIDs['heat_pump'], IPS_GetModuleList(), true)) {
throw new RuntimeException('Das Enelix-Waermepumpenmodul fehlt.');
}
$properties = $item['properties'];
$contact1 = (int) $properties['Kontakt1VariableID'];
$contact2 = (int) $properties['Kontakt2VariableID'];
if ($contact1 === $contact2 || (int) $properties['Nennleistung'] <= 0) {
throw new RuntimeException('Waermepumpe: zwei unterschiedliche Kontakte und Nennleistung erforderlich.');
}
foreach ([$contact1, $contact2] as $contact) {
if (!IPS_VariableExists($contact)) throw new RuntimeException('Waermepumpe: Kontaktvariable fehlt.');
$variable = IPS_GetVariable($contact);
if ($variable['VariableType'] !== 0 || max($variable['VariableAction'], $variable['VariableCustomAction']) <= 0) {
throw new RuntimeException('Waermepumpe: Kontakte muessen schaltbare Boolean-Variablen sein.');
}
}
$feedback = (int) $properties['IstleistungVariableID'];
if (!IPS_VariableExists($feedback)) throw new RuntimeException('Waermepumpe: Betriebsrueckmeldung fehlt.');
$feedbackType = IPS_GetVariable($feedback)['VariableType'];
$allowedTypes = (int) $properties['Rueckmeldungsart'] === 1 ? [0] : [1, 2];
if (!in_array($feedbackType, $allowedTypes, true)) {
throw new RuntimeException('Waermepumpe: Typ der Betriebsrueckmeldung passt nicht zur Rueckmeldungsart.');
}
}
$root = enxCategory(0, 'ENELIX_System', (string) $config['name']);
$visualisation = enxCategory($root, 'Visualisierung', 'Visualisierung');
$energyView = enxCategory($visualisation, 'Energie', 'Energie und Bilanz');
$consumerView = enxCategory($visualisation, 'Verbraucher', 'Verbraucher und Speicher');
$serviceView = enxCategory($visualisation, 'Service', 'Service und Diagnose');
$manager = enxInstance($root, 'Manager', 'Enelix Manager', $managerModuleID);
enxLink($energyView, 'Manager', 'Enelix Manager', $manager);
foreach (($config['manager']['properties'] ?? []) as $property => $value) {
IPS_SetProperty($manager, (string) $property, $value);
}
if (($config['manager']['properties']['AutomatischeSuche'] ?? true) && (int) ($config['manager']['properties']['SuchbereichID'] ?? 0) === 0) {
IPS_SetProperty($manager, 'SuchbereichID', $root);
}
IPS_ApplyChanges($manager);
$froniusHost = '';
$energyVariables = [];
$froniusProfiles = ['fronius_solar_api_v1', 'fronius_hybrid_storage'];
foreach ([$config['pvSystem'] ?? [], $config['gridMeasurement'] ?? []] as $source) {
if (in_array((string) ($source['profile'] ?? ''), $froniusProfiles, true) && (string) ($source['host'] ?? '') !== '') {
$froniusHost = (string) $source['host'];
break;
}
}
if ($froniusHost === '') {
foreach (($config['devices']['battery']['items'] ?? []) as $item) {
$source = $item['integration'] ?? [];
if (in_array((string) ($source['profile'] ?? ''), $froniusProfiles, true) && (string) ($source['host'] ?? '') !== '') {
$froniusHost = (string) $source['host'];
break;
}
}
}
if ($froniusHost !== '') {
$energy = enxCategory($root, 'FroniusEnergy', 'Fronius Energiefluss');
$energyVariables = [];
$variableDefinitions = [
'GridPower' => ['Netzleistung', '~Watt'],
'PvPower' => ['PV-Leistung', '~Watt'],
'LoadPower' => ['Verbrauch', '~Watt'],
'BatteryPower' => ['Batterieleistung', '~Watt'],
'BatterySoc' => ['Batterie-Ladezustand', '~Intensity.100'],
];
foreach ($variableDefinitions as $ident => [$name, $profile]) {
$energyVariables[$ident] = enxVariable($energy, $ident, $name, $profile);
}
$script = enxScript($energy, 'FroniusPoll', 'Fronius Solar API einlesen');
$scriptContent = '$host = ' . var_export($froniusHost, true) . ";\n" . <<<'ENELIX'
$energy = IPS_GetParent($_IPS['SELF']);
try {
$raw = Sys_GetURLContent('http://' . $host . '/solar_api/v1/GetPowerFlowRealtimeData.fcgi');
$payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
$site = $payload['Body']['Data']['Site'] ?? [];
$inverters = $payload['Body']['Data']['Inverters'] ?? [];
$firstInverter = is_array($inverters) && $inverters !== [] ? reset($inverters) : [];
$values = [
'GridPower' => $site['P_Grid'] ?? null,
'PvPower' => $site['P_PV'] ?? null,
'LoadPower' => isset($site['P_Load']) ? abs((float) $site['P_Load']) : null,
'BatteryPower' => $site['P_Akku'] ?? null,
'BatterySoc' => is_array($firstInverter) ? ($firstInverter['SOC'] ?? null) : null,
];
foreach ($values as $ident => $value) {
$variable = @IPS_GetObjectIDByIdent($ident, $energy);
if ($variable !== false && is_numeric($value)) SetValueFloat((int) $variable, (float) $value);
}
} catch (Throwable $error) {
IPS_LogMessage('Enelix Fronius', $error->getMessage());
}
ENELIX;
IPS_SetScriptContent($script, "<?php\n" . $scriptContent);
IPS_SetScriptTimer($script, 60);
IPS_RunScript($script);
if (in_array((string) ($config['gridMeasurement']['profile'] ?? ''), $froniusProfiles, true)) {
IPS_SetProperty($manager, 'NetzleistungVariableID', $energyVariables['GridPower']);
}
IPS_SetProperty($manager, 'MessungPVLeistungVariableID', $energyVariables['PvPower']);
IPS_SetProperty($manager, 'MessungHausverbrauchLeistungVariableID', $energyVariables['LoadPower']);
IPS_SetProperty($manager, 'MessungBatterieleistungVariableID', $energyVariables['BatteryPower']);
}
if ((int) ($config['gridMeasurement']['sourceVariableId'] ?? 0) > 0) {
IPS_SetProperty($manager, 'NetzleistungVariableID', (int) $config['gridMeasurement']['sourceVariableId']);
}
IPS_ApplyChanges($manager);
foreach ([
'EnergyPie' => 'Energie-Kreisdiagramm',
'Energiefluss' => 'Energiefluss',
'Energiediagramm' => 'Energiediagramme',
'EnergieFunFacts' => 'Energy Facts',
] as $ident => $name) {
$target = enxFind($manager, $ident);
if ($target > 0) enxLink($energyView, $ident, $name, $target);
}
foreach (['Aktiv' => 'Manager aktiv', 'Betriebsart' => 'Betriebsart', 'Netzleistung' => 'Netzleistung'] as $ident => $name) {
$target = enxFind($manager, $ident);
if ($target > 0) enxLink($serviceView, 'Manager_' . $ident, $name, $target);
}
$easeeGateway = 0;
foreach ($config['devices'] as $key => $device) {
foreach (($device['items'] ?? []) as $position => $item) {
$index = $position + 1;
$ident = strtoupper($key) . '_' . $index;
$label = (string) ($item['name'] ?? ($key . ' ' . $index));
$gatewayVariant = $key === 'ev_charger' && ($item['variant'] ?? 'standalone') === 'gateway';
$moduleID = $gatewayVariant ? $evGatewayModuleID : $moduleIDs[$key];
$instance = enxInstance($root, $ident, $label, $moduleID);
if ($gatewayVariant) {
if ($easeeGateway === 0) {
$easeeGateway = enxInstance($root, 'EaseeGateway', 'Easee Gateway', $easeeGatewayModuleID);
IPS_ApplyChanges($easeeGateway);
enxLink($serviceView, 'EaseeGateway', 'Easee Gateway', $easeeGateway);
}
$connection = (int) (IPS_GetInstance($instance)['ConnectionID'] ?? 0);
if ($connection === 0) IPS_ConnectInstance($instance, $easeeGateway);
elseif ($connection !== $easeeGateway) throw new RuntimeException('Unerwartete Easee-Gateway-Verbindung bei ' . $label);
}
foreach (($item['properties'] ?? []) as $property => $value) {
IPS_SetProperty($instance, (string) $property, $value);
}
$integration = $item['integration'] ?? [];
$profile = (string) ($integration['profile'] ?? '');
$sourceVariableID = (int) ($integration['sourceVariableId'] ?? 0);
if ($key === 'consumer_single' && $sourceVariableID > 0) {
IPS_SetProperty($instance, 'SchaltkontaktVariableID', $sourceVariableID);
IPS_SetProperty($instance, 'RueckmeldungVariableID', (int) ($integration['feedbackVariableId'] ?? $sourceVariableID) ?: $sourceVariableID);
}
if ($key === 'battery' && $sourceVariableID > 0) {
IPS_SetProperty($instance, 'IstleistungVariableID', $sourceVariableID);
}
if ($key === 'battery' && $profile === 'fronius_hybrid_storage' && $energyVariables !== []) {
$maxCharge = enxVariable($instance, 'AutoMaxLadeleistung', 'Maximale Ladeleistung');
$maxDischarge = enxVariable($instance, 'AutoMaxEntladeleistung', 'Maximale Entladeleistung');
SetValueFloat($maxCharge, 1000 * (float) ($item['topology']['MaxLadeleistung_kW'] ?? 0));
SetValueFloat($maxDischarge, 1000 * (float) ($item['topology']['MaxEntladeleistung_kW'] ?? 0));
IPS_SetProperty($instance, 'MaxLadeleistungVariableID', $maxCharge);
IPS_SetProperty($instance, 'MaxEntladeleistungVariableID', $maxDischarge);
IPS_SetProperty($instance, 'LadezustandVariableID', $energyVariables['BatterySoc']);
IPS_SetProperty($instance, 'NetzleistungVariableID', $energyVariables['GridPower']);
IPS_SetProperty($instance, 'IstleistungVariableID', $energyVariables['BatteryPower']);
}
$sensor = $item['sensor'] ?? [];
$sensorVariableID = (int) ($sensor['sourceVariableId'] ?? 0);
if ($sensorVariableID > 0 && $key === 'boiler_multi') IPS_SetProperty($instance, 'Boilerfuehler_PT1', $sensorVariableID);
if ($sensorVariableID > 0 && $key === 'buffer_storage') IPS_SetProperty($instance, 'PufferfuehlerVariableID', $sensorVariableID);
IPS_ApplyChanges($instance);
enxLink($consumerView, $ident, $label, $instance);
if ($key === 'consumer_single' && str_starts_with($profile, 'shelly_')) {
enxShellyBinding($instance, 'MAIN', $label, (string) $integration['topicPrefix'], (int) ($integration['channel'] ?? 0), $instance, 'consumer', 0, $shellyModuleID);
}
foreach (($item['powerLevels'] ?? []) as $levelIndex => $level) {
$levelIntegration = $level['integration'] ?? [];
if (str_starts_with((string) ($levelIntegration['profile'] ?? ''), 'shelly_')) {
enxShellyBinding($instance, 'LEVEL_' . ($levelIndex + 1), $label . ' Stufe ' . ($levelIndex + 1), (string) $levelIntegration['topicPrefix'], (int) ($levelIntegration['channel'] ?? 0), $instance, 'level', $levelIndex, $shellyModuleID);
}
}
if (str_starts_with((string) ($sensor['profile'] ?? ''), 'shelly_')) {
enxShellyBinding($instance, 'SENSOR', $label . ' Temperatur', (string) $sensor['topicPrefix'], 0, $instance, 'sensor', 0, $shellyModuleID);
}
}
}
if (($config['costReport']['enabled'] ?? false) === true) {
$report = enxInstance($root, 'Verbrauchskostenreport', 'Verbrauchskostenreport', $costReportModuleID);
IPS_SetProperty($report, 'PropertyText', (string) $config['name']);
IPS_ApplyChanges($report);
enxLink($energyView, 'Verbrauchskostenreport', 'Verbrauchskostenreport', $report);
}
echo 'Enelix-System wurde angelegt. Bitte Lizenzcodes, Variablenzuordnungen, MQTT-Verbindung und Geraetezugriffe kontrollieren.';
`;
}
function integrationChecklist(configuration) {
const lines = [
"Enelix Geräteintegrationen",
"",
"Automatisch bedeutet, dass das Bootstrap-Skript die lokale Auslesung oder Zuordnung vorbereitet.",
"Modellspezifisch bedeutet, dass Modell, Firmware und Registerstand vor der Inbetriebnahme geprüft werden müssen.",
""
];
const sources = [
["Netzmessung", configuration.gridMeasurement],
["PV-Wechselrichter", configuration.pvSystem?.enabled ? configuration.pvSystem : null]
];
for (const [label, source] of sources) {
if (!source) continue;
lines.push(label + ": " + source.manufacturer + " / " + source.protocol + " / " + source.setup);
if (source.host) lines.push(" Adresse: " + source.host + ":" + source.port + ", Geräte-ID " + source.unitId);
}
for (const [key, device] of Object.entries(configuration.devices)) {
for (const [index, item] of (device.items || []).entries()) {
const source = item.integration || {};
lines.push((item.name || key + " " + (index + 1)) + ": " + (source.manufacturer || "Geräte-API") + " / " + (source.protocol || source.profile || "manuell") + " / " + (source.setup || "manuell"));
if (source.host) lines.push(" Adresse: " + source.host + ":" + source.port + ", Geräte-ID " + source.unitId);
if (source.topicPrefix) lines.push(" MQTT: " + source.topicPrefix + ", Ausgang " + source.channel);
for (const level of item.powerLevels || []) {
const actuator = level.integration || {};
lines.push(" Stufe " + level.Stufe + ": " + level.Leistung + " W / " + (actuator.profile || "manuell"));
if (actuator.topicPrefix) lines.push(" MQTT: " + actuator.topicPrefix + ", Ausgang " + actuator.channel);
}
const sensor = item.sensor || {};
if (sensor.profile && sensor.profile !== "manual") lines.push(" Temperaturfühler: " + sensor.profile + " / MQTT " + sensor.topicPrefix);
}
}
return lines.join("\n") + "\n";
}
export function buildSymconPackage(configuration, recommendation) {
const normalizedConfiguration = normalizeSystemConfiguration(configuration);
const normalizedRecommendation = recommendLicenses(normalizedConfiguration);
const readme = `Enelix Symcon-Einrichtungspaket
Dieses Paket ist kein blind einspielbares Vollbackup. Es enthaelt ein idempotentes
Bootstrap-Skript fuer die IP-Symcon-Schnellausfuehrung und die normalisierte
Systemkonfiguration. Das ist sicherer, weil Objekt-IDs, Zugangsdaten und lokale
Schnittstellen erst auf der Zielinstallation zugeordnet werden koennen. Beim Lauf
werden Manager, Verbraucher, Geraeteanbindungen und die Visualisierungsstruktur
mit Energiefluss, Kreisdiagramm, Diagrammen und Verbraucherlinks angelegt.
Voraussetzungen:
- IP-Symcon 8.x
- Enelix EMS Module aus dem develop-Stand installiert
- Vor Ausfuehrung ein Backup der Zielinstallation erstellen
Ausfuehrung:
1. bootstrap.php in der IP-Symcon-Schnellausfuehrung oeffnen.
2. Inhalt ausfuehren.
3. Angelegte Instanzen, Variablenzuordnungen und Geraetezugriffe pruefen.
4. Den nach dem Kauf erzeugten Aktivierungscode im Enelix Manager eingeben.
5. Bei gewähltem Verbrauchskostenreport dessen separaten Lizenzcode in der Report-Instanz eintragen.
6. Bei Shelly Gen2+ den automatisch verbundenen MQTT Server prüfen und dasselbe Topic-Präfix am Gerät konfigurieren.
7. Schaltkontakte, Rückmeldungen, Leistungsstufen und Shelly-Temperaturfühler werden nach dem ersten MQTT-Datenpunkt automatisch zugeordnet.
8. INTEGRATIONEN.txt prüfen. Fronius Solar API und Shelly MQTT werden automatisch vorbereitet.
9. Bei Huawei, GoodWe, Sigenergy und modellabhängigen SunSpec-Geräten Modell, Firmware und Registerstand vor der Freigabe verifizieren.
`;
return zipStore([
{ name: "bootstrap.php", data: provisioningScript(normalizedConfiguration) },
{ name: "configuration.json", data: JSON.stringify({
configuration: normalizedConfiguration,
recommendation: normalizedRecommendation
}, null, 2) },
{ name: "INTEGRATIONEN.txt", data: integrationChecklist(normalizedConfiguration) },
{ name: "README.txt", data: readme }
]);
}
@@ -0,0 +1,35 @@
{
"date": "2026-10-06",
"host": "enelix-services",
"status": "tested, not deployed",
"files": {
"stripe.mjs": {
"before": "66fccada6f48e1a15c3c418ae8ae99ed26b723866d6a6b911b25321c7a208571",
"after": "5060b88265436a8e60ba9eeca11175809a20c9c925631e468602cbb1d5c7065b"
},
"portal-domain.mjs": {
"before": "63a2f443b02aaa2e6b5ab85179cb5e5b199e3795f48d69606723c766c2ec0935",
"after": "13986aaecb4a427269c8f0aefc373faab662269318074a2623524b04fbbb8d1c"
},
"server.mjs": {
"before": "e1a3ede87638717a03e1a2505eb87ecc32caa22d7611c84b80ddb1aa99a6daff",
"after": "e1e785f837872022186b80a571fdf80554647221d59cb778b1f37725547a82c0"
},
"symcon-package.mjs": {
"before": "dc2444dc72bd68c1369e861bbb0a080e7edd5344412c2e87f68523bbb30b18ed",
"after": "10da57f396d7be369f2410518432b2606286e86b8954204234bdb33bc1ac6d8e"
},
"public/index.html": {
"before": "ec9bba90ccca3d57abd9c37a99b7efda27272050cb6013b540bc7cd0cec29c87",
"after": "d800a160f6daf1be1839c6cf787cd9020d2c83b11be0b3713c7d35aeae99bba5"
},
"public/app.js": {
"before": "f0d88f6daaa6ea6135821ace6c36f515053afff17ee08bc7c5d814dab4263330",
"after": "b4828a1bd58a5cc29a3970475c8eebadf89a3fda788c269d6951572603fa5dd9"
},
"public/styles.css": {
"before": "aea743390d4c873fb5b95d97fefabf3b4e35abdcef268dc9fa6f7df0eecbd082",
"after": "507a028a5deec9bf33150786c587262ecdd158bfd6cee217e14417839cf42325"
}
}
}

Some files were not shown because too many files have changed in this diff Show More