From 372b997876a2faa1ff563b6e004f9c864ed2504b Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 6 Oct 2026 17:40:45 +0000 Subject: [PATCH] feat(forecast): integrate full forecast charts and complete SDL visualization --- Manager/module.php | 5 + docs/FORECAST_SDL_RELEASE_20261006.md | 98 + docs/NETPLAN_V4_AGENT_HANDOFF.md | 19 + libs/ManagerEnergieTrait.php | 27 +- libs/ManagerNetzfahrplanV4AktivtestTrait.php | 2 +- libs/ManagerNetzfahrplanV4DatenTrait.php | 2 +- libs/ManagerNetzfahrplanV4EmpfangTrait.php | 6 +- libs/ManagerPrognoseFormular.php | 11 + .../20261006-forecast-integration/README.md | 17 + .../20261006-forecast-integration/deploy.py | 62 + .../manifest.json | 32 + .../portal-integration.test.mjs | 157 + .../public/app.js | 2593 +++++++++++++++++ .../public/forecast-chart.js | 182 ++ .../public/index.html | 382 +++ .../public/netplan-v4.css | 49 + .../public/netplan-v4.html | 1 + .../public/netplan-v4.js | 138 + .../gui/forecast-chart.browser-test.mjs | 119 + services/netplan-v4/gui/forecast-chart.js | 182 ++ .../netplan-v4/gui/forecast-chart.test.mjs | 68 + services/netplan-v4/gui/netplan-v4.css | 50 +- services/netplan-v4/gui/netplan-v4.html | 2 +- services/netplan-v4/gui/netplan-v4.js | 111 +- services/netplan-v4/netplan_v4/optimizer.py | 1 + services/netplan-v4/netplan_v4/service.py | 10 +- services/netplan-v4/tests/test_v4.py | 19 + tests/ManagerSDLTest.php | 62 + tests/PrognoseFormular/checks.php | 11 + tests/Support/ManagerSDLRuntime.php | 12 + 30 files changed, 4377 insertions(+), 53 deletions(-) create mode 100644 docs/FORECAST_SDL_RELEASE_20261006.md create mode 100644 services/license/changes/20261006-forecast-integration/README.md create mode 100644 services/license/changes/20261006-forecast-integration/deploy.py create mode 100644 services/license/changes/20261006-forecast-integration/manifest.json create mode 100644 services/license/changes/20261006-forecast-integration/portal-integration.test.mjs create mode 100644 services/license/changes/20261006-forecast-integration/public/app.js create mode 100644 services/license/changes/20261006-forecast-integration/public/forecast-chart.js create mode 100644 services/license/changes/20261006-forecast-integration/public/index.html create mode 100644 services/license/changes/20261006-forecast-integration/public/netplan-v4.css create mode 100644 services/license/changes/20261006-forecast-integration/public/netplan-v4.html create mode 100644 services/license/changes/20261006-forecast-integration/public/netplan-v4.js create mode 100644 services/netplan-v4/gui/forecast-chart.browser-test.mjs create mode 100644 services/netplan-v4/gui/forecast-chart.js create mode 100644 services/netplan-v4/gui/forecast-chart.test.mjs diff --git a/Manager/module.php b/Manager/module.php index b0a5863..b509adb 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -276,6 +276,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle $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(); diff --git a/docs/FORECAST_SDL_RELEASE_20261006.md b/docs/FORECAST_SDL_RELEASE_20261006.md new file mode 100644 index 0000000..ee15022 --- /dev/null +++ b/docs/FORECAST_SDL_RELEASE_20261006.md @@ -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. diff --git a/docs/NETPLAN_V4_AGENT_HANDOFF.md b/docs/NETPLAN_V4_AGENT_HANDOFF.md index b2209b5..1c31c7b 100644 --- a/docs/NETPLAN_V4_AGENT_HANDOFF.md +++ b/docs/NETPLAN_V4_AGENT_HANDOFF.md @@ -1,5 +1,24 @@ # 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 diff --git a/libs/ManagerEnergieTrait.php b/libs/ManagerEnergieTrait.php index a19fb43..78a7ae4 100644 --- a/libs/ManagerEnergieTrait.php +++ b/libs/ManagerEnergieTrait.php @@ -273,8 +273,9 @@ trait ManagerEnergieTrait throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.'); } IPS_SetHidden($instanzID, false); + $this->synchronisiereEnergyPieAnteile($instanzID); - return 'Bestehende Energy-Pie-Konfiguration bleibt unveraendert.'; + return 'Bestehende Energy-Pie-Messquellen und Darstellung bleiben unveraendert.'; } IPS_SetName($instanzID, 'Energy Pie'); IPS_SetPosition($instanzID, 500); @@ -288,11 +289,35 @@ 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)) { diff --git a/libs/ManagerNetzfahrplanV4AktivtestTrait.php b/libs/ManagerNetzfahrplanV4AktivtestTrait.php index c6a8e9d..4de49a7 100644 --- a/libs/ManagerNetzfahrplanV4AktivtestTrait.php +++ b/libs/ManagerNetzfahrplanV4AktivtestTrait.php @@ -23,7 +23,7 @@ trait ManagerNetzfahrplanV4AktivtestTrait $this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0); $this->RegisterVariableBoolean( 'NetzfahrplanV4Aktivtest', - 'V4 Prognosemanager aktiv (Testbetrieb)', + 'Netzfahrplansteuerung (Testbetrieb)', '~Switch', 952 ); diff --git a/libs/ManagerNetzfahrplanV4DatenTrait.php b/libs/ManagerNetzfahrplanV4DatenTrait.php index 98a4a14..dc9b32a 100644 --- a/libs/ManagerNetzfahrplanV4DatenTrait.php +++ b/libs/ManagerNetzfahrplanV4DatenTrait.php @@ -21,7 +21,7 @@ trait ManagerNetzfahrplanV4DatenTrait $this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0); $this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0); $this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0); - $this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'V4 Messdaten und Modellversorgung', '', 199); + $this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'Prognosedaten und Modellversorgung', '', 199); $this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);"); } diff --git a/libs/ManagerNetzfahrplanV4EmpfangTrait.php b/libs/ManagerNetzfahrplanV4EmpfangTrait.php index d66bc4d..eb215b0 100644 --- a/libs/ManagerNetzfahrplanV4EmpfangTrait.php +++ b/libs/ManagerNetzfahrplanV4EmpfangTrait.php @@ -20,8 +20,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait $this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}'); $this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}'); $this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0); - $this->RegisterVariableString('NetzfahrplanV4Vorschau', 'V4 Sollwert-Vorschau (KEINE Stellfreigabe)', '~HTMLBox', 950); - $this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'V4 Vorschau Diagnose (nur lesen)', '', 951); + $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, @@ -256,7 +256,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait $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 = '

V4: NUR VORSCHAU - KEINE STELLFREIGABE

'; + $html = '

Netzfahrplanvorschau - keine Stellfreigabe

'; 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) { diff --git a/libs/ManagerPrognoseFormular.php b/libs/ManagerPrognoseFormular.php index fe36a24..ed85c45 100644 --- a/libs/ManagerPrognoseFormular.php +++ b/libs/ManagerPrognoseFormular.php @@ -9,6 +9,17 @@ 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', diff --git a/services/license/changes/20261006-forecast-integration/README.md b/services/license/changes/20261006-forecast-integration/README.md new file mode 100644 index 0000000..1da1332 --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/README.md @@ -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. diff --git a/services/license/changes/20261006-forecast-integration/deploy.py b/services/license/changes/20261006-forecast-integration/deploy.py new file mode 100644 index 0000000..f524fef --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/deploy.py @@ -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)})) diff --git a/services/license/changes/20261006-forecast-integration/manifest.json b/services/license/changes/20261006-forecast-integration/manifest.json new file mode 100644 index 0000000..74d43ac --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/manifest.json @@ -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" + } +] diff --git a/services/license/changes/20261006-forecast-integration/portal-integration.test.mjs b/services/license/changes/20261006-forecast-integration/portal-integration.test.mjs new file mode 100644 index 0000000..2cdb6ce --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/portal-integration.test.mjs @@ -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'); +}); diff --git a/services/license/changes/20261006-forecast-integration/public/app.js b/services/license/changes/20261006-forecast-integration/public/app.js new file mode 100644 index 0000000..24c18b8 --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/public/app.js @@ -0,0 +1,2593 @@ +(function () { + "use strict"; + + var state = { session: null, plants: [], orders: [], selectedOrder: [], stripeConfigured: false, vatRate: 8.1, configuredPlantId: null, checkoutConfigurationId: null, replacementOrderId: null, codeDeletionOrderId: null, plantDeletion: null, wizardStep: 0, forecastRequest: 0, forecastResult: null, forecastPlantId: null, forecastVisibleSeries: null, forecastRangeStart: null, forecastRangeEnd: null, forecastHistoryDays: 1, faultRequest: 0, wizardCatalog: null, deviceDrafts: {}, pvSurfaceDrafts: [] }; + var managerPrices = { + manager_standard: { name: "Manager ohne Peak Shaving", price: 308.15, setup: 114.60 }, + manager_peak: { name: "Manager mit Peak Shaving", price: 329.05, setup: 141.80 }, + manager_peak_upgrade: { name: "Upgrade auf Peak Shaving", price: 20.90, setup: 0 } + }; + var deviceLabels = { battery: "Batteriespeicher", buffer_storage: "Pufferspeicher", ev_charger: "EV-Ladestation", consumer_single: "Verbraucher einstufig", boiler_multi: "Boiler mehrstufig", heat_pump: "Wärmepumpe" }; + var roleLabels = { owner: "Eigentümer", admin: "Administrator", operator: "Operator", billing: "Abrechnung", viewer: "Lesend" }; + var money = new Intl.NumberFormat("de-CH", { style: "currency", currency: "CHF" }); + var byId = function (id) { return document.getElementById(id); }; + var toastTimer; + var resetToken = ""; + + function escapeHtml(value) { + return String(value == null ? "" : value).replace(/[&<>"']/g, function (character) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character]; + }); + } + + function applyCatalog(result) { + var items = result.items || {}; + state.vatRate = Number(result.vatRate || 0); + ["manager_standard", "manager_peak", "manager_peak_upgrade"].forEach(function (key) { + if (!items[key]) return; + managerPrices[key] = { + name: items[key].name, + price: Number(items[key].unitAmount) / 100, + setup: Number(items[key].setupAmount || 0) / 100 + }; + }); + document.querySelectorAll(".module-row").forEach(function (row) { + var item = items[row.dataset.key]; + if (!item) return; + row.dataset.name = item.name; + row.dataset.price = String(Number(item.unitAmount) / 100); + row.dataset.setup = String(Number(item.setupAmount || 0) / 100); + row.dataset.termMonths = String(Number(item.termMonths || 0)); + row.children[1].textContent = money.format(Number(item.unitAmount) / 100) + (item.termMonths ? " / Jahr" : ""); + row.children[2].textContent = item.termMonths ? "–" : money.format(Number(item.setupAmount || 0) / 100); + }); + byId("plantManagerStandardPrice").textContent = money.format(managerPrices.manager_standard.price) + " netto"; + byId("plantManagerPeakPrice").textContent = money.format(managerPrices.manager_peak.price) + " netto"; + byId("billingBasePrice").textContent = money.format(Number(items.cost_report?.unitAmount || 0) / 100) + " netto"; + byId("billingPowerPrice").textContent = money.format(Number(items.cost_meter_power?.unitAmount || 0) / 100) + " netto pro Zähler"; + byId("billingAuxPrice").textContent = money.format(Number(items.cost_meter_aux?.unitAmount || 0) / 100) + " netto pro Zähler"; + byId("vatLabel").textContent = "MwSt. " + state.vatRate.toLocaleString("de-CH") + " %"; + } + + async function api(path, options) { + var request = Object.assign({ headers: {} }, options || {}); + request.headers = Object.assign({}, request.headers); + if (request.body && typeof request.body !== "string") { + request.headers["Content-Type"] = "application/json"; + request.body = JSON.stringify(request.body); + } + if (state.session && state.session.csrfToken && request.method && request.method !== "GET") request.headers["X-CSRF-Token"] = state.session.csrfToken; + var response = await fetch(path, request); + var result = await response.json().catch(function () { return {}; }); + if (!response.ok) throw new Error(result.error || "Die Anfrage konnte nicht verarbeitet werden."); + return result; + } + + function toast(message) { + var node = byId("toast"); + node.textContent = message; + node.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { node.classList.remove("show"); }, 3600); + } + + function setBusy(form, busy) { + var button = form.querySelector("button[type='submit']"); + if (!button.dataset.label) button.dataset.label = button.textContent; + button.disabled = busy; + button.textContent = busy ? "Bitte warten …" : button.dataset.label; + } + + function switchAuth(mode) { + var login = mode === "login"; + byId("authTabs").classList.remove("hidden"); + byId("loginTab").classList.toggle("active", login); + byId("registerTab").classList.toggle("active", !login); + byId("loginForm").classList.toggle("hidden", !login); + byId("registerForm").classList.toggle("hidden", login); + byId("forgotForm").classList.add("hidden"); + byId("resetForm").classList.add("hidden"); + } + + function specialAuth(formId) { + byId("authTabs").classList.add("hidden"); + ["loginForm", "registerForm", "forgotForm", "resetForm"].forEach(function (id) { byId(id).classList.toggle("hidden", id !== formId); }); + } + + function authNotice(message, error) { + var notice = byId("authNotice"); + notice.textContent = message || ""; + notice.classList.toggle("hidden", !message); + notice.classList.toggle("error", Boolean(error)); + } + + function initials(name) { + return String(name || "EN").split(/\s+/).slice(0, 2).map(function (part) { return part[0]; }).join("").toUpperCase(); + } + + function showAuth() { + ++state.forecastRequest; + if (integratedPlanner) integratedPlanner.dispose(); + integratedPlanner = null; + byId("portalApp").classList.add("hidden"); + byId("authScreen").classList.remove("hidden"); + } + + function workspaceOptions() { + var organizations = state.session.organizations || []; + if (!organizations.length) return ""; + return organizations.map(function (organization) { return ""; }).join(""); + } + + function renderSession() { + var session = state.session; + var context = session.organization; + var organizations = session.organizations || []; + byId("topInitials").textContent = initials(session.user.name); + byId("topName").textContent = session.user.name; + byId("overviewOrg").textContent = context.name; + byId("billingOrg").textContent = context.name; + byId("accountName").textContent = session.user.name; + byId("accountEmail").textContent = session.user.email; + byId("accountOrg").textContent = context.name; + byId("workspaceRole").textContent = roleLabels[context.role] || context.role; + ["workspaceSelect", "accountWorkspaceSelect"].forEach(function (id) { + byId(id).innerHTML = workspaceOptions(); + byId(id).value = context.id == null ? "" : String(context.id); + byId(id).disabled = organizations.length <= 1; + }); + byId("accountWorkspaceLabel").classList.toggle("hidden", organizations.length <= 1); + byId("organizationCreateSection").classList.toggle("hidden", organizations.length > 0); + var canInvite = context.type === "organization" && ["owner", "admin"].includes(context.role); + byId("invitationSection").classList.toggle("hidden", !canInvite); + var canManage = context.type === "personal" || ["owner", "admin", "operator"].includes(context.role); + document.querySelectorAll("[data-open-plant]").forEach(function (button) { button.disabled = !canManage; }); + } + + async function acceptStoredInvitation() { + var token = sessionStorage.getItem("enelix_invitation_token"); + if (!token || !state.session) return; + try { + var result = await api("/api/invitations/accept", { method: "POST", body: { token: token } }); + sessionStorage.removeItem("enelix_invitation_token"); + state.session = result.session; + renderSession(); + toast(result.message); + } catch (error) { + sessionStorage.removeItem("enelix_invitation_token"); + toast(error.message); + } + } + + async function showPortal(session) { + state.session = session; + byId("authScreen").classList.add("hidden"); + byId("portalApp").classList.remove("hidden"); + renderSession(); + await acceptStoredInvitation(); + await loadPortalHealth(); + await Promise.all([loadPlants(), loadOrders(), loadInvitations()]); + var initialView = location.hash.replace("#", ""); + var allowedViews = ["overview", "licenses", "forecast", "faults", "account", "configurator"]; + showView(allowedViews.includes(initialView) ? initialView : "overview"); + var query = new URLSearchParams(location.search); + if (query.get("checkout") === "success") toast("Zahlung abgeschlossen. Stripe bestätigt die Freischaltung in wenigen Sekunden."); + if (query.get("checkout") === "free") toast("Kostenlose Jahreslizenz wurde freigeschaltet."); + if (query.get("checkout") === "cancelled") toast("Zahlung abgebrochen. Es wurde keine Lizenz aktiviert."); + if (query.has("checkout")) history.replaceState(null, "", location.pathname + location.hash); + } + + function showView(name) { + if (name !== "forecast") { + ++state.forecastRequest; + if (integratedPlanner) integratedPlanner.dispose(); + integratedPlanner = null; + } + document.querySelectorAll(".view").forEach(function (view) { view.classList.toggle("active", view.id === "view-" + name); }); + document.querySelectorAll(".nav-item").forEach(function (item) { item.classList.toggle("active", item.dataset.view === name); }); + history.replaceState(null, "", location.pathname + location.search + "#" + name); + if (name === "account") { + loadInvitations(); + loadOrders(); + } + if (name === "forecast") updateForecast(); + if (name === "faults") updateFaults(); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + async function loadPortalHealth() { + var health = await api("/healthz"); + state.stripeConfigured = health.stripe === "configured"; + ["stripeStatus", "billingStripeStatus"].forEach(function (id) { + var node = byId(id); + if (!node) return; + node.textContent = state.stripeConfigured ? "Stripe Sandbox bereit" : "Stripe Sandbox offen"; + node.className = "status " + (state.stripeConfigured ? "available" : "review"); + }); + if (byId("stripeProviderState")) byId("stripeProviderState").textContent = state.stripeConfigured ? "Bereit" : "Nicht konfiguriert"; + } + + async function loadPlants() { + state.plants = (await api("/api/plants")).plants || []; + renderPlants(); + } + + async function loadOrders() { + if (!state.session) return; + state.orders = (await api("/api/orders")).orders || []; + renderOrders(); + renderPlants(); + } + + async function loadInvitations() { + if (!state.session || byId("invitationSection").classList.contains("hidden")) return void (byId("pendingInvitations").innerHTML = ""); + var invitations = (await api("/api/invitations")).invitations || []; + byId("pendingInvitations").innerHTML = invitations.map(function (invitation) { + return "
" + escapeHtml(invitation.invited_email) + "" + escapeHtml(roleLabels[invitation.role] || invitation.role) + "offen
"; + }).join(""); + } + + function managerLabel(value) { + return value === "billing_manager" + ? "Abrechnungsmanager" + : (managerPrices[value] || managerPrices.manager_standard).name; + } + function connectionLabel(plant) { return plant.installation_id ? plant.installation_id : "Noch nicht verknüpft"; } + + function activationOrderForPlant(plantId) { + var paid = state.orders.filter(function (order) { return order.plant_id === plantId && order.status === "paid"; }); + var managerOrders = paid.filter(function (order) { + return (order.lines || []).some(function (line) { + return line.line_type === "license" && ["manager_standard", "manager_peak"].includes(line.catalog_key); + }); + }); + var candidates = managerOrders.length ? managerOrders : paid; + return candidates.find(function (order) { return Boolean(order.activated_installation_id); }) + || candidates.find(function (order) { return Boolean(order.code_hint); }) + || candidates[candidates.length - 1] + || null; + } + + function openReplacementDialog(orderId, plantName, connected) { + state.replacementOrderId = orderId; + byId("replaceCodeSystem").textContent = plantName; + byId("replaceCodeBinding").classList.toggle("hidden", !connected); + byId("replaceCodeError").textContent = ""; + byId("replaceCodeDialog").showModal(); + } + + function openCodeDeletionDialog(orderId, plantName) { + state.codeDeletionOrderId = orderId; + byId("deleteCodeSystem").textContent = plantName; + byId("deleteCodeError").textContent = ""; + byId("deleteCodeDialog").showModal(); + } + + function openPlantDeletionDialog(plantId, plantName, hasPaidLicense) { + state.plantDeletion = { id: plantId, name: plantName, hasPaidLicense: hasPaidLicense }; + byId("deletePlantSystem").textContent = plantName; + byId("deletePaidSystem").textContent = plantName; + byId("deletePlantError").textContent = ""; + byId("deletePaidError").textContent = ""; + byId("deletePlantDialog").showModal(); + } + + function renderPlants() { + var hasPlants = state.plants.length > 0; + var managerPlants = state.plants.filter(function (plant) { return plant.manager_variant !== "billing_manager"; }); + var hasManagerPlants = managerPlants.length > 0; + var paidOrders = state.orders.filter(function (order) { return order.status === "paid"; }); + var paidLicenses = paidOrders.reduce(function (sum, order) { + return sum + (order.lines || []).reduce(function (lineSum, line) { + return lineSum + (line.line_type === "license" ? Number(line.quantity) : 0); + }, 0); + }, 0); + byId("metricPlants").textContent = state.plants.length; + byId("metricManagers").textContent = paidLicenses; + byId("metricConnected").textContent = state.plants.filter(function (plant) { return Boolean(plant.installation_id); }).length; + byId("moduleNoPlant").classList.toggle("hidden", hasPlants); + byId("moduleConfigurator").classList.toggle("hidden", !hasPlants); + byId("forecastEmpty").classList.toggle("hidden", hasManagerPlants); + byId("forecastContent").classList.toggle("hidden", !hasManagerPlants); + byId("faultEmpty").classList.toggle("hidden", hasManagerPlants); + byId("faultContent").classList.toggle("hidden", !hasManagerPlants); + var canDelete = state.session.organization.type === "personal" || ["owner", "admin"].includes(state.session.organization.role); + byId("overviewPlants").innerHTML = hasPlants ? "
" + state.plants.map(function (plant) { + var orders = state.orders.filter(function (order) { return order.plant_id === plant.id; }); + var paid = orders.filter(function (order) { return order.status === "paid"; }); + var licenseCount = paid.reduce(function (sum, order) { + return sum + (order.lines || []).reduce(function (lineSum, line) { + return lineSum + (line.line_type === "license" ? Number(line.quantity) : 0); + }, 0); + }, 0); + var activated = paid.some(function (order) { return Boolean(order.activated_installation_id); }); + var licenseState = activated ? "Verwendet" : (paid.length ? "Bezahlt" : "Noch nicht gekauft"); + var stateClass = activated ? "available" : (paid.length ? "neutral" : "review"); + var codeOrder = activationOrderForPlant(plant.id); + var codeAction = codeOrder + ? "" + : ""; + var deleteCodeAction = canDelete && codeOrder && codeOrder.code_hint && codeOrder.code_hint !== "Gelöscht" + ? "" + : ""; + var deletePlantAction = canDelete + ? "" + : ""; + var billingPlant = plant.manager_variant === "billing_manager"; + var extendAction = billingPlant + ? "" + : ""; + return "
" + escapeHtml(plant.name) + "" + escapeHtml(plant.location) + "" + + "" + (billingPlant ? "Lizenztyp" : "Manager") + "" + escapeHtml(managerLabel(plant.manager_variant)) + "" + + "Lizenzen" + licenseCount + "" + + "Verwendung" + licenseState + "" + + "Verbindung" + escapeHtml(plant.installation_id ? (billingPlant ? "Abrechnung verbunden" : "Manager verbunden") : "Nicht verbunden") + "" + + "" + extendAction + codeAction + deleteCodeAction + deletePlantAction + "
"; + }).join("") + "
" : "
Noch keine Lizenz vorhanden

