diff --git a/services/license/changes/20261006-heat-pump-header/README.md b/services/license/changes/20261006-heat-pump-header/README.md new file mode 100644 index 0000000..49150ed --- /dev/null +++ b/services/license/changes/20261006-heat-pump-header/README.md @@ -0,0 +1,86 @@ +# Portal: Waermepumpe und Header + +Stand: 6. Oktober 2026. Entwickelt und getestet, noch nicht live veroeffentlicht. + +## Aenderung / Pull-Request-Text + +Der bestehende Katalogeintrag `heat_pump` wird im Lizenzkauf beruecksichtigt. +Die bestehenden Preise bleiben unveraendert: CHF 20.95 Lizenz und CHF 27.25 +Generator / Ersteinrichtung, jeweils netto. Die Bereitstellung aktiviert nur die +Verfuegbarkeit des Produkts; sie kauft keine Lizenz und vergibt keine kostenlose +Berechtigung an eine Anlage. + +Der Wizard hat einen eigenen Waermepumpenschritt mit SG Ready oder Sperre/Erhoehung, +zwei Kontakten, Betriebsrueckmeldung und Schutzzeiten. Gespeicherte Konfigurationen, +Lizenzvorschlag und generiertes Symcon-Paket verwenden das Modul +`{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Freie Bestandskontingente von +`consumer_single` werden wie im Manager angerechnet, ohne Doppelbelegung. +Technische Variablenkennungen duerfen zur Planung noch 0 sein. Das Bootstrap-Skript +bricht vor Objektveraenderungen ab, wenn die Waermepumpen-Zuordnung unvollstaendig +ist, das Modul fehlt oder die Rueckmeldung einen unpassenden Typ hat. + +Dokumentation und Preise stehen als Textlinks rechts im Portalheader und nicht +mehr in der Seitennavigation. Oeffentliche Links auf der Anmeldeseite bleiben. + +## Voraussetzungen und Konfliktschutz + +- Ziel: `/home/agent/services/license` auf `enelix-services`. +- `portal.patch` zeigt ausschliesslich Aenderungen gegen den gelesenen Live-Stand. +- `manifest.json` enthaelt SHA-256 vor und nach jeder Quellaenderung. +- Die weiterentwickelte, nicht veroeffentlichte Wizard-Fassung auf `ubuntu` wird + nicht durch diesen Live-Patch ersetzt. +- Keine Aenderung an Symcon-Anlagen, Docker-Compose, Datenbankschema, Geheimnissen, + Prognosediensten oder fremden Git-Aenderungen. Daniel Haefliger hat Commit und + Push auf `develop` sowie den Portalneustart ausdruecklich freigegeben. + +## Tests + +- 32 Node-Tests: vorhandene Portalregressionen plus acht neue Waermepumpentests. +- Browserpruefung mit isolierten API-Fixtures: Header bei 1440, 768, 390 und + 320 Pixeln; Wizard konfigurieren, speichern, Lizenzvorschlag und erneut oeffnen. +- Keine Browserfehler. Kein echter Kauf, keine Live-Lizenzvergabe und keine + Schaltbefehle an eine Waermepumpe. +- JavaScript-Syntax und Anwendbarkeit des Patches geprueft. PHP-Syntaxpruefung + und Symcon-Laufzeittest konnten nicht ausgefuehrt werden: keine native PHP-CLI + und kein Zugriff auf die Docker-Sockets in dieser Agent-Ausfuehrung. +- `tests/` enthaelt neue bzw. angepasste Tests. Im Testcheckout in dessen + vorhandenes `tests/` uebernehmen und `node --test tests/*.test.mjs` ausfuehren. +- Browserpruefung: `node tests/browser-smoke.mjs` mit installiertem Playwright und + Chromium; optional `BROWSER_PACKAGE` und `BROWSER_EXECUTABLE` fuer vorhandene + Testwerkzeuge setzen. API-Antworten stammen ausschliesslich aus Test-Fixtures. + +## Bereitstellung + +Der Portalneustart ist vom Auftraggeber freigegeben; dem Serverkonto `agent` +fehlt jedoch der technische Zugriff auf den Docker-Socket. Ein berechtigter +Administrator prueft dieses Paket und fuehrt auf dem Diensteserver aus: + +```sh +python3 /PFAD/ZUM/PAKET/deploy.py +``` + +Das Skript verlangt Root, prueft alle Ausgangshashes und Container-Mounts und +bricht bei Abweichungen ohne Quellaenderung ab. Es sichert die betroffenen Dateien +und den Katalog, stoppt nur `enelix_license_portal`, schreibt die Quelldateien +unter Erhalt der Datei-Bind-Mounts und setzt ausschliesslich die Produktfreigabe +`items.heat_pump.available` auf `true`. Anschliessend startet es den Portalcontainer +und prueft Healthcheck, Wizardkatalog und ausgelieferte Oberflaeche. Es nimmt keine +Bestellung vor und laesst alle Produktpreise unveraendert. + +Das Stoppen/Starten verursacht eine kurze Portalunterbrechung. Backoffice und +Anlagensteuerungen werden nicht neu gestartet. Vor Ausfuehrung parallele +Katalogbearbeitungen im Backoffice fuer das Wartungsfenster aussetzen. + +## Ruecksetzen und Nachkontrolle + +Bei Fehlern stellt das Skript die gesicherten Quellinhalte wieder her und startet +den Portalcontainer erneut. Ein nachtraeglich anderweitig veraenderter Katalog +wird dabei nicht blind ueberschrieben. Sicherungen liegen unter +`/home/agent/services/license/change-backups/heat-pump-header-`. + +Nach erfolgreicher Bereitstellung angemeldet Headerlinks, neue Lizenzzeile, +Waermepumpenschritt, Speichern und Wiedereroeffnen kontrollieren. Zahlungen nur +mit einem ausdruecklich freigegebenen Sandbox-Test abschliessen. Das erzeugte +Symcon-Paket zuerst in einer isolierten IP-Symcon-8-Installation pruefen; das +Ausfuehren des Pakets kann Kontakte schalten und ist kein Bestandteil dieser +Portalbereitstellung. diff --git a/services/license/changes/20261006-heat-pump-header/deploy.py b/services/license/changes/20261006-heat-pump-header/deploy.py new file mode 100644 index 0000000..3791824 --- /dev/null +++ b/services/license/changes/20261006-heat-pump-header/deploy.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Apply the reviewed portal patch as an authorized server administrator.""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone +from urllib.request import urlopen + +APP = Path('/home/agent/services/license') +PACKAGE = Path(__file__).resolve().parent +CONTAINER = 'enelix_license_portal' + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + + +def docker(*args): + return subprocess.run(['docker', *args], check=True, capture_output=True, text=True).stdout + + +def write_in_place(path, content): + # Keep existing file bind mounts attached to the same inode. + with path.open('wb') as target: + target.write(content) + target.flush() + os.fsync(target.fileno()) + + +def verify_runtime(): + with urlopen('https://license.enelix.ch/healthz', timeout=5) as response: + if response.status != 200: + raise RuntimeError('Portal health check failed') + with urlopen('https://license.enelix.ch/api/wizard-catalog', timeout=5) as response: + catalog = json.load(response) + if 'heat_pump' not in catalog.get('consumerFields', {}): + raise RuntimeError('Heat-pump wizard is not served') + with urlopen('https://license.enelix.ch/', timeout=5) as response: + html = response.read().decode() + if 'class="header-links"' not in html or 'data-device-quantity="heat_pump"' not in html: + raise RuntimeError('New portal frontend is not served') + + +def write_catalog(path, content): + metadata = path.stat() + descriptor, temporary = tempfile.mkstemp(prefix='.catalog-release-', dir=path.parent) + try: + with os.fdopen(descriptor, 'wb') as target: + target.write(content) + target.flush() + os.fsync(target.fileno()) + os.chmod(temporary, metadata.st_mode & 0o777) + os.chown(temporary, metadata.st_uid, metadata.st_gid) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def main(): + if os.geteuid() != 0: + raise RuntimeError('Run this reviewed deployment as an authorized administrator (root).') + manifest = json.loads((PACKAGE / 'manifest.json').read_text()) + for name, hashes in manifest['files'].items(): + if Path(name).is_absolute() or '..' in Path(name).parts: + raise RuntimeError('Invalid file path') + if digest(APP / name) != hashes['before']: + raise RuntimeError('Live file changed; stop for conflict review: ' + name) + if digest(PACKAGE / 'files' / name) != hashes['after']: + raise RuntimeError('Package checksum mismatch: ' + name) + state = json.loads(docker('inspect', '--format', '{{json .State}}', CONTAINER)) + if not state.get('Running'): + raise RuntimeError('Portal is not running; resolve its state before deployment.') + mounts = json.loads(docker('inspect', '--format', '{{json .Mounts}}', CONTAINER)) + for name in ['server.mjs', 'stripe.mjs', 'portal-domain.mjs', 'symcon-package.mjs', 'public', 'data']: + if not any(m['Source'] == str(APP / name) and m['Destination'] == '/app/' + name for m in mounts): + raise RuntimeError('Unexpected container mount: ' + name) + catalog_path = APP / 'data/catalog.json' + catalog_bytes = catalog_path.read_bytes() + catalog = json.loads(catalog_bytes) + if catalog.get('items', {}).get('heat_pump', {}).get('sku') != 'ENX-HEAT-PUMP': + raise RuntimeError('Expected heat-pump catalog entry is missing.') + backup = APP / 'change-backups' / ('heat-pump-header-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')) + backup.mkdir(mode=0o700, parents=True, exist_ok=False) + for name in manifest['files']: + destination = backup / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(APP / name, destination) + (backup / 'catalog.json').write_bytes(catalog_bytes) + (backup / 'catalog.json').chmod(0o600) + # Refuse concurrent source edits before stopping the portal. + for name, hashes in manifest['files'].items(): + if digest(APP / name) != hashes['before']: + raise RuntimeError('Concurrent source change: ' + name) + if catalog_path.read_bytes() != catalog_bytes: + raise RuntimeError('Catalog changed during preflight; retry after review.') + written_catalog = None + docker('stop', '--time', '20', CONTAINER) + try: + if catalog_path.read_bytes() != catalog_bytes: + raise RuntimeError('Catalog changed during stop; no catalog migration applied.') + for name in manifest['files']: + write_in_place(APP / name, (PACKAGE / 'files' / name).read_bytes()) + catalog['items']['heat_pump']['available'] = True + catalog['updatedAt'] = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') + written_catalog = (json.dumps(catalog, ensure_ascii=False, indent=2) + '\n').encode() + write_catalog(catalog_path, written_catalog) + docker('start', CONTAINER) + for attempt in range(30): + try: + verify_runtime() + print('Portal deployed and verified. Backup: ' + str(backup)) + return + except Exception: + if attempt == 29: + raise + time.sleep(1) + except Exception: + docker('stop', '--time', '20', CONTAINER) + for name in manifest['files']: + write_in_place(APP / name, (backup / name).read_bytes()) + # Do not overwrite a concurrent backoffice catalog update. + current_catalog = catalog_path.read_bytes() + if written_catalog is not None and current_catalog == written_catalog: + write_catalog(catalog_path, catalog_bytes) + docker('start', CONTAINER) + print('Deployment failed; original application files restored. Backup: ' + str(backup), file=sys.stderr) + raise + + +if __name__ == '__main__': + main() diff --git a/services/license/changes/20261006-heat-pump-header/files/portal-domain.mjs b/services/license/changes/20261006-heat-pump-header/files/portal-domain.mjs new file mode 100644 index 0000000..bf7dc9a --- /dev/null +++ b/services/license/changes/20261006-heat-pump-header/files/portal-domain.mjs @@ -0,0 +1,665 @@ +import { randomBytes } from "node:crypto"; + +export const systemDeviceTypes = Object.freeze({ + battery: { name: "Batteriespeicher", catalogKey: "battery", moduleId: "{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}" }, + buffer_storage: { name: "Pufferspeicher", catalogKey: "buffer_storage", moduleId: "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}" }, + ev_charger: { name: "EV-Ladestation", catalogKey: "ev_charger", moduleId: "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}", gatewayModuleId: "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}" }, + consumer_single: { name: "Verbraucher einstufig", catalogKey: "consumer_single", moduleId: "{15879A4E-D0C2-4495-83DE-46E1E462591E}" }, + boiler_multi: { name: "Boiler mehrstufig", catalogKey: "boiler_multi", moduleId: "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" }, + heat_pump: { name: "Wärmepumpe", catalogKey: "heat_pump", moduleId: "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}" } +}); + +export const integrationProfiles = Object.freeze({ + energy: [ + { id: "manual", name: "Bestehende Symcon-Variablen", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresHost: false }, + { id: "fronius_solar_api_v1", name: "Fronius Solar API v1", manufacturer: "Fronius", protocol: "HTTP Solar API v1", defaultPort: 80, setup: "automatic", requiresHost: true }, + { id: "huawei_sun2000_modbus", name: "SUN2000 / Smart Dongle", manufacturer: "Huawei", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true }, + { id: "sma_sunspec_modbus", name: "Sunny Boy / Tripower SunSpec", manufacturer: "SMA", protocol: "SunSpec Modbus TCP", defaultPort: 502, setup: "sunspec", requiresHost: true }, + { id: "goodwe_et_modbus", name: "ET / BT / EH Serie", manufacturer: "GoodWe", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true }, + { id: "solaredge_sunspec_modbus", name: "SetApp SunSpec", manufacturer: "SolarEdge", protocol: "SunSpec Modbus TCP", defaultPort: 1502, setup: "sunspec", requiresHost: true } + ], + storage: [ + { id: "manual", name: "Bestehende Symcon-Variablen", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresHost: false }, + { id: "fronius_hybrid_storage", name: "BYD über Fronius Hybridwechselrichter", manufacturer: "Fronius", protocol: "HTTP Solar API v1", defaultPort: 80, setup: "automatic", requiresHost: true }, + { id: "huawei_luna2000", name: "LUNA2000 über SUN2000", manufacturer: "Huawei", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true }, + { id: "sma_home_storage", name: "SMA Home Storage", manufacturer: "SMA", protocol: "SunSpec Modbus TCP", defaultPort: 502, setup: "sunspec", requiresHost: true }, + { id: "goodwe_lynx", name: "Lynx Home über Hybridwechselrichter", manufacturer: "GoodWe", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true }, + { id: "solaredge_home_battery", name: "SolarEdge Home Battery", manufacturer: "SolarEdge", protocol: "SunSpec Modbus TCP", defaultPort: 1502, setup: "sunspec", requiresHost: true }, + { id: "sigenergy_sigenstor", name: "SigenStor / Energy Controller", manufacturer: "Sigenergy", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true } + ], + sensor: [ + { id: "manual", name: "Bestehender Temperaturfühler", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresTopic: false }, + { id: "shelly_addon_temperature", name: "Shelly Plus Add-on Temperaturfühler", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", defaultPort: 0, setup: "automatic", requiresTopic: true } + ], + actuator: [ + { id: "manual", name: "Bestehender Symcon-Schaltkontakt", manufacturer: "Allgemein", protocol: "Variablenzuordnung", outputs: 1, setup: "manual", requiresTopic: false }, + { id: "shelly_plus_1pm", name: "Shelly Plus 1PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true }, + { id: "shelly_plus_2pm", name: "Shelly Plus 2PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 2, setup: "automatic", requiresTopic: true }, + { id: "shelly_pro_1pm", name: "Shelly Pro 1PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true }, + { id: "shelly_pro_2pm", name: "Shelly Pro 2PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 2, setup: "automatic", requiresTopic: true }, + { id: "shelly_pro_4pm", name: "Shelly Pro 4PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 4, setup: "automatic", requiresTopic: true }, + { id: "shelly_plug_s_gen3", name: "Shelly Plug S Gen3", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true } + ] +}); + +const monthLimits = ["Januar", "Februar", "Maerz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] + .map((Monat, index) => ({ Monat, Grenze_W: 0, MonatIndex: index + 1 })); + +const f = (key, label, type, defaultValue, section, extra = {}) => ({ key, label, type, default: defaultValue, section, ...extra }); + +export const managerFields = Object.freeze([ + f("Rolle", "Rolle", "select", 0, "Grundeinstellungen", { options: [{ value: 0, label: "Alleine" }, { value: 1, label: "Hauptmanager" }, { value: 2, label: "Untermanager" }] }), + f("NetzleistungVariableID", "Netzleistung Variable-ID", "integer", 0, "Grundeinstellungen", { min: 0 }), + f("Netzleistungsfaktor", "Netzleistungsfaktor", "number", 1, "Grundeinstellungen"), + f("MesswertMaxAlter", "Maximales Messwertalter (s)", "integer", 60, "Grundeinstellungen", { min: 1 }), + f("VerbraucherZuordnung", "Manuelle Verbraucherzuordnung (JSON)", "json", "[]", "Grundeinstellungen"), + f("AutomatischeVerbraucherZuordnung", "Automatische Verbraucherzuordnung (JSON)", "json", "[]", "Grundeinstellungen"), + f("AutomatischeSuche", "Verbraucher automatisch suchen", "boolean", true, "Grundeinstellungen"), + f("SuchbereichID", "Suchbereich Objekt-ID", "integer", 0, "Grundeinstellungen", { min: 0 }), + f("KeepAlive", "Keep-Alive (s)", "integer", 60, "Grundeinstellungen", { min: 1 }), + f("VerbraucherTimeout", "Verbraucher-Timeout (s)", "integer", 60, "Grundeinstellungen", { min: 1 }), + f("Lastspitzenmodus", "Lastspitzenmodus", "select", 0, "Regelung", { options: [{ value: 0, label: "Aus" }, { value: 1, label: "Konstant" }, { value: 2, label: "Monatlich" }] }), + f("Lastspitzengrenze", "Lastspitzengrenze (W)", "number", 0, "Regelung", { min: 0 }), + f("Monatsgrenzen", "Monatliche Grenzen (JSON)", "json", JSON.stringify(monthLimits), "Regelung"), + f("SollwertSolarladen", "Sollwert Solarladen (W)", "number", 0, "Regelung"), + f("Umschaltdifferenz", "Umschaltdifferenz (W)", "number", 5, "Regelung", { min: 0 }), + f("EinspeisebegrenzungAktiv", "Einspeisebegrenzung aktiv", "boolean", false, "Regelung"), + f("Einspeisegrenze", "Maximale Einspeisung (W)", "number", 0, "Regelung", { min: 0 }), + f("PrognoseAktiv", "Prognose aktiv", "boolean", false, "Prognose und Dienste"), + f("NetzfahrplanAktiv", "Netzfahrplan aktiv", "boolean", false, "Prognose und Dienste"), + f("PrognosePVVariableID", "PV-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }), + f("PrognoseHausverbrauchVariableID", "Hausverbrauch-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }), + f("PrognoseSOCVariableID", "SOC-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }), + f("PrognoseSendeintervall", "Prognose-Sendeintervall (s)", "integer", 60, "Prognose und Dienste", { min: 1 }), + f("PrognoseAnschluss", "Prognose-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"), + f("SDLAnschluss", "SDL-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"), + f("LizenzAnschluss", "Lizenz-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"), + f("StoermeldeAnschluss", "Störmelde-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"), + f("StoerueberwachungAktiv", "Störüberwachung aktiv", "boolean", false, "Prognose und Dienste"), + f("StoerungsSendeintervall", "Störungs-Sendeintervall (s)", "integer", 300, "Prognose und Dienste", { min: 10 }), + f("AnlagenWechselrichter", "Wechselrichter (JSON)", "json", "[]", "Anlagentopologie"), + f("AnlagenPVFlaechen", "PV-Flächen (JSON)", "json", "[]", "Anlagentopologie"), + f("AnlagenBatterien", "Batterien (JSON)", "json", "[]", "Anlagentopologie"), + f("NetzbezugEnergieVariableID", "Netzbezug Energie Variable-ID", "integer", 0, "Anlagentopologie", { min: 0 }), + f("NetzbezugEnergiefaktor", "Netzbezug Energiefaktor", "number", 1, "Anlagentopologie"), + f("NetzeinspeisungEnergieVariableID", "Netzeinspeisung Energie Variable-ID", "integer", 0, "Anlagentopologie", { min: 0 }), + f("NetzeinspeisungEnergiefaktor", "Netzeinspeisung Energiefaktor", "number", 1, "Anlagentopologie"), + f("EnergieaufzeichnungAktiv", "Energieaufzeichnung aktiv", "boolean", true, "Messung und Darstellung"), + f("MessungPVLeistungVariableID", "PV-Leistung Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }), + f("MessungHausverbrauchLeistungVariableID", "Hausverbrauch Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }), + f("MessungBatterieleistungVariableID", "Batterieleistung Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }), + f("MessungPVLeistungsfaktor", "PV-Leistungsfaktor", "number", 1, "Messung und Darstellung"), + f("MessungHausverbrauchLeistungsfaktor", "Hausverbrauch-Leistungsfaktor", "number", 1, "Messung und Darstellung"), + f("MessungBatterieleistungsfaktor", "Batterie-Leistungsfaktor", "number", 1, "Messung und Darstellung"), + f("MesswerteAnzeigen", "Messwerte anzeigen", "boolean", false, "Messung und Darstellung"), + f("LeistungsaufzeichnungMinuten", "Leistungsaufzeichnung (min)", "integer", 1, "Messung und Darstellung", { min: 1 }), + f("LeistungLoeschenMonate", "Leistungsdaten löschen (Monate)", "integer", 12, "Messung und Darstellung", { min: 0 }), + f("EnergieaufzeichnungMinuten", "Energieaufzeichnung (min)", "integer", 5, "Messung und Darstellung", { min: 1 }), + f("EnergieVerdichtenMonate", "Energiedaten verdichten (Monate)", "integer", 12, "Messung und Darstellung", { min: 0 }), + f("EnergieLoeschenMonate", "Energiedaten löschen (Monate)", "integer", 0, "Messung und Darstellung", { min: 0 }), + f("EnergyPieAnzeigen", "Energie-Kreisdiagramm anzeigen", "boolean", true, "Messung und Darstellung"), + f("EnergiediagrammeAnzeigen", "Energiediagramme anzeigen", "boolean", true, "Messung und Darstellung"), + f("FunFactsAnzeigen", "Fun Facts anzeigen", "boolean", true, "Messung und Darstellung"), + f("EnergieflussAnzeigen", "Energiefluss anzeigen", "boolean", true, "Messung und Darstellung"), + f("DiagnosevariablenAnzeigen", "Diagnosevariablen anzeigen", "boolean", false, "Diagnose"), + f("LoggingEin", "Diagnoseprotokoll aktivieren", "boolean", false, "Diagnose") +]); + +const managerWizardFieldKeys = new Set([ + "Lastspitzengrenze", + "SollwertSolarladen", + "EinspeisebegrenzungAktiv", + "Einspeisegrenze", + "PrognoseAktiv", + "NetzfahrplanAktiv", + "StoerueberwachungAktiv", + "EnergyPieAnzeigen", + "EnergiediagrammeAnzeigen", + "FunFactsAnzeigen", + "EnergieflussAnzeigen" +]); + +export const managerWizardFields = Object.freeze( + managerFields + .filter(field => managerWizardFieldKeys.has(field.key)) + .map(field => ({ + ...field, + section: field.section === "Messung und Darstellung" ? "Darstellung" : field.section + })) +); + +export const topologyFields = Object.freeze({ + pvSurface: [ + f("Name", "Bezeichnung", "text", "PV-Fläche", "PV-Fläche"), + f("DCLeistung_kWp", "DC-Leistung (kWp)", "number", 0, "PV-Fläche", { min: 0 }), + f("Neigung_Grad", "Neigung (Grad)", "number", 30, "Ausrichtung", { min: 0, max: 90 }), + f("Azimut_Grad", "Azimut (Grad)", "number", 0, "Ausrichtung", { min: -180, max: 180 }), + f("MPPT", "MPPT / Eingang", "text", "", "Zuordnung"), + f("Modulanzahl", "Anzahl Module", "integer", 0, "Module", { min: 0 }), + f("Modulleistung_Wp", "Modulleistung (Wp)", "number", 0, "Module", { min: 0 }) + ], + battery: [ + f("Nennkapazitaet_kWh", "Nennkapazität (kWh)", "number", 0, "Anlagentopologie", { min: 0 }), + f("Nutzkapazitaet_kWh", "Nutzkapazität (kWh)", "number", 0, "Anlagentopologie", { min: 0 }), + f("MaxLadeleistung_kW", "Maximale Ladeleistung (kW)", "number", 0, "Anlagentopologie", { min: 0 }), + f("MaxEntladeleistung_kW", "Maximale Entladeleistung (kW)", "number", 0, "Anlagentopologie", { min: 0 }), + f("Kopplung", "Kopplung", "choice", "ac", "Anlagentopologie", { options: [ + { value: "ac", label: "AC-gekoppelt" }, + { value: "dc", label: "DC-gekoppelt" }, + { value: "hybrid", label: "Hybrid" } + ] }) + ] +}); + +const commonConsumerFields = [ + f("PrioritaetPV", "Priorität PV", "integer", 0, "Manager und Zeitverhalten", { min: 0 }), + f("PrioritaetPeak", "Priorität Peak", "integer", 0, "Manager und Zeitverhalten", { min: 0 }), + f("Meldeintervall", "Meldeintervall (s)", "integer", 10, "Manager und Zeitverhalten", { min: 1 }), + f("VorgabeTimeout", "Vorgabe-Timeout (s)", "integer", 120, "Manager und Zeitverhalten", { min: 1 }), + f("EinstellungenInVisu", "Einstellungen in Visualisierung", "boolean", true, "Darstellung und Diagnose"), + f("DiagnosevariablenAnzeigen", "Diagnosevariablen anzeigen", "boolean", false, "Darstellung und Diagnose"), + f("LoggingEin", "Diagnoseprotokoll aktivieren", "boolean", false, "Darstellung und Diagnose") +]; + +export const consumerFields = Object.freeze({ + battery: [...commonConsumerFields, + f("Batterietyp", "Batterietyp", "select", 0, "Batterie", { options: [{ value: 0, label: "Unkonfiguriert" }, { value: 1, label: "Herstellerunabhängig" }, { value: 2, label: "GoodWe" }, { value: 3, label: "SolarEdge" }, { value: 4, label: "Sigenergy" }] }), + f("Batteriemanagement", "Steuerung", "select", 1, "Batterie", { options: [{ value: 1, label: "Wechselrichter" }, { value: 2, label: "Enelix Manager" }] }), + ...["MaxLadeleistungVariableID", "MaxEntladeleistungVariableID", "LadezustandVariableID", "NetzleistungVariableID", "IstleistungVariableID", "ManagementRegisterVariableID", "ModusRegisterVariableID", "LeistungsRegisterVariableID", "LadeleistungRegisterVariableID", "EntladeleistungRegisterVariableID"].map(key => f(key, key.replace(/VariableID$/, " Variable-ID"), "integer", 0, "Variablenzuordnung", { min: 0 })), + f("ReserveLadezustand", "Reserve für Peak Shaving (%)", "number", 20, "Batterie", { min: 0, max: 100 }), + f("MindestLadezustand", "Minimaler Ladezustand (%)", "number", 10, "Batterie", { min: 0, max: 100 }), + f("LadezustandHysterese", "Reserve-Hysterese (%)", "number", 2, "Batterie", { min: 0, max: 100 }), + f("MesswertMaxAlter", "Maximales Messwertalter (s)", "integer", 60, "Batterie", { min: 1 }), + f("Aenderungssperre", "Sperrzeit nach Leistungsänderung (s)", "integer", 4, "Batterie", { min: 0 }) + ], + buffer_storage: [...commonConsumerFields, + f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Pufferspeicher"), + f("PufferfuehlerVariableID", "Puffertemperatur Variable-ID", "integer", 0, "Pufferspeicher", { min: 0 }), + f("AussentemperaturVariableID", "Außentemperatur Variable-ID", "integer", 0, "Pufferspeicher", { min: 0 }), + f("WaermepumpenSolltemperaturVariableID", "Wärmepumpen-Solltemperatur Variable-ID", "integer", 0, "Heizkurve", { min: 0 }), + f("FusspunktVorlauftemperatur", "Fußpunkt Vorlauftemperatur (°C)", "number", 35, "Heizkurve"), + f("HeizkurvenSteigung", "Heizkurvensteigung", "number", 1, "Heizkurve", { min: 0 }), + f("HeizkurveMinimaltemperatur", "Minimale Solltemperatur (°C)", "number", 20, "Heizkurve"), + f("HeizkurveMaximaltemperatur", "Maximale Solltemperatur (°C)", "number", 80, "Heizkurve"), + f("Hysterese", "Hysterese (K)", "number", 5, "Heizkurve", { min: 0.1 }), + f("MindesttemperaturModus", "Mindesttemperatur", "select", 0, "Heizkurve", { options: [{ value: 0, label: "Aus" }, { value: 1, label: "Statisch" }, { value: 2, label: "Differenz zur Solltemperatur" }] }), + f("Mindesttemperatur", "Statische Mindesttemperatur (°C)", "number", 20, "Heizkurve"), + f("MindesttemperaturDifferenz", "Differenz zur Solltemperatur (K)", "number", 5, "Heizkurve", { min: 0 }), + f("LastwechselSperrzeit", "Lastwechsel-Sperrzeit (s)", "integer", 5, "Pufferspeicher", { min: 0 }), + f("TemperaturMaxAlter", "Maximales Temperaturalter (s)", "integer", 120, "Pufferspeicher", { min: 1 }), + f("PuffertemperaturGlaetten", "Puffertemperatur glätten", "boolean", false, "Pufferspeicher"), + f("ZeitKonstante", "PT1-Zeitkonstante (s)", "integer", 120, "Pufferspeicher", { min: 1 }) + ], + ev_charger: [...commonConsumerFields, + f("Geraetetyp", "Gerätetyp", "select", 0, "Ladestation", { options: [{ value: 0, label: "Nicht konfiguriert" }, { value: 1, label: "go-e alte API" }, { value: 2, label: "go-e Gemini" }, { value: 3, label: "smart-me Pico" }] }), + f("Geraeteadresse", "IP-Adresse oder Hostname", "text", "", "Ladestation"), + f("GeraeteID", "Geräte-ID", "text", "", "Ladestation"), + f("Seriennummer", "Seriennummer", "text", "", "Ladestation"), + f("Benutzername", "Benutzername", "text", "", "Ladestation"), + f("MaximalerLadestrom", "Maximaler Ladestrom (A)", "integer", 16, "Ladestation", { min: 6, max: 32 }), + f("Abfrageintervall", "Statusabfrage (s)", "integer", 5, "Ladestation", { min: 1 }), + f("Ladefreigabe", "Ladefreigabe beim Start", "boolean", true, "Ladestation"), + f("Solarladen", "Solarladen beim Start", "boolean", true, "Ladestation") + ], + consumer_single: [...commonConsumerFields, + f("Nennleistung", "Nennleistung (W)", "integer", 0, "Verbraucher", { min: 0 }), + f("SchaltkontaktVariableID", "Schaltkontakt Variable-ID", "integer", 0, "Verbraucher", { min: 0 }), + f("SchaltkontaktInvertiert", "Schaltkontakt invertieren", "boolean", false, "Verbraucher"), + f("RueckmeldungVariableID", "Rückmeldung Variable-ID", "integer", 0, "Verbraucher", { min: 0 }), + f("Mindestlaufzeit", "Tägliche Mindestlaufzeit (s)", "integer", 0, "Verbraucher", { min: 0, max: 86400 }), + f("PeakSperreBeiMindestlaufzeitAnbieten", "Bei Peak-Sperre während Mindestlaufzeit anbieten", "boolean", true, "Verbraucher"), + f("Mindesteinschaltdauer", "Mindest-Einschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 }), + f("Mindestausschaltdauer", "Mindest-Ausschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 }) + ], + heat_pump: [...commonConsumerFields, + f("Kontaktart", "Kontaktart", "select", 0, "Kontakte", { min: 0, max: 1, options: [{ value: 0, label: "Sperre und Erhöhung" }, { value: 1, label: "SG Ready" }] }), + f("Kontakt1VariableID", "Kontakt 1: Sperre / SG Ready 1 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }), + f("Kontakt2VariableID", "Kontakt 2: Erhöhung / SG Ready 2 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }), + f("Kontakt1Invertiert", "Kontakt 1 invertieren", "boolean", false, "Kontakte"), + f("Kontakt2Invertiert", "Kontakt 2 invertieren", "boolean", false, "Kontakte"), + f("Nennleistung", "Elektrische Nennleistung (W)", "integer", 0, "Wärmepumpe", { min: 0 }), + f("Rueckmeldungsart", "Rückmeldungsart", "select", 0, "Betriebsrückmeldung", { min: 0, max: 1, options: [{ value: 0, label: "Gemessene Leistung" }, { value: 1, label: "Boolean-Betriebsstatus" }] }), + f("IstleistungVariableID", "Leistung oder Betriebsstatus (Variable-ID)", "integer", 0, "Betriebsrückmeldung", { min: 0 }), + f("Laufschwelle", "Laufschwelle bei Leistungsmessung (W)", "number", 100, "Betriebsrückmeldung", { min: 0 }), + f("Anlaufwartezeit", "Anlaufwartezeit (s)", "integer", 30, "Schutzzeiten", { min: 0 }), + f("Wiederholsperre", "Wiederholsperre nach erfolglosem Anlauf (s)", "integer", 300, "Schutzzeiten", { min: 0 }), + f("Mindestlaufzeit", "Mindestlaufzeit nach bestätigtem Start (s)", "integer", 1200, "Schutzzeiten", { min: 0 }), + f("Mindestsperrzeit", "Mindest-Sperrzeit (s)", "integer", 1200, "Schutzzeiten", { min: 0 }), + f("MaximaleSperrzeit", "Maximale Sperrzeit am Stück (s; 0 = aus)", "integer", 7200, "Schutzzeiten", { min: 0 }), + f("MaximaleSperrzeit24h", "Maximale Sperrzeit in 24 Stunden (s; 0 = aus)", "integer", 21600, "Schutzzeiten", { min: 0, max: 86400 }) + ], + boiler_multi: [...commonConsumerFields, + f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Warmwassererwärmer"), + f("Boilerfuehler_PT1", "Boilerfühler Variable-ID", "integer", 0, "Warmwassererwärmer", { min: 0 }), + f("LegionellenfunktionAktiv", "Legionellenschaltung aktiv", "boolean", true, "Warmwassererwärmer"), + f("LegionellenMinimalintervallTage", "Frühestens nach Tagen", "integer", 4, "Warmwassererwärmer", { min: 1 }), + f("LegionellenMaximalintervallTage", "Spätestens nach Tagen", "integer", 7, "Warmwassererwärmer", { min: 1 }), + f("Boilervolumen", "Boilervolumen (l)", "integer", 300, "Warmwassererwärmer", { min: 1 }), + f("Hysterese", "Temperaturhysterese (K)", "number", 5, "Warmwassererwärmer", { min: 0.1 }), + f("Zeitplan", "Solltemperatur-Zeitplan (JSON)", "json", "[]", "Warmwassererwärmer"), + f("LastwechselSperrzeit", "Mindestzeit zwischen Lastwechseln (s)", "integer", 5, "Warmwassererwärmer", { min: 1 }), + f("TemperaturMaxAlter", "Maximales Messwertalter (s)", "integer", 30, "Warmwassererwärmer", { min: 1 }), + f("BoilertemperaturGlaetten", "Boilertemperatur glätten", "boolean", false, "Warmwassererwärmer"), + f("ZeitKonstante", "PT1-Zeitkonstante (s)", "integer", 120, "Warmwassererwärmer", { min: 1 }) + ] +}); + +export const evGatewayFields = Object.freeze([ + ...commonConsumerFields, + f("Betriebsmodus", "Betriebsart", "select", 0, "Gateway-Ladestation", { options: [ + { value: 0, label: "Easee" }, + { value: 1, label: "Easee · nur Solarladen" } + ] }), + f("Ladestationskennung", "Easee Seriennummer", "text", "", "Gateway-Ladestation"), + f("MaximalerLadestrom", "Maximaler Ladestrom (A)", "integer", 16, "Gateway-Ladestation", { min: 6, max: 32 }), + f("Ladefreigabe", "Ladefreigabe beim Start", "boolean", true, "Gateway-Ladestation"), + f("Solarladen", "Solarladen beim Start", "boolean", true, "Gateway-Ladestation") +]); + +const consumerWizardFieldKeys = Object.freeze({ + battery: new Set(["Batteriemanagement", "ReserveLadezustand", "MindestLadezustand"]), + buffer_storage: new Set([ + "FusspunktVorlauftemperatur", "HeizkurvenSteigung", "HeizkurveMinimaltemperatur", + "HeizkurveMaximaltemperatur", "Hysterese", "MindesttemperaturModus", + "Mindesttemperatur", "MindesttemperaturDifferenz" + ]), + ev_charger: new Set([ + "Geraetetyp", "Geraeteadresse", "GeraeteID", "Seriennummer", "Benutzername", + "MaximalerLadestrom", "Ladefreigabe", "Solarladen" + ]), + consumer_single: new Set([ + "Nennleistung", "SchaltkontaktInvertiert", "Mindestlaufzeit", + "Mindesteinschaltdauer", "Mindestausschaltdauer" + ]), + heat_pump: new Set([ + "Kontaktart", "Kontakt1VariableID", "Kontakt2VariableID", "Kontakt1Invertiert", + "Kontakt2Invertiert", "Nennleistung", "Rueckmeldungsart", "IstleistungVariableID", + "Laufschwelle", "Anlaufwartezeit", "Wiederholsperre", "Mindestlaufzeit", + "Mindestsperrzeit", "MaximaleSperrzeit", "MaximaleSperrzeit24h" + ]), + boiler_multi: new Set([ + "Boilervolumen", "Hysterese", "LegionellenfunktionAktiv", + "LegionellenMinimalintervallTage", "LegionellenMaximalintervallTage" + ]) +}); + +export const consumerWizardFields = Object.freeze(Object.fromEntries( + Object.entries(consumerFields).map(([key, fields]) => [ + key, + fields.filter(field => consumerWizardFieldKeys[key].has(field.key)) + ]) +)); + +export const evGatewayWizardFields = Object.freeze( + evGatewayFields.filter(field => [ + "Betriebsmodus", "Ladestationskennung", "MaximalerLadestrom", "Ladefreigabe", "Solarladen" + ].includes(field.key)) +); + +export const deviceListFields = Object.freeze({ + powerLevel: [ + f("Stufe", "Stufe", "integer", 1, "Leistungsstufe", { min: 1 }), + f("Leistung", "Leistung (W)", "integer", 0, "Leistungsstufe", { min: 1 }) + ], + schedule: [ + f("Uhrzeit", "Uhrzeit", "text", "00:00", "Zeitplan"), + f("Solltemperatur", "Solltemperatur (°C)", "number", 50, "Zeitplan", { min: 0, max: 100 }) + ] +}); + +function text(value, maxLength) { + return String(value || "").trim().replace(/\s+/g, " ").slice(0, maxLength); +} + +function count(value, maximum = 50) { + const parsed = Number(value || 0); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > maximum) { + throw Object.assign(new Error("invalid_quantity"), { status: 400, publicMessage: "Mengen müssen zwischen 0 und " + maximum + " liegen." }); + } + return parsed; +} + +function bool(value) { + return value === true || value === 1 || value === "1" || value === "true" || value === "on"; +} + +function finite(value, fallback, minimum = -Number.MAX_VALUE, maximum = Number.MAX_VALUE, integer = false) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + const bounded = Math.max(minimum, Math.min(maximum, parsed)); + return integer ? Math.round(bounded) : bounded; +} + +function jsonText(value, fallback) { + if (value === undefined || value === null || value === "") return fallback; + try { + return JSON.stringify(typeof value === "string" ? JSON.parse(value) : value); + } catch { + throw Object.assign(new Error("invalid_configuration_json"), { status: 400, publicMessage: "Ein JSON-Konfigurationsfeld ist ungültig." }); + } +} + +function normalizeProperties(input, fields) { + const source = input && typeof input === "object" ? input : {}; + return Object.fromEntries(fields.map(field => { + const raw = Object.prototype.hasOwnProperty.call(source, field.key) ? source[field.key] : field.default; + if (field.type === "boolean") return [field.key, bool(raw)]; + if (field.type === "integer" || field.type === "select") return [field.key, finite(raw, field.default, field.min, field.max, true)]; + if (field.type === "number") return [field.key, finite(raw, field.default, field.min, field.max, false)]; + if (field.type === "json") return [field.key, jsonText(raw, field.default)]; + return [field.key, text(raw, 240)]; + })); +} + +function parseList(value) { + if (Array.isArray(value)) return value; + if (typeof value !== "string" || value.trim() === "") return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function normalizeTopologyValues(input, fields) { + const source = input && typeof input === "object" ? input : {}; + return Object.fromEntries(fields.map(field => { + const raw = Object.prototype.hasOwnProperty.call(source, field.key) ? source[field.key] : field.default; + if (field.type === "integer") return [field.key, finite(raw, field.default, field.min, field.max, true)]; + if (field.type === "number") return [field.key, finite(raw, field.default, field.min, field.max, false)]; + if (field.type === "choice") { + const allowed = (field.options || []).map(option => option.value); + return [field.key, allowed.includes(raw) ? raw : field.default]; + } + return [field.key, text(raw, 120)]; + })); +} + +function normalizePvSurfaces(input) { + return (Array.isArray(input) ? input : []).slice(0, 50).map((item, index) => ({ + ID: text(item?.ID || "pv-" + (index + 1), 60), + ...normalizeTopologyValues(item, topologyFields.pvSurface), + WechselrichterID: "wr-1" + })); +} + +function normalizeBatteryTopology(input) { + return normalizeTopologyValues(input, topologyFields.battery); +} + +function normalizePowerLevels(input) { + return parseList(input).slice(0, 12).map((item, index) => ({ + ...normalizeTopologyValues({ ...item, Stufe: item?.Stufe ?? index + 1 }, deviceListFields.powerLevel), + integration: normalizeIntegration(item?.integration || { profile: "manual", sourceVariableId: item?.Schaltkontakt_Stufe || 0 }, "actuator") + })); +} + +function normalizeSchedule(input) { + return parseList(input).slice(0, 24).map(item => { + const value = normalizeTopologyValues(item, deviceListFields.schedule); + return { + Uhrzeit: /^([01]\d|2[0-3]):[0-5]\d$/.test(value.Uhrzeit) ? value.Uhrzeit : "00:00", + Solltemperatur: value.Solltemperatur + }; + }); +} + +function batteryTypeForProfile(profileId) { + if (profileId === "goodwe_lynx") return 2; + if (profileId === "solaredge_home_battery") return 3; + if (profileId === "sigenergy_sigenstor") return 4; + return profileId === "manual" ? 0 : 1; +} + +function profile(kind, value) { + const profiles = integrationProfiles[kind]; + return profiles.find(item => item.id === value) || profiles[0]; +} + +function normalizeIntegration(input, kind) { + const source = input && typeof input === "object" ? input : {}; + const selected = profile(kind, text(source.profile, 60)); + const result = { + profile: selected.id, + manufacturer: selected.manufacturer, + protocol: selected.protocol, + setup: selected.setup, + host: text(source.host, 120), + port: finite(source.port, selected.defaultPort || 0, 0, 65535, true), + unitId: finite(source.unitId, 1, 0, 247, true), + topicPrefix: text(source.topicPrefix, 180), + channel: finite(source.channel, 0, 0, Math.max(0, (selected.outputs || 1) - 1), true), + sourceVariableId: finite(source.sourceVariableId, 0, 0, 2147483647, true), + feedbackVariableId: finite(source.feedbackVariableId, 0, 0, 2147483647, true), + notes: text(source.notes, 300) + }; + if (selected.requiresHost && result.host === "") { + throw Object.assign(new Error("integration_host_missing"), { status: 400, publicMessage: "Bitte für " + selected.name + " eine IP-Adresse oder einen Hostnamen angeben." }); + } + if (selected.requiresTopic && result.topicPrefix === "") { + throw Object.assign(new Error("integration_topic_missing"), { status: 400, publicMessage: "Bitte für " + selected.name + " das MQTT-Topic-Präfix angeben." }); + } + return result; +} + +function legacyItem(key, source, index) { + const integration = {}; + if (key === "battery") { + integration.profile = source.profile || "manual"; + integration.host = source.address || source.host || ""; + } else if (["buffer_storage", "consumer_single", "boiler_multi"].includes(key)) { + integration.profile = source.hardware === "shelly_1pm" ? "shelly_plus_1pm" : "manual"; + integration.topicPrefix = source.topicPrefix || ""; + } + const properties = {}; + if (key === "consumer_single" && source.ratedPower) properties.Nennleistung = source.ratedPower; + if (key === "boiler_multi" && source.volume) properties.Boilervolumen = source.volume; + if (key === "ev_charger") { + const types = { goe_legacy: 1, goe_gemini: 2, smartme_pico: 3 }; + properties.Geraetetyp = types[source.model] || 0; + properties.Geraeteadresse = source.address || ""; + } + return { + name: source.name || systemDeviceTypes[key].name + " " + (index + 1), + variant: key === "ev_charger" ? "standalone" : undefined, + integration, + properties, + topology: key === "battery" ? normalizeBatteryTopology(source.topology) : undefined, + powerLevels: ["buffer_storage", "boiler_multi"].includes(key) ? [] : undefined, + schedule: key === "boiler_multi" ? [] : undefined, + sensor: ["buffer_storage", "boiler_multi"].includes(key) ? { profile: "manual", sourceVariableId: 0 } : undefined + }; +} + +function normalizeDevice(key, input) { + const source = input && typeof input === "object" ? input : {}; + const requested = count(source.quantity); + const rawItems = Array.isArray(source.items) ? source.items.slice(0, requested) : []; + while (rawItems.length < requested) rawItems.push(legacyItem(key, source, rawItems.length)); + const items = rawItems.map((item, index) => { + const variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone"; + const kind = key === "battery" ? "storage" : (["buffer_storage", "consumer_single", "boiler_multi", "heat_pump"].includes(key) ? "actuator" : null); + const integration = kind ? normalizeIntegration(key === "heat_pump" ? { profile: "manual" } : item.integration, kind) : { + profile: text(item.integration?.profile || "device_api", 60), + host: text(item.integration?.host, 120), + port: finite(item.integration?.port, 0, 0, 65535, true), + unitId: finite(item.integration?.unitId, 1, 0, 247, true), + notes: text(item.integration?.notes, 300) + }; + const propertyFields = key === "ev_charger" && variant === "gateway" ? evGatewayFields : consumerFields[key]; + const properties = normalizeProperties(item.properties, propertyFields); + if (key === "battery") properties.Batterietyp = batteryTypeForProfile(integration.profile); + + const result = { + name: text(item.name || systemDeviceTypes[key].name + " " + (index + 1), 80), + variant, + integration, + properties + }; + if (key === "battery") result.topology = normalizeBatteryTopology(item.topology); + if (["buffer_storage", "boiler_multi"].includes(key)) { + result.powerLevels = normalizePowerLevels(item.powerLevels ?? item.properties?.LeistungsStufen); + result.sensor = normalizeIntegration(item.sensor || { + profile: "manual", + sourceVariableId: key === "boiler_multi" + ? item.properties?.Boilerfuehler_PT1 + : item.properties?.PufferfuehlerVariableID + }, "sensor"); + result.properties.LeistungsStufen = JSON.stringify(result.powerLevels.map(level => ({ + Stufe: level.Stufe, + Leistung: level.Leistung, + Schaltkontakt_Stufe: level.integration.sourceVariableId || 0 + }))); + } + if (key === "boiler_multi") { + result.schedule = normalizeSchedule(item.schedule ?? item.properties?.Zeitplan); + result.properties.Zeitplan = JSON.stringify(result.schedule); + } + return result; + }); + return { quantity: requested, items }; +} + +export function normalizeSystemConfiguration(input = {}) { + const inputDevices = input.devices || {}; + const hasSeparatedStorage = Object.prototype.hasOwnProperty.call(inputDevices, "buffer_storage"); + const devices = {}; + for (const key of Object.keys(systemDeviceTypes)) { + let source = inputDevices[key] || {}; + if (!hasSeparatedStorage && key === "buffer_storage") source = inputDevices.battery || {}; + if (!hasSeparatedStorage && key === "battery") source = {}; + devices[key] = normalizeDevice(key, source); + } + const legacyGridProfile = input.gridMeasurement?.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual"; + const legacyPvProfile = input.pvSystem?.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual"; + const legacyInverter = parseList(input.manager?.properties?.AnlagenWechselrichter)[0] || {}; + const legacyPvSurfaces = parseList(input.manager?.properties?.AnlagenPVFlaechen); + const pvEnabled = bool(input.pvSystem?.enabled); + const pvSystem = { + enabled: pvEnabled, + id: "wr-1", + name: text(input.pvSystem?.name || legacyInverter.Name || "PV-Wechselrichter", 80), + acPowerKw: finite(input.pvSystem?.acPowerKw ?? legacyInverter.ACNennleistung_kW, 0, 0), + ...normalizeIntegration({ + ...input.pvSystem, + profile: pvEnabled ? (input.pvSystem?.profile || legacyPvProfile) : "manual" + }, "energy") + }; + const topology = { + pvSurfaces: normalizePvSurfaces(input.topology?.pvSurfaces ?? legacyPvSurfaces) + }; + const result = { + schemaVersion: 4, + name: text(input.name, 100), + location: text(input.location, 120), + siteType: ["Privat", "Gewerbe", "Industrie"].includes(input.siteType) ? input.siteType : "Gewerbe", + peakShaving: bool(input.peakShaving), + manager: { properties: normalizeProperties(input.manager?.properties, managerFields) }, + gridMeasurement: normalizeIntegration({ ...input.gridMeasurement, profile: input.gridMeasurement?.profile || legacyGridProfile }, "energy"), + pvSystem, + topology, + costReport: { + enabled: bool(input.costReport?.enabled), + powerMeters: count(input.costReport?.powerMeters, 999), + auxiliaryMeters: count(input.costReport?.auxiliaryMeters, 999) + }, + devices + }; + result.manager.properties.Lastspitzenmodus = result.peakShaving + ? Math.max(1, result.manager.properties.Lastspitzenmodus) + : 0; + + if (result.pvSystem.enabled && result.topology.pvSurfaces.length === 0) { + result.topology.pvSurfaces = normalizePvSurfaces([{ Name: "PV-Fläche 1" }]); + } + if (!result.pvSystem.enabled) { + result.pvSystem = { ...result.pvSystem, profile: "manual", host: "", port: 0 }; + result.topology.pvSurfaces = []; + } + + const inverters = result.pvSystem.enabled ? [{ + ID: "wr-1", + Name: result.pvSystem.name, + Typ: "pv", + ACNennleistung_kW: result.pvSystem.acPowerKw, + IstleistungVariableID: result.pvSystem.sourceVariableId, + Istleistungsfaktor: 1, + ErzeugungsenergieVariableID: 0, + Erzeugungsenergiefaktor: 1, + BegrenzungVariableID: 0, + Begrenzungsart: "percent" + }] : []; + const batteries = result.devices.battery.items.map((item, index) => ({ + ID: "bat-" + (index + 1), + Name: item.name, + ...item.topology, + WechselrichterID: item.topology.Kopplung === "ac" || !result.pvSystem.enabled ? "" : "wr-1", + LeistungVariableID: item.integration.sourceVariableId || item.properties.IstleistungVariableID || 0, + Leistungsfaktor: 1, + SOCVariableID: item.properties.LadezustandVariableID || 0, + LadeenergieVariableID: 0, + EntladeenergieVariableID: 0, + Energiefaktor: 1 + })); + result.manager.properties.AnlagenWechselrichter = JSON.stringify(inverters); + result.manager.properties.AnlagenPVFlaechen = JSON.stringify(result.topology.pvSurfaces); + result.manager.properties.AnlagenBatterien = JSON.stringify(batteries); + + if (result.name.length < 2 || result.location.length < 2) { + throw Object.assign(new Error("invalid_system_configuration"), { status: 400, publicMessage: "Bitte Systemname und Standort angeben." }); + } + return result; +} + +export function licenseCoverage(requested = {}, owned = {}) { + const coverage = { ...owned }; + const spareConsumers = Math.max(0, Number(owned.consumer_single || 0) - Number(requested.consumer_single || 0)); + coverage.heat_pump = Number(owned.heat_pump || 0) + Math.min(spareConsumers, + Math.max(0, Number(requested.heat_pump || 0) - Number(owned.heat_pump || 0))); + return coverage; +} + +export function recommendLicenses(configuration) { + const modules = {}; + for (const key of Object.keys(systemDeviceTypes)) modules[key] = configuration.devices[key].quantity; + return { + managerVariant: configuration.peakShaving ? "manager_peak" : "manager_standard", + modules, + costReport: configuration.costReport + }; +} + +export function publicWizardCatalog() { + return { + schemaVersion: 4, + integrations: integrationProfiles, + managerFields: managerWizardFields, + topologyFields, + consumerFields: consumerWizardFields, + evGatewayFields: evGatewayWizardFields, + deviceListFields, + evVariants: [ + { value: "standalone", label: "Direkt / Stand-alone" }, + { value: "gateway", label: "Easee über Gateway" } + ], + deviceTypes: Object.fromEntries(Object.entries(systemDeviceTypes).map(([key, item]) => [key, { name: item.name }])) + }; +} + +const activationAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +export function createActivationCode() { + const bytes = randomBytes(16); + let body = ""; + for (let index = 0; index < 16; index += 1) body += activationAlphabet[bytes[index] % activationAlphabet.length]; + return "ENX-" + body.slice(0, 4) + "-" + body.slice(4, 8) + "-" + body.slice(8, 12) + "-" + body.slice(12, 16); +} + +export function normalizeActivationCode(value) { + return String(value || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); +} + +export function activationCodeValid(value) { + return /^ENX[A-HJ-NP-Z2-9]{16}$/.test(normalizeActivationCode(value)); +} diff --git a/services/license/changes/20261006-heat-pump-header/files/public/app.js b/services/license/changes/20261006-heat-pump-header/files/public/app.js new file mode 100644 index 0000000..0cff9ea --- /dev/null +++ b/services/license/changes/20261006-heat-pump-header/files/public/app.js @@ -0,0 +1,2564 @@ +(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() { + 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) { + 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 "" + 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 + ""; + }).join("") : "Noch keine Bestellungen in dieser Organisation."; + } + + 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 "" + + "" + 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") + "" + + ""; + }).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("