Lege eine Lizenz direkt an oder starte mit dem Systemkonfigurator.

"; + var options = managerPlants.map(function (plant) { return ""; }).join(""); + var orderValue = byId("orderPlant").value; + var forecastValue = byId("forecastPlant").value; + var faultValue = byId("faultPlant").value; + byId("orderPlant").innerHTML = options; + byId("forecastPlant").innerHTML = options; + byId("faultPlant").innerHTML = options; + if (managerPlants.some(function (plant) { return plant.id === orderValue; })) byId("orderPlant").value = orderValue; + if (managerPlants.some(function (plant) { return plant.id === forecastValue; })) byId("forecastPlant").value = forecastValue; + if (managerPlants.some(function (plant) { return plant.id === faultValue; })) byId("faultPlant").value = faultValue; + if (state.configuredPlantId && managerPlants.some(function (plant) { return plant.id === state.configuredPlantId; })) byId("orderPlant").value = state.configuredPlantId; + prepareOrderEditor(); + updateForecast(); + updateFaults(); + } + + function renderOrders() { + var labels = { creating_checkout: "Checkout wird erstellt", checkout_open: "Zahlung offen", checkout_failed: "Checkout fehlgeschlagen", payment_processing: "Zahlung wird verarbeitet", payment_failed: "Zahlung fehlgeschlagen", expired: "Abgelaufen", paid: "Bezahlt" }; + var canDelete = state.session.organization.type === "personal" || ["owner", "admin"].includes(state.session.organization.role); + byId("ordersBody").innerHTML = state.orders.length ? state.orders.map(function (order) { + var count = (order.lines || []).reduce(function (sum, line) { return sum + (line.line_type === "license" ? line.quantity : 0); }, 0); + var hasLicenseLines = (order.lines || []).some(function (line) { return line.line_type === "license"; }); + var action = "–"; + if (order.status === "paid" && !hasLicenseLines) { + action = "Generator / Ersteinrichtung bezahlt"; + } else if (order.status === "paid" && order.code_hint === "Gelöscht") { + action = "Code gelöscht"; + } else if (order.status === "paid" && order.code_hint) { + action = "" + + (canDelete ? "" : "") + + "" + escapeHtml(order.activated_installation_id ? "Verknüpft · " + order.code_hint : order.code_hint) + ""; + } else if (order.status === "paid") action = ""; + var receipt = order.status === "paid" ? "PDF" : "–"; + return ""; + }).join("") : ""; + } + + function selectedPlant(selectId) { + return state.plants.find(function (plant) { return plant.id === byId(selectId).value && plant.manager_variant !== "billing_manager"; }) + || state.plants.find(function (plant) { return plant.manager_variant !== "billing_manager"; }) + || null; + } + + function plantRecommendation(plant) { + if (!plant || !plant.recommendation_json) return null; + try { return JSON.parse(plant.recommendation_json); } catch (_) { return null; } + } + + function entitlementTime(value) { + return value ? Date.parse(String(value).replace(" ", "T") + "Z") : null; + } + + function entitlementIsActive(entitlement) { + var now = Date.now(); + var from = entitlementTime(entitlement.valid_from); + var until = entitlementTime(entitlement.valid_until); + return entitlement.status === "active" && (!from || from <= now) && (!until || until > now); + } + + function paidLicenseSummary(plantId) { + var summary = {}; + state.orders.filter(function (order) { + return order.plant_id === plantId && order.status === "paid"; + }).forEach(function (order) { + (order.entitlements || []).filter(entitlementIsActive).forEach(function (entitlement) { + summary[entitlement.catalog_key] = Number(summary[entitlement.catalog_key] || 0) + Number(entitlement.quantity || 0); + }); + }); + return summary; + } + + function latestEntitlementUntil(plantId, catalogKey) { + var values = []; + state.orders.filter(function (order) { + return order.plant_id === plantId && order.status === "paid"; + }).forEach(function (order) { + (order.entitlements || []).forEach(function (entitlement) { + if (entitlement.catalog_key === catalogKey && entitlement.valid_until) values.push(entitlement.valid_until); + }); + }); + return values.sort().at(-1) || null; + } + + function paidSetupSummary(plantId) { + var summary = {}; + state.orders.filter(function (order) { + return order.plant_id === plantId && order.status === "paid"; + }).forEach(function (order) { + (order.lines || []).forEach(function (line) { + if (line.line_type !== "setup") return; + summary[line.catalog_key] = Number(summary[line.catalog_key] || 0) + Number(line.quantity || 0); + }); + }); + return summary; + } + + function paidManagerVariant(plant) { + var managerKeys = []; + state.orders.filter(function (order) { + return order.plant_id === plant.id && order.status === "paid"; + }).forEach(function (order) { + (order.lines || []).forEach(function (line) { + if (line.line_type === "license" && ["manager_standard", "manager_peak"].includes(line.catalog_key)) managerKeys.push(line.catalog_key); + }); + }); + if (managerKeys.includes("manager_peak")) return "manager_peak"; + if (managerKeys.includes("manager_standard")) return "manager_standard"; + return null; + } + + function currentManagerVariant(plant) { + return paidManagerVariant(plant) || plantRecommendation(plant)?.managerVariant || plant.manager_variant || "manager_standard"; + } + + function selectedManagerVariant(plant) { + var ownedManager = paidManagerVariant(plant); + if (!ownedManager) return plantRecommendation(plant)?.managerVariant || plant.manager_variant || "manager_standard"; + if (ownedManager === "manager_standard" && byId("peakUpgrade").checked) return "manager_peak"; + return ownedManager; + } + + function licenseCoverage(requested, owned) { + var coverage = Object.assign({}, owned); + var 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; + } + + function prepareOrderEditor(configurationId) { + var plant = selectedPlant("orderPlant"); + if (!plant) return; + var configurationPurchase = Boolean(configurationId); + state.checkoutConfigurationId = configurationPurchase ? configurationId : null; + var recommendation = plantRecommendation(plant); + var owned = paidLicenseSummary(plant.id); + var setupOwned = paidSetupSummary(plant.id); + var coverage = licenseCoverage(recommendation?.modules || {}, owned); + var ownedManager = paidManagerVariant(plant); + var desiredManager = recommendation?.managerVariant || plant.manager_variant || "manager_standard"; + byId("managerCurrentName").textContent = ownedManager ? managerLabel(ownedManager) : managerLabel(desiredManager); + byId("ownedManager").textContent = ownedManager ? "Bereits gekauft" : "Wird beim Erstkauf einmalig berechnet"; + byId("peakUpgradeOption").classList.toggle("hidden", ownedManager !== "manager_standard"); + byId("peakUpgrade").checked = ownedManager === "manager_standard" && desiredManager === "manager_peak"; + byId("peakUpgrade").disabled = configurationPurchase; + document.querySelectorAll(".module-row").forEach(function (row) { + var recommendedQuantity = recommendation ? Number(recommendation.modules[row.dataset.key] || 0) : 0; + var ownedQuantity = Number(owned[row.dataset.key] || 0); + var licenseQuantity = Math.max(0, recommendedQuantity - Number(coverage[row.dataset.key] || 0)); + var setupQuantity = configurationPurchase + ? Math.max(0, recommendedQuantity - Number(setupOwned[row.dataset.key] || 0)) + : 0; + var quantity = Math.max(licenseQuantity, setupQuantity); + row.dataset.licenseQuantity = String(licenseQuantity); + row.dataset.setupQuantity = String(setupQuantity); + var validUntil = latestEntitlementUntil(plant.id, row.dataset.key); + var ownedLabel = row.querySelector(".owned-count"); + ownedLabel.textContent = Number(row.dataset.termMonths || 0) > 0 + ? (validUntil ? "Gültig bis " + new Date(entitlementTime(validUntil)).toLocaleDateString("de-CH") : "Nicht aktiviert") + : "Vorhanden: " + ownedQuantity; + row.querySelector(".module-check").checked = configurationPurchase + ? quantity > 0 + : licenseQuantity > 0; + row.querySelector(".qty").value = quantity > 0 ? quantity : 1; + row.querySelector(".module-check").disabled = configurationPurchase; + row.querySelector(".qty").disabled = configurationPurchase; + }); + updateOrder(); + } + + function updateOrder() { + var plant = selectedPlant("orderPlant"); + if (!plant) return; + var configurationPurchase = Boolean(state.checkoutConfigurationId); + var ownedManager = paidManagerVariant(plant); + var setupOwned = paidSetupSummary(plant.id); + var targetManager = selectedManagerVariant(plant); + var managerItem = null; + if (!ownedManager) managerItem = managerPrices[targetManager] || managerPrices.manager_standard; + else if (ownedManager === "manager_standard" && targetManager === "manager_peak") managerItem = managerPrices.manager_peak_upgrade; + + var managerSetupPaid = Number(setupOwned.manager_standard || 0) > 0 + || Number(setupOwned.manager_peak || 0) > 0; + var managerSetupPrice = configurationPurchase && !managerSetupPaid + ? (managerPrices[targetManager] || managerPrices.manager_standard).setup + : 0; + byId("managerPrice").textContent = managerItem ? money.format(managerItem.price) : money.format(0); + byId("managerSetup").textContent = configurationPurchase + ? (managerSetupPrice > 0 + ? "Generator / Ersteinrichtung " + money.format(managerSetupPrice) + : "Ersteinrichtung bereits bezahlt") + : (managerItem ? "Nur Lizenz" : "Kein weiterer Managerkauf"); + + state.selectedOrder = []; + document.querySelectorAll(".module-row").forEach(function (row) { + var quantity = Math.min(999, Math.max(1, Number(row.querySelector(".qty").value) || 1)); + row.querySelector(".qty").value = quantity; + var licenseQuantity = configurationPurchase + ? Number(row.dataset.licenseQuantity || 0) + : (row.querySelector(".module-check").checked ? quantity : 0); + var setupQuantity = configurationPurchase + ? Number(row.dataset.setupQuantity || 0) + : 0; + if (licenseQuantity > 0 || setupQuantity > 0) { + state.selectedOrder.push({ + key: row.dataset.key, + name: row.dataset.name, + price: Number(row.dataset.price), + setup: Number(row.dataset.setup), + licenseQuantity: licenseQuantity, + setupQuantity: setupQuantity + }); + } + }); + + var managerLicensePrice = managerItem ? managerItem.price : 0; + var licensePrice = managerLicensePrice + state.selectedOrder.reduce(function (sum, item) { + return sum + item.price * item.licenseQuantity; + }, 0); + var setupPrice = managerSetupPrice + state.selectedOrder.reduce(function (sum, item) { + return sum + item.setup * item.setupQuantity; + }, 0); + var items = []; + if (managerItem) items.push({ name: managerItem.name, quantity: 1, total: managerItem.price }); + state.selectedOrder.forEach(function (item) { + if (item.licenseQuantity > 0) { + items.push({ name: item.name, quantity: item.licenseQuantity, total: item.price * item.licenseQuantity }); + } + }); + if (managerSetupPrice > 0) { + items.push({ name: "Generator / Ersteinrichtung Manager", quantity: 1, total: managerSetupPrice }); + } + state.selectedOrder.forEach(function (item) { + if (item.setupQuantity > 0) { + items.push({ + name: "Generator / Ersteinrichtung " + item.name, + quantity: item.setupQuantity, + total: item.setup * item.setupQuantity + }); + } + }); + byId("orderItems").innerHTML = items.length + ? items.map(function (item) { return "
" + escapeHtml(item.name) + " × " + item.quantity + "" + money.format(item.total) + "
"; }).join("") + : "

Keine zusätzliche Lizenz oder Ersteinrichtung erforderlich.

"; + var netTotal = licensePrice + setupPrice; + var vatTotal = Math.round(netTotal * state.vatRate) / 100; + byId("moduleTotal").textContent = money.format(licensePrice); + byId("setupTotal").textContent = money.format(setupPrice); + byId("setupTotalRow").classList.toggle("hidden", !configurationPurchase); + byId("netTotal").textContent = money.format(netTotal); + byId("vatTotal").textContent = money.format(vatTotal); + byId("grandTotal").textContent = money.format(netTotal + vatTotal); + var canCheckout = state.session.organization.type === "personal" || ["owner", "admin", "billing"].includes(state.session.organization.role); + var checkoutButton = byId("checkoutButton"); + var freeOrder = netTotal === 0 && items.length > 0; + checkoutButton.dataset.free = freeOrder ? "true" : "false"; + checkoutButton.textContent = freeOrder ? "Kostenlos freischalten" : "Mit Stripe bezahlen"; + checkoutButton.disabled = (!state.stripeConfigured && !freeOrder) || !canCheckout || items.length === 0; + } + + var forecastSeriesLabels = { + PV: "PV-Messwert", + Hausverbrauch: "Verbrauchsmesswert", + Netzleistung: "Netzmesswert", + SOC: "Batterie-SOC", + prog_var_1: "PV-Prognose · Modell 1", + prog_var_2: "Verbrauchsprognose · Modell 2", + prog_var_3: "Netzfahrplan · Modell 3 (+ Bezug / − Einspeisung)", + prog_var_3_battery: "Batterieplan · Modell 3 (+ Laden / − Entladen)", + prog_var_10: "PV-Prognose · Modell 10", + prog_var_11: "Verbrauchsprognose · Modell 11", + prog_var_13: "Netzfahrplan · Modell 13 (+ Bezug / − Einspeisung)", + prog_var_13_battery: "Batterieplan · Modell 13 (+ Laden / − Entladen)", + prog_var_21: "PV-Prognose · Modell 21", + prog_var_22: "Verbrauchsprognose · Modell 22", + prog_var_23: "Netzfahrplan · Modell 23 (+ Bezug / − Einspeisung)", + prog_var_23_battery: "Batterieplan · Modell 23 (+ Laden / − Entladen)" + }; + var forecastColors = { + PV: "#006b62", + prog_var_1: "#2a9d8f", + prog_var_10: "#62bbb0", + prog_var_21: "#98d7cf", + Hausverbrauch: "#b93242", + prog_var_2: "#dd5a68", + prog_var_11: "#ea8d98", + prog_var_22: "#f2bcc2", + Netzleistung: "#2f6ecb", + prog_var_3: "#5a8ce0", + prog_var_13: "#86adeb", + prog_var_23: "#b2caf2", + prog_var_3_battery: "#8c4b78", + prog_var_13_battery: "#a96b91", + prog_var_23_battery: "#c38eac", + SOC: "#d68a00" + }; + var forecastFamilies = [ + { pv: "prog_var_1", load: "prog_var_2", schedule: "prog_var_3" }, + { pv: "prog_var_10", load: "prog_var_11", schedule: "prog_var_13" }, + { pv: "prog_var_21", load: "prog_var_22", schedule: "prog_var_23" } + ]; + + function forecastSeriesLabel(key) { + return forecastSeriesLabels[key] || key; + } + + function forecastSeriesColor(key) { + return forecastColors[key] || "#7b8784"; + } + + function forecastDefaultVisibleSeries(result) { + var available = new Set(((result && result.series) || []).map(function (entry) { return entry.key; })); + var schedule = (result && result.scheduleSourceModel) + || ["prog_var_3", "prog_var_13", "prog_var_23"].find(function (key) { return available.has(key); }) + || "prog_var_3"; + var family = forecastFamilies.find(function (entry) { return entry.schedule === schedule; }) + || forecastFamilies[0]; + return new Set([ + "PV", + "Hausverbrauch", + "Netzleistung", + "SOC", + family.pv, + family.load, + schedule, + schedule + "_battery" + ]); + } + + function forecastInputValue(timestamp) { + var date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ""; + var local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return local.toISOString().slice(0, 16); + } + + function forecastWindowLabel(start, end) { + var hours = Math.max(0, Math.round((end - start) / 3600000)); + if (hours >= 48 && hours % 24 === 0) return (hours / 24) + " Tage"; + return hours + " h"; + } + + function formatForecastHoverDate(value) { + var date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return new Intl.DateTimeFormat("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(date); + } + + function formatForecastHoverValue(key, value) { + var number = Number(value); + if (!Number.isFinite(number)) return "-"; + if (key === "SOC") return number.toLocaleString("de-CH", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " %"; + return number.toLocaleString("de-CH", { maximumFractionDigits: 0 }) + " W"; + } + + function attachForecastHover(series, minTime, maxTime, width, height, padding) { + var chart = byId("forecastChart"); + var tooltip = byId("forecastTooltip"); + chart.onpointermove = function (event) { + var rect = chart.getBoundingClientRect(); + var sx = (event.clientX - rect.left) / rect.width * width; + if (sx < padding.left || sx > width - padding.right) { + tooltip.classList.add("hidden"); + var outsideLine = byId("forecastHoverLine"); + if (outsideLine) outsideLine.classList.add("hidden"); + return; + } + var time = minTime + (sx - padding.left) / (width - padding.left - padding.right) * (maxTime - minTime); + var tolerance = Math.max(30, Number(state.forecastResult && state.forecastResult.intervalMinutes) || 15) * 60 * 1000 * 1.6; + var rows = series.map(function (entry) { + var nearest = null; + entry.points.forEach(function (point) { + var pt = Date.parse(point.time), value = Number(point.value); + if (!Number.isFinite(pt) || !Number.isFinite(value)) return; + if (!nearest || Math.abs(pt - time) < Math.abs(nearest.time - time)) nearest = { time: pt, value: value }; + }); + return nearest && Math.abs(nearest.time - time) <= tolerance ? { key: entry.key, time: nearest.time, value: nearest.value } : null; + }).filter(Boolean); + if (!rows.length) return; + var referenceTime = rows.reduce(function (best, row) { return Math.abs(row.time - time) < Math.abs(best - time) ? row.time : best; }, rows[0].time); + tooltip.innerHTML = "" + escapeHtml(formatForecastHoverDate(referenceTime)) + "" + rows.map(function (row) { + return "" + escapeHtml(forecastSeriesLabel(row.key)) + "" + escapeHtml(formatForecastHoverValue(row.key, row.value)) + ""; + }).join(""); + tooltip.classList.remove("hidden"); + tooltip.style.left = Math.min(Math.max(event.clientX - rect.left + 14, 8), rect.width - tooltip.offsetWidth - 8) + "px"; + tooltip.style.top = Math.min(Math.max(event.clientY - rect.top + 14, 8), rect.height - tooltip.offsetHeight - 8) + "px"; + var line = byId("forecastHoverLine"); + if (line) { line.setAttribute("x1", sx); line.setAttribute("x2", sx); line.classList.remove("hidden"); } + }; + chart.onpointerleave = function () { + tooltip.classList.add("hidden"); + var line = byId("forecastHoverLine"); + if (line) line.classList.add("hidden"); + }; + } + + function formatForecastDate(value) { + var date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "–"; + return new Intl.DateTimeFormat("de-CH", { + day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" + }).format(date); + } + + function setForecastState(message) { + byId("forecastState").textContent = message; + byId("forecastState").classList.remove("hidden"); + byId("forecastChart").classList.add("hidden"); + } + + function resetForecastChart() { + byId("forecastChart").innerHTML = ""; + byId("forecastChart").classList.add("hidden"); + byId("forecastSeriesControls").innerHTML = ""; + byId("forecastLegend").innerHTML = ""; + byId("forecastLegend").classList.add("hidden"); + byId("forecastTooltip").classList.add("hidden"); + byId("forecastLastUpdated").textContent = "–"; + byId("forecastSeriesCount").textContent = "0"; + byId("forecastQualityStatus").className = "status review"; + byId("forecastQualityStatus").textContent = "Aufbauphase"; + byId("forecastQualityState").textContent = "Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie."; + byId("forecastQualityState").classList.remove("hidden"); + byId("forecastQualityTable").classList.add("hidden"); + byId("forecastQualityBody").innerHTML = ""; + byId("forecastConfigSection").classList.add("hidden"); + byId("forecastConfigError").textContent = ""; + } + + function renderForecastLicenseWarning(plant) { + var warning = byId("forecastOfflineWarning"); + var lastSeen = plant.license_last_seen_at + ? new Date(String(plant.license_last_seen_at).replace(" ", "T") + "Z") + : null; + var now = Date.now(); + var refreshDue = lastSeen ? lastSeen.getTime() + 24 * 60 * 60 * 1000 : 0; + var offlineUntil = lastSeen ? lastSeen.getTime() + 14 * 24 * 60 * 60 * 1000 : 0; + var showOffline = Boolean(plant.installation_id && lastSeen && !Number.isNaN(lastSeen.getTime()) && now > refreshDue); + warning.classList.toggle("hidden", !showOffline); + if (!showOffline) return; + + var remainingDays = Math.max(0, Math.ceil((offlineUntil - now) / (24 * 60 * 60 * 1000))); + byId("forecastOfflineTitle").textContent = remainingDays > 0 + ? "Kein Internet am Manager erkannt" + : "Offline-Freigabe abgelaufen"; + byId("forecastOfflineText").textContent = remainingDays > 0 + ? "Die lokale Lizenzfreigabe läuft in " + remainingDays + " " + (remainingDays === 1 ? "Tag" : "Tagen") + " ab, falls keine Verbindung zum Lizenzserver hergestellt wird." + : "Der Manager benötigt eine Internetverbindung für eine neue Lizenzprüfung."; + } + + function formatForecastMetric(value, digits, suffix) { + if (value === null || value === undefined || value === "") return "–"; + var number = Number(value); + if (!Number.isFinite(number)) return "–"; + return number.toLocaleString("de-CH", { + minimumFractionDigits: digits, + maximumFractionDigits: digits + }) + (suffix || ""); + } + + function renderForecastQuality(quality, capabilities) { + var available = quality && Array.isArray(quality.series) + ? quality.series.filter(function (entry) { + return entry && entry.key && Number(entry.samples) > 0; + }) + : []; + var byKey = new Map(available.map(function (entry) { return [entry.key, entry]; })); + var rows = []; + var addPreferred = function (keys) { + var candidates = keys.map(function (key) { return byKey.get(key); }).filter(Boolean); + candidates.sort(function (left, right) { + var a = Number.isFinite(Number(left.r2)) ? Number(left.r2) : -Infinity; + var b = Number.isFinite(Number(right.r2)) ? Number(right.r2) : -Infinity; + return b - a; + }); + if (candidates.length) rows.push(candidates[0]); + }; + if (capabilities && capabilities.pv) addPreferred(["prog_var_1", "prog_var_10", "prog_var_21"]); + if (capabilities && capabilities.load) addPreferred(["prog_var_2", "prog_var_11", "prog_var_22"]); + var status = byId("forecastQualityStatus"); + + if (!rows.length) { + status.className = "status review"; + status.textContent = "Aufbauphase"; + byId("forecastQualityState").textContent = "Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie."; + byId("forecastQualityState").classList.remove("hidden"); + byId("forecastQualityTable").classList.add("hidden"); + byId("forecastQualityBody").innerHTML = ""; + return; + } + + var mature = rows.length > 0 + && rows.every(function (entry) { return Number(entry.samples) >= 2016; }); + status.className = mature ? "status available" : "status review"; + status.textContent = mature ? "Vergleich bereit" : "Aufbauphase"; + byId("forecastQualityBody").innerHTML = rows.map(function (entry) { + var target = entry.target === "PV" ? "PV" : "Verbrauch"; + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + }).join(""); + byId("forecastQualityState").classList.add("hidden"); + byId("forecastQualityTable").classList.remove("hidden"); + } + + function renderForecastResult(result) { + state.forecastResult = result; + if (!state.forecastVisibleSeries) state.forecastVisibleSeries = forecastDefaultVisibleSeries(result); + byId("forecastLastUpdated").textContent = formatForecastDate(result.generatedAt); + byId("forecastSeriesCount").textContent = String((result.series || []).length); + renderForecastQuality(result.quality, result.forecastCapabilities || { + pv: true, + load: true, + schedule: true + }); + if (result.status === "license_required") { + byId("forecastApiStatus").textContent = "Keine aktive Prognoselizenz"; + return setForecastState("Für dieses System ist eine aktive Prognoselizenz erforderlich."); + } + if (result.status === "not_connected") { + byId("forecastApiStatus").textContent = "Manager noch nicht verbunden"; + return setForecastState("Verbinde den Manager zuerst mit dem Lizenzcode dieses Systems."); + } + if (result.status === "no_data") { + byId("forecastApiStatus").textContent = "Verbindung bereit, noch keine Daten"; + return setForecastState("Für dieses System liegen im gewählten Zeitfenster noch keine Prognosedaten vor."); + } + byId("forecastApiStatus").textContent = result.configured + ? "Prognose aktiv" + : "Daten empfangen, Konfiguration offen"; + renderForecastChart(result.series || []); + } + + function renderForecastChart(inputSeries) { + var priority = [ + "PV", "Hausverbrauch", "Netzleistung", "SOC", + "prog_var_1", "prog_var_2", "prog_var_3", "prog_var_3_battery", + "prog_var_10", "prog_var_11", "prog_var_13", "prog_var_13_battery", + "prog_var_21", "prog_var_22", "prog_var_23", "prog_var_23_battery" + ]; + var availableSeries = inputSeries.filter(function (entry) { + return entry && Array.isArray(entry.points) && entry.points.length; + }).sort(function (left, right) { + var a = priority.indexOf(left.key); + var b = priority.indexOf(right.key); + return (a < 0 ? 99 : a) - (b < 0 ? 99 : b); + }); + var availablePoints = []; + availableSeries.forEach(function (entry) { + entry.points.forEach(function (point) { + var time = Date.parse(point.time); + var value = Number(point.value); + if (Number.isFinite(time) && Number.isFinite(value)) availablePoints.push({ time: time, value: value }); + }); + }); + if (!availablePoints.length) return setForecastState("Keine darstellbaren Prognosedaten vorhanden."); + + var availableMin = Math.min.apply(null, availablePoints.map(function (point) { return point.time; })); + var availableMax = Math.max.apply(null, availablePoints.map(function (point) { return point.time; })); + if (!Number.isFinite(state.forecastRangeStart) || state.forecastRangeStart < availableMin || state.forecastRangeStart >= availableMax) { + state.forecastRangeStart = availableMin; + } + if (!Number.isFinite(state.forecastRangeEnd) || state.forecastRangeEnd > availableMax || state.forecastRangeEnd <= availableMin) { + state.forecastRangeEnd = availableMax; + } + if (state.forecastRangeStart >= state.forecastRangeEnd) { + state.forecastRangeStart = availableMin; + state.forecastRangeEnd = availableMax; + } + + var startInput = byId("forecastRangeStart"); + var endInput = byId("forecastRangeEnd"); + startInput.min = forecastInputValue(availableMin); + startInput.max = forecastInputValue(availableMax); + endInput.min = forecastInputValue(availableMin); + endInput.max = forecastInputValue(availableMax); + startInput.value = forecastInputValue(state.forecastRangeStart); + endInput.value = forecastInputValue(state.forecastRangeEnd); + byId("forecastWindowValue").textContent = forecastWindowLabel(state.forecastRangeStart, state.forecastRangeEnd); + + if (!state.forecastVisibleSeries) { + state.forecastVisibleSeries = forecastDefaultVisibleSeries(state.forecastResult || {}); + } + byId("forecastSeriesControls").innerHTML = availableSeries.map(function (entry) { + var checked = state.forecastVisibleSeries.has(entry.key) ? " checked" : ""; + var prediction = entry.key.indexOf("prog_var_") === 0 ? " prediction" : ""; + var activeSchedule = state.forecastResult && state.forecastResult.scheduleSourceModel === entry.key ? " · aktiv" : ""; + return ""; + }).join(""); + + var series = availableSeries.filter(function (entry) { + return state.forecastVisibleSeries.has(entry.key); + }).map(function (entry) { + return { + key: entry.key, + points: entry.points.filter(function (point) { + var time = Date.parse(point.time); + return Number.isFinite(time) && time >= state.forecastRangeStart && time <= state.forecastRangeEnd; + }) + }; + }).filter(function (entry) { return entry.points.length; }); + + var legend = byId("forecastLegend"); + legend.innerHTML = series.map(function (entry) { + var prediction = entry.key.indexOf("prog_var_") === 0 ? " prediction" : ""; + var activeSchedule = state.forecastResult && state.forecastResult.scheduleSourceModel === entry.key ? " · aktiv" : ""; + return "" + escapeHtml(forecastSeriesLabel(entry.key) + activeSchedule) + ""; + }).join(""); + legend.classList.toggle("hidden", !series.length); + + byId("forecastSeriesCount").textContent = String(series.length); + if (!series.length) return setForecastState("Im gewählten Zeitfenster sind für die eingeblendeten Reihen keine Daten vorhanden."); + + var allPoints = []; + series.forEach(function (entry) { + entry.points.forEach(function (point) { + var time = Date.parse(point.time); + var value = Number(point.value); + if (Number.isFinite(time) && Number.isFinite(value)) allPoints.push({ time: time, value: value }); + }); + }); + if (!allPoints.length) return setForecastState("Im gewählten Zeitfenster sind keine darstellbaren Daten vorhanden."); + + var width = 960; + var height = 320; + var padding = { left: 62, right: 24, top: 22, bottom: 42 }; + var minTime = state.forecastRangeStart; + var maxTime = state.forecastRangeEnd; + var minValue = Math.min(0, Math.min.apply(null, allPoints.map(function (point) { return point.value; }))); + var maxValue = Math.max(0, Math.max.apply(null, allPoints.map(function (point) { return point.value; }))); + if (maxTime === minTime) maxTime += 1; + if (maxValue === minValue) maxValue += 1; + var x = function (time) { return padding.left + (time - minTime) / (maxTime - minTime) * (width - padding.left - padding.right); }; + var y = function (value) { return padding.top + (maxValue - value) / (maxValue - minValue) * (height - padding.top - padding.bottom); }; + var svg = []; + + for (var grid = 0; grid <= 4; grid += 1) { + var gy = padding.top + grid / 4 * (height - padding.top - padding.bottom); + var label = maxValue - grid / 4 * (maxValue - minValue); + svg.push(""); + svg.push("" + label.toFixed(1) + ""); + } + + var now = Date.now(); + if (now >= minTime && now <= maxTime) { + var nowX = x(now); + svg.push(""); + svg.push("Jetzt"); + } + + series.forEach(function (entry) { + var coordinates = entry.points.map(function (point) { + return { time: Date.parse(point.time), value: Number(point.value) }; + }).filter(function (point) { + return Number.isFinite(point.time) && Number.isFinite(point.value); + }).sort(function (a, b) { return a.time - b.time; }).map(function (point) { + return x(point.time).toFixed(2) + "," + y(point.value).toFixed(2); + }).join(" "); + var lineClass = entry.key.indexOf("prog_var_") === 0 + ? "forecast-series-line forecast-series-prediction" + : "forecast-series-line"; + svg.push(""); + }); + + svg.push(""; + }).join(""); + } + + function renderFaultResult(result, plant) { + var active = result.active || []; + var history = result.history || []; + var labels = { + available: ["Aktuell", "available", "Störungsdaten sind aktuell."], + offline: ["Offline", "review", "Seit mehr als 15 Minuten keine Daten empfangen."], + no_data: ["Wartet auf Daten", "neutral", "Der Manager hat noch keinen Störungssnapshot gesendet."], + not_connected: ["Nicht verbunden", "review", "Das System ist noch nicht mit einem Manager verknüpft."], + license_required: ["Managerlizenz erforderlich", "review", "Für dieses System ist noch keine aktive Managerlizenz vorhanden."] + }; + var status = labels[result.status] || ["Unbekannt", "review", "Der Störungsstatus konnte nicht bestimmt werden."]; + var managerInactive = result.manager && !result.manager.active; + byId("faultConnectionStatus").textContent = managerInactive ? "Manager inaktiv" : status[0]; + byId("faultConnectionStatus").className = "status " + (managerInactive ? "review" : status[1]); + byId("faultConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft"; + byId("faultApiStatus").textContent = managerInactive ? "Der Manager meldet sich als inaktiv." : status[2]; + byId("faultSignal").classList.toggle("offline", result.status !== "available" || managerInactive); + byId("faultActiveCount").textContent = String(active.length); + byId("faultUrgentCount").textContent = String(active.filter(function (fault) { return ["critical", "error"].includes(fault.severity); }).length); + byId("faultLastUpdated").textContent = faultDate(result.lastReceivedAt); + + byId("faultActiveBody").innerHTML = faultRows(active, false); + byId("faultActiveTable").classList.toggle("hidden", active.length === 0); + byId("faultState").classList.toggle("hidden", active.length > 0); + byId("faultState").textContent = result.status === "available" && active.length === 0 + ? "Keine aktiven Störungen." + : status[2]; + + byId("faultHistoryBody").innerHTML = faultRows(history, true); + byId("faultHistoryTable").classList.toggle("hidden", history.length === 0); + byId("faultHistoryState").classList.toggle("hidden", history.length > 0); + byId("faultHistoryState").textContent = "Noch keine behobenen Störungen."; + } + + async function updateFaults() { + var plant = selectedPlant("faultPlant"); + var requestId = ++state.faultRequest; + resetFaultDisplay("Störungsdaten werden geladen."); + resetFaultRecipients("Empfänger werden geladen."); + if (!plant) { + byId("faultConnectionStatus").textContent = "Kein System"; + byId("faultConnectionStatus").className = "status neutral"; + resetFaultRecipients("Für die Empfängerverwaltung muss eine Anlage gewählt sein."); + return; + } + + byId("faultConnection").textContent = plant.installation_id ? "Verbunden mit " + plant.installation_id : "Noch nicht verknüpft"; + if (!byId("view-faults").classList.contains("active")) return; + + byId("faultApiStatus").textContent = "Datenverbindung wird geprüft"; + var responses = await Promise.allSettled([ + api("/api/plants/" + plant.id + "/faults"), + api("/api/plants/" + plant.id + "/fault-notification-recipients") + ]); + if (requestId !== state.faultRequest) return; + if (responses[0].status === "fulfilled") { + renderFaultResult(responses[0].value, plant); + } else { + byId("faultConnectionStatus").textContent = "Nicht erreichbar"; + byId("faultConnectionStatus").className = "status review"; + byId("faultApiStatus").textContent = "Störungsdienst nicht erreichbar"; + byId("faultSignal").classList.add("offline"); + resetFaultDisplay(responses[0].reason.message); + } + if (responses[1].status === "fulfilled") { + renderFaultRecipients(responses[1].value); + } else { + resetFaultRecipients(responses[1].reason.message); + } + } + + function configurePlantLegacy(plant) { + if (!plant) return; + var owned = paidLicenseSummary(plant.id); + var configuration = null; + try { configuration = plant.configuration_json ? JSON.parse(plant.configuration_json) : null; } catch (_) { configuration = null; } + configuration = configuration || { + name: plant.name, + location: plant.location, + siteType: plant.type, + peakShaving: currentManagerVariant(plant) === "manager_peak", + gridMeasurement: { manufacturer: "", host: "", source: "", notes: "" }, + pvSystem: { enabled: false, manufacturer: "", host: "" }, + costReport: { enabled: false, powerMeters: 0, auxiliaryMeters: 0 }, + devices: { + battery: { quantity: owned.battery || 0, name: "" }, + buffer_storage: { quantity: owned.buffer_storage || 0, name: "" }, + ev_charger: { quantity: owned.ev_charger || 0, model: "", address: "" }, + consumer_single: { quantity: owned.consumer_single || 0, name: "", ratedPower: 0 }, + boiler_multi: { quantity: owned.boiler_multi || 0, name: "", volume: 0 } + } + }; + configuration.devices = configuration.devices || {}; + if (!Object.prototype.hasOwnProperty.call(configuration.devices, "buffer_storage")) { + configuration.devices.buffer_storage = configuration.devices.battery || {}; + configuration.devices.battery = {}; + } + Object.keys(deviceLabels).forEach(function (key) { + configuration.devices[key] = configuration.devices[key] || {}; + configuration.devices[key].quantity = Math.max(Number(configuration.devices[key].quantity || 0), Number(owned[key] || 0)); + }); + var form = byId("systemForm"); + form.dataset.plantId = plant.id; + form.elements.name.value = configuration.name || plant.name; + form.elements.location.value = configuration.location || plant.location; + form.elements.siteType.value = configuration.siteType || plant.type; + form.elements.peakShaving.checked = Boolean(configuration.peakShaving); + form.elements.gridManufacturer.value = configuration.gridMeasurement?.manufacturer || ""; + form.elements.gridHost.value = configuration.gridMeasurement?.host || ""; + form.elements.gridSource.value = configuration.gridMeasurement?.source || ""; + form.elements.gridNotes.value = configuration.gridMeasurement?.notes || ""; + form.elements.pvEnabled.checked = Boolean(configuration.pvSystem?.enabled); + form.elements.pvManufacturer.value = configuration.pvSystem?.manufacturer || ""; + form.elements.pvHost.value = configuration.pvSystem?.host || ""; + form.elements.batteryQuantity.value = configuration.devices?.battery?.quantity || 0; + form.elements.batteryName.value = configuration.devices?.battery?.name || ""; + form.elements.bufferQuantity.value = configuration.devices?.buffer_storage?.quantity || 0; + form.elements.bufferName.value = configuration.devices?.buffer_storage?.name || ""; + form.elements.evQuantity.value = configuration.devices?.ev_charger?.quantity || 0; + form.elements.evModel.value = configuration.devices?.ev_charger?.model || ""; + form.elements.evAddress.value = configuration.devices?.ev_charger?.address || ""; + form.elements.consumerQuantity.value = configuration.devices?.consumer_single?.quantity || 0; + form.elements.consumerName.value = configuration.devices?.consumer_single?.name || ""; + form.elements.consumerPower.value = configuration.devices?.consumer_single?.ratedPower || 0; + form.elements.consumerHardware.value = configuration.devices?.consumer_single?.hardware || ""; + form.elements.consumerTopic.value = configuration.devices?.consumer_single?.topicPrefix || ""; + form.elements.boilerQuantity.value = configuration.devices?.boiler_multi?.quantity || 0; + form.elements.boilerName.value = configuration.devices?.boiler_multi?.name || ""; + form.elements.boilerVolume.value = configuration.devices?.boiler_multi?.volume || 0; + form.elements.boilerHardware.value = configuration.devices?.boiler_multi?.hardware || ""; + form.elements.boilerTopic.value = configuration.devices?.boiler_multi?.topicPrefix || ""; + form.elements.costReportEnabled.checked = Boolean(configuration.costReport?.enabled); + form.elements.costPowerMeters.value = configuration.costReport?.powerMeters || 0; + form.elements.costAuxMeters.value = configuration.costReport?.auxiliaryMeters || 0; + byId("packageResult").classList.add("hidden"); + showWizardStep(0); + updateRecommendation(); + showView("configurator"); + } + + function systemPayloadLegacy(form) { + var values = Object.fromEntries(new FormData(form).entries()); + return { + plantId: form.dataset.plantId || "", + name: values.name, location: values.location, siteType: values.siteType, peakShaving: Boolean(values.peakShaving), + gridMeasurement: { manufacturer: values.gridManufacturer, host: values.gridHost, source: values.gridSource, notes: values.gridNotes }, + pvSystem: { enabled: Boolean(values.pvEnabled), manufacturer: values.pvManufacturer, host: values.pvHost }, + costReport: { enabled: Boolean(values.costReportEnabled), powerMeters: Number(values.costPowerMeters || 0), auxiliaryMeters: Number(values.costAuxMeters || 0) }, + devices: { + battery: { quantity: Number(values.batteryQuantity), name: values.batteryName }, + buffer_storage: { quantity: Number(values.bufferQuantity), name: values.bufferName }, + ev_charger: { quantity: Number(values.evQuantity), name: "Ladestation", model: values.evModel, address: values.evAddress }, + consumer_single: { quantity: Number(values.consumerQuantity), name: values.consumerName, ratedPower: Number(values.consumerPower || 0), hardware: values.consumerHardware, topicPrefix: values.consumerTopic }, + boiler_multi: { quantity: Number(values.boilerQuantity), name: values.boilerName, volume: Number(values.boilerVolume || 0), hardware: values.boilerHardware, topicPrefix: values.boilerTopic } + } + }; + } + + function schemaDefaults(fields) { + return Object.fromEntries((fields || []).map(function (field) { return [field.key, field.default]; })); + } + + function renderSchemaField(field, value, attribute) { + var data = " " + attribute + "='" + escapeHtml(field.key) + "'"; + if (field.type === "boolean") { + return ""; + } + if (field.type === "select" || field.type === "choice") { + var options = (field.options || []).map(function (option) { + return ""; + }).join(""); + return ""; + } + if (field.type === "json") { + return ""; + } + var type = ["integer", "number"].includes(field.type) ? "number" : "text"; + var step = field.type === "number" ? " step='any'" : ""; + var min = field.min == null ? "" : " min='" + escapeHtml(field.min) + "'"; + var max = field.max == null ? "" : " max='" + escapeHtml(field.max) + "'"; + return ""; + } + + function renderSchemaGroups(fields, values, attribute) { + var groups = {}; + (fields || []).forEach(function (field) { + groups[field.section] = groups[field.section] || []; + groups[field.section].push(field); + }); + return Object.keys(groups).map(function (section) { + var controls = groups[section].map(function (field) { + return renderSchemaField(field, Object.prototype.hasOwnProperty.call(values || {}, field.key) ? values[field.key] : field.default, attribute); + }).join(""); + return "

" + escapeHtml(section) + "

" + controls + "
"; + }).join(""); + } + + function readSchemaValues(root, fields, attribute) { + var result = {}; + (fields || []).forEach(function (field) { + var control = root.querySelector("[" + attribute + "='" + field.key + "']"); + if (!control) return; + if (field.type === "boolean") result[field.key] = control.checked; + else if (field.type === "integer" || field.type === "select") result[field.key] = Number(control.value || 0); + else if (field.type === "choice") result[field.key] = control.value; + else if (field.type === "number") result[field.key] = Number(control.value || 0); + else result[field.key] = control.value; + }); + return result; + } + + function profileOptions(kind, selected) { + return (state.wizardCatalog?.integrations?.[kind] || []).map(function (profile) { + var label = profile.manufacturer + " · " + profile.name; + return ""; + }).join(""); + } + + function selectedProfile(kind, id) { + var profiles = state.wizardCatalog?.integrations?.[kind] || []; + return profiles.find(function (entry) { return entry.id === id; }) || profiles[0] || {}; + } + + function integrationKind(key) { + if (key === "battery") return "storage"; + if (["buffer_storage", "consumer_single", "boiler_multi"].includes(key)) return "actuator"; + return ""; + } + + function renderIntegrationFields(key, integration, compact) { + var kind = integrationKind(key); + if (!kind) return ""; + var selected = selectedProfile(kind, integration.profile || "manual"); + if (kind === "storage") { + var manualStorage = selected.id === "manual" + ? "" + : ""; + return "

Speicheranbindung

" + + "" + + "" + + "" + + "" + + manualStorage + "

Batterietyp und Variablen werden aus dem Profil abgeleitet.

"; + } + var manual = selected.id === "manual" + ? "" + + (key === "consumer_single" ? "" : "") + : ""; + return "

" + (compact ? "Aktor" : "Schaltaktor") + "

" + + "" + + "" + + "" + + manual + "

Schaltkontakt und Rückmeldung werden bei Geräteprofilen automatisch verbunden.

"; + } + + function clientLegacyItem(key, source, index) { + var properties = schemaDefaults(state.wizardCatalog?.consumerFields?.[key]); + var integration = { profile: "manual", host: "", port: 0, unitId: 1, topicPrefix: "", channel: 0, sourceVariableId: 0 }; + if (key === "consumer_single") { + properties.Nennleistung = Number(source.ratedPower || 0); + if (source.hardware === "shelly_1pm") integration.profile = "shelly_plus_1pm"; + integration.topicPrefix = source.topicPrefix || ""; + } + if (key === "boiler_multi") { + properties.Boilervolumen = Number(source.volume || 300); + if (source.hardware === "shelly_1pm") integration.profile = "shelly_plus_1pm"; + integration.topicPrefix = source.topicPrefix || ""; + } + if (key === "buffer_storage" && source.hardware === "shelly_1pm") { + integration.profile = "shelly_plus_1pm"; + integration.topicPrefix = source.topicPrefix || ""; + } + if (key === "ev_charger") { + properties.Geraetetyp = { goe_legacy: 1, goe_gemini: 2, smartme_pico: 3 }[source.model] || 0; + properties.Geraeteadresse = source.address || ""; + } + return { + name: source.name || deviceLabels[key] + " " + (index + 1), + variant: key === "ev_charger" ? "standalone" : undefined, + integration: integration, + properties: properties, + topology: key === "battery" ? schemaDefaults(state.wizardCatalog?.topologyFields?.battery) : undefined, + powerLevels: ["buffer_storage", "boiler_multi"].includes(key) ? [] : undefined, + schedule: key === "boiler_multi" ? [] : undefined, + sensor: ["buffer_storage", "boiler_multi"].includes(key) + ? { profile: "manual", topicPrefix: "", sourceVariableId: 0 } + : undefined + }; + } + + function collectIntegration(root) { + var integration = {}; + var scope = root.querySelector("[data-main-integration]") || root; + scope.querySelectorAll("[data-integration-field]").forEach(function (control) { + var key = control.dataset.integrationField; + integration[key] = ["port", "unitId", "channel", "sourceVariableId", "feedbackVariableId"].includes(key) ? Number(control.value || 0) : control.value; + }); + return integration; + } + + function renderSensorFields(sensor) { + var selected = selectedProfile("sensor", sensor?.profile || "manual"); + var source = selected.id === "manual" + ? "" + : ""; + return "

Temperaturfühler

" + + "" + + source + "

Beim Shelly Plus Add-on wird die Temperaturvariable automatisch zugeordnet.

"; + } + + function renderPowerLevels(key, levels) { + var fields = state.wizardCatalog?.deviceListFields?.powerLevel || []; + var rows = (levels || []).map(function (level, index) { + var values = Object.assign(schemaDefaults(fields), level || {}, { Stufe: index + 1 }); + return "
Stufe " + (index + 1) + + "
" + + renderSchemaGroups(fields, values, "data-power-level-field") + "
" + + renderIntegrationFields(key, level.integration || { profile: "manual" }, true) + "
"; + }).join(""); + return "

Leistungsstufen

Leistung und Aktor je Stufe.

" + + "
" + rows + "
"; + } + + function renderScheduleRows(rows) { + var fields = state.wizardCatalog?.deviceListFields?.schedule || []; + var content = (rows || []).map(function (row, index) { + return "
Zeitpunkt " + (index + 1) + + "
" + + renderSchemaGroups(fields, Object.assign(schemaDefaults(fields), row || {}), "data-schedule-field") + "
"; + }).join(""); + return "

Solltemperatur-Zeitplan

Uhrzeiten und Solltemperaturen als Liste.

" + + "
" + content + "
"; + } + + function collectPowerLevels(root) { + var fields = state.wizardCatalog?.deviceListFields?.powerLevel || []; + return Array.from(root.querySelectorAll("[data-power-level]")).map(function (row, index) { + return { + ...readSchemaValues(row, fields, "data-power-level-field"), + Stufe: index + 1, + integration: collectIntegration(row) + }; + }); + } + + function collectScheduleRows(root) { + var fields = state.wizardCatalog?.deviceListFields?.schedule || []; + return Array.from(root.querySelectorAll("[data-schedule-row]")).map(function (row) { + return readSchemaValues(row, fields, "data-schedule-field"); + }); + } + + function collectDeviceItems(key) { + return Array.from(document.querySelectorAll("[data-device-editor='" + key + "'] [data-device-instance]")).map(function (root) { + var variant = root.querySelector("[data-ev-variant]")?.value || "standalone"; + var fields = key === "ev_charger" && variant === "gateway" + ? state.wizardCatalog?.evGatewayFields || [] + : state.wizardCatalog?.consumerFields?.[key] || []; + var result = { + name: root.querySelector("[data-device-name]").value, + variant: variant, + integration: ["buffer_storage", "boiler_multi"].includes(key) ? { profile: "manual" } : collectIntegration(root), + properties: readSchemaValues(root, fields, "data-device-property") + }; + if (key === "battery") { + result.topology = readSchemaValues(root, state.wizardCatalog?.topologyFields?.battery || [], "data-battery-topology"); + } + if (["buffer_storage", "boiler_multi"].includes(key)) { + result.powerLevels = collectPowerLevels(root); + result.sensor = collectIntegration(root.querySelector("[data-sensor-integration]")); + } + if (key === "boiler_multi") result.schedule = collectScheduleRows(root); + return result; + }); + } + + function renderDeviceEditor(key, quantity, sourceItems) { + var editor = document.querySelector("[data-device-editor='" + key + "']"); + if (!editor || !state.wizardCatalog) return; + var items = sourceItems || (editor.children.length ? collectDeviceItems(key) : state.deviceDrafts[key] || []); + while (items.length < quantity) items.push(clientLegacyItem(key, {}, items.length)); + items = items.slice(0, quantity); + state.deviceDrafts[key] = items; + editor.innerHTML = items.map(function (item, index) { + var variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone"; + var fields = key === "ev_charger" && variant === "gateway" + ? state.wizardCatalog.evGatewayFields || [] + : state.wizardCatalog.consumerFields[key] || []; + var properties = Object.assign(schemaDefaults(fields), item.properties || {}); + var name = item.name || deviceLabels[key] + " " + (index + 1); + var identity = "
"; + if (key === "ev_charger") { + identity += ""; + } + identity += "
"; + var topology = key === "battery" + ? "
" + renderSchemaGroups( + state.wizardCatalog?.topologyFields?.battery || [], + Object.assign(schemaDefaults(state.wizardCatalog?.topologyFields?.battery), item.topology || {}), + "data-battery-topology" + ) + "
" + : ""; + var integration = ["battery", "consumer_single"].includes(key) + ? renderIntegrationFields(key, item.integration || {}) + : ""; + var structured = ["buffer_storage", "boiler_multi"].includes(key) + ? renderSensorFields(item.sensor || {}) + renderPowerLevels(key, item.powerLevels || []) + : ""; + if (key === "boiler_multi") structured += renderScheduleRows(item.schedule || []); + return "
" + + escapeHtml(name) + "
" + identity + integration + topology + structured + + "
" + renderSchemaGroups(fields, properties, "data-device-property") + "
"; + }).join(""); + } + + function collectPvSurfaces() { + var fields = state.wizardCatalog?.topologyFields?.pvSurface || []; + return Array.from(document.querySelectorAll("[data-topology-editor='pvSurfaces'] [data-topology-instance]")).map(function (root, index) { + return { + ID: "pv-" + (index + 1), + ...readSchemaValues(root, fields, "data-pv-surface") + }; + }); + } + + function renderPvSurfaceEditor(quantity, sourceItems) { + var editor = document.querySelector("[data-topology-editor='pvSurfaces']"); + if (!editor || !state.wizardCatalog) return; + var fields = state.wizardCatalog.topologyFields?.pvSurface || []; + var items = sourceItems || (editor.children.length ? collectPvSurfaces() : state.pvSurfaceDrafts || []); + while (items.length < quantity) { + items.push({ ID: "pv-" + (items.length + 1), ...schemaDefaults(fields), Name: "PV-Fläche " + (items.length + 1) }); + } + items = items.slice(0, quantity); + state.pvSurfaceDrafts = items; + editor.innerHTML = items.map(function (item, index) { + var values = Object.assign(schemaDefaults(fields), item || {}); + return "
" + + escapeHtml(values.Name || "PV-Fläche " + (index + 1)) + "
" + + renderSchemaGroups(fields, values, "data-pv-surface") + "
"; + }).join(""); + } + + function renderManagerFields(properties) { + if (!state.wizardCatalog || !byId("managerConfigurationFields")) return; + byId("managerConfigurationFields").innerHTML = renderSchemaGroups(state.wizardCatalog.managerFields, properties || {}, "data-manager-property"); + } + + function applyWizardCatalog(catalog) { + state.wizardCatalog = catalog; + document.querySelectorAll("[data-profile-select='energy']").forEach(function (select) { + select.innerHTML = profileOptions("energy", "manual"); + }); + renderManagerFields({}); + Object.keys(deviceLabels).forEach(function (key) { renderDeviceEditor(key, 0, []); }); + renderPvSurfaceEditor(0, []); + updateEnergySourceVisibility(byId("systemForm"), "grid"); + updateEnergySourceVisibility(byId("systemForm"), "pv"); + updatePvTopologyVisibility(byId("systemForm")); + } + + function updateEnergySourceVisibility(form, prefix) { + var manual = form.elements[prefix + "Profile"]?.value === "manual"; + form.querySelector("[data-manual-source='" + prefix + "']")?.classList.toggle("hidden", !manual); + } + + function updatePvTopologyVisibility(form) { + form.querySelector("[data-pv-topology]")?.classList.toggle("hidden", !form.elements.pvEnabled.checked); + } + + function setEnergyIntegration(form, prefix, integration) { + var select = form.elements[prefix + "Profile"]; + select.value = integration.profile || (integration.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual"); + form.elements[prefix + "Host"].value = integration.host || ""; + form.elements[prefix + "Port"].value = integration.port || selectedProfile("energy", select.value).defaultPort || 0; + form.elements[prefix + "UnitId"].value = integration.unitId == null ? 1 : integration.unitId; + if (form.elements[prefix + "SourceVariableId"]) { + form.elements[prefix + "SourceVariableId"].value = integration.sourceVariableId || 0; + } + updateEnergySourceVisibility(form, prefix); + } + + function configurePlant(plant) { + if (!plant || !state.wizardCatalog) return; + var owned = paidLicenseSummary(plant.id); + var configuration = null; + try { configuration = plant.configuration_json ? JSON.parse(plant.configuration_json) : null; } catch (_) { configuration = null; } + configuration = configuration || { + name: plant.name, location: plant.location, siteType: plant.type, + peakShaving: currentManagerVariant(plant) === "manager_peak", + manager: { properties: {} }, + gridMeasurement: { profile: "manual" }, + pvSystem: { enabled: false, profile: "manual" }, + costReport: { enabled: false, powerMeters: 0, auxiliaryMeters: 0 }, + devices: {} + }; + configuration.devices = configuration.devices || {}; + if (!Object.prototype.hasOwnProperty.call(configuration.devices, "buffer_storage") && configuration.devices.battery) { + configuration.devices.buffer_storage = configuration.devices.battery; + configuration.devices.battery = {}; + } + var form = byId("systemForm"); + form.dataset.plantId = plant.id; + form.elements.name.value = configuration.name || plant.name; + form.elements.location.value = configuration.location || plant.location; + form.elements.siteType.value = configuration.siteType || plant.type; + form.elements.peakShaving.checked = Boolean(configuration.peakShaving); + setEnergyIntegration(form, "grid", configuration.gridMeasurement || {}); + form.elements.gridSourceVariableId.value = configuration.gridMeasurement?.sourceVariableId || Number(configuration.gridMeasurement?.source || 0); + form.elements.gridNotes.value = configuration.gridMeasurement?.notes || ""; + form.elements.pvEnabled.checked = Boolean(configuration.pvSystem?.enabled); + setEnergyIntegration(form, "pv", configuration.pvSystem || {}); + form.elements.pvName.value = configuration.pvSystem?.name || "PV-Wechselrichter"; + form.elements.pvAcPowerKw.value = configuration.pvSystem?.acPowerKw || 0; + var pvSurfaces = Array.isArray(configuration.topology?.pvSurfaces) ? configuration.topology.pvSurfaces.slice() : []; + if (!pvSurfaces.length && configuration.manager?.properties?.AnlagenPVFlaechen) { + try { + var legacySurfaces = JSON.parse(configuration.manager.properties.AnlagenPVFlaechen); + if (Array.isArray(legacySurfaces)) pvSurfaces = legacySurfaces; + } catch (_) {} + } + var pvSurfaceQuantity = configuration.pvSystem?.enabled ? Math.max(1, pvSurfaces.length) : 0; + form.elements.pvSurfaceQuantity.value = pvSurfaceQuantity; + renderPvSurfaceEditor(pvSurfaceQuantity, pvSurfaces); + updatePvTopologyVisibility(form); + renderManagerFields(configuration.manager?.properties || {}); + var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" }; + Object.keys(deviceLabels).forEach(function (key) { + var source = configuration.devices[key] || {}; + var quantity = Math.max(Number(source.quantity || 0), Number(owned[key] || 0)); + var items = Array.isArray(source.items) ? source.items.slice() : []; + while (items.length < quantity) items.push(clientLegacyItem(key, source, items.length)); + form.elements[quantityNames[key]].value = quantity; + renderDeviceEditor(key, quantity, items); + }); + form.elements.costReportEnabled.checked = Boolean(configuration.costReport?.enabled); + form.elements.costPowerMeters.value = configuration.costReport?.powerMeters || 0; + form.elements.costAuxMeters.value = configuration.costReport?.auxiliaryMeters || 0; + byId("packageResult").classList.add("hidden"); + showWizardStep(0); + updateRecommendation(); + showView("configurator"); + } + + function energyIntegration(values, prefix) { + return { + profile: values[prefix + "Profile"], + host: values[prefix + "Host"], + port: Number(values[prefix + "Port"] || 0), + unitId: Number(values[prefix + "UnitId"] || 1), + sourceVariableId: Number(values[prefix + "SourceVariableId"] || 0), + notes: values[prefix + "Notes"] || "" + }; + } + + function systemPayload(form) { + var values = Object.fromEntries(new FormData(form).entries()); + var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" }; + var devices = {}; + Object.keys(deviceLabels).forEach(function (key) { + devices[key] = { quantity: Number(values[quantityNames[key]] || 0), items: collectDeviceItems(key) }; + }); + return { + plantId: form.dataset.plantId || "", + name: values.name, + location: values.location, + siteType: values.siteType, + peakShaving: Boolean(values.peakShaving), + manager: { properties: readSchemaValues(byId("managerConfigurationFields"), state.wizardCatalog?.managerFields || [], "data-manager-property") }, + gridMeasurement: energyIntegration(values, "grid"), + pvSystem: Object.assign({ + enabled: Boolean(values.pvEnabled), + name: values.pvName, + acPowerKw: Number(values.pvAcPowerKw || 0) + }, energyIntegration(values, "pv")), + topology: { pvSurfaces: collectPvSurfaces() }, + costReport: { enabled: Boolean(values.costReportEnabled), powerMeters: Number(values.costPowerMeters || 0), auxiliaryMeters: Number(values.costAuxMeters || 0) }, + devices: devices + }; + } + + var wizardTitles = ["Anlage", "Manager, Netz und PV", "Batteriespeicher", "Pufferspeicher", "EV-Ladestation", "Einstufige Verbraucher", "Boiler", "Wärmepumpe", "Verbrauchskostenreport", "Angaben prüfen"]; + + function updateRecommendation() { + var payload = systemPayload(byId("systemForm")); + var manager = payload.peakShaving ? "Manager mit Peak Shaving" : "Manager ohne Peak Shaving"; + byId("recommendManager").textContent = manager; + var selectedDevices = Object.keys(deviceLabels).filter(function (key) { return payload.devices[key].quantity > 0; }); + var configuredPlant = state.plants.find(function (plant) { return plant.id === byId("systemForm").dataset.plantId; }); + var owned = configuredPlant ? paidLicenseSummary(configuredPlant.id) : {}; + var requestedModules = Object.fromEntries(Object.entries(payload.devices).map(function (entry) { return [entry[0], entry[1].quantity]; })); + var coverage = licenseCoverage(requestedModules, owned); + var rows = selectedDevices.map(function (key) { + var requested = Number(payload.devices[key].quantity || 0); + var licensed = Number(coverage[key] || 0); + var missing = Math.max(0, requested - licensed); + var licenseText = missing + ? licensed + " lizenziert · " + missing + " zusätzliche " + (missing === 1 ? "Lizenz" : "Lizenzen") + " erforderlich" + : requested + " vollständig lizenziert"; + return "
" + escapeHtml(deviceLabels[key]) + "" + escapeHtml(licenseText) + "× " + requested + "
"; + }); + byId("recommendModules").innerHTML = rows.length ? rows.join("") : "

Keine Verbrauchermodule gewählt.

"; + if (byId("wizardReview")) { + var deviceSummary = rows.length ? rows.join("") : "
VerbrauchermoduleKeine
"; + byId("wizardReview").innerHTML = + "
System" + escapeHtml(payload.name || "Noch nicht angegeben") + "
" + + "
Standort" + escapeHtml(payload.location || "Noch nicht angegeben") + "
" + + "
Anlagentyp" + escapeHtml(payload.siteType) + "
" + + "
Manager" + escapeHtml(manager) + "
" + + "
Netzmessung" + escapeHtml(selectedProfile("energy", payload.gridMeasurement.profile).name + (payload.gridMeasurement.host ? " · " + payload.gridMeasurement.host : "")) + "
" + + "
PV-Anlage" + escapeHtml(payload.pvSystem.enabled ? selectedProfile("energy", payload.pvSystem.profile).name + (payload.pvSystem.host ? " · " + payload.pvSystem.host : "") : "Nicht gewählt") + "
" + + "
Verbrauchskostenreport" + escapeHtml(payload.costReport.enabled ? "Ja · separater Lizenzcode" : "Nein") + "
" + deviceSummary; + } + } + + function showWizardStep(step) { + var steps = document.querySelectorAll("[data-wizard-step]"); + state.wizardStep = Math.max(0, Math.min(steps.length - 1, step)); + steps.forEach(function (node, index) { node.classList.toggle("active", index === state.wizardStep); }); + document.querySelectorAll("[data-wizard-marker]").forEach(function (node, index) { + node.classList.toggle("active", index === state.wizardStep); + node.classList.toggle("done", index < state.wizardStep); + }); + byId("wizardStepCount").textContent = "Schritt " + (state.wizardStep + 1) + " von " + steps.length; + byId("wizardStepTitle").textContent = wizardTitles[state.wizardStep]; + byId("wizardBack").disabled = state.wizardStep === 0; + byId("wizardNext").classList.toggle("hidden", state.wizardStep === steps.length - 1); + byId("wizardSubmit").classList.toggle("hidden", state.wizardStep !== steps.length - 1); + if (state.wizardStep === steps.length - 1) updateRecommendation(); + } + + function currentWizardStepValid() { + var step = document.querySelector("[data-wizard-step='" + state.wizardStep + "']"); + var form = byId("systemForm"); + step.querySelectorAll("input, select, textarea").forEach(function (field) { field.setCustomValidity(""); }); + if (state.wizardStep === 1) { + [["grid", true], ["pv", form.elements.pvEnabled.checked]].forEach(function (entry) { + if (!entry[1]) return; + var profile = selectedProfile("energy", form.elements[entry[0] + "Profile"].value); + var host = form.elements[entry[0] + "Host"]; + if (profile.requiresHost && !host.value.trim()) host.setCustomValidity("Bitte IP-Adresse oder Hostnamen angeben."); + }); + } + step.querySelectorAll("[data-integration-field='profile']").forEach(function (select) { + var instance = select.closest("[data-device-instance]"); + var editor = select.closest("[data-device-editor]"); + var scope = select.closest("[data-main-integration], [data-sensor-integration]") || instance; + var kind = select.closest("[data-sensor-integration]") ? "sensor" : integrationKind(editor?.dataset.deviceEditor || ""); + var profile = selectedProfile(kind, select.value); + var requiredField = profile.requiresHost ? "host" : (profile.requiresTopic ? "topicPrefix" : ""); + var control = requiredField ? scope.querySelector("[data-integration-field='" + requiredField + "']") : null; + if (control && !control.value.trim()) control.setCustomValidity(profile.requiresHost + ? "Bitte IP-Adresse oder Hostnamen angeben." + : "Bitte MQTT-Topic-Präfix angeben."); + }); + var invalid = Array.from(step.querySelectorAll("input, select, textarea")).find(function (field) { return !field.checkValidity(); }); + if (!invalid) return true; + invalid.reportValidity(); + return false; + } + + async function submitAuth(form, endpoint, errorId) { + var errorNode = byId(errorId); errorNode.textContent = ""; setBusy(form, true); + try { + var result = await api(endpoint, { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); + form.reset(); + if (result.pendingVerification) { switchAuth("login"); authNotice(result.message, !result.delivered); return; } + await showPortal(result); + toast(endpoint.endsWith("register") ? "Konto wurde erstellt." : "Erfolgreich angemeldet."); + } catch (error) { errorNode.textContent = error.message; } finally { setBusy(form, false); } + } + + byId("loginTab").addEventListener("click", function () { switchAuth("login"); }); + byId("registerTab").addEventListener("click", function () { switchAuth("register"); }); + byId("forgotPasswordButton").addEventListener("click", function () { authNotice(""); specialAuth("forgotForm"); }); + byId("resendVerificationButton").addEventListener("click", async function () { + var email = byId("loginForm").querySelector("input[name='email']").value.trim(); + if (!email) return void (byId("loginError").textContent = "Bitte zuerst die E-Mail-Adresse eingeben."); + try { authNotice((await api("/api/auth/resend-verification", { method: "POST", body: { email: email } })).message); } catch (error) { byId("loginError").textContent = error.message; } + }); + document.querySelectorAll("[data-auth-login]").forEach(function (button) { button.addEventListener("click", function () { resetToken = ""; authNotice(""); switchAuth("login"); }); }); + byId("loginForm").addEventListener("submit", function (event) { event.preventDefault(); submitAuth(event.currentTarget, "/api/auth/login", "loginError"); }); + byId("registerForm").addEventListener("submit", function (event) { event.preventDefault(); submitAuth(event.currentTarget, "/api/auth/register", "registerError"); }); + byId("forgotForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { var result = await api("/api/auth/forgot-password", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); switchAuth("login"); authNotice(result.message); } catch (error) { byId("forgotError").textContent = error.message; } finally { setBusy(form, false); } }); + byId("resetForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; var values = Object.fromEntries(new FormData(form).entries()); if (values.password !== values.password_repeat) return void (byId("resetError").textContent = "Die Passwörter stimmen nicht überein."); setBusy(form, true); try { var result = await api("/api/auth/reset-password", { method: "POST", body: { token: resetToken, password: values.password } }); resetToken = ""; form.reset(); switchAuth("login"); authNotice(result.message); } catch (error) { byId("resetError").textContent = error.message; } finally { setBusy(form, false); } }); + + document.querySelectorAll(".nav-item").forEach(function (item) { item.addEventListener("click", function () { showView(item.dataset.view); }); }); + document.addEventListener("click", async function (event) { + var goto = event.target.closest("[data-goto]"); + if (goto) { + var gotoDialog = goto.closest("dialog"); + if (gotoDialog) gotoDialog.close(); + if (goto.dataset.goto === "configurator" && goto.closest("#newLicenseDialog")) { + byId("systemForm").reset(); + delete byId("systemForm").dataset.plantId; + state.deviceDrafts = {}; + state.pvSurfaceDrafts = []; + renderManagerFields({}); + Object.keys(deviceLabels).forEach(function (key) { renderDeviceEditor(key, 0, []); }); + renderPvSurfaceEditor(0, []); + updateEnergySourceVisibility(byId("systemForm"), "grid"); + updateEnergySourceVisibility(byId("systemForm"), "pv"); + updatePvTopologyVisibility(byId("systemForm")); + showWizardStep(0); + updateRecommendation(); + } + showView(goto.dataset.goto); + } + var newLicense = event.target.closest("[data-open-new-license]"); + if (newLicense) byId("newLicenseDialog").showModal(); + var open = event.target.closest("[data-open-plant]"); + if (open) { + var sourceDialog = open.closest("dialog"); + if (sourceDialog) sourceDialog.close(); + byId("plantDialog").showModal(); + } + var openBilling = event.target.closest("[data-open-billing]"); + if (openBilling) { + var billingSourceDialog = openBilling.closest("dialog"); + if (billingSourceDialog) billingSourceDialog.close(); + var billingForm = byId("billingForm"); + billingForm.reset(); + delete billingForm.dataset.plantId; + byId("billingIdentityFields").classList.remove("hidden"); + byId("billingDialog").showModal(); + } + var billingOrder = event.target.closest("[data-billing-order]"); + if (billingOrder) { + var existingBillingForm = byId("billingForm"); + existingBillingForm.reset(); + existingBillingForm.dataset.plantId = billingOrder.dataset.billingOrder; + var existingBillingPlant = state.plants.find(function (plant) { return plant.id === billingOrder.dataset.billingOrder; }); + existingBillingForm.querySelector("[name='name']").value = existingBillingPlant?.name || "Abrechnung"; + existingBillingForm.querySelector("[name='location']").value = existingBillingPlant?.location || "Schweiz"; + byId("billingIdentityFields").classList.add("hidden"); + byId("billingDialog").showModal(); + } + var order = event.target.closest("[data-order]"); if (order) { byId("orderPlant").value = order.dataset.order; showView("licenses"); prepareOrderEditor(); } + var configure = event.target.closest("[data-configure-current]"); + if (configure) configurePlant(selectedPlant("orderPlant")); + var replacementButton = event.target.closest("[data-replace-code]"); + if (replacementButton) { + openReplacementDialog( + replacementButton.dataset.replaceCode, + replacementButton.dataset.replacePlant, + replacementButton.dataset.replaceConnected === "true" + ); + } + var deleteCodeButton = event.target.closest("[data-delete-code]"); + if (deleteCodeButton) openCodeDeletionDialog(deleteCodeButton.dataset.deleteCode, deleteCodeButton.dataset.deleteCodePlant); + var deletePlantButton = event.target.closest("[data-delete-plant]"); + if (deletePlantButton) { + openPlantDeletionDialog( + deletePlantButton.dataset.deletePlant, + deletePlantButton.dataset.deletePlantName, + deletePlantButton.dataset.deletePlantPaid === "true" + ); + } + var codeButton = event.target.closest("[data-code]"); + if (codeButton) { + if (codeButton.dataset.rotate === "true") { + var sourceOrder = state.orders.find(function (order) { return order.id === codeButton.dataset.code; }); + openReplacementDialog(codeButton.dataset.code, sourceOrder?.plant_name || "dieses System", Boolean(sourceOrder?.activated_installation_id)); + return; + } + codeButton.disabled = true; + try { + var result = await api("/api/orders/" + codeButton.dataset.code + "/activation-code", { method: "POST", body: { rotate: false } }); + toast(result.message); + await loadOrders(); + } catch (error) { toast(error.message); } finally { codeButton.disabled = false; } + } + }); + document.querySelectorAll("[data-close-dialog]").forEach(function (button) { button.addEventListener("click", function () { button.closest("dialog").close(); }); }); + document.querySelectorAll(".module-check, .qty").forEach(function (input) { input.addEventListener("change", updateOrder); }); + byId("peakUpgrade").addEventListener("change", updateOrder); + byId("orderPlant").addEventListener("change", function () { prepareOrderEditor(); }); + byId("forecastPlant").addEventListener("change", updateForecast); + byId("forecastHistoryDays").addEventListener("change", function (event) { + state.forecastHistoryDays = Number(event.target.value) || 1; + state.forecastRangeStart = null; + state.forecastRangeEnd = null; + state.forecastVisibleSeries = null; + updateForecast(); + }); + byId("forecastSeriesControls").addEventListener("change", function (event) { + var control = event.target.closest("[data-forecast-series]"); + if (!control || !state.forecastResult) return; + if (!state.forecastVisibleSeries) state.forecastVisibleSeries = new Set(); + if (control.checked) state.forecastVisibleSeries.add(control.dataset.forecastSeries); + else state.forecastVisibleSeries.delete(control.dataset.forecastSeries); + renderForecastChart(state.forecastResult.series || []); + }); + ["forecastRangeStart", "forecastRangeEnd"].forEach(function (id) { + byId(id).addEventListener("change", function () { + if (!state.forecastResult) return; + var start = Date.parse(byId("forecastRangeStart").value); + var end = Date.parse(byId("forecastRangeEnd").value); + if (Number.isFinite(start)) state.forecastRangeStart = start; + if (Number.isFinite(end)) state.forecastRangeEnd = end; + renderForecastChart(state.forecastResult.series || []); + }); + }); + byId("forecastRangeReset").addEventListener("click", function () { + if (!state.forecastResult) return; + state.forecastRangeStart = null; + state.forecastRangeEnd = null; + renderForecastChart(state.forecastResult.series || []); + }); + byId("faultPlant").addEventListener("change", updateFaults); + byId("faultRecipientForm").addEventListener("submit", async function (event) { + event.preventDefault(); + var form = event.currentTarget; + var plant = selectedPlant("faultPlant"); + if (!plant) return; + byId("faultRecipientError").textContent = ""; + setBusy(form, true); + try { + var result = await api("/api/plants/" + plant.id + "/fault-notification-recipients", { + method: "POST", + body: { email: byId("faultRecipientEmail").value } + }); + form.reset(); + toast(result.message); + await updateFaults(); + } catch (error) { + byId("faultRecipientError").textContent = error.message; + } finally { + setBusy(form, false); + } + }); + byId("faultRecipientList").addEventListener("click", async function (event) { + var button = event.target.closest("[data-remove-fault-recipient]"); + var plant = selectedPlant("faultPlant"); + if (!button || !plant) return; + button.disabled = true; + try { + var result = await api("/api/plants/" + plant.id + "/fault-notification-recipients", { + method: "DELETE", + body: { email: button.dataset.removeFaultRecipient } + }); + toast(result.message); + await updateFaults(); + } catch (error) { + byId("faultRecipientError").textContent = error.message; + button.disabled = false; + } + }); + ["import", "export"].forEach(function (direction) { + var form = byId("forecastConfigForm"); + var prefix = direction === "import" ? "bezug" : "einspeisung"; + form.elements["tarif_" + prefix + "_modus"].addEventListener("change", function () { + renderTariffSelector(form, direction, this.value, "", form.elements["tarif_" + prefix + "_fest"].value); + }); + form.elements["tarif_" + prefix].addEventListener("change", function () { + refreshTariffSelector(form, direction); + }); + }); + byId("forecastAddRoof").addEventListener("click", function () { + var roofs = Array.from(byId("forecastRoofs").querySelectorAll(".forecast-roof-row")).map(function (row) { + return { + kwp: Number(row.querySelector("[data-roof-field='kwp']").value), + neigung: Number(row.querySelector("[data-roof-field='neigung']").value), + azimut: Number(row.querySelector("[data-roof-field='azimut']").value) + }; + }); + roofs.push({ kwp: 10, neigung: 30, azimut: 180 }); + renderForecastRoofs(roofs, false); + }); + byId("forecastRoofs").addEventListener("click", function (event) { + var remove = event.target.closest("[data-remove-roof]"); + if (remove) remove.closest(".forecast-roof-row").remove(); + }); + byId("forecastConfigForm").addEventListener("submit", async function (event) { + event.preventDefault(); + var form = event.currentTarget; + var plantId = form.dataset.plantId; + byId("forecastConfigError").textContent = ""; + var payload; + try { + payload = forecastConfigurationPayload(form); + } catch (error) { + byId("forecastConfigError").textContent = error.message; + return; + } + setBusy(form, true); + try { + var result = await api("/api/plants/" + plantId + "/prognosis/configuration", { + method: "PUT", + body: payload + }); + var plant = state.plants.find(function (entry) { return entry.id === plantId; }); + if (plant) fillForecastConfiguration(plant, result); + toast(result.message); + await updateForecast(); + } catch (error) { + byId("forecastConfigError").textContent = error.message; + } finally { + setBusy(form, false); + } + }); + byId("accountButton").addEventListener("click", function () { showView("account"); }); + byId("systemForm").addEventListener("click", function (event) { + var instance = event.target.closest("[data-device-instance]"); + var editor = event.target.closest("[data-device-editor]"); + if (!instance || !editor) return; + var key = editor.dataset.deviceEditor; + if (event.target.closest("[data-add-power-level]")) { + var items = collectDeviceItems(key); + var position = Number(instance.dataset.deviceInstance); + items[position].powerLevels = items[position].powerLevels || []; + items[position].powerLevels.push({ + Stufe: items[position].powerLevels.length + 1, + Leistung: 0, + integration: { profile: "manual", sourceVariableId: 0 } + }); + renderDeviceEditor(key, items.length, items); + } + if (event.target.closest("[data-remove-power-level]")) { + var levelIndex = Array.from(instance.querySelectorAll("[data-power-level]")).indexOf(event.target.closest("[data-power-level]")); + var levelItems = collectDeviceItems(key); + levelItems[Number(instance.dataset.deviceInstance)].powerLevels.splice(levelIndex, 1); + renderDeviceEditor(key, levelItems.length, levelItems); + } + if (event.target.closest("[data-add-schedule-row]")) { + var scheduleItems = collectDeviceItems(key); + scheduleItems[Number(instance.dataset.deviceInstance)].schedule.push({ Uhrzeit: "00:00", Solltemperatur: 50 }); + renderDeviceEditor(key, scheduleItems.length, scheduleItems); + } + if (event.target.closest("[data-remove-schedule-row]")) { + var scheduleIndex = Array.from(instance.querySelectorAll("[data-schedule-row]")).indexOf(event.target.closest("[data-schedule-row]")); + var reducedItems = collectDeviceItems(key); + reducedItems[Number(instance.dataset.deviceInstance)].schedule.splice(scheduleIndex, 1); + renderDeviceEditor(key, reducedItems.length, reducedItems); + } + updateRecommendation(); + }); + byId("systemForm").addEventListener("input", function (event) { + var key = event.target.dataset.deviceQuantity; + if (key) renderDeviceEditor(key, Math.max(0, Math.min(50, Number(event.target.value || 0)))); + if (event.target.dataset.topologyQuantity === "pvSurfaces") { + renderPvSurfaceEditor(Math.max(0, Math.min(50, Number(event.target.value || 0)))); + } + updateRecommendation(); + }); + byId("systemForm").addEventListener("change", function (event) { + if (event.target.matches("[data-profile-select='energy']")) { + var prefix = event.target.name === "gridProfile" ? "grid" : "pv"; + var port = event.currentTarget.elements[prefix + "Port"]; + var selected = selectedProfile("energy", event.target.value); + if (port && (!Number(port.value) || Number(port.value) === 80 || Number(port.value) === 502 || Number(port.value) === 1502)) { + port.value = selected.defaultPort || 0; + } + updateEnergySourceVisibility(event.currentTarget, prefix); + } + if (event.target.name === "pvEnabled") { + if (event.target.checked && Number(event.currentTarget.elements.pvSurfaceQuantity.value || 0) === 0) { + event.currentTarget.elements.pvSurfaceQuantity.value = 1; + renderPvSurfaceEditor(1); + } + updatePvTopologyVisibility(event.currentTarget); + } + if (event.target.matches("[data-ev-variant]")) { + var evEditor = event.target.closest("[data-device-editor]"); + var evItems = collectDeviceItems("ev_charger"); + renderDeviceEditor("ev_charger", evItems.length, evItems); + } + if (event.target.matches("[data-integration-field='profile']")) { + var editor = event.target.closest("[data-device-editor]"); + var instance = event.target.closest("[data-device-instance]"); + var key = editor?.dataset.deviceEditor || ""; + var scope = event.target.closest("[data-main-integration], [data-sensor-integration]"); + var kind = event.target.closest("[data-sensor-integration]") ? "sensor" : integrationKind(key); + var selectedDevice = selectedProfile(kind, event.target.value); + var devicePort = scope?.querySelector("[data-integration-field='port']"); + var channel = scope?.querySelector("[data-integration-field='channel']"); + if (devicePort) devicePort.value = selectedDevice.defaultPort || 0; + if (channel) { + channel.max = Math.max(0, Number(selectedDevice.outputs || 1) - 1); + channel.value = Math.min(Number(channel.value || 0), Number(channel.max)); + } + var quantity = event.currentTarget.querySelector("[data-device-quantity='" + key + "']"); + renderDeviceEditor(key, Number(quantity?.value || 0)); + } + updateRecommendation(); + }); + byId("wizardBack").addEventListener("click", function () { showWizardStep(state.wizardStep - 1); }); + byId("wizardNext").addEventListener("click", function () { if (currentWizardStepValid()) showWizardStep(state.wizardStep + 1); }); + byId("confirmReplaceCode").addEventListener("click", async function () { + if (!state.replacementOrderId) return; + var button = byId("confirmReplaceCode"); + var originalLabel = button.textContent; + byId("replaceCodeError").textContent = ""; + button.disabled = true; + button.textContent = "Ersatzcode wird versendet …"; + try { + var result = await api("/api/orders/" + state.replacementOrderId + "/activation-code", { method: "POST", body: { rotate: true } }); + byId("replaceCodeDialog").close(); + state.replacementOrderId = null; + toast(result.message); + await loadOrders(); + } catch (error) { + byId("replaceCodeError").textContent = error.message; + } finally { + button.disabled = false; + button.textContent = originalLabel; + } + }); + + byId("confirmDeleteCode").addEventListener("click", async function () { + if (!state.codeDeletionOrderId) return; + var button = byId("confirmDeleteCode"); + var originalLabel = button.textContent; + byId("deleteCodeError").textContent = ""; + button.disabled = true; + button.textContent = "Code wird gelöscht …"; + try { + var result = await api("/api/orders/" + state.codeDeletionOrderId + "/activation-code", { method: "DELETE", body: { confirm: true } }); + byId("deleteCodeDialog").close(); + state.codeDeletionOrderId = null; + toast(result.message); + await loadOrders(); + } catch (error) { + byId("deleteCodeError").textContent = error.message; + } finally { + button.disabled = false; + button.textContent = originalLabel; + } + }); + + async function deleteSelectedPlant(deletePaidLicenses, errorId, buttonId) { + if (!state.plantDeletion) return; + var button = byId(buttonId); + var originalLabel = button.textContent; + byId(errorId).textContent = ""; + button.disabled = true; + button.textContent = "Anlage wird gelöscht …"; + try { + var result = await api("/api/plants/" + state.plantDeletion.id, { + method: "DELETE", + body: { plantName: state.plantDeletion.name, deletePaidLicenses: deletePaidLicenses } + }); + byId("deletePlantDialog").close(); + byId("deletePaidDialog").close(); + state.plantDeletion = null; + state.configuredPlantId = null; + await Promise.all([loadPlants(), loadOrders()]); + showView("overview"); + toast(result.message); + } catch (error) { + byId(errorId).textContent = error.message; + } finally { + button.disabled = false; + button.textContent = originalLabel; + } + } + + byId("confirmDeletePlant").addEventListener("click", async function () { + if (!state.plantDeletion) return; + if (state.plantDeletion.hasPaidLicense) { + byId("deletePlantDialog").close(); + byId("deletePaidDialog").showModal(); + return; + } + await deleteSelectedPlant(false, "deletePlantError", "confirmDeletePlant"); + }); + byId("confirmDeletePaid").addEventListener("click", function () { + deleteSelectedPlant(true, "deletePaidError", "confirmDeletePaid"); + }); + + async function changeWorkspace(value) { + try { state.session = await api("/api/workspace", { method: "POST", body: { organizationId: value || null } }); renderSession(); await Promise.all([loadPlants(), loadOrders(), loadInvitations()]); showView("overview"); toast("Organisation gewechselt."); } + catch (error) { toast(error.message); renderSession(); } + } + byId("workspaceSelect").addEventListener("change", function (event) { changeWorkspace(event.target.value); }); + byId("accountWorkspaceSelect").addEventListener("change", function (event) { changeWorkspace(event.target.value); }); + + byId("plantForm").addEventListener("submit", async function (event) { + event.preventDefault(); var form = event.currentTarget; byId("plantError").textContent = ""; setBusy(form, true); + try { var result = await api("/api/plants", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); form.querySelector("[value='manager_standard']").checked = true; byId("plantDialog").close(); state.configuredPlantId = result.plant.id; await loadPlants(); showView("licenses"); toast("Lizenzpaket wurde angelegt."); } + catch (error) { byId("plantError").textContent = error.message; } finally { setBusy(form, false); } + }); + + byId("billingForm").addEventListener("submit", async function (event) { + event.preventDefault(); + var form = event.currentTarget; + var values = Object.fromEntries(new FormData(form).entries()); + var plantId = form.dataset.plantId || ""; + byId("billingError").textContent = ""; + setBusy(form, true); + try { + if (!plantId) { + var created = await api("/api/plants", { + method: "POST", + body: { name: values.name, type: "Abrechnung", location: values.location, manager: "billing_manager" } + }); + plantId = created.plant.id; + form.dataset.plantId = plantId; + } + var checkout = await api("/api/checkout", { + method: "POST", + body: { + plantId: plantId, + modules: { + cost_meter_power: Number(values.powerMeters || 0), + cost_meter_aux: Number(values.auxMeters || 0) + } + } + }); + window.location.assign(checkout.checkoutUrl); + } catch (error) { + byId("billingError").textContent = error.message; + setBusy(form, false); + } + }); + + byId("systemForm").addEventListener("submit", async function (event) { + event.preventDefault(); var form = event.currentTarget; byId("systemError").textContent = ""; setBusy(form, true); + try { + var result = await api("/api/configurations", { method: "POST", body: systemPayload(form) }); + state.configuredPlantId = result.plant.id; + byId("packageResult").classList.toggle("hidden", !result.packageReady); + if (result.packageReady) byId("packageDownload").href = result.packageUrl; + else byId("packageDownload").removeAttribute("href"); + await Promise.all([loadPlants(), loadOrders()]); + byId("orderPlant").value = result.plant.id; + prepareOrderEditor(result.configurationId); + showView("licenses"); + if (result.purchaseProposal?.required) { + toast(result.costReportPlant + ? "Konfiguration gespeichert. Manager-Lizenzen und der separate Verbrauchskosten-Lizenzsatz sind in der Übersicht vorbereitet." + : "Konfiguration gespeichert. Fehlende Lizenzen und die kostenpflichtige Ersteinrichtung durch den Generator sind zur Bestellung vorbereitet."); + } else { + toast(result.delivered + ? "Systemkonfiguration gespeichert und lizenziertes Einrichtungspaket per E-Mail versendet." + : "Systemkonfiguration gespeichert. Das lizenzierte Einrichtungspaket konnte nicht per E-Mail versendet werden."); + } + } catch (error) { byId("systemError").textContent = error.message; } finally { setBusy(form, false); } + }); + byId("orderRecommendation").addEventListener("click", function () { + showView("licenses"); + if (state.configuredPlantId) byId("orderPlant").value = state.configuredPlantId; + var plant = selectedPlant("orderPlant"); + prepareOrderEditor(plant?.configuration_id || null); + }); + + byId("organizationForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { state.session = await api("/api/organizations", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); renderSession(); await Promise.all([loadPlants(), loadOrders(), loadInvitations()]); toast("Organisation wurde erstellt."); } catch (error) { byId("organizationError").textContent = error.message; } finally { setBusy(form, false); } }); + byId("invitationForm").addEventListener("submit", async function (event) { event.preventDefault(); var form = event.currentTarget; setBusy(form, true); try { var result = await api("/api/invitations", { method: "POST", body: Object.fromEntries(new FormData(form).entries()) }); form.reset(); await loadInvitations(); toast(result.message); } catch (error) { byId("invitationError").textContent = error.message; } finally { setBusy(form, false); } }); + byId("passwordForm").addEventListener("submit", async function (event) { + event.preventDefault(); + var form = event.currentTarget; + var values = Object.fromEntries(new FormData(form).entries()); + byId("passwordError").textContent = ""; + if (values.newPassword !== values.newPasswordRepeat) return void (byId("passwordError").textContent = "Die neuen Passwörter stimmen nicht überein."); + setBusy(form, true); + try { + var result = await api("/api/account/password", { method: "POST", body: { currentPassword: values.currentPassword, newPassword: values.newPassword } }); + form.reset(); + toast(result.message); + } catch (error) { byId("passwordError").textContent = error.message; } finally { setBusy(form, false); } + }); + + byId("checkoutButton").addEventListener("click", async function () { + var plant = selectedPlant("orderPlant"); if (!plant) return; + var button = byId("checkoutButton"); var modules = {}; + state.selectedOrder.forEach(function (item) { + if (item.licenseQuantity > 0) modules[item.key] = item.licenseQuantity; + }); + byId("checkoutError").textContent = ""; button.disabled = true; button.textContent = button.dataset.free === "true" ? "Wird freigeschaltet …" : "Stripe Checkout wird geöffnet …"; + try { var result = await api("/api/checkout", { method: "POST", body: { plantId: plant.id, configurationId: state.checkoutConfigurationId, managerVariant: selectedManagerVariant(plant), modules: modules } }); window.location.assign(result.checkoutUrl); } + catch (error) { byId("checkoutError").textContent = error.message; toast(error.message); button.disabled = false; button.textContent = button.dataset.free === "true" ? "Kostenlos freischalten" : "Mit Stripe bezahlen"; } + }); + + byId("logoutButton").addEventListener("click", async function () { try { await api("/api/auth/logout", { method: "POST" }); } catch (_) { /* local logout */ } state.session = null; state.plants = []; state.orders = []; state.faultRequest += 1; history.replaceState(null, "", location.pathname); showAuth(); }); + + setInterval(function () { + if (state.session && byId("view-faults").classList.contains("active")) updateFaults(); + }, 60000); + + async function handleInvitationLink(token) { + try { var preview = await api("/api/invitations/preview", { method: "POST", body: { token: token } }); sessionStorage.setItem("enelix_invitation_token", token); byId("loginForm").querySelector("input[name='email']").value = preview.email; byId("registerForm").querySelector("input[name='email']").value = preview.email; authNotice("Einladung zu " + preview.organization + " für " + preview.email + "."); } + catch (error) { authNotice(error.message, true); } + } + + async function bootstrap() { + showAuth(); + var catalogs = await Promise.allSettled([api("/api/catalog"), api("/api/wizard-catalog")]); + if (catalogs[0].status === "fulfilled") applyCatalog(catalogs[0].value); + if (catalogs[1].status === "fulfilled") applyWizardCatalog(catalogs[1].value); + else byId("systemError").textContent = "Der Systemkonfigurator konnte nicht geladen werden."; + updateRecommendation(); + showWizardStep(0); + if (location.hash.startsWith("#verify=")) { var verificationToken = decodeURIComponent(location.hash.slice(8)); history.replaceState(null, "", location.pathname); switchAuth("login"); try { authNotice((await api("/api/auth/verify-email", { method: "POST", body: { token: verificationToken } })).message); } catch (error) { authNotice(error.message, true); } return; } + if (location.hash.startsWith("#reset=")) { resetToken = decodeURIComponent(location.hash.slice(7)); history.replaceState(null, "", location.pathname); specialAuth("resetForm"); return; } + var invitationToken = location.hash.startsWith("#invite=") ? decodeURIComponent(location.hash.slice(8)) : ""; + if (invitationToken) history.replaceState(null, "", location.pathname); + try { var session = await api("/api/session"); if (invitationToken) await handleInvitationLink(invitationToken); if (session.authenticated) return showPortal(session); switchAuth("login"); } + catch (_) { switchAuth("login"); byId("loginError").textContent = "Das Portal ist momentan nicht erreichbar."; } + } + + bootstrap(); +}()); diff --git a/services/license/changes/20261006-forecast-integration/public/forecast-chart.js b/services/license/changes/20261006-forecast-integration/public/forecast-chart.js new file mode 100644 index 0000000..73efc87 --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/public/forecast-chart.js @@ -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(); } }; +} diff --git a/services/license/changes/20261006-forecast-integration/public/index.html b/services/license/changes/20261006-forecast-integration/public/index.html new file mode 100644 index 0000000..2e5818d --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/public/index.html @@ -0,0 +1,382 @@ + + + + + + + Enelix EMS Portal + + + + + + + + + +
+ +
+
+
EMS Portal
+

Energiemanagement

Systeme planen.
Lizenzen verbinden.

Ein Zugang für Anlagen, Prognosemanager und Lizenzierung.

+ +
+ +
+
+ +
+ + +
+ +
+

Willkommen zurück

Am Portal anmelden

+ + + + + + + + +

Offene Registrierung

Neues Konto erstellen

+ + + + + + + +

Kontozugang

Passwort zurücksetzen

+ + + + + + +

Kontozugang

Neues Passwort

+ + + + + + +
+
+
+ +
" + escapeHtml(new Date(order.created_at + "Z").toLocaleDateString("de-CH")) + "" + escapeHtml(order.plant_name) + "" + count + "" + escapeHtml(money.format(order.amount_total / 100)) + "" + escapeHtml(labels[order.status] || order.status) + "" + action + "" + receipt + "
Noch keine Bestellungen in dieser Organisation.
" + escapeHtml(forecastSeriesLabel(entry.key)) + "" + escapeHtml(target) + "" + formatForecastMetric(entry.r2, 3, "") + "" + formatForecastMetric(entry.mae, 0, " W") + "" + formatForecastMetric(entry.rmse, 0, " W") + "" + Number(entry.samples).toLocaleString("de-CH") + "
" + + escapeHtml(faultSeverityLabel(fault.severity)) + "" + source + + "" + message + "" + escapeHtml(faultDate(time)) + "
+ + +
ModellZielR²MAERMSEVergleiche
+ + + +
+

Lizenzierte Variante

Manager

LizenzdauerUnbeschränkt gültig
+

Datenumfang

Live-Prognose

Modus
Konfiguration und Anzeige
Simulation
Nicht enthalten
Zugriff
Lizenzgebunden
+
+ + + +
+

Betrieb

Störüberwachung

Aktive und behobene Störungen der verbundenen Enelix Manager und Geräte.

Status wird geprüft
+ +
+
+ +
Noch nicht verknüpft

Status wird geprüft

+
+
+
0Aktive Störungen
+
0Kritisch oder Fehler
+
–Letzter Empfang
+
+
+

Aktive Störungen

Aktueller Vollsnapshot des Managers.

+
Störungsdaten werden geladen.
+ +
+
+

Behobene Störungen

Die letzten 100 abgeschlossenen Ereignisse.

+
Noch keine behobenen Störungen.
+ +
+
+

E-Mail-Benachrichtigungen

Empfänger für neue und behobene Störungen dieser Anlage.

+
+ + +
+ +
+

Empfänger werden geladen.

+
+
+
+ +
+

Einstellungen

Konto und Abrechnung

Zugang, Organisation, Personen und Zahlungsverlauf.

+ +

Abrechnung

Zahlungen, Lizenzcodes und PDF-Belege der aktuellen Organisation.

Stripe Sandbox–Prüfung
DatumSystemLizenzenGesamt inkl. MwSt.StatusLizenzcodeBeleg
+
+ + + + +

Neue Lizenz

Wie möchtest du starten?

+ +

Direkter Kauf

Neue Lizenz vorbereiten

Prognosemanager

+ +

Abrechnung

Abrechnungslizenz vorbereiten

Zählerlizenzen

Grundlizenz: CHF 245.15 netto. Zuzüglich MwSt.

+ +

Sicherheitsaktion

Lizenzcode neu generieren

Achtung: Der aktuelle Lizenzcode wird ungültig.

Für dieses System wird ein neuer Code erstellt und ausschließlich als PDF an die Konto-E-Mail-Adresse gesendet.

+ +

Sicherheitsaktion

Lizenzcode löschen?

Sind Sie sicher?

Der aktuelle Lizenzcode für dieses System wird sofort und unwiderruflich ungültig.

Die bezahlte Lizenz und eine bestehende Installationsbindung bleiben erhalten. Ein neuer Code kann später wieder per E-Mail erzeugt werden.

+ +

Anlage löschen

Sind Sie sicher?

Die Anlage wird dauerhaft entfernt.

Möchten Sie diese Anlage wirklich löschen? Konfigurationen und noch nicht bezahlte Bestellungen dieser Anlage werden ebenfalls entfernt.

+ +

Zweite Bestätigung

Auch Lizenz löschen?

Achtung: Für diese Anlage besteht eine bezahlte Lizenz.

Sollen die Anlage und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?

Diese Aktion kann nicht rückgängig gemacht werden.

+ + + + diff --git a/services/license/changes/20261006-forecast-integration/public/netplan-v4.css b/services/license/changes/20261006-forecast-integration/public/netplan-v4.css new file mode 100644 index 0000000..fea7ff9 --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/public/netplan-v4.css @@ -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)); } +} diff --git a/services/license/changes/20261006-forecast-integration/public/netplan-v4.html b/services/license/changes/20261006-forecast-integration/public/netplan-v4.html new file mode 100644 index 0000000..fecb816 --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/public/netplan-v4.html @@ -0,0 +1 @@ +ENELIX Prognose und Netzfahrplan
Zurueck zum Portal

Prognose und Netzfahrplan

diff --git a/services/license/changes/20261006-forecast-integration/public/netplan-v4.js b/services/license/changes/20261006-forecast-integration/public/netplan-v4.js new file mode 100644 index 0000000..f7665db --- /dev/null +++ b/services/license/changes/20261006-forecast-integration/public/netplan-v4.js @@ -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; } +} diff --git a/services/netplan-v4/gui/forecast-chart.browser-test.mjs b/services/netplan-v4/gui/forecast-chart.browser-test.mjs new file mode 100644 index 0000000..45018bd --- /dev/null +++ b/services/netplan-v4/gui/forecast-chart.browser-test.mjs @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +const { chromium } = createRequire(import.meta.url)('playwright'); + +const stamp = minute => new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString(); +const state = { + checkedAt: stamp(0), controlledTrialAuthorized: true, fresh: true, settings: { revision: 2, family: '3', trainingCadence: 'daily' }, + families: [{ key: '3', label: 'Testfamilie' }], + plan: { + planId: 'test-plan', sourceFamily: '3', configRevision: 2, status: 'optimal', runMode: 'shadow', generatedAt: stamp(0), validUntil: stamp(60), forecastUntil: stamp(120), pricesKnownUntil: stamp(60), + inputQuality: { loadBasis: 'base_load' }, + forecastPoints: Array.from({ length: 24 }, (_, index) => ({ + time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400, externalW: index === 4 ? null : 200 + })), + points: Array.from({ length: 12 }, (_, index) => ({ + time: stamp(index * 5), validUntil: stamp((index + 1) * 5), pvW: 1200 + index * 150, loadW: 2400, + externalW: index === 4 ? null : 200, gridTargetW: index * -50 + 400, baselineGridW: 1400 - index * 150, + batteryTargetW: index * 100 - 800, socEndPercent: { battery: 50 + index }, + importPriceChfKwh: .2512, exportPriceChfKwh: .0801, + cumulativeCashCostChf: index * .15, cumulativeBaselineCashCostChf: index * .25 + })) + } +}; + +test('embedded planner: responsive charts, keyboard/pointer inspection, toggles and lifecycle', async () => { + const errors = [], requests = []; + const html = '
Legacy
'; + const server = createServer(async (request, response) => { + try { + if (request.url === '/') { response.setHeader('Content-Type', 'text/html'); response.end(html); return; } + if (!['/netplan-v4.js', '/forecast-chart.js', '/netplan-v4.css'].includes(request.url)) { response.writeHead(404); response.end(); return; } + response.setHeader('Content-Type', request.url.endsWith('.css') ? 'text/css' : 'text/javascript'); + response.end(await readFile(new URL('.' + request.url, import.meta.url))); + } catch { response.writeHead(500); response.end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + let browser; + try { + browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } : {}) }); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/api/**', async route => { + const request = route.request(); requests.push({ url: request.url(), method: request.method(), csrf: request.headers()['x-csrf-token'] }); + if (request.url().includes('/slow/')) await new Promise(resolve => setTimeout(resolve, 150)); + const result = structuredClone(state); + if (request.url().includes('/legacy/')) delete result.plan.forecastPoints; + if (request.url().includes('/house-total/')) { + result.plan.inputQuality.loadBasis = 'house_total'; + result.plan.forecastPoints.forEach(point => { point.externalW = null; }); + } + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(request.method() === 'PUT' ? { revision: 3 } : result) }); + }); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.waitForSelector('.forecast-chart svg'); + assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 6); + assert.equal(await page.locator('#legacy-chart').evaluate(node => getComputedStyle(node).height), '320px'); + assert.equal(await page.locator('#legacy-legend').evaluate(node => getComputedStyle(node).borderBottomWidth), '1px'); + assert.equal(await page.locator('select#plants').count(), 0); + assert.equal(requests.length, 1); + assert.match(await page.locator('#host').innerText(), /tatsaechliche Anlagenbetrieb wird hier nicht bestaetigt/); + assert.match(await page.locator('#host').innerText(), /Vorhanden; lokale Schutzpruefung/); + const chart = page.locator('.forecast-planner .forecast-chart').first(); + const slider = chart.locator('input[type=range]'); + assert.equal(await slider.getAttribute('max'), '23'); + assert.equal(await page.locator('.forecast-planner .forecast-chart').nth(1).locator('input[type=range]').getAttribute('max'), '11'); + assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Vollstaendige/); + await slider.focus(); await slider.press('ArrowRight'); + assert.equal(await slider.inputValue(), '1'); + assert.match(await slider.getAttribute('aria-valuetext'), /1.350 W/); + await chart.getByLabel('PV-Prognose', { exact: true }).uncheck(); + assert.equal(await chart.locator('dt').count(), 2); + await chart.getByLabel('PV-Prognose', { exact: true }).check(); + await chart.locator('svg').scrollIntoViewIfNeeded(); + const bounds = await chart.locator('svg').boundingBox(); + await page.mouse.move(bounds.x + bounds.width - 20, bounds.y + 70); + assert.equal(await slider.inputValue(), '23'); + for (const width of [1280, 390, 320]) { + await page.setViewportSize({ width, height: 900 }); + await chart.scrollIntoViewIfNeeded(); + await page.waitForTimeout(60); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `no horizontal overflow at ${width}`); + assert.ok((await chart.boundingBox()).height > 320, 'legacy SVG height must not constrain new chart sections'); + assert.ok(await chart.locator('polyline').count() >= 3); + if (process.env.FORECAST_SCREENSHOT_DIR) await chart.screenshot({ path: join(process.env.FORECAST_SCREENSHOT_DIR, `forecast-chart-${width}.png`) }); + } + await page.locator('summary').click(); + await page.getByRole('button', { name: 'Speichern und neu berechnen', exact: true }).click(); + await page.getByRole('status').filter({ hasText: 'Revision 3 gespeichert' }).waitFor(); + assert.equal(requests.at(-1).csrf, 'test-only'); + assert.equal(requests.some(request => /trial|arm|live/.test(request.url)), false); + await page.evaluate(async () => { + window.planner.dispose(); + window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'legacy', session: {} }); + await window.planner.ready; + }); + assert.equal(await slider.getAttribute('max'), '11'); + assert.match(await chart.locator('.forecast-chart-caption').innerText(), /Aelterer Plan/); + await page.evaluate(async () => { + window.planner.dispose(); + window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'house-total', session: {} }); + await window.planner.ready; + }); + assert.equal(await chart.locator('dd').nth(2).innerText(), 'nicht vorhanden'); + await page.evaluate(async () => { + window.planner.dispose(); + window.planner = window.mountPlanner(document.querySelector('#host'), { plantId: 'slow', session: {} }); + window.planner.dispose(); + await window.planner.ready; + }); + assert.equal(await page.locator('.forecast-planner .forecast-chart').count(), 0); + assert.deepEqual(errors, []); + } finally { + await browser?.close(); await new Promise(resolve => server.close(resolve)); + } +}); diff --git a/services/netplan-v4/gui/forecast-chart.js b/services/netplan-v4/gui/forecast-chart.js new file mode 100644 index 0000000..73efc87 --- /dev/null +++ b/services/netplan-v4/gui/forecast-chart.js @@ -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(); } }; +} diff --git a/services/netplan-v4/gui/forecast-chart.test.mjs b/services/netplan-v4/gui/forecast-chart.test.mjs new file mode 100644 index 0000000..15ed45a --- /dev/null +++ b/services/netplan-v4/gui/forecast-chart.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const source = await readFile(new URL('./forecast-chart.js', import.meta.url), 'utf8'); +const { finiteValue, formatNumber, formatTime, chartDomain, seriesSegments, pointIndexAt, forecastSeries } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64')); +const point = (minute, value, duration = 5) => ({ time: new Date(Date.UTC(2026, 9, 6, 10, minute)).toISOString(), validUntil: new Date(Date.UTC(2026, 9, 6, 10, minute + duration)).toISOString(), value }); +const series = { value: row => row.value }; + +test('missing values are not fabricated as zero', () => { + for (const value of [null, undefined, '12', NaN, Infinity]) assert.equal(finiteValue(value), false); + assert.equal(finiteValue(0), true); + assert.equal(formatNumber(null), 'nicht vorhanden'); + assert.equal(chartDomain([point(0, null)], [series]).valid, false); +}); +test('domains include negative values and interval ends', () => { + const result = chartDomain([point(0, -10), point(5, 20)], [series]); + assert.equal(result.low, -10); assert.equal(result.high, 20); + assert.equal(result.end - result.start, 600000); + assert.equal(chartDomain([], [series]), null); + assert.equal(chartDomain([{ time: 'invalid', value: 5 }], [series]), null); +}); +test('power curves cover entire intervals and split missing values', () => { + const rows = [point(0, 5), point(5, null), point(10, 7)]; + const segments = seriesSegments(rows, series, value => value, value => value); + assert.equal(segments.length, 2); assert.equal(segments[0].length, 2); + assert.equal(segments[0][1][0], Date.parse(rows[0].validUntil)); +}); +test('time gaps and reversed intervals do not join curves', () => { + assert.equal(seriesSegments([point(0, 5), point(10, 7)], series, x => x, y => y).length, 2); + assert.equal(seriesSegments([point(0, 5, -1)], series, x => x, y => y).length, 0); +}); +test('cumulative and SOC samples use the interval end', () => { + const row = point(0, 7); + assert.deepEqual(seriesSegments([row], { ...series, position: 'end' }, x => x, y => y), [[[Date.parse(row.validUntil), 7]]]); +}); +test('pointer selection identifies the containing interval and clamps endpoints', () => { + const rows = [point(0, 1), point(5, 2), point(10, 3)]; + assert.equal(pointIndexAt(rows, Date.parse(rows[0].time) - 100), 0); + assert.equal(pointIndexAt(rows, Date.parse(rows[1].time)), 1); + assert.equal(pointIndexAt(rows, Date.parse(rows[1].time) + 1000), 1); + assert.equal(pointIndexAt(rows, Date.parse(rows[2].validUntil) + 100), 2); +}); +test('time labels use Zurich and reject invalid timestamps', () => { + assert.match(formatTime('2026-10-06T10:00:00Z'), /12:00/); + assert.equal(formatTime('invalid'), 'nicht vorhanden'); +}); +test('full forecast horizon takes precedence without extending optimized plan points', () => { + const optimized = [point(0, 1)], full = [point(0, 1), point(5, 2)]; + const plan = { points: optimized, forecastPoints: full }; + assert.deepEqual(forecastSeries(plan), { fullHorizon: true, points: full }); + assert.equal(plan.points.length, 1); +}); +test('older or empty forecasts fall back to explicitly limited optimized points', () => { + const optimized = [point(0, 1)]; + for (const forecastPoints of [undefined, null, [], {}, 'invalid']) { + assert.deepEqual(forecastSeries({ points: optimized, forecastPoints }), { fullHorizon: false, points: optimized }); + } + assert.deepEqual(forecastSeries({}), { fullHorizon: false, points: [] }); +}); +test('chart styles stay scoped to the planner, including grouped and mobile selectors', async () => { + const css = await readFile(new URL('./netplan-v4.css', import.meta.url), 'utf8'); + const selectors = [...css.matchAll(/([^{}]+)\{/g)].flatMap(match => match[1].split(',').map(selector => selector.trim())); + const chartSelectors = selectors.filter(selector => selector.includes('.forecast-chart')); + assert.ok(chartSelectors.length > 20, 'chart selectors are present and checked'); + for (const selector of chartSelectors) assert.match(selector, /^\.forecast-planner\s+/, `unscoped chart selector: ${selector}`); + assert.match(css, /\.forecast-planner \.forecast-chart\s*\{[^}]*height:\s*auto\s*;/, 'legacy SVG height must not constrain the new chart sections'); +}); diff --git a/services/netplan-v4/gui/netplan-v4.css b/services/netplan-v4/gui/netplan-v4.css index 2460732..fea7ff9 100644 --- a/services/netplan-v4/gui/netplan-v4.css +++ b/services/netplan-v4/gui/netplan-v4.css @@ -1 +1,49 @@ -body{font:16px system-ui,sans-serif;background:#eef4f4;color:#243c42}main{max-width:1100px;margin:2rem auto;padding:1.5rem;background:white}a{color:#006b83}label{display:block;margin:.8rem 0}select,button{font:inherit;padding:.6rem;margin:.3rem;border:1px solid #bacaca;border-radius:.25rem}button{background:#006b83;color:white;cursor:pointer}.notice{background:#e9f7fa;padding:1rem;border-left:4px solid #006b83}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#eef4f4;padding:1rem}svg{width:100%;height:auto}.curve{fill:none;stroke-width:2}.baseline{stroke:#7d888c}.optimized{stroke:#006b83}.axis{stroke:#b7c8cc}button:disabled{opacity:.5} +.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)); } +} diff --git a/services/netplan-v4/gui/netplan-v4.html b/services/netplan-v4/gui/netplan-v4.html index 1a96991..fecb816 100644 --- a/services/netplan-v4/gui/netplan-v4.html +++ b/services/netplan-v4/gui/netplan-v4.html @@ -1 +1 @@ -ENELIX Netzfahrplan V4
Zurueck zum Portal

Netzfahrplan V4

Schattenbetrieb. Diese Ansicht steuert keine Verbraucher. Die bestehende Regelung bleibt unveraendert.

+ENELIX Prognose und Netzfahrplan
Zurueck zum Portal

Prognose und Netzfahrplan

diff --git a/services/netplan-v4/gui/netplan-v4.js b/services/netplan-v4/gui/netplan-v4.js index 7e76572..f7665db 100644 --- a/services/netplan-v4/gui/netplan-v4.js +++ b/services/netplan-v4/gui/netplan-v4.js @@ -1,7 +1,8 @@ -const message = document.getElementById('message'); -const panel = document.getElementById('panel'); -const plants = document.getElementById('plants'); -let session, generation = 0; +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'; @@ -25,35 +26,21 @@ function table(title, rows) { 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 curve(title, points, series) { - panel.append(element('h3', title)); - if (!points.length) { panel.append(element('p', 'Keine Daten.')); return; } - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 1000 210'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', title); - const values = points.flatMap(p => series.map(s => s.value(p))).filter(v => typeof v === 'number' && Number.isFinite(v)); - if (!values.length) { panel.append(element('p', 'Keine gueltigen Werte.')); return; } - const low = Math.min(0, ...values), high = Math.max(1, ...values); - const start = Date.parse(points[0].time), end = Date.parse(points[points.length - 1].validUntil || points[points.length - 1].time); - for (const [i, source] of series.entries()) { - let section = []; - const flush = () => { if (!section.length) return; const line = document.createElementNS(svg.namespaceURI, 'polyline'); line.setAttribute('class', 'curve ' + (i ? 'optimized' : 'baseline')); line.setAttribute('points', section.join(' ')); svg.append(line); section = []; }; - for (const p of points) { - const value = source.value(p); - if (typeof value !== 'number' || !Number.isFinite(value)) { flush(); continue; } - const x = 10 + 980 * (Date.parse(p.time) - start) / Math.max(1, end - start); - const y = 190 - (value - low) / (high - low) * 180; section.push(`${x},${y}`); - } - flush(); - } - panel.append(svg, element('p', `${num(low)} bis ${num(high)}. ${series.map((s, i) => (i ? 'Blau: ' : 'Grau: ') + s.label).join('; ')}. ${time(points[0].time)} – ${time(points[points.length - 1].validUntil)}.`)); +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; panel.replaceChildren(); - panel.append(element('p', 'SCHATTENBETRIEB: Die angezeigten Vorgaben steuern keine Geraete.')); + 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'); @@ -72,10 +59,11 @@ async function render() { 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) { message.textContent = error.message; } finally { save.disabled = false; } + } catch (error) { if (ticket === generation) message.textContent = error.message; } finally { save.disabled = false; } }); - replan.addEventListener('click', async () => { try { await request(base + '/replan', 'POST', {}); message.textContent = 'Neuberechnung angefordert.'; } catch (error) { message.textContent = error.message; } }); + 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.')); @@ -89,25 +77,62 @@ async function render() { 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.')); - table('Plan und Kosten', [['Plan-ID', plan.planId], ['Modellfamilie', plan.sourceFamily], ['Revision', plan.configRevision], ['Prognose bis', time(plan.forecastUntil)], ['Bekannte Preise bis', time(plan.pricesKnownUntil)], ['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'], ['V4 steuert die Anlage', 'Nein']]); + 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]]); - curve('Netzleistung (W)', plan.points, [{ label: 'ohne Optimierung', value: p => p.baselineGridW }, { label: 'optimiert', value: p => p.gridTargetW }]); - curve('Batterieleistung (W; positiv = Laden)', plan.points, [{ label: 'Batterieplan', value: p => p.batteryTargetW }]); - for (const asset of Object.keys(plan.points[0]?.socEndPercent || {})) curve(`SOC ${asset} (%)`, plan.points, [{ label: 'geplanter SOC', value: p => p.socEndPercent?.[asset] }]); - curve('Kumulierte Zahlungen inklusive vollem Peakpreis (CHF)', plan.points, [{ label: 'ohne Optimierung', value: p => p.cumulativeBaselineCashCostChf }, { label: 'optimiert', value: p => p.cumulativeCashCostChf }]); - panel.append(element('p', 'Kostenverlaeufe sind Prognosen. Restwert der Batterie und Restmonatsbewertung sind keine bereits erzielten Erloese.')); + 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) { panel.replaceChildren(); message.textContent = error.message; } } + } 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; } } -try { - session = await request('/api/session'); if (!session.authenticated) throw new Error('Bitte zuerst im Portal anmelden.'); - const data = await request('/api/plants'); - for (const plant of data.plants || []) { if (!plant.installation_id) continue; const option = element('option', plant.name); option.value = plant.id; plants.append(option); } - if (!plants.options.length) throw new Error('Keine verknuepfte Anlage vorhanden.'); - plants.addEventListener('change', render); await render(); -} catch (error) { message.textContent = error.message; } diff --git a/services/netplan-v4/netplan_v4/optimizer.py b/services/netplan-v4/netplan_v4/optimizer.py index 8dabed5..ff95c49 100644 --- a/services/netplan-v4/netplan_v4/optimizer.py +++ b/services/netplan-v4/netplan_v4/optimizer.py @@ -162,6 +162,7 @@ def optimize(steps,batteries,*,at,limits=None,observed_peaks=None,peak_prices=No elif battery_w< -1:intent='export' if p_export>.001 else 'discharge' else:intent='hold' points.append({'time':utc(step.start).isoformat(),'validUntil':step.end.isoformat(), + 'pvW':float(step.pv_w),'loadW':float(step.base_load_w),'externalW':float(step.external_w), 'baselineGridW':float(step.residual_w),'gridTargetW':(p_import-p_export)*1000, 'batteryTargetW':battery_w,'assetTargetsW':targets,'socEndPercent':soc_end, 'pvCurtailmentW':float(x[curtail[i]]*1000),'intent':intent, diff --git a/services/netplan-v4/netplan_v4/service.py b/services/netplan-v4/netplan_v4/service.py index 0143c79..0689029 100644 --- a/services/netplan-v4/netplan_v4/service.py +++ b/services/netplan-v4/netplan_v4/service.py @@ -176,7 +176,7 @@ def price_at(store,plant,side,config,start,end,now): class AwaitingInput(ValueError):pass -def assemble(store,plant,family,now): +def assemble(store,plant,family,now,*,include_forecast_points=False): values={k:latest(store,plant,k) for k in ('operation','forecast','tariffs')} for k,v in values.items(): if not v:raise AwaitingInput('Missing '+k+' input') @@ -206,6 +206,11 @@ def assemble(store,plant,family,now): external=p.get('externalW',0.) if source['loadBasis']=='base_load' else 0. steps.append(Step(start,p['loadW'],p['pvW'],price_at(store,plant,'import',tariffs['import'],start,end,decision),price_at(store,plant,'export',tariffs['export'],start,end,decision),external,int((end-start).total_seconds()))) if not steps or steps[0].start!=decision:raise AwaitingInput('Forecast has no current interval') + if include_forecast_points: + # Preserve the exact selected input horizon separately from price-limited dispatch. + input_quality['forecastPoints']=[{'time':s.start.isoformat(),'validUntil':s.end.isoformat(), + 'pvW':float(s.pv_w),'loadW':float(s.base_load_w), + 'externalW':float(s.external_w) if source['loadBasis']=='base_load' else None} for s in steps] full_end=steps[-1].end;steps=priced_prefix(steps,decision) if not steps:raise AwaitingInput('No complete published-price billing quarter') q=quarter_start(decision);elapsed=int((decision-q).total_seconds());past={} @@ -291,10 +296,11 @@ def run_once(store,now): selection=choose_family(settings['family'],current,economic_replay.scores(store,plant,now),registry=store.registry,now=now, lookback_days=settings['autoLookbackDays'],minimum_days=settings['autoMinimumDays'], minimum_coverage=settings['autoMinimumCoverage'],margin_chf=settings['autoSwitchMarginChf']) - data,full_end,quality=assemble(store,plant,selection['family'],now) + data,full_end,quality=assemble(store,plant,selection['family'],now,include_forecast_points=True) data['batteries']=[replace(b,roundtrip_efficiency=settings['roundtripEfficiency']) for b in data['batteries']] result=optimize(**data,config_revision=settings['revision'],family=selection['family']) if result['executable']: + result['forecastPoints']=quality.pop('forecastPoints') result.update({'installationId':plant,'inputRefs':quality.pop('inputRefs'),'controlContext':quality.pop('controlContext'),'runMode':'shadow','liveEnabled':False,'sourceSelection':selection,'forecastUntil':full_end.isoformat(),'pricesKnownUntil':result['validUntil'],'inputQuality':quality,'warnings':quality['warnings']+([] if quality['loadBasis']=='base_load' else ['Aggregate house forecast: base-load/SDL separation not verified; shadow only'])}) store.publish_shadow(plant,result,settings['revision'],now,claim['sequence'],claim['lease_token']) economic_replay.capture(store,plant,now,assemble,optimize) diff --git a/services/netplan-v4/tests/test_v4.py b/services/netplan-v4/tests/test_v4.py index ced4fc4..f6e9bb5 100644 --- a/services/netplan-v4/tests/test_v4.py +++ b/services/netplan-v4/tests/test_v4.py @@ -36,6 +36,16 @@ def inputs(at=AT): return op,forecast,tariff class OptimizerTest(unittest.TestCase): + def test_plan_preserves_exact_forecast_components(self): + steps = [replace(s, external_w=725.) for s in seq([2100., 2200., 2300.], pv=[3100., 3200., 3300.])] + plan = solve(steps) + self.assertTrue(plan['executable']) + for step, point in zip(steps, plan['points']): + self.assertEqual(point['pvW'], step.pv_w) + self.assertEqual(point['loadW'], step.base_load_w) + self.assertEqual(point['externalW'], step.external_w) + self.assertAlmostEqual(point['baselineGridW'], point['loadW'] + point['externalW'] - point['pvW']) + def test_grid_price_arbitrage(self): p=solve(seq([1000.]*6,buy=[.01]*3+[.7]*3),[batt()]);self.assertTrue(p['executable']);self.assertGreater(p['points'][0]['batteryTargetW'],0);self.assertLess(p['points'][-1]['batteryTargetW'],0) def test_no_future_energy(self): @@ -157,12 +167,21 @@ class ServiceIntegrationTest(unittest.TestCase): op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar)) price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(minutes=15)).isoformat(),'value':-2.,'unit':'Rp/kWh','observedAt':AT.isoformat(),'sourceKind':'published_interval'}]} ingest(self.store,AID,'prices',price,AT);p=run_once(self.store,AT);self.assertEqual(len(p['points']),3);self.assertEqual(p['points'][0]['importPriceChfKwh'],-.02) + self.assertEqual(len(p['forecastPoints']), 6) + self.assertEqual(p['forecastPoints'][-1]['validUntil'], p['forecastUntil']) + self.assertGreater(p['forecastUntil'], p['pricesKnownUntil']) + self.assertNotIn('gridTargetW', p['forecastPoints'][-1]) + self.assertNotIn('forecastPoints', p['inputQuality']) def test_carried_price_does_not_extend_horizon(self): op,fc,tar=inputs();tar['import']={'mode':'dynamic','tariffId':'buy'};self.load((op,fc,tar)) price={'version':1,'eventId':'price1','observedAt':AT.isoformat(),'periods':[{'tariffId':'buy','side':'import','start':AT.isoformat(),'end':(AT+timedelta(hours=48)).isoformat(),'value':.1,'unit':'CHF/kWh','observedAt':AT.isoformat(),'sourceKind':'carried_forward'}]} ingest(self.store,AID,'prices',price,AT);self.assertEqual(run_once(self.store,AT)['status'],'awaiting_inputs') def test_aggregate_load_warning(self): op,fc,tar=inputs();fc['families']['3']['loadBasis']='house_total';self.load((op,fc,tar));self.assertTrue(run_once(self.store,AT)['warnings']) + def test_aggregate_forecast_does_not_invent_separate_sdl(self): + op,fc,tar=inputs();fc['families']['3']['loadBasis']='house_total' + self.load((op,fc,tar));plan=run_once(self.store,AT) + self.assertTrue(all(point['externalW'] is None for point in plan['forecastPoints'])) def test_extra_credentials_not_stored(self): op,fc,tar=inputs();op['password']='do-not-store' with self.assertRaises(ValueError):ingest(self.store,AID,'operation',op,AT) diff --git a/tests/ManagerSDLTest.php b/tests/ManagerSDLTest.php index 6cb27f9..9f096ae 100644 --- a/tests/ManagerSDLTest.php +++ b/tests/ManagerSDLTest.php @@ -386,6 +386,68 @@ final class ManagerSDLTest extends TestCase } } + public function testEnergyPieAttributionTracksSDLWithoutChangingCustomSettings(): void + { + $custom = [ + 'ProduktionZaehlerID' => 801, + 'NetzbezugZaehlerID' => 802, + 'EinspeisungZaehlerID' => 803, + 'VerbrauchZaehlerID' => 804, + 'FarbeEigenverbrauch' => '#123456', + 'FarbeAutarkie' => '#ABCDEF', + 'Standardzeitraum' => 'month', + 'EnergieanteileBerechenbar' => true, + ]; + ManagerSDLRuntime::$properties[303] = $custom; + $this->properties(['SDLAktiv' => true]); + + $this->invoke('synchronisiereEnergyPieAnteile', [303]); + self::assertSame(array_replace($custom, ['EnergieanteileBerechenbar' => false]), + ManagerSDLRuntime::$properties[303]); + $writes = ManagerSDLRuntime::$writes; + $this->invoke('synchronisiereEnergyPieAnteile', [303]); + self::assertSame($writes, ManagerSDLRuntime::$writes); + + $this->properties(['SDLAktiv' => false]); + $this->invoke('synchronisiereEnergyPieAnteile', [303]); + self::assertSame($custom, ManagerSDLRuntime::$properties[303]); + } + + public function testOlderEnergyPieStaysVisibleWithoutSDLAndKeepsConfiguration(): void + { + $custom = ['ProduktionZaehlerID' => 801, 'FarbeEigenverbrauch' => '#123456']; + ManagerSDLRuntime::$properties[303] = $custom; + $this->properties(['SDLAktiv' => false]); + + $this->invoke('synchronisiereEnergyPieAnteile', [303]); + + self::assertSame($custom, ManagerSDLRuntime::$properties[303]); + self::assertFalse(ManagerSDLRuntime::$hidden[303]); + self::assertSame([], ManagerSDLRuntime::$logs); + } + + public function testOlderEnergyPieIsHiddenWithSDLAndRestoredAfterUtilsUpdate(): void + { + $custom = ['ProduktionZaehlerID' => 801, 'FarbeEigenverbrauch' => '#123456']; + ManagerSDLRuntime::$properties[303] = $custom; + $this->properties(['SDLAktiv' => true]); + + $this->invoke('synchronisiereEnergyPieAnteile', [303]); + + self::assertSame($custom, ManagerSDLRuntime::$properties[303]); + self::assertTrue(ManagerSDLRuntime::$hidden[303]); + self::assertCount(1, ManagerSDLRuntime::$logs); + self::assertStringContainsString('Enelix Utils aktualisieren', ManagerSDLRuntime::$logs[0][1]); + + ManagerSDLRuntime::$properties[303]['EnergieanteileBerechenbar'] = true; + $this->invoke('synchronisiereEnergyPieAnteile', [303]); + + self::assertFalse(ManagerSDLRuntime::$hidden[303]); + self::assertSame(array_replace($custom, ['EnergieanteileBerechenbar' => false]), + ManagerSDLRuntime::$properties[303]); + self::assertCount(1, ManagerSDLRuntime::$logs); + } + private function advanceCounters(): void { foreach ([201 => 102.0, 202 => 11.0, 203 => 5.2, 204 => 10.4, 205 => 8.1] as $id => $value) { diff --git a/tests/PrognoseFormular/checks.php b/tests/PrognoseFormular/checks.php index b07dee1..2febbc0 100644 --- a/tests/PrognoseFormular/checks.php +++ b/tests/PrognoseFormular/checks.php @@ -36,6 +36,17 @@ function fields(array $form): array throw new RuntimeException('Forecast panel missing'); } +$anzeigen = ManagerPrognoseFormular::laufzeitAnzeige(); +check(count($anzeigen) === 4, 'only known forecast runtime objects are consolidated'); +foreach ($anzeigen as $ident => $name) { + check(str_starts_with($ident, 'NetzfahrplanV4'), 'stable existing identifier retained'); + check(!str_contains($name, 'V4'), 'technical version removed from display name'); +} +check(str_contains($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'test limitation remains visible'); +$module = file_get_contents(__DIR__ . '/../../Manager/module.php'); +check(str_contains($module, 'ManagerPrognoseFormular::laufzeitAnzeige()'), 'runtime display migration is applied'); +check(str_contains($module, 'IPS_SetHidden($objektID, true)'), 'legacy diagnostics are hidden, not deleted'); + $base = json_decode(file_get_contents(__DIR__ . '/../../Manager/form.json'), true, 512, JSON_THROW_ON_ERROR); $state = [ 'prognoseAktiv' => true, 'lizenziert' => true, 'senderAktiv' => true, 'empfangAktiv' => true, diff --git a/tests/Support/ManagerSDLRuntime.php b/tests/Support/ManagerSDLRuntime.php index ac5bd61..5dead87 100644 --- a/tests/Support/ManagerSDLRuntime.php +++ b/tests/Support/ManagerSDLRuntime.php @@ -11,6 +11,7 @@ final class ManagerSDLRuntime public static array $media = []; public static array $writes = []; public static array $hidden = []; + public static array $logs = []; private static int $nextID = 10000; public static function reset(): void @@ -21,6 +22,7 @@ final class ManagerSDLRuntime self::$media = []; self::$writes = []; self::$hidden = []; + self::$logs = []; self::$nextID = 10000; } @@ -145,6 +147,16 @@ function IPS_GetProperty(int $instanceID, string $property) return ManagerSDLRuntime::$properties[$instanceID][$property]; } +function IPS_GetConfiguration(int $instanceID): string +{ + return json_encode(ManagerSDLRuntime::$properties[$instanceID] ?? [], JSON_THROW_ON_ERROR); +} + +function IPS_LogMessage(string $sender, string $message): void +{ + ManagerSDLRuntime::$logs[] = [$sender, $message]; +} + function IPS_SetProperty(int $instanceID, string $property, $value): void { ManagerSDLRuntime::$properties[$instanceID][$property] = $value;