feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s

This commit is contained in:
dh
2026-10-06 14:35:17 +00:00
parent faa905ab09
commit 5687596799
14 changed files with 9125 additions and 0 deletions
@@ -0,0 +1,86 @@
# Portal: Waermepumpe und Header
Stand: 6. Oktober 2026. Entwickelt und getestet, noch nicht live veroeffentlicht.
## Aenderung / Pull-Request-Text
Der bestehende Katalogeintrag `heat_pump` wird im Lizenzkauf beruecksichtigt.
Die bestehenden Preise bleiben unveraendert: CHF 20.95 Lizenz und CHF 27.25
Generator / Ersteinrichtung, jeweils netto. Die Bereitstellung aktiviert nur die
Verfuegbarkeit des Produkts; sie kauft keine Lizenz und vergibt keine kostenlose
Berechtigung an eine Anlage.
Der Wizard hat einen eigenen Waermepumpenschritt mit SG Ready oder Sperre/Erhoehung,
zwei Kontakten, Betriebsrueckmeldung und Schutzzeiten. Gespeicherte Konfigurationen,
Lizenzvorschlag und generiertes Symcon-Paket verwenden das Modul
`{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Freie Bestandskontingente von
`consumer_single` werden wie im Manager angerechnet, ohne Doppelbelegung.
Technische Variablenkennungen duerfen zur Planung noch 0 sein. Das Bootstrap-Skript
bricht vor Objektveraenderungen ab, wenn die Waermepumpen-Zuordnung unvollstaendig
ist, das Modul fehlt oder die Rueckmeldung einen unpassenden Typ hat.
Dokumentation und Preise stehen als Textlinks rechts im Portalheader und nicht
mehr in der Seitennavigation. Oeffentliche Links auf der Anmeldeseite bleiben.
## Voraussetzungen und Konfliktschutz
- Ziel: `/home/agent/services/license` auf `enelix-services`.
- `portal.patch` zeigt ausschliesslich Aenderungen gegen den gelesenen Live-Stand.
- `manifest.json` enthaelt SHA-256 vor und nach jeder Quellaenderung.
- Die weiterentwickelte, nicht veroeffentlichte Wizard-Fassung auf `ubuntu` wird
nicht durch diesen Live-Patch ersetzt.
- Keine Aenderung an Symcon-Anlagen, Docker-Compose, Datenbankschema, Geheimnissen,
Prognosediensten oder fremden Git-Aenderungen. Daniel Haefliger hat Commit und
Push auf `develop` sowie den Portalneustart ausdruecklich freigegeben.
## Tests
- 32 Node-Tests: vorhandene Portalregressionen plus acht neue Waermepumpentests.
- Browserpruefung mit isolierten API-Fixtures: Header bei 1440, 768, 390 und
320 Pixeln; Wizard konfigurieren, speichern, Lizenzvorschlag und erneut oeffnen.
- Keine Browserfehler. Kein echter Kauf, keine Live-Lizenzvergabe und keine
Schaltbefehle an eine Waermepumpe.
- JavaScript-Syntax und Anwendbarkeit des Patches geprueft. PHP-Syntaxpruefung
und Symcon-Laufzeittest konnten nicht ausgefuehrt werden: keine native PHP-CLI
und kein Zugriff auf die Docker-Sockets in dieser Agent-Ausfuehrung.
- `tests/` enthaelt neue bzw. angepasste Tests. Im Testcheckout in dessen
vorhandenes `tests/` uebernehmen und `node --test tests/*.test.mjs` ausfuehren.
- Browserpruefung: `node tests/browser-smoke.mjs` mit installiertem Playwright und
Chromium; optional `BROWSER_PACKAGE` und `BROWSER_EXECUTABLE` fuer vorhandene
Testwerkzeuge setzen. API-Antworten stammen ausschliesslich aus Test-Fixtures.
## Bereitstellung
Der Portalneustart ist vom Auftraggeber freigegeben; dem Serverkonto `agent`
fehlt jedoch der technische Zugriff auf den Docker-Socket. Ein berechtigter
Administrator prueft dieses Paket und fuehrt auf dem Diensteserver aus:
```sh
python3 /PFAD/ZUM/PAKET/deploy.py
```
Das Skript verlangt Root, prueft alle Ausgangshashes und Container-Mounts und
bricht bei Abweichungen ohne Quellaenderung ab. Es sichert die betroffenen Dateien
und den Katalog, stoppt nur `enelix_license_portal`, schreibt die Quelldateien
unter Erhalt der Datei-Bind-Mounts und setzt ausschliesslich die Produktfreigabe
`items.heat_pump.available` auf `true`. Anschliessend startet es den Portalcontainer
und prueft Healthcheck, Wizardkatalog und ausgelieferte Oberflaeche. Es nimmt keine
Bestellung vor und laesst alle Produktpreise unveraendert.
Das Stoppen/Starten verursacht eine kurze Portalunterbrechung. Backoffice und
Anlagensteuerungen werden nicht neu gestartet. Vor Ausfuehrung parallele
Katalogbearbeitungen im Backoffice fuer das Wartungsfenster aussetzen.
## Ruecksetzen und Nachkontrolle
Bei Fehlern stellt das Skript die gesicherten Quellinhalte wieder her und startet
den Portalcontainer erneut. Ein nachtraeglich anderweitig veraenderter Katalog
wird dabei nicht blind ueberschrieben. Sicherungen liegen unter
`/home/agent/services/license/change-backups/heat-pump-header-<Zeitpunkt>`.
Nach erfolgreicher Bereitstellung angemeldet Headerlinks, neue Lizenzzeile,
Waermepumpenschritt, Speichern und Wiedereroeffnen kontrollieren. Zahlungen nur
mit einem ausdruecklich freigegebenen Sandbox-Test abschliessen. Das erzeugte
Symcon-Paket zuerst in einer isolierten IP-Symcon-8-Installation pruefen; das
Ausfuehren des Pakets kann Kontakte schalten und ist kein Bestandteil dieser
Portalbereitstellung.
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Apply the reviewed portal patch as an authorized server administrator."""
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timezone
from urllib.request import urlopen
APP = Path('/home/agent/services/license')
PACKAGE = Path(__file__).resolve().parent
CONTAINER = 'enelix_license_portal'
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
def docker(*args):
return subprocess.run(['docker', *args], check=True, capture_output=True, text=True).stdout
def write_in_place(path, content):
# Keep existing file bind mounts attached to the same inode.
with path.open('wb') as target:
target.write(content)
target.flush()
os.fsync(target.fileno())
def verify_runtime():
with urlopen('https://license.enelix.ch/healthz', timeout=5) as response:
if response.status != 200:
raise RuntimeError('Portal health check failed')
with urlopen('https://license.enelix.ch/api/wizard-catalog', timeout=5) as response:
catalog = json.load(response)
if 'heat_pump' not in catalog.get('consumerFields', {}):
raise RuntimeError('Heat-pump wizard is not served')
with urlopen('https://license.enelix.ch/', timeout=5) as response:
html = response.read().decode()
if 'class="header-links"' not in html or 'data-device-quantity="heat_pump"' not in html:
raise RuntimeError('New portal frontend is not served')
def write_catalog(path, content):
metadata = path.stat()
descriptor, temporary = tempfile.mkstemp(prefix='.catalog-release-', dir=path.parent)
try:
with os.fdopen(descriptor, 'wb') as target:
target.write(content)
target.flush()
os.fsync(target.fileno())
os.chmod(temporary, metadata.st_mode & 0o777)
os.chown(temporary, metadata.st_uid, metadata.st_gid)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def main():
if os.geteuid() != 0:
raise RuntimeError('Run this reviewed deployment as an authorized administrator (root).')
manifest = json.loads((PACKAGE / 'manifest.json').read_text())
for name, hashes in manifest['files'].items():
if Path(name).is_absolute() or '..' in Path(name).parts:
raise RuntimeError('Invalid file path')
if digest(APP / name) != hashes['before']:
raise RuntimeError('Live file changed; stop for conflict review: ' + name)
if digest(PACKAGE / 'files' / name) != hashes['after']:
raise RuntimeError('Package checksum mismatch: ' + name)
state = json.loads(docker('inspect', '--format', '{{json .State}}', CONTAINER))
if not state.get('Running'):
raise RuntimeError('Portal is not running; resolve its state before deployment.')
mounts = json.loads(docker('inspect', '--format', '{{json .Mounts}}', CONTAINER))
for name in ['server.mjs', 'stripe.mjs', 'portal-domain.mjs', 'symcon-package.mjs', 'public', 'data']:
if not any(m['Source'] == str(APP / name) and m['Destination'] == '/app/' + name for m in mounts):
raise RuntimeError('Unexpected container mount: ' + name)
catalog_path = APP / 'data/catalog.json'
catalog_bytes = catalog_path.read_bytes()
catalog = json.loads(catalog_bytes)
if catalog.get('items', {}).get('heat_pump', {}).get('sku') != 'ENX-HEAT-PUMP':
raise RuntimeError('Expected heat-pump catalog entry is missing.')
backup = APP / 'change-backups' / ('heat-pump-header-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
for name in manifest['files']:
destination = backup / name
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(APP / name, destination)
(backup / 'catalog.json').write_bytes(catalog_bytes)
(backup / 'catalog.json').chmod(0o600)
# Refuse concurrent source edits before stopping the portal.
for name, hashes in manifest['files'].items():
if digest(APP / name) != hashes['before']:
raise RuntimeError('Concurrent source change: ' + name)
if catalog_path.read_bytes() != catalog_bytes:
raise RuntimeError('Catalog changed during preflight; retry after review.')
written_catalog = None
docker('stop', '--time', '20', CONTAINER)
try:
if catalog_path.read_bytes() != catalog_bytes:
raise RuntimeError('Catalog changed during stop; no catalog migration applied.')
for name in manifest['files']:
write_in_place(APP / name, (PACKAGE / 'files' / name).read_bytes())
catalog['items']['heat_pump']['available'] = True
catalog['updatedAt'] = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
written_catalog = (json.dumps(catalog, ensure_ascii=False, indent=2) + '\n').encode()
write_catalog(catalog_path, written_catalog)
docker('start', CONTAINER)
for attempt in range(30):
try:
verify_runtime()
print('Portal deployed and verified. Backup: ' + str(backup))
return
except Exception:
if attempt == 29:
raise
time.sleep(1)
except Exception:
docker('stop', '--time', '20', CONTAINER)
for name in manifest['files']:
write_in_place(APP / name, (backup / name).read_bytes())
# Do not overwrite a concurrent backoffice catalog update.
current_catalog = catalog_path.read_bytes()
if written_catalog is not None and current_catalog == written_catalog:
write_catalog(catalog_path, catalog_bytes)
docker('start', CONTAINER)
print('Deployment failed; original application files restored. Backup: ' + str(backup), file=sys.stderr)
raise
if __name__ == '__main__':
main()
@@ -0,0 +1,665 @@
import { randomBytes } from "node:crypto";
export const systemDeviceTypes = Object.freeze({
battery: { name: "Batteriespeicher", catalogKey: "battery", moduleId: "{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}" },
buffer_storage: { name: "Pufferspeicher", catalogKey: "buffer_storage", moduleId: "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}" },
ev_charger: { name: "EV-Ladestation", catalogKey: "ev_charger", moduleId: "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}", gatewayModuleId: "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}" },
consumer_single: { name: "Verbraucher einstufig", catalogKey: "consumer_single", moduleId: "{15879A4E-D0C2-4495-83DE-46E1E462591E}" },
boiler_multi: { name: "Boiler mehrstufig", catalogKey: "boiler_multi", moduleId: "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" },
heat_pump: { name: "Wärmepumpe", catalogKey: "heat_pump", moduleId: "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}" }
});
export const integrationProfiles = Object.freeze({
energy: [
{ id: "manual", name: "Bestehende Symcon-Variablen", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresHost: false },
{ id: "fronius_solar_api_v1", name: "Fronius Solar API v1", manufacturer: "Fronius", protocol: "HTTP Solar API v1", defaultPort: 80, setup: "automatic", requiresHost: true },
{ id: "huawei_sun2000_modbus", name: "SUN2000 / Smart Dongle", manufacturer: "Huawei", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "sma_sunspec_modbus", name: "Sunny Boy / Tripower SunSpec", manufacturer: "SMA", protocol: "SunSpec Modbus TCP", defaultPort: 502, setup: "sunspec", requiresHost: true },
{ id: "goodwe_et_modbus", name: "ET / BT / EH Serie", manufacturer: "GoodWe", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "solaredge_sunspec_modbus", name: "SetApp SunSpec", manufacturer: "SolarEdge", protocol: "SunSpec Modbus TCP", defaultPort: 1502, setup: "sunspec", requiresHost: true }
],
storage: [
{ id: "manual", name: "Bestehende Symcon-Variablen", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresHost: false },
{ id: "fronius_hybrid_storage", name: "BYD über Fronius Hybridwechselrichter", manufacturer: "Fronius", protocol: "HTTP Solar API v1", defaultPort: 80, setup: "automatic", requiresHost: true },
{ id: "huawei_luna2000", name: "LUNA2000 über SUN2000", manufacturer: "Huawei", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "sma_home_storage", name: "SMA Home Storage", manufacturer: "SMA", protocol: "SunSpec Modbus TCP", defaultPort: 502, setup: "sunspec", requiresHost: true },
{ id: "goodwe_lynx", name: "Lynx Home über Hybridwechselrichter", manufacturer: "GoodWe", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true },
{ id: "solaredge_home_battery", name: "SolarEdge Home Battery", manufacturer: "SolarEdge", protocol: "SunSpec Modbus TCP", defaultPort: 1502, setup: "sunspec", requiresHost: true },
{ id: "sigenergy_sigenstor", name: "SigenStor / Energy Controller", manufacturer: "Sigenergy", protocol: "Modbus TCP", defaultPort: 502, setup: "model_specific", requiresHost: true }
],
sensor: [
{ id: "manual", name: "Bestehender Temperaturfühler", manufacturer: "Allgemein", protocol: "Variablenzuordnung", defaultPort: 0, setup: "manual", requiresTopic: false },
{ id: "shelly_addon_temperature", name: "Shelly Plus Add-on Temperaturfühler", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", defaultPort: 0, setup: "automatic", requiresTopic: true }
],
actuator: [
{ id: "manual", name: "Bestehender Symcon-Schaltkontakt", manufacturer: "Allgemein", protocol: "Variablenzuordnung", outputs: 1, setup: "manual", requiresTopic: false },
{ id: "shelly_plus_1pm", name: "Shelly Plus 1PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true },
{ id: "shelly_plus_2pm", name: "Shelly Plus 2PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 2, setup: "automatic", requiresTopic: true },
{ id: "shelly_pro_1pm", name: "Shelly Pro 1PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true },
{ id: "shelly_pro_2pm", name: "Shelly Pro 2PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 2, setup: "automatic", requiresTopic: true },
{ id: "shelly_pro_4pm", name: "Shelly Pro 4PM", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 4, setup: "automatic", requiresTopic: true },
{ id: "shelly_plug_s_gen3", name: "Shelly Plug S Gen3", manufacturer: "Shelly", protocol: "MQTT/RPC Gen2+", outputs: 1, setup: "automatic", requiresTopic: true }
]
});
const monthLimits = ["Januar", "Februar", "Maerz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
.map((Monat, index) => ({ Monat, Grenze_W: 0, MonatIndex: index + 1 }));
const f = (key, label, type, defaultValue, section, extra = {}) => ({ key, label, type, default: defaultValue, section, ...extra });
export const managerFields = Object.freeze([
f("Rolle", "Rolle", "select", 0, "Grundeinstellungen", { options: [{ value: 0, label: "Alleine" }, { value: 1, label: "Hauptmanager" }, { value: 2, label: "Untermanager" }] }),
f("NetzleistungVariableID", "Netzleistung Variable-ID", "integer", 0, "Grundeinstellungen", { min: 0 }),
f("Netzleistungsfaktor", "Netzleistungsfaktor", "number", 1, "Grundeinstellungen"),
f("MesswertMaxAlter", "Maximales Messwertalter (s)", "integer", 60, "Grundeinstellungen", { min: 1 }),
f("VerbraucherZuordnung", "Manuelle Verbraucherzuordnung (JSON)", "json", "[]", "Grundeinstellungen"),
f("AutomatischeVerbraucherZuordnung", "Automatische Verbraucherzuordnung (JSON)", "json", "[]", "Grundeinstellungen"),
f("AutomatischeSuche", "Verbraucher automatisch suchen", "boolean", true, "Grundeinstellungen"),
f("SuchbereichID", "Suchbereich Objekt-ID", "integer", 0, "Grundeinstellungen", { min: 0 }),
f("KeepAlive", "Keep-Alive (s)", "integer", 60, "Grundeinstellungen", { min: 1 }),
f("VerbraucherTimeout", "Verbraucher-Timeout (s)", "integer", 60, "Grundeinstellungen", { min: 1 }),
f("Lastspitzenmodus", "Lastspitzenmodus", "select", 0, "Regelung", { options: [{ value: 0, label: "Aus" }, { value: 1, label: "Konstant" }, { value: 2, label: "Monatlich" }] }),
f("Lastspitzengrenze", "Lastspitzengrenze (W)", "number", 0, "Regelung", { min: 0 }),
f("Monatsgrenzen", "Monatliche Grenzen (JSON)", "json", JSON.stringify(monthLimits), "Regelung"),
f("SollwertSolarladen", "Sollwert Solarladen (W)", "number", 0, "Regelung"),
f("Umschaltdifferenz", "Umschaltdifferenz (W)", "number", 5, "Regelung", { min: 0 }),
f("EinspeisebegrenzungAktiv", "Einspeisebegrenzung aktiv", "boolean", false, "Regelung"),
f("Einspeisegrenze", "Maximale Einspeisung (W)", "number", 0, "Regelung", { min: 0 }),
f("PrognoseAktiv", "Prognose aktiv", "boolean", false, "Prognose und Dienste"),
f("NetzfahrplanAktiv", "Netzfahrplan aktiv", "boolean", false, "Prognose und Dienste"),
f("PrognosePVVariableID", "PV-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }),
f("PrognoseHausverbrauchVariableID", "Hausverbrauch-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }),
f("PrognoseSOCVariableID", "SOC-Prognose Variable-ID", "integer", 0, "Prognose und Dienste", { min: 0 }),
f("PrognoseSendeintervall", "Prognose-Sendeintervall (s)", "integer", 60, "Prognose und Dienste", { min: 1 }),
f("PrognoseAnschluss", "Prognose-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("SDLAnschluss", "SDL-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("LizenzAnschluss", "Lizenz-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("StoermeldeAnschluss", "Störmelde-Anschluss (JSON)", "json", "{}", "Prognose und Dienste"),
f("StoerueberwachungAktiv", "Störüberwachung aktiv", "boolean", false, "Prognose und Dienste"),
f("StoerungsSendeintervall", "Störungs-Sendeintervall (s)", "integer", 300, "Prognose und Dienste", { min: 10 }),
f("AnlagenWechselrichter", "Wechselrichter (JSON)", "json", "[]", "Anlagentopologie"),
f("AnlagenPVFlaechen", "PV-Flächen (JSON)", "json", "[]", "Anlagentopologie"),
f("AnlagenBatterien", "Batterien (JSON)", "json", "[]", "Anlagentopologie"),
f("NetzbezugEnergieVariableID", "Netzbezug Energie Variable-ID", "integer", 0, "Anlagentopologie", { min: 0 }),
f("NetzbezugEnergiefaktor", "Netzbezug Energiefaktor", "number", 1, "Anlagentopologie"),
f("NetzeinspeisungEnergieVariableID", "Netzeinspeisung Energie Variable-ID", "integer", 0, "Anlagentopologie", { min: 0 }),
f("NetzeinspeisungEnergiefaktor", "Netzeinspeisung Energiefaktor", "number", 1, "Anlagentopologie"),
f("EnergieaufzeichnungAktiv", "Energieaufzeichnung aktiv", "boolean", true, "Messung und Darstellung"),
f("MessungPVLeistungVariableID", "PV-Leistung Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("MessungHausverbrauchLeistungVariableID", "Hausverbrauch Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("MessungBatterieleistungVariableID", "Batterieleistung Variable-ID", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("MessungPVLeistungsfaktor", "PV-Leistungsfaktor", "number", 1, "Messung und Darstellung"),
f("MessungHausverbrauchLeistungsfaktor", "Hausverbrauch-Leistungsfaktor", "number", 1, "Messung und Darstellung"),
f("MessungBatterieleistungsfaktor", "Batterie-Leistungsfaktor", "number", 1, "Messung und Darstellung"),
f("MesswerteAnzeigen", "Messwerte anzeigen", "boolean", false, "Messung und Darstellung"),
f("LeistungsaufzeichnungMinuten", "Leistungsaufzeichnung (min)", "integer", 1, "Messung und Darstellung", { min: 1 }),
f("LeistungLoeschenMonate", "Leistungsdaten löschen (Monate)", "integer", 12, "Messung und Darstellung", { min: 0 }),
f("EnergieaufzeichnungMinuten", "Energieaufzeichnung (min)", "integer", 5, "Messung und Darstellung", { min: 1 }),
f("EnergieVerdichtenMonate", "Energiedaten verdichten (Monate)", "integer", 12, "Messung und Darstellung", { min: 0 }),
f("EnergieLoeschenMonate", "Energiedaten löschen (Monate)", "integer", 0, "Messung und Darstellung", { min: 0 }),
f("EnergyPieAnzeigen", "Energie-Kreisdiagramm anzeigen", "boolean", true, "Messung und Darstellung"),
f("EnergiediagrammeAnzeigen", "Energiediagramme anzeigen", "boolean", true, "Messung und Darstellung"),
f("FunFactsAnzeigen", "Fun Facts anzeigen", "boolean", true, "Messung und Darstellung"),
f("EnergieflussAnzeigen", "Energiefluss anzeigen", "boolean", true, "Messung und Darstellung"),
f("DiagnosevariablenAnzeigen", "Diagnosevariablen anzeigen", "boolean", false, "Diagnose"),
f("LoggingEin", "Diagnoseprotokoll aktivieren", "boolean", false, "Diagnose")
]);
const managerWizardFieldKeys = new Set([
"Lastspitzengrenze",
"SollwertSolarladen",
"EinspeisebegrenzungAktiv",
"Einspeisegrenze",
"PrognoseAktiv",
"NetzfahrplanAktiv",
"StoerueberwachungAktiv",
"EnergyPieAnzeigen",
"EnergiediagrammeAnzeigen",
"FunFactsAnzeigen",
"EnergieflussAnzeigen"
]);
export const managerWizardFields = Object.freeze(
managerFields
.filter(field => managerWizardFieldKeys.has(field.key))
.map(field => ({
...field,
section: field.section === "Messung und Darstellung" ? "Darstellung" : field.section
}))
);
export const topologyFields = Object.freeze({
pvSurface: [
f("Name", "Bezeichnung", "text", "PV-Fläche", "PV-Fläche"),
f("DCLeistung_kWp", "DC-Leistung (kWp)", "number", 0, "PV-Fläche", { min: 0 }),
f("Neigung_Grad", "Neigung (Grad)", "number", 30, "Ausrichtung", { min: 0, max: 90 }),
f("Azimut_Grad", "Azimut (Grad)", "number", 0, "Ausrichtung", { min: -180, max: 180 }),
f("MPPT", "MPPT / Eingang", "text", "", "Zuordnung"),
f("Modulanzahl", "Anzahl Module", "integer", 0, "Module", { min: 0 }),
f("Modulleistung_Wp", "Modulleistung (Wp)", "number", 0, "Module", { min: 0 })
],
battery: [
f("Nennkapazitaet_kWh", "Nennkapazität (kWh)", "number", 0, "Anlagentopologie", { min: 0 }),
f("Nutzkapazitaet_kWh", "Nutzkapazität (kWh)", "number", 0, "Anlagentopologie", { min: 0 }),
f("MaxLadeleistung_kW", "Maximale Ladeleistung (kW)", "number", 0, "Anlagentopologie", { min: 0 }),
f("MaxEntladeleistung_kW", "Maximale Entladeleistung (kW)", "number", 0, "Anlagentopologie", { min: 0 }),
f("Kopplung", "Kopplung", "choice", "ac", "Anlagentopologie", { options: [
{ value: "ac", label: "AC-gekoppelt" },
{ value: "dc", label: "DC-gekoppelt" },
{ value: "hybrid", label: "Hybrid" }
] })
]
});
const commonConsumerFields = [
f("PrioritaetPV", "Priorität PV", "integer", 0, "Manager und Zeitverhalten", { min: 0 }),
f("PrioritaetPeak", "Priorität Peak", "integer", 0, "Manager und Zeitverhalten", { min: 0 }),
f("Meldeintervall", "Meldeintervall (s)", "integer", 10, "Manager und Zeitverhalten", { min: 1 }),
f("VorgabeTimeout", "Vorgabe-Timeout (s)", "integer", 120, "Manager und Zeitverhalten", { min: 1 }),
f("EinstellungenInVisu", "Einstellungen in Visualisierung", "boolean", true, "Darstellung und Diagnose"),
f("DiagnosevariablenAnzeigen", "Diagnosevariablen anzeigen", "boolean", false, "Darstellung und Diagnose"),
f("LoggingEin", "Diagnoseprotokoll aktivieren", "boolean", false, "Darstellung und Diagnose")
];
export const consumerFields = Object.freeze({
battery: [...commonConsumerFields,
f("Batterietyp", "Batterietyp", "select", 0, "Batterie", { options: [{ value: 0, label: "Unkonfiguriert" }, { value: 1, label: "Herstellerunabhängig" }, { value: 2, label: "GoodWe" }, { value: 3, label: "SolarEdge" }, { value: 4, label: "Sigenergy" }] }),
f("Batteriemanagement", "Steuerung", "select", 1, "Batterie", { options: [{ value: 1, label: "Wechselrichter" }, { value: 2, label: "Enelix Manager" }] }),
...["MaxLadeleistungVariableID", "MaxEntladeleistungVariableID", "LadezustandVariableID", "NetzleistungVariableID", "IstleistungVariableID", "ManagementRegisterVariableID", "ModusRegisterVariableID", "LeistungsRegisterVariableID", "LadeleistungRegisterVariableID", "EntladeleistungRegisterVariableID"].map(key => f(key, key.replace(/VariableID$/, " Variable-ID"), "integer", 0, "Variablenzuordnung", { min: 0 })),
f("ReserveLadezustand", "Reserve für Peak Shaving (%)", "number", 20, "Batterie", { min: 0, max: 100 }),
f("MindestLadezustand", "Minimaler Ladezustand (%)", "number", 10, "Batterie", { min: 0, max: 100 }),
f("LadezustandHysterese", "Reserve-Hysterese (%)", "number", 2, "Batterie", { min: 0, max: 100 }),
f("MesswertMaxAlter", "Maximales Messwertalter (s)", "integer", 60, "Batterie", { min: 1 }),
f("Aenderungssperre", "Sperrzeit nach Leistungsänderung (s)", "integer", 4, "Batterie", { min: 0 })
],
buffer_storage: [...commonConsumerFields,
f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Pufferspeicher"),
f("PufferfuehlerVariableID", "Puffertemperatur Variable-ID", "integer", 0, "Pufferspeicher", { min: 0 }),
f("AussentemperaturVariableID", "Außentemperatur Variable-ID", "integer", 0, "Pufferspeicher", { min: 0 }),
f("WaermepumpenSolltemperaturVariableID", "Wärmepumpen-Solltemperatur Variable-ID", "integer", 0, "Heizkurve", { min: 0 }),
f("FusspunktVorlauftemperatur", "Fußpunkt Vorlauftemperatur (°C)", "number", 35, "Heizkurve"),
f("HeizkurvenSteigung", "Heizkurvensteigung", "number", 1, "Heizkurve", { min: 0 }),
f("HeizkurveMinimaltemperatur", "Minimale Solltemperatur (°C)", "number", 20, "Heizkurve"),
f("HeizkurveMaximaltemperatur", "Maximale Solltemperatur (°C)", "number", 80, "Heizkurve"),
f("Hysterese", "Hysterese (K)", "number", 5, "Heizkurve", { min: 0.1 }),
f("MindesttemperaturModus", "Mindesttemperatur", "select", 0, "Heizkurve", { options: [{ value: 0, label: "Aus" }, { value: 1, label: "Statisch" }, { value: 2, label: "Differenz zur Solltemperatur" }] }),
f("Mindesttemperatur", "Statische Mindesttemperatur (°C)", "number", 20, "Heizkurve"),
f("MindesttemperaturDifferenz", "Differenz zur Solltemperatur (K)", "number", 5, "Heizkurve", { min: 0 }),
f("LastwechselSperrzeit", "Lastwechsel-Sperrzeit (s)", "integer", 5, "Pufferspeicher", { min: 0 }),
f("TemperaturMaxAlter", "Maximales Temperaturalter (s)", "integer", 120, "Pufferspeicher", { min: 1 }),
f("PuffertemperaturGlaetten", "Puffertemperatur glätten", "boolean", false, "Pufferspeicher"),
f("ZeitKonstante", "PT1-Zeitkonstante (s)", "integer", 120, "Pufferspeicher", { min: 1 })
],
ev_charger: [...commonConsumerFields,
f("Geraetetyp", "Gerätetyp", "select", 0, "Ladestation", { options: [{ value: 0, label: "Nicht konfiguriert" }, { value: 1, label: "go-e alte API" }, { value: 2, label: "go-e Gemini" }, { value: 3, label: "smart-me Pico" }] }),
f("Geraeteadresse", "IP-Adresse oder Hostname", "text", "", "Ladestation"),
f("GeraeteID", "Geräte-ID", "text", "", "Ladestation"),
f("Seriennummer", "Seriennummer", "text", "", "Ladestation"),
f("Benutzername", "Benutzername", "text", "", "Ladestation"),
f("MaximalerLadestrom", "Maximaler Ladestrom (A)", "integer", 16, "Ladestation", { min: 6, max: 32 }),
f("Abfrageintervall", "Statusabfrage (s)", "integer", 5, "Ladestation", { min: 1 }),
f("Ladefreigabe", "Ladefreigabe beim Start", "boolean", true, "Ladestation"),
f("Solarladen", "Solarladen beim Start", "boolean", true, "Ladestation")
],
consumer_single: [...commonConsumerFields,
f("Nennleistung", "Nennleistung (W)", "integer", 0, "Verbraucher", { min: 0 }),
f("SchaltkontaktVariableID", "Schaltkontakt Variable-ID", "integer", 0, "Verbraucher", { min: 0 }),
f("SchaltkontaktInvertiert", "Schaltkontakt invertieren", "boolean", false, "Verbraucher"),
f("RueckmeldungVariableID", "Rückmeldung Variable-ID", "integer", 0, "Verbraucher", { min: 0 }),
f("Mindestlaufzeit", "Tägliche Mindestlaufzeit (s)", "integer", 0, "Verbraucher", { min: 0, max: 86400 }),
f("PeakSperreBeiMindestlaufzeitAnbieten", "Bei Peak-Sperre während Mindestlaufzeit anbieten", "boolean", true, "Verbraucher"),
f("Mindesteinschaltdauer", "Mindest-Einschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 }),
f("Mindestausschaltdauer", "Mindest-Ausschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 })
],
heat_pump: [...commonConsumerFields,
f("Kontaktart", "Kontaktart", "select", 0, "Kontakte", { min: 0, max: 1, options: [{ value: 0, label: "Sperre und Erhöhung" }, { value: 1, label: "SG Ready" }] }),
f("Kontakt1VariableID", "Kontakt 1: Sperre / SG Ready 1 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }),
f("Kontakt2VariableID", "Kontakt 2: Erhöhung / SG Ready 2 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }),
f("Kontakt1Invertiert", "Kontakt 1 invertieren", "boolean", false, "Kontakte"),
f("Kontakt2Invertiert", "Kontakt 2 invertieren", "boolean", false, "Kontakte"),
f("Nennleistung", "Elektrische Nennleistung (W)", "integer", 0, "Wärmepumpe", { min: 0 }),
f("Rueckmeldungsart", "Rückmeldungsart", "select", 0, "Betriebsrückmeldung", { min: 0, max: 1, options: [{ value: 0, label: "Gemessene Leistung" }, { value: 1, label: "Boolean-Betriebsstatus" }] }),
f("IstleistungVariableID", "Leistung oder Betriebsstatus (Variable-ID)", "integer", 0, "Betriebsrückmeldung", { min: 0 }),
f("Laufschwelle", "Laufschwelle bei Leistungsmessung (W)", "number", 100, "Betriebsrückmeldung", { min: 0 }),
f("Anlaufwartezeit", "Anlaufwartezeit (s)", "integer", 30, "Schutzzeiten", { min: 0 }),
f("Wiederholsperre", "Wiederholsperre nach erfolglosem Anlauf (s)", "integer", 300, "Schutzzeiten", { min: 0 }),
f("Mindestlaufzeit", "Mindestlaufzeit nach bestätigtem Start (s)", "integer", 1200, "Schutzzeiten", { min: 0 }),
f("Mindestsperrzeit", "Mindest-Sperrzeit (s)", "integer", 1200, "Schutzzeiten", { min: 0 }),
f("MaximaleSperrzeit", "Maximale Sperrzeit am Stück (s; 0 = aus)", "integer", 7200, "Schutzzeiten", { min: 0 }),
f("MaximaleSperrzeit24h", "Maximale Sperrzeit in 24 Stunden (s; 0 = aus)", "integer", 21600, "Schutzzeiten", { min: 0, max: 86400 })
],
boiler_multi: [...commonConsumerFields,
f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Warmwassererwärmer"),
f("Boilerfuehler_PT1", "Boilerfühler Variable-ID", "integer", 0, "Warmwassererwärmer", { min: 0 }),
f("LegionellenfunktionAktiv", "Legionellenschaltung aktiv", "boolean", true, "Warmwassererwärmer"),
f("LegionellenMinimalintervallTage", "Frühestens nach Tagen", "integer", 4, "Warmwassererwärmer", { min: 1 }),
f("LegionellenMaximalintervallTage", "Spätestens nach Tagen", "integer", 7, "Warmwassererwärmer", { min: 1 }),
f("Boilervolumen", "Boilervolumen (l)", "integer", 300, "Warmwassererwärmer", { min: 1 }),
f("Hysterese", "Temperaturhysterese (K)", "number", 5, "Warmwassererwärmer", { min: 0.1 }),
f("Zeitplan", "Solltemperatur-Zeitplan (JSON)", "json", "[]", "Warmwassererwärmer"),
f("LastwechselSperrzeit", "Mindestzeit zwischen Lastwechseln (s)", "integer", 5, "Warmwassererwärmer", { min: 1 }),
f("TemperaturMaxAlter", "Maximales Messwertalter (s)", "integer", 30, "Warmwassererwärmer", { min: 1 }),
f("BoilertemperaturGlaetten", "Boilertemperatur glätten", "boolean", false, "Warmwassererwärmer"),
f("ZeitKonstante", "PT1-Zeitkonstante (s)", "integer", 120, "Warmwassererwärmer", { min: 1 })
]
});
export const evGatewayFields = Object.freeze([
...commonConsumerFields,
f("Betriebsmodus", "Betriebsart", "select", 0, "Gateway-Ladestation", { options: [
{ value: 0, label: "Easee" },
{ value: 1, label: "Easee · nur Solarladen" }
] }),
f("Ladestationskennung", "Easee Seriennummer", "text", "", "Gateway-Ladestation"),
f("MaximalerLadestrom", "Maximaler Ladestrom (A)", "integer", 16, "Gateway-Ladestation", { min: 6, max: 32 }),
f("Ladefreigabe", "Ladefreigabe beim Start", "boolean", true, "Gateway-Ladestation"),
f("Solarladen", "Solarladen beim Start", "boolean", true, "Gateway-Ladestation")
]);
const consumerWizardFieldKeys = Object.freeze({
battery: new Set(["Batteriemanagement", "ReserveLadezustand", "MindestLadezustand"]),
buffer_storage: new Set([
"FusspunktVorlauftemperatur", "HeizkurvenSteigung", "HeizkurveMinimaltemperatur",
"HeizkurveMaximaltemperatur", "Hysterese", "MindesttemperaturModus",
"Mindesttemperatur", "MindesttemperaturDifferenz"
]),
ev_charger: new Set([
"Geraetetyp", "Geraeteadresse", "GeraeteID", "Seriennummer", "Benutzername",
"MaximalerLadestrom", "Ladefreigabe", "Solarladen"
]),
consumer_single: new Set([
"Nennleistung", "SchaltkontaktInvertiert", "Mindestlaufzeit",
"Mindesteinschaltdauer", "Mindestausschaltdauer"
]),
heat_pump: new Set([
"Kontaktart", "Kontakt1VariableID", "Kontakt2VariableID", "Kontakt1Invertiert",
"Kontakt2Invertiert", "Nennleistung", "Rueckmeldungsart", "IstleistungVariableID",
"Laufschwelle", "Anlaufwartezeit", "Wiederholsperre", "Mindestlaufzeit",
"Mindestsperrzeit", "MaximaleSperrzeit", "MaximaleSperrzeit24h"
]),
boiler_multi: new Set([
"Boilervolumen", "Hysterese", "LegionellenfunktionAktiv",
"LegionellenMinimalintervallTage", "LegionellenMaximalintervallTage"
])
});
export const consumerWizardFields = Object.freeze(Object.fromEntries(
Object.entries(consumerFields).map(([key, fields]) => [
key,
fields.filter(field => consumerWizardFieldKeys[key].has(field.key))
])
));
export const evGatewayWizardFields = Object.freeze(
evGatewayFields.filter(field => [
"Betriebsmodus", "Ladestationskennung", "MaximalerLadestrom", "Ladefreigabe", "Solarladen"
].includes(field.key))
);
export const deviceListFields = Object.freeze({
powerLevel: [
f("Stufe", "Stufe", "integer", 1, "Leistungsstufe", { min: 1 }),
f("Leistung", "Leistung (W)", "integer", 0, "Leistungsstufe", { min: 1 })
],
schedule: [
f("Uhrzeit", "Uhrzeit", "text", "00:00", "Zeitplan"),
f("Solltemperatur", "Solltemperatur (°C)", "number", 50, "Zeitplan", { min: 0, max: 100 })
]
});
function text(value, maxLength) {
return String(value || "").trim().replace(/\s+/g, " ").slice(0, maxLength);
}
function count(value, maximum = 50) {
const parsed = Number(value || 0);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > maximum) {
throw Object.assign(new Error("invalid_quantity"), { status: 400, publicMessage: "Mengen müssen zwischen 0 und " + maximum + " liegen." });
}
return parsed;
}
function bool(value) {
return value === true || value === 1 || value === "1" || value === "true" || value === "on";
}
function finite(value, fallback, minimum = -Number.MAX_VALUE, maximum = Number.MAX_VALUE, integer = false) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
const bounded = Math.max(minimum, Math.min(maximum, parsed));
return integer ? Math.round(bounded) : bounded;
}
function jsonText(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
try {
return JSON.stringify(typeof value === "string" ? JSON.parse(value) : value);
} catch {
throw Object.assign(new Error("invalid_configuration_json"), { status: 400, publicMessage: "Ein JSON-Konfigurationsfeld ist ungültig." });
}
}
function normalizeProperties(input, fields) {
const source = input && typeof input === "object" ? input : {};
return Object.fromEntries(fields.map(field => {
const raw = Object.prototype.hasOwnProperty.call(source, field.key) ? source[field.key] : field.default;
if (field.type === "boolean") return [field.key, bool(raw)];
if (field.type === "integer" || field.type === "select") return [field.key, finite(raw, field.default, field.min, field.max, true)];
if (field.type === "number") return [field.key, finite(raw, field.default, field.min, field.max, false)];
if (field.type === "json") return [field.key, jsonText(raw, field.default)];
return [field.key, text(raw, 240)];
}));
}
function parseList(value) {
if (Array.isArray(value)) return value;
if (typeof value !== "string" || value.trim() === "") return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function normalizeTopologyValues(input, fields) {
const source = input && typeof input === "object" ? input : {};
return Object.fromEntries(fields.map(field => {
const raw = Object.prototype.hasOwnProperty.call(source, field.key) ? source[field.key] : field.default;
if (field.type === "integer") return [field.key, finite(raw, field.default, field.min, field.max, true)];
if (field.type === "number") return [field.key, finite(raw, field.default, field.min, field.max, false)];
if (field.type === "choice") {
const allowed = (field.options || []).map(option => option.value);
return [field.key, allowed.includes(raw) ? raw : field.default];
}
return [field.key, text(raw, 120)];
}));
}
function normalizePvSurfaces(input) {
return (Array.isArray(input) ? input : []).slice(0, 50).map((item, index) => ({
ID: text(item?.ID || "pv-" + (index + 1), 60),
...normalizeTopologyValues(item, topologyFields.pvSurface),
WechselrichterID: "wr-1"
}));
}
function normalizeBatteryTopology(input) {
return normalizeTopologyValues(input, topologyFields.battery);
}
function normalizePowerLevels(input) {
return parseList(input).slice(0, 12).map((item, index) => ({
...normalizeTopologyValues({ ...item, Stufe: item?.Stufe ?? index + 1 }, deviceListFields.powerLevel),
integration: normalizeIntegration(item?.integration || { profile: "manual", sourceVariableId: item?.Schaltkontakt_Stufe || 0 }, "actuator")
}));
}
function normalizeSchedule(input) {
return parseList(input).slice(0, 24).map(item => {
const value = normalizeTopologyValues(item, deviceListFields.schedule);
return {
Uhrzeit: /^([01]\d|2[0-3]):[0-5]\d$/.test(value.Uhrzeit) ? value.Uhrzeit : "00:00",
Solltemperatur: value.Solltemperatur
};
});
}
function batteryTypeForProfile(profileId) {
if (profileId === "goodwe_lynx") return 2;
if (profileId === "solaredge_home_battery") return 3;
if (profileId === "sigenergy_sigenstor") return 4;
return profileId === "manual" ? 0 : 1;
}
function profile(kind, value) {
const profiles = integrationProfiles[kind];
return profiles.find(item => item.id === value) || profiles[0];
}
function normalizeIntegration(input, kind) {
const source = input && typeof input === "object" ? input : {};
const selected = profile(kind, text(source.profile, 60));
const result = {
profile: selected.id,
manufacturer: selected.manufacturer,
protocol: selected.protocol,
setup: selected.setup,
host: text(source.host, 120),
port: finite(source.port, selected.defaultPort || 0, 0, 65535, true),
unitId: finite(source.unitId, 1, 0, 247, true),
topicPrefix: text(source.topicPrefix, 180),
channel: finite(source.channel, 0, 0, Math.max(0, (selected.outputs || 1) - 1), true),
sourceVariableId: finite(source.sourceVariableId, 0, 0, 2147483647, true),
feedbackVariableId: finite(source.feedbackVariableId, 0, 0, 2147483647, true),
notes: text(source.notes, 300)
};
if (selected.requiresHost && result.host === "") {
throw Object.assign(new Error("integration_host_missing"), { status: 400, publicMessage: "Bitte für " + selected.name + " eine IP-Adresse oder einen Hostnamen angeben." });
}
if (selected.requiresTopic && result.topicPrefix === "") {
throw Object.assign(new Error("integration_topic_missing"), { status: 400, publicMessage: "Bitte für " + selected.name + " das MQTT-Topic-Präfix angeben." });
}
return result;
}
function legacyItem(key, source, index) {
const integration = {};
if (key === "battery") {
integration.profile = source.profile || "manual";
integration.host = source.address || source.host || "";
} else if (["buffer_storage", "consumer_single", "boiler_multi"].includes(key)) {
integration.profile = source.hardware === "shelly_1pm" ? "shelly_plus_1pm" : "manual";
integration.topicPrefix = source.topicPrefix || "";
}
const properties = {};
if (key === "consumer_single" && source.ratedPower) properties.Nennleistung = source.ratedPower;
if (key === "boiler_multi" && source.volume) properties.Boilervolumen = source.volume;
if (key === "ev_charger") {
const types = { goe_legacy: 1, goe_gemini: 2, smartme_pico: 3 };
properties.Geraetetyp = types[source.model] || 0;
properties.Geraeteadresse = source.address || "";
}
return {
name: source.name || systemDeviceTypes[key].name + " " + (index + 1),
variant: key === "ev_charger" ? "standalone" : undefined,
integration,
properties,
topology: key === "battery" ? normalizeBatteryTopology(source.topology) : undefined,
powerLevels: ["buffer_storage", "boiler_multi"].includes(key) ? [] : undefined,
schedule: key === "boiler_multi" ? [] : undefined,
sensor: ["buffer_storage", "boiler_multi"].includes(key) ? { profile: "manual", sourceVariableId: 0 } : undefined
};
}
function normalizeDevice(key, input) {
const source = input && typeof input === "object" ? input : {};
const requested = count(source.quantity);
const rawItems = Array.isArray(source.items) ? source.items.slice(0, requested) : [];
while (rawItems.length < requested) rawItems.push(legacyItem(key, source, rawItems.length));
const items = rawItems.map((item, index) => {
const variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone";
const kind = key === "battery" ? "storage" : (["buffer_storage", "consumer_single", "boiler_multi", "heat_pump"].includes(key) ? "actuator" : null);
const integration = kind ? normalizeIntegration(key === "heat_pump" ? { profile: "manual" } : item.integration, kind) : {
profile: text(item.integration?.profile || "device_api", 60),
host: text(item.integration?.host, 120),
port: finite(item.integration?.port, 0, 0, 65535, true),
unitId: finite(item.integration?.unitId, 1, 0, 247, true),
notes: text(item.integration?.notes, 300)
};
const propertyFields = key === "ev_charger" && variant === "gateway" ? evGatewayFields : consumerFields[key];
const properties = normalizeProperties(item.properties, propertyFields);
if (key === "battery") properties.Batterietyp = batteryTypeForProfile(integration.profile);
const result = {
name: text(item.name || systemDeviceTypes[key].name + " " + (index + 1), 80),
variant,
integration,
properties
};
if (key === "battery") result.topology = normalizeBatteryTopology(item.topology);
if (["buffer_storage", "boiler_multi"].includes(key)) {
result.powerLevels = normalizePowerLevels(item.powerLevels ?? item.properties?.LeistungsStufen);
result.sensor = normalizeIntegration(item.sensor || {
profile: "manual",
sourceVariableId: key === "boiler_multi"
? item.properties?.Boilerfuehler_PT1
: item.properties?.PufferfuehlerVariableID
}, "sensor");
result.properties.LeistungsStufen = JSON.stringify(result.powerLevels.map(level => ({
Stufe: level.Stufe,
Leistung: level.Leistung,
Schaltkontakt_Stufe: level.integration.sourceVariableId || 0
})));
}
if (key === "boiler_multi") {
result.schedule = normalizeSchedule(item.schedule ?? item.properties?.Zeitplan);
result.properties.Zeitplan = JSON.stringify(result.schedule);
}
return result;
});
return { quantity: requested, items };
}
export function normalizeSystemConfiguration(input = {}) {
const inputDevices = input.devices || {};
const hasSeparatedStorage = Object.prototype.hasOwnProperty.call(inputDevices, "buffer_storage");
const devices = {};
for (const key of Object.keys(systemDeviceTypes)) {
let source = inputDevices[key] || {};
if (!hasSeparatedStorage && key === "buffer_storage") source = inputDevices.battery || {};
if (!hasSeparatedStorage && key === "battery") source = {};
devices[key] = normalizeDevice(key, source);
}
const legacyGridProfile = input.gridMeasurement?.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual";
const legacyPvProfile = input.pvSystem?.manufacturer === "fronius" ? "fronius_solar_api_v1" : "manual";
const legacyInverter = parseList(input.manager?.properties?.AnlagenWechselrichter)[0] || {};
const legacyPvSurfaces = parseList(input.manager?.properties?.AnlagenPVFlaechen);
const pvEnabled = bool(input.pvSystem?.enabled);
const pvSystem = {
enabled: pvEnabled,
id: "wr-1",
name: text(input.pvSystem?.name || legacyInverter.Name || "PV-Wechselrichter", 80),
acPowerKw: finite(input.pvSystem?.acPowerKw ?? legacyInverter.ACNennleistung_kW, 0, 0),
...normalizeIntegration({
...input.pvSystem,
profile: pvEnabled ? (input.pvSystem?.profile || legacyPvProfile) : "manual"
}, "energy")
};
const topology = {
pvSurfaces: normalizePvSurfaces(input.topology?.pvSurfaces ?? legacyPvSurfaces)
};
const result = {
schemaVersion: 4,
name: text(input.name, 100),
location: text(input.location, 120),
siteType: ["Privat", "Gewerbe", "Industrie"].includes(input.siteType) ? input.siteType : "Gewerbe",
peakShaving: bool(input.peakShaving),
manager: { properties: normalizeProperties(input.manager?.properties, managerFields) },
gridMeasurement: normalizeIntegration({ ...input.gridMeasurement, profile: input.gridMeasurement?.profile || legacyGridProfile }, "energy"),
pvSystem,
topology,
costReport: {
enabled: bool(input.costReport?.enabled),
powerMeters: count(input.costReport?.powerMeters, 999),
auxiliaryMeters: count(input.costReport?.auxiliaryMeters, 999)
},
devices
};
result.manager.properties.Lastspitzenmodus = result.peakShaving
? Math.max(1, result.manager.properties.Lastspitzenmodus)
: 0;
if (result.pvSystem.enabled && result.topology.pvSurfaces.length === 0) {
result.topology.pvSurfaces = normalizePvSurfaces([{ Name: "PV-Fläche 1" }]);
}
if (!result.pvSystem.enabled) {
result.pvSystem = { ...result.pvSystem, profile: "manual", host: "", port: 0 };
result.topology.pvSurfaces = [];
}
const inverters = result.pvSystem.enabled ? [{
ID: "wr-1",
Name: result.pvSystem.name,
Typ: "pv",
ACNennleistung_kW: result.pvSystem.acPowerKw,
IstleistungVariableID: result.pvSystem.sourceVariableId,
Istleistungsfaktor: 1,
ErzeugungsenergieVariableID: 0,
Erzeugungsenergiefaktor: 1,
BegrenzungVariableID: 0,
Begrenzungsart: "percent"
}] : [];
const batteries = result.devices.battery.items.map((item, index) => ({
ID: "bat-" + (index + 1),
Name: item.name,
...item.topology,
WechselrichterID: item.topology.Kopplung === "ac" || !result.pvSystem.enabled ? "" : "wr-1",
LeistungVariableID: item.integration.sourceVariableId || item.properties.IstleistungVariableID || 0,
Leistungsfaktor: 1,
SOCVariableID: item.properties.LadezustandVariableID || 0,
LadeenergieVariableID: 0,
EntladeenergieVariableID: 0,
Energiefaktor: 1
}));
result.manager.properties.AnlagenWechselrichter = JSON.stringify(inverters);
result.manager.properties.AnlagenPVFlaechen = JSON.stringify(result.topology.pvSurfaces);
result.manager.properties.AnlagenBatterien = JSON.stringify(batteries);
if (result.name.length < 2 || result.location.length < 2) {
throw Object.assign(new Error("invalid_system_configuration"), { status: 400, publicMessage: "Bitte Systemname und Standort angeben." });
}
return result;
}
export function licenseCoverage(requested = {}, owned = {}) {
const coverage = { ...owned };
const spareConsumers = Math.max(0, Number(owned.consumer_single || 0) - Number(requested.consumer_single || 0));
coverage.heat_pump = Number(owned.heat_pump || 0) + Math.min(spareConsumers,
Math.max(0, Number(requested.heat_pump || 0) - Number(owned.heat_pump || 0)));
return coverage;
}
export function recommendLicenses(configuration) {
const modules = {};
for (const key of Object.keys(systemDeviceTypes)) modules[key] = configuration.devices[key].quantity;
return {
managerVariant: configuration.peakShaving ? "manager_peak" : "manager_standard",
modules,
costReport: configuration.costReport
};
}
export function publicWizardCatalog() {
return {
schemaVersion: 4,
integrations: integrationProfiles,
managerFields: managerWizardFields,
topologyFields,
consumerFields: consumerWizardFields,
evGatewayFields: evGatewayWizardFields,
deviceListFields,
evVariants: [
{ value: "standalone", label: "Direkt / Stand-alone" },
{ value: "gateway", label: "Easee über Gateway" }
],
deviceTypes: Object.fromEntries(Object.entries(systemDeviceTypes).map(([key, item]) => [key, { name: item.name }]))
};
}
const activationAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function createActivationCode() {
const bytes = randomBytes(16);
let body = "";
for (let index = 0; index < 16; index += 1) body += activationAlphabet[bytes[index] % activationAlphabet.length];
return "ENX-" + body.slice(0, 4) + "-" + body.slice(4, 8) + "-" + body.slice(8, 12) + "-" + body.slice(12, 16);
}
export function normalizeActivationCode(value) {
return String(value || "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
export function activationCodeValid(value) {
return /^ENX[A-HJ-NP-Z2-9]{16}$/.test(normalizeActivationCode(value));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Enelix EMS Portal für Systeme, Lizenzen und Prognosemanager">
<title>Enelix EMS Portal</title>
<link rel="stylesheet" href="/styles.css?v=20261006-heat-pump">
<link rel="stylesheet" href="/docs-portal.css?v=20261006">
</head>
<body>
<div id="toast" class="toast" role="status"></div>
<main id="authScreen" class="auth-screen">
<section class="auth-brand">
<div class="brand-lockup"><span class="brand-mark">E</span><span><strong>enelix</strong><small>EMS Portal</small></span></div>
<div class="auth-message"><p class="eyebrow">Energiemanagement</p><h1>Systeme planen.<br>Lizenzen verbinden.</h1><p>Ein Zugang für Anlagen, Prognosemanager und Lizenzierung.</p></div>
<div class="energy-lines" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</section>
<section class="auth-workspace">
<div class="auth-box">
<nav class="public-doc-links" aria-label="Öffentliche Informationen"><a href="/docs/index.html">Dokumentation</a><a href="/docs/faq.html">FAQ</a><a href="/docs/preise.html">Preise</a></nav>
<div class="auth-tabs" id="authTabs" role="tablist" aria-label="Anmeldung">
<button class="auth-tab active" id="loginTab" type="button">Anmelden</button>
<button class="auth-tab" id="registerTab" type="button">Konto erstellen</button>
</div>
<p class="auth-notice hidden" id="authNotice" role="status"></p>
<form id="loginForm" class="auth-form">
<div><p class="eyebrow">Willkommen zurück</p><h2>Am Portal anmelden</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<p class="form-error" id="loginError" role="alert"></p>
<button class="button primary wide" type="submit">Anmelden</button>
<button class="auth-link" id="forgotPasswordButton" type="button">Passwort vergessen?</button>
<button class="auth-link" id="resendVerificationButton" type="button">Bestätigungs-E-Mail erneut senden</button>
</form>
<form id="registerForm" class="auth-form hidden">
<div><p class="eyebrow">Offene Registrierung</p><h2>Neues Konto erstellen</h2></div>
<label>Name<input name="name" autocomplete="name" required minlength="2"></label>
<label>E-Mail-Adresse<input name="email" type="email" autocomplete="email" required></label>
<label>Passwort<input name="password" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label>
<p class="form-error" id="registerError" role="alert"></p>
<button class="button primary wide" type="submit">Konto erstellen</button>
</form>
<form id="forgotForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Passwort zurücksetzen</h2></div>
<label>E-Mail-Adresse<input name="email" type="email" required></label>
<p class="form-error" id="forgotError" role="alert"></p>
<button class="button primary wide" type="submit">Link anfordern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
<form id="resetForm" class="auth-form hidden">
<div><p class="eyebrow">Kontozugang</p><h2>Neues Passwort</h2></div>
<label>Neues Passwort<input name="password" type="password" required minlength="10"></label>
<label>Passwort wiederholen<input name="password_repeat" type="password" required minlength="10"></label>
<p class="form-error" id="resetError" role="alert"></p>
<button class="button primary wide" type="submit">Passwort speichern</button>
<button class="auth-link" data-auth-login type="button">Zurück zur Anmeldung</button>
</form>
</div>
</section>
</main>
<div id="portalApp" class="portal hidden">
<header class="topbar">
<a class="brand" href="#overview"><span class="brand-mark">E</span><span><strong>enelix</strong><small>EMS Portal</small></span></a>
<div class="top-actions"><nav class="header-links" aria-label="Dokumentation und Preise"><a href="/docs/index.html">Dokumentation</a><a href="/docs/preise.html">Preise</a></nav><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
</header>
<div class="app-shell">
<aside class="sidebar">
<nav>
<button class="nav-item active" data-view="overview">Lizenzen</button>
<button class="nav-item" data-view="forecast">Prognose</button>
<button class="nav-item" data-view="faults">Störüberwachung</button>
<button class="nav-item" data-view="account">Konto</button>
</nav>
<div class="workspace-switcher"><label>Organisation<select id="workspaceSelect"></select></label><small id="workspaceRole">Eigentümer</small></div>
</aside>
<main class="content">
<section class="view active" id="view-overview">
<div class="page-head"><div><p class="eyebrow" id="overviewOrg">Arbeitsbereich</p><h1>Lizenzübersicht</h1><p>Gekaufte Lizenzen, Verwendung und Managerbindung auf einen Blick.</p></div><button class="button primary" type="button" data-open-new-license>Neue Lizenz</button></div>
<div class="metrics"><div><span id="metricPlants">0</span><small>Lizenzpakete</small></div><div><span id="metricManagers">0</span><small>Bezahlte Lizenzen</small></div><div><span id="metricConnected">0</span><small>Verknüpfte Manager</small></div></div>
<section class="work-section"><div class="section-head"><div><h2>Lizenzen und Manager</h2><p>Status, Verwendung und verfügbare Erweiterungen.</p></div></div><div id="overviewPlants"></div></section>
</section>
<section class="view" id="view-licenses">
<div class="page-head"><div><p class="eyebrow">Kauf · CHF netto</p><h1>Lizenz erweitern</h1><p>Vorhandene Lizenzen prüfen, zusätzliche Module ergänzen und Peak Shaving bei Bedarf upgraden.</p></div><div class="page-actions"><button class="button secondary" type="button" data-configure-current>System konfigurieren</button><span class="status" id="stripeStatus">Stripe Sandbox</span></div></div>
<div id="moduleNoPlant" class="empty-state hidden"><strong>Noch keine Lizenz vorhanden</strong><p>Lege ein Lizenzpaket direkt an oder ermittle es mit dem Systemkonfigurator.</p><button class="button primary" data-open-new-license>Neue Lizenz</button></div>
<div id="moduleConfigurator" class="config-layout">
<section class="module-panel">
<label class="select-line">System<select id="orderPlant"></select></label>
<div class="manager-summary"><div><span class="field-label">Manager</span><strong id="managerCurrentName">Noch keine bezahlte Manager-Lizenz</strong><small id="ownedManager">Der Manager wird beim Erstkauf einmalig berechnet.</small></div><label id="peakUpgradeOption" class="upgrade-option hidden"><input id="peakUpgrade" type="checkbox"><span><strong>Auf Peak Shaving upgraden</strong><small>Einmalig nur die Preisdifferenz bezahlen.</small></span></label><span><b id="managerPrice">CHF 0.00</b><small id="managerSetup">Kein weiterer Managerkauf</small></span></div>
<div class="module-list">
<div class="module-header"><span>Verbrauchermodul</span><span>Lizenz</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="battery" data-name="Batteriespeicher" data-price="41.95" data-setup="54.55"><span><input type="checkbox" class="module-check"><strong>Batteriespeicher</strong><small>Batterie</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 41.95</span><span>CHF 54.55</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="buffer_storage" data-name="Pufferspeicher" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Pufferspeicher</strong><small>VerbraucherPufferspeicher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="ev_charger" data-name="EV-Ladestation" data-price="33.65" data-setup="43.75"><span><input type="checkbox" class="module-check"><strong>EV-Ladestation</strong><small>Stand-Alone oder Gateway</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 33.65</span><span>CHF 43.75</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>Ein schaltbarer Verbraucher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="heat_pump" data-name="Wärmepumpe" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Wärmepumpe</strong><small>Sperre / Erhöhung oder SG Ready</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="boiler_multi" data-name="Boiler mehrstufig" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Boiler mehrstufig</strong><small>Warmwassererwärmer</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<div class="module-header"><span>Prognoselizenz</span><span>Jahrespreis</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="forecast_pv" data-name="PV-Ertragsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>PV-Ertragsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="forecast_load" data-name="Verbrauchsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Verbrauchsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
<label class="module-row" data-key="grid_schedule" data-name="Intelligenter Netzfahrplan" data-price="50" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>Intelligenter Netzfahrplan</strong><small>Jahreslizenz · Manager mit Peak Shaving erforderlich</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 50.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
</div>
</section>
<aside class="order-panel"><p class="eyebrow">Bestellung</p><div id="orderItems" class="order-items"></div><dl><div><dt>Lizenzen netto</dt><dd id="moduleTotal">CHF 0.00</dd></div><div id="setupTotalRow" class="hidden"><dt>Generator / Ersteinrichtung</dt><dd id="setupTotal">CHF 0.00</dd></div><div><dt>Gesamt netto</dt><dd id="netTotal">CHF 0.00</dd></div><div><dt id="vatLabel">MwSt. 8.1 %</dt><dd id="vatTotal">CHF 0.00</dd></div><div class="grand-total"><dt>Gesamt inkl. MwSt.</dt><dd id="grandTotal">CHF 0.00</dd></div></dl><button class="button primary wide" id="checkoutButton" type="button">Mit Stripe bezahlen</button><p class="form-error" id="checkoutError"></p></aside>
</div>
</section>
<section class="view" id="view-configurator">
<div class="page-head"><div><p class="eyebrow">Planung</p><h1>System konfigurieren</h1><p>Geräte erfassen und passende Lizenzen ermitteln.</p></div><span class="status available">Enelix EMS</span></div>
<form id="systemForm" class="system-configurator">
<section class="config-form wizard">
<header class="wizard-header">
<div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 10</p><h2 id="wizardStepTitle">Anlage</h2></div>
<ol class="wizard-progress" aria-label="Fortschritt">
<li class="active" data-wizard-marker="0">Anlage</li><li data-wizard-marker="1">Energiequellen</li>
<li data-wizard-marker="2">Batterie</li><li data-wizard-marker="3">Pufferspeicher</li>
<li data-wizard-marker="4">Ladestation</li><li data-wizard-marker="5">Verbraucher</li>
<li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Wärmepumpe</li>
<li data-wizard-marker="8">Abrechnung</li><li data-wizard-marker="9">Prüfen</li>
</ol>
</header>
<section class="wizard-step active" data-wizard-step="0">
<p class="wizard-question">Wie heisst die Anlage und wo befindet sie sich?</p>
<div class="field-grid"><label>Systemname<input name="name" required minlength="2" placeholder="z. B. Werk Zürich"></label><label>Standort<input name="location" required minlength="2" placeholder="Ort, Schweiz"></label><label>Anlagentyp<select name="siteType"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div>
</section>
<section class="wizard-step" data-wizard-step="1">
<p class="wizard-question">Wie soll der Manager arbeiten und über welche Schnittstellen werden Energiedaten gelesen?</p>
<label class="setup-option"><input name="peakShaving" type="checkbox"><span><strong>Peak Shaving erforderlich</strong><small>Wählt die Manager-Variante mit Lastspitzenregelung.</small></span></label>
<div class="configuration-band">
<h3>Netzmessung</h3>
<div class="field-grid">
<label>Geräteprofil<select name="gridProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="gridHost" maxlength="120" placeholder="z. B. 192.168.1.40"></label>
<label>Port<input name="gridPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="gridUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="grid">Bestehende Leistungsvariable<input name="gridSourceVariableId" type="number" min="0" value="0"></label>
<label>Hinweis<textarea name="gridNotes" rows="3" maxlength="300"></textarea></label>
</div>
</div>
<label class="setup-option"><input name="pvEnabled" type="checkbox"><span><strong>PV-Anlage einlesen</strong><small>Herstellerprofil und lokale Schnittstelle für die Erzeugungsmessung verwenden.</small></span></label>
<div class="configuration-band">
<h3>PV-Wechselrichter</h3>
<div class="field-grid">
<label>Bezeichnung<input name="pvName" maxlength="80" value="PV-Wechselrichter"></label>
<label>AC-Nennleistung (kW)<input name="pvAcPowerKw" type="number" min="0" step="0.1" value="0"></label>
<label>Geräteprofil<select name="pvProfile" data-profile-select="energy"></select></label>
<label>IP-Adresse oder Hostname<input name="pvHost" maxlength="120"></label>
<label>Port<input name="pvPort" type="number" min="0" max="65535" value="0"></label>
<label>Modbus Geräte-ID<input name="pvUnitId" type="number" min="0" max="247" value="1"></label>
<label data-manual-source="pv">Bestehende Leistungsvariable<input name="pvSourceVariableId" type="number" min="0" value="0"></label>
</div>
</div>
<div class="configuration-band" data-pv-topology>
<div class="structured-editor-head"><div><h3>PV-Flächen</h3><p>Dachflächen und Ausrichtung ohne JSON erfassen.</p></div></div>
<div class="quantity-line"><label>Anzahl PV-Flächen<input name="pvSurfaceQuantity" data-topology-quantity="pvSurfaces" type="number" min="0" max="50" value="0"></label><small>Bei aktivierter PV wird mindestens eine Fläche angelegt.</small></div>
<div class="device-editor" data-topology-editor="pvSurfaces"></div>
</div>
<details class="config-details"><summary>Optionale Manager-Funktionen</summary><p>Nur betriebliche Optionen. Technische Zuordnungen, Prognoseanschlüsse, Archivierung und Aufbewahrung werden automatisch gesetzt.</p><div id="managerConfigurationFields" class="schema-groups"></div></details>
</section>
<section class="wizard-step" data-wizard-step="2">
<p class="wizard-question">Welche Batteriespeicher soll der Manager steuern?</p>
<div class="quantity-line"><label>Batteriespeicher<input name="batteryQuantity" data-device-quantity="battery" type="number" min="0" max="50" value="0"></label><small>Geräteprofil, Leistungsdaten und Grenzen genügen; Typ und Variablen werden automatisch zugeordnet.</small></div>
<div class="device-editor" data-device-editor="battery"></div>
</section>
<section class="wizard-step" data-wizard-step="3">
<p class="wizard-question">Welche Pufferspeicher sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Pufferspeicher<input name="bufferQuantity" data-device-quantity="buffer_storage" type="number" min="0" max="50" value="0"></label><small>Fühler, Heizkurve und Leistungsstufen werden je Pufferspeicher erfasst.</small></div>
<div class="device-editor" data-device-editor="buffer_storage"></div>
</section>
<section class="wizard-step" data-wizard-step="4">
<p class="wizard-question">Welche EV-Ladestationen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="evQuantity" data-device-quantity="ev_charger" type="number" min="0" max="50" value="0"></label><small>Je Station kann zwischen direkter Anbindung und Easee Gateway gewählt werden.</small></div>
<div class="device-editor" data-device-editor="ev_charger"></div>
</section>
<section class="wizard-step" data-wizard-step="5">
<p class="wizard-question">Welche einstufigen Verbraucher sollen berücksichtigt werden?</p>
<div class="quantity-line"><label>Anzahl<input name="consumerQuantity" data-device-quantity="consumer_single" type="number" min="0" max="50" value="0"></label><small>Bei Shelly-Profilen werden Schaltkontakt und Rückmeldung automatisch verbunden.</small></div>
<div class="device-editor" data-device-editor="consumer_single"></div>
</section>
<section class="wizard-step" data-wizard-step="6">
<p class="wizard-question">Gibt es mehrstufige Boiler oder Warmwassererwärmer?</p>
<div class="quantity-line"><label>Anzahl<input name="boilerQuantity" data-device-quantity="boiler_multi" type="number" min="0" max="50" value="0"></label><small>Leistungsstufen, Zeitplan und Temperaturfühler werden als Listen konfiguriert.</small></div>
<div class="device-editor" data-device-editor="boiler_multi"></div>
</section>
<section class="wizard-step" data-wizard-step="7">
<p class="wizard-question">Welche Wärmepumpen sollen eingerichtet werden?</p>
<div class="quantity-line"><label>Anzahl<input name="heatPumpQuantity" data-device-quantity="heat_pump" type="number" min="0" max="50" value="0"></label></div>
<div class="device-editor" data-device-editor="heat_pump"></div>
</section>
<section class="wizard-step" data-wizard-step="8">
<p class="wizard-question">Soll im selben Symcon-System ein Verbrauchskostenreport eingerichtet werden?</p>
<label class="setup-option"><input name="costReportEnabled" type="checkbox"><span><strong>Verbrauchskostenreport hinzufügen</strong><small>Wird als eigene Instanz eingerichtet und verwendet einen separaten Lizenzcode.</small></span></label>
<div class="field-grid"><label>Stromzähler<input name="costPowerMeters" type="number" min="0" max="999" value="0"></label><label>Wärme- oder Wasserzähler<input name="costAuxMeters" type="number" min="0" max="999" value="0"></label></div>
</section>
<section class="wizard-step" data-wizard-step="9">
<p class="wizard-question">Bitte prüfe die Angaben vor dem Speichern.</p>
<div id="wizardReview" class="wizard-review"></div>
</section>
<p class="form-error" id="systemError"></p>
<footer class="wizard-actions"><button class="button secondary" id="wizardBack" type="button">Zurück</button><button class="button primary" id="wizardNext" type="button">Weiter</button><button class="button primary hidden" id="wizardSubmit" type="submit">Konfiguration abschliessen</button></footer>
</section>
<aside class="recommendation-panel"><p class="eyebrow">Aktueller Vorschlag</p><h2 id="recommendManager">Manager ohne Peak Shaving</h2><div id="recommendModules" class="recommend-list"></div><p>Der Vorschlag enthält den Manager und alle gewählten Verbrauchermodule.</p><div id="packageResult" class="package-result hidden"><strong>Einrichtungspaket bereit</strong><a class="button secondary wide" id="packageDownload" href="#">Symcon-Paket herunterladen</a><button class="button primary wide" id="orderRecommendation" type="button">Lizenzen bestellen</button></div></aside>
</form>
</section>
<section class="view" id="view-forecast">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Prognose</h1><p>Messwerte und berechnete Verläufe des lizenzierten Prognosemanagers.</p></div><span class="status available">Nur Anzeige</span></div>
<div id="forecastEmpty" class="empty-state hidden"><strong>Kein System verfügbar</strong></div>
<div id="forecastContent">
<section class="forecast-toolbar">
<label>System<select id="forecastPlant"></select></label>
<div class="connection-state"><span class="signal"></span><div><strong id="forecastConnection">Noch nicht verknüpft</strong><p id="forecastApiStatus">Status wird geprüft</p></div></div>
</section>
<div class="forecast-metrics">
<div><span id="forecastLastUpdated">–</span><small>Aktualisiert</small></div>
<div><span id="forecastSeriesCount">0</span><small>Datenreihen</small></div>
<div><span id="forecastWindowValue">72 h</span><small>Zeitfenster</small></div>
</div>
<section class="forecast-chart-section">
<div class="section-head"><div><h2>Leistung und Prognose</h2><p>Messwerte und Prognosen gemeinsam vergleichen. Reihen können einzeln ein- und ausgeblendet werden.</p></div></div>
<div class="forecast-series-panel">
<strong>Kurven auswählen</strong>
<div id="forecastSeriesControls" class="forecast-series-controls"></div>
</div>
<div class="forecast-chart-controls">
<label>Historie
<select id="forecastHistoryDays">
<option value="1" selected>1 Tag</option>
<option value="7">7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
<option value="90">90 Tage</option>
<option value="180">180 Tage</option>
</select>
</label>
<label>Von<input id="forecastRangeStart" type="datetime-local"></label>
<label>Bis<input id="forecastRangeEnd" type="datetime-local"></label>
<button id="forecastRangeReset" class="button secondary compact" type="button">Gesamtes Zeitfenster</button>
</div>
<div id="forecastState" class="forecast-state" role="status">Prognosedaten werden geladen.</div>
<div id="forecastLegend" class="forecast-chart-legend hidden" aria-label="Legende der angezeigten Kurven"></div>
<div class="forecast-chart-wrap">
<svg id="forecastChart" class="forecast-chart hidden" viewBox="0 0 960 320" preserveAspectRatio="none" role="img" aria-label="Zeitverlauf der Prognosedaten"></svg>
<div id="forecastTooltip" class="forecast-tooltip hidden"></div>
</div>
</section>
<section class="forecast-quality-section">
<div class="section-head"><div><h2>Prognosequalität</h2><p>Vergleich der Modellprognosen mit den tatsächlich gemessenen Werten.</p></div><span id="forecastQualityStatus" class="status review">Aufbauphase</span></div>
<div id="forecastQualityState" class="forecast-quality-state" role="status">Qualitätswerte werden gesammelt. Aussagekräftige Vergleiche entstehen mit wachsender Datenhistorie.</div>
<div id="forecastQualityTable" class="table-wrap hidden">
<table>
<thead><tr><th>Modell</th><th>Ziel</th><th>R²</th><th>MAE</th><th>RMSE</th><th>Vergleiche</th></tr></thead>
<tbody id="forecastQualityBody"></tbody>
</table>
</div>
</section>
<section id="forecastConfigSection" class="forecast-config-section hidden">
<div class="section-head">
<div><h2>Prognosekonfiguration</h2><p>Anlagenparameter für Berechnung und Tarife.</p></div>
<span id="forecastConfigStatus" class="status review">Nicht gespeichert</span>
</div>
<form id="forecastConfigForm">
<div class="forecast-config-grid">
<label>Standort<input name="standort" maxlength="120" required></label>
<label>Kanton<select name="kanton"><option>AG</option><option>AI</option><option>AR</option><option>BE</option><option>BL</option><option>BS</option><option>FR</option><option>GE</option><option>GL</option><option>GR</option><option>JU</option><option>LU</option><option>NE</option><option>NW</option><option>OW</option><option>SG</option><option>SH</option><option>SO</option><option>SZ</option><option>TG</option><option>TI</option><option>UR</option><option>VD</option><option>VS</option><option>ZG</option><option>ZH</option></select></label>
<label>PV AC-Leistung kW<input name="ac_leistung" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batteriekapazität kWh<input name="batt_capacity_kwh" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Batterieleistung kW<input name="batt_power_kw" data-technical-field type="number" min="0" max="100000" step="0.1" required></label>
<label>Peakpreis CHF/kW<input name="tarif_peak_fest" type="number" min="0" max="100000" step="0.01" required></label>
<label>Bezug Tarifart<select name="tarif_bezug_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Bezugstarif<select name="tarif_bezug"></select></label>
<label data-tariff-custom="import">Bezugspreis CHF/kWh<input name="tarif_bezug_fest" type="number" min="0" max="100" step="0.001" required></label>
<label>Einspeisung Tarifart<select name="tarif_einspeisung_modus"><option value="dynamisch">Dynamisch</option><option value="statisch">Statisch</option></select></label>
<label>Einspeisetarif<select name="tarif_einspeisung"></select></label>
<label data-tariff-custom="export">Einspeisevergütung CHF/kWh<input name="tarif_einspeisung_fest" type="number" min="0" max="100" step="0.001" required></label>
</div>
<div id="forecastTopologyBlock" class="forecast-config-block hidden">
<div class="config-block-head"><div><h3>Technische Anlagendaten</h3><p>Schreibgeschützt aus dem verbundenen Enelix Manager.</p></div><span class="status available">Manager</span></div>
<div id="forecastTopology" class="forecast-topology"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>PV-Dächer</h3><p>Leistung, Neigung und Azimut je Dachfläche.</p></div><button id="forecastAddRoof" class="button secondary compact" type="button">Dach hinzufügen</button></div>
<div id="forecastRoofs" class="forecast-roofs"></div>
</div>
<div class="forecast-config-block">
<div class="config-block-head"><div><h3>Prognoseumfang</h3><p>Funktionen für diese Anlage. Der Netzfahrplan schliesst PV- und Verbrauchsprognose ein.</p></div></div>
<div class="forecast-variants">
<label><input type="checkbox" name="forecast_pv">PV-Ertragsprognose</label>
<label><input type="checkbox" name="forecast_load">Verbrauchsprognose</label>
<label><input type="checkbox" name="grid_schedule">Intelligenter Netzfahrplan</label>
</div>
</div>
<p id="forecastConfigError" class="form-error" role="alert"></p>
<div class="forecast-config-actions"><button class="button primary" type="submit">Konfiguration speichern</button></div>
</form>
</section>
<div class="forecast-layout">
<section class="manager-detail"><p class="eyebrow">Lizenzierte Variante</p><h2 id="forecastVariant">Manager</h2><div class="license-validity"><span>Lizenzdauer</span><strong>Unbeschränkt gültig</strong></div><div id="forecastOfflineWarning" class="offline-license-warning hidden" role="status"><strong id="forecastOfflineTitle">Keine Verbindung zum Lizenzserver</strong><p id="forecastOfflineText"></p></div></section>
<section class="forecast-scope"><p class="eyebrow">Datenumfang</p><h2>Live-Prognose</h2><dl class="detail-list"><div><dt>Modus</dt><dd>Konfiguration und Anzeige</dd></div><div><dt>Simulation</dt><dd>Nicht enthalten</dd></div><div><dt>Zugriff</dt><dd>Lizenzgebunden</dd></div></dl></section>
</div>
</div>
</section>
<section class="view" id="view-faults">
<div class="page-head"><div><p class="eyebrow">Betrieb</p><h1>Störüberwachung</h1><p>Aktive und behobene Störungen der verbundenen Enelix Manager und Geräte.</p></div><span class="status neutral" id="faultConnectionStatus">Status wird geprüft</span></div>
<div id="faultEmpty" class="empty-state hidden"><strong>Kein Manager-System verfügbar</strong></div>
<div id="faultContent">
<section class="fault-toolbar">
<label>System<select id="faultPlant"></select></label>
<div class="connection-state"><span class="signal" id="faultSignal"></span><div><strong id="faultConnection">Noch nicht verknüpft</strong><p id="faultApiStatus">Status wird geprüft</p></div></div>
</section>
<div class="fault-metrics">
<div><span id="faultActiveCount">0</span><small>Aktive Störungen</small></div>
<div><span id="faultUrgentCount">0</span><small>Kritisch oder Fehler</small></div>
<div><span id="faultLastUpdated">–</span><small>Letzter Empfang</small></div>
</div>
<section class="fault-section">
<div class="section-head"><div><h2>Aktive Störungen</h2><p>Aktueller Vollsnapshot des Managers.</p></div></div>
<div id="faultState" class="fault-state" role="status">Störungsdaten werden geladen.</div>
<div id="faultActiveTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Seit</th></tr></thead><tbody id="faultActiveBody"></tbody></table></div>
</section>
<section class="fault-section">
<div class="section-head"><div><h2>Behobene Störungen</h2><p>Die letzten 100 abgeschlossenen Ereignisse.</p></div></div>
<div id="faultHistoryState" class="fault-state">Noch keine behobenen Störungen.</div>
<div id="faultHistoryTable" class="table-wrap hidden"><table><thead><tr><th>Schweregrad</th><th>Quelle</th><th>Meldung</th><th>Behoben</th></tr></thead><tbody id="faultHistoryBody"></tbody></table></div>
</section>
<section class="fault-section fault-notification-section">
<div class="section-head"><div><h2>E-Mail-Benachrichtigungen</h2><p>Empfänger für neue und behobene Störungen dieser Anlage.</p></div></div>
<form id="faultRecipientForm" class="fault-recipient-form">
<label>E-Mail-Adresse<input id="faultRecipientEmail" name="email" type="email" maxlength="254" autocomplete="email" placeholder="technik@beispiel.ch" required></label>
<button class="button secondary" type="submit">Hinzufügen</button>
</form>
<p id="faultRecipientError" class="form-error" role="alert"></p>
<div id="faultRecipientList" class="fault-recipient-list"></div>
<p id="faultRecipientStatus" class="fault-recipient-status">Empfänger werden geladen.</p>
</section>
</div>
</section>
<section class="view" id="view-account">
<div class="page-head"><div><p class="eyebrow">Einstellungen</p><h1>Konto und Abrechnung</h1><p>Zugang, Organisation, Personen und Zahlungsverlauf.</p></div><button class="button secondary" id="logoutButton" type="button">Abmelden</button></div>
<div class="account-layout">
<section><h2>Benutzerkonto</h2><dl class="detail-list"><div><dt>Name</dt><dd id="accountName">–</dd></div><div><dt>E-Mail</dt><dd id="accountEmail">–</dd></div><div><dt>Arbeitsbereich</dt><dd id="accountOrg">–</dd></div></dl><label id="accountWorkspaceLabel">Organisation<select id="accountWorkspaceSelect"></select></label></section>
<section id="organizationCreateSection"><h2>Organisation anlegen</h2><form id="organizationForm" class="compact-form"><label>Name<input name="name" required minlength="2"></label><p class="form-error" id="organizationError"></p><button class="button secondary" type="submit">Organisation erstellen</button></form></section>
<section id="invitationSection" class="hidden"><h2>Person einladen</h2><form id="invitationForm" class="compact-form"><label>E-Mail-Adresse<input name="email" type="email" required></label><label>Rolle<select name="role"><option value="operator">Operator</option><option value="admin">Administrator</option><option value="billing">Abrechnung</option><option value="viewer">Lesend</option></select></label><p class="form-error" id="invitationError"></p><button class="button secondary" type="submit">Einladung senden</button></form><div id="pendingInvitations" class="pending-list"></div></section>
<section><h2>Passwort ändern</h2><form id="passwordForm" class="compact-form"><label>Aktuelles Passwort<input name="currentPassword" type="password" autocomplete="current-password" required></label><label>Neues Passwort<input name="newPassword" type="password" autocomplete="new-password" required minlength="10"><small>Mindestens 10 Zeichen.</small></label><label>Neues Passwort wiederholen<input name="newPasswordRepeat" type="password" autocomplete="new-password" required minlength="10"></label><p class="form-error" id="passwordError"></p><button class="button secondary" type="submit">Passwort speichern</button></form></section>
</div>
<section class="work-section billing-orders"><div class="section-head"><div><h2>Abrechnung</h2><p>Zahlungen, Lizenzcodes und PDF-Belege der aktuellen Organisation.</p></div><div class="billing-summary"><span class="status review" id="billingStripeStatus">Stripe Sandbox</span><span id="billingOrg">–</span><span id="stripeProviderState">Prüfung</span></div></div><div class="table-wrap"><table><thead><tr><th>Datum</th><th>System</th><th>Lizenzen</th><th>Gesamt inkl. MwSt.</th><th>Status</th><th>Lizenzcode</th><th>Beleg</th></tr></thead><tbody id="ordersBody"></tbody></table></div></section>
</section>
</main>
</div>
</div>
<dialog id="newLicenseDialog" class="decision-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Neue Lizenz</p><h2>Wie möchtest du starten?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="purchase-paths"><button type="button" data-open-plant><span><strong>Lizenz direkt kaufen</strong><small>Lizenzpaket anlegen und benötigte Module auswählen.</small></span><b>Weiter</b></button><button type="button" data-goto="configurator"><span><strong>System mit Manager konfigurieren</strong><small>Geräte erfassen und passende Lizenzen ermitteln.</small></span><b>Konfigurieren</b></button><button type="button" data-open-billing><span><strong>Abrechnungslizenz lösen</strong><small>Abrechnungsmanager und benötigte Zähler lizenzieren.</small></span><b>Auswählen</b></button></div></div></dialog>
<dialog id="plantDialog"><form id="plantForm"><div class="dialog-head"><div><p class="eyebrow">Direkter Kauf</p><h2>Neue Lizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Werk Zürich"></label><label>Typ<select name="type"><option>Gewerbe</option><option>Privat</option><option>Industrie</option></select></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label><fieldset><legend>Prognosemanager</legend><label class="choice-row"><input type="radio" name="manager" value="manager_standard" checked><span><strong>Ohne Peak Shaving</strong><small id="plantManagerStandardPrice">CHF 308.15 netto</small></span></label><label class="choice-row"><input type="radio" name="manager" value="manager_peak"><span><strong>Mit Peak Shaving</strong><small id="plantManagerPeakPrice">CHF 329.05 netto</small></span></label></fieldset><p class="form-error" id="plantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zur Lizenzwahl</button></div></form></dialog>
<dialog id="billingDialog"><form id="billingForm"><div class="dialog-head"><div><p class="eyebrow">Abrechnung</p><h2>Abrechnungslizenz vorbereiten</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div id="billingIdentityFields"><div class="field-pair"><label>Bezeichnung<input name="name" required placeholder="z. B. Abrechnung Werk Zürich"></label><label>Typ<input name="type" value="Abrechnung" readonly></label></div><label>Standort<input name="location" required placeholder="Ort, Schweiz"></label></div><fieldset><legend>Zählerlizenzen</legend><div class="field-pair"><label>Stromzähler<input name="powerMeters" type="number" min="0" max="999" value="0" required><small id="billingPowerPrice">CHF 5.00 netto pro Zähler</small></label><label>Wärme- oder Wasserzähler<input name="auxMeters" type="number" min="0" max="999" value="0" required><small id="billingAuxPrice">CHF 3.00 netto pro Zähler</small></label></div></fieldset><p class="billing-base-price">Grundlizenz: <strong id="billingBasePrice">CHF 245.15 netto</strong>. Zuzüglich MwSt.</p><p class="form-error" id="billingError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button primary" type="submit">Weiter zu Stripe</button></div></form></dialog>
<dialog id="replaceCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode neu generieren</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Achtung: Der aktuelle Lizenzcode wird ungültig.</strong><p>Für <b id="replaceCodeSystem">dieses System</b> wird ein neuer Code erstellt und ausschließlich als PDF an die Konto-E-Mail-Adresse gesendet.</p><p id="replaceCodeBinding" class="hidden">Die bisherige Installationsbindung wird gelöst. Der Ersatzcode kann anschließend einmalig mit einer neuen Symcon-Installation verbunden werden.</p></div><p class="form-error" id="replaceCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmReplaceCode" type="button">Alten Code ersetzen</button></div></div></dialog>
<dialog id="deleteCodeDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Sicherheitsaktion</p><h2>Lizenzcode löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Sind Sie sicher?</strong><p>Der aktuelle Lizenzcode für <b id="deleteCodeSystem">dieses System</b> wird sofort und unwiderruflich ungültig.</p><p>Die bezahlte Lizenz und eine bestehende Installationsbindung bleiben erhalten. Ein neuer Code kann später wieder per E-Mail erzeugt werden.</p></div><p class="form-error" id="deleteCodeError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeleteCode" type="button">Lizenzcode löschen</button></div></div></dialog>
<dialog id="deletePlantDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Anlage löschen</p><h2>Sind Sie sicher?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning"><strong>Die Anlage wird dauerhaft entfernt.</strong><p>Möchten Sie <b id="deletePlantSystem">diese Anlage</b> wirklich löschen? Konfigurationen und noch nicht bezahlte Bestellungen dieser Anlage werden ebenfalls entfernt.</p></div><p class="form-error" id="deletePlantError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePlant" type="button">Anlage löschen</button></div></div></dialog>
<dialog id="deletePaidDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Zweite Bestätigung</p><h2>Auch Lizenz löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning critical-warning"><strong>Achtung: Für diese Anlage besteht eine bezahlte Lizenz.</strong><p>Sollen <b id="deletePaidSystem">die Anlage</b> und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p></div><p class="form-error" id="deletePaidError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePaid" type="button">Anlage und Lizenz löschen</button></div></div></dialog>
<script src="/app.js?v=20261006-heat-pump"></script>
</body>
</html>
@@ -0,0 +1,853 @@
:root {
--ink: #172124;
--muted: #667579;
--line: #d8e0de;
--surface: #ffffff;
--canvas: #f2f5f4;
--petrol: #2f6b66;
--petrol-dark: #1f4d49;
--lime: #d5df38;
--blue: #386b91;
--amber: #8d6518;
--danger: #9a3939;
--radius: 6px;
color: var(--ink);
background: var(--canvas);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--canvas); }
body { margin: 0; min-height: 100vh; background: var(--canvas); }
button, input, select, textarea { font: inherit; letter-spacing: 0; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 7px; font-size: clamp(1.8rem, 3vw, 2.65rem); line-height: 1.06; letter-spacing: 0; }
h2 { margin-bottom: 6px; font-size: 1.08rem; letter-spacing: 0; }
p { color: var(--muted); line-height: 1.55; }
.hidden { display: none !important; }
.brand { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
.brand-mark { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 4px; background: var(--lime); color: #203031; font-weight: 850; font-size: 1.25rem; }
.brand > span:last-child { display: grid; line-height: 1; }
.brand strong { font-size: 1.28rem; font-weight: 800; }
.brand small { margin-top: 5px; color: var(--muted); font-size: .68rem; text-transform: uppercase; letter-spacing: .09em; }
.brand-light { color: #fff; }
.brand-light small { color: #b7ccca; }
.auth-screen { display: grid; min-height: 100svh; grid-template-columns: minmax(360px, .92fr) minmax(480px, 1.08fr); background: var(--surface); }
.auth-brand { position: relative; display: flex; flex-direction: column; justify-content: space-between; min-height: 100svh; overflow: hidden; padding: 44px clamp(34px, 5vw, 76px); background: #173f3c; color: #fff; }
.auth-brand::after { position: absolute; inset: auto -15% -25% 25%; height: 55%; border: 1px solid rgba(213, 223, 56, .24); transform: skewY(-12deg); content: ""; }
.auth-message { position: relative; z-index: 1; max-width: 560px; padding: 70px 0; }
.auth-message h1 { max-width: 520px; font-size: clamp(2.25rem, 4.5vw, 4.6rem); line-height: .98; }
.auth-message > p:last-child { max-width: 510px; color: #c2d2d0; font-size: 1.04rem; }
.eyebrow { margin-bottom: 9px; color: var(--petrol); font-size: .7rem; font-weight: 800; text-transform: uppercase; letter-spacing: .11em; }
.auth-brand .eyebrow { color: var(--lime); }
.energy-lines { position: absolute; inset: auto 0 10% 0; display: grid; gap: 18px; opacity: .38; transform: rotate(-8deg); }
.energy-lines i { display: block; width: 76%; height: 1px; background: rgba(213,223,56,.78); }
.energy-lines i:nth-child(2) { width: 91%; transform: translateX(8%); }
.energy-lines i:nth-child(3) { width: 68%; transform: translateX(-12%); }
.energy-lines i:nth-child(4) { width: 82%; transform: translateX(18%); }
.auth-workspace { display: grid; min-height: 100svh; place-items: center; padding: 36px; }
.auth-box { width: min(480px, 100%); }
.auth-tabs { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 38px; border-bottom: 1px solid var(--line); }
.auth-tab { padding: 13px 8px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: var(--muted); font-weight: 750; }
.auth-tab.active { border-color: var(--petrol); color: var(--ink); }
.auth-form { display: grid; gap: 18px; }
.auth-form h2 { font-size: 1.65rem; }
.field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
label { display: grid; gap: 7px; color: #3e4b4e; font-size: .82rem; font-weight: 700; }
label small { color: var(--muted); font-weight: 450; }
input, select, textarea { width: 100%; min-width: 0; border: 1px solid #c9d3d1; border-radius: 5px; outline: 0; background: #fff; color: var(--ink); padding: 0 12px; }
input, select { height: 43px; }
textarea { min-height: 72px; resize: vertical; padding-top: 10px; }
input:focus, select:focus, textarea:focus { border-color: var(--petrol); box-shadow: 0 0 0 3px rgba(47, 107, 102, .12); }
.form-error { min-height: 20px; margin: -5px 0 0; color: var(--danger); font-size: .82rem; }
.legal-copy { margin: 0; font-size: .76rem; }
.auth-link { justify-self: center; border: 0; background: transparent; color: var(--petrol); padding: 0; font-size: .79rem; font-weight: 750; }
.auth-notice { border-left: 3px solid var(--lime); background: #eef5f3; color: #274a47; margin: 0 0 20px; padding: 12px 14px; font-size: .82rem; }
.auth-notice.error { border-left-color: var(--danger); background: #fff0f0; color: var(--danger); }
.button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 5px; padding: 9px 15px; font-size: .84rem; font-weight: 760; transition: background .16s ease, border-color .16s ease, transform .16s ease; }
.button:hover { transform: translateY(-1px); }
.button.primary { background: var(--petrol); color: #fff; }
.button.primary:hover { background: var(--petrol-dark); }
.button.secondary { border-color: #cbd6d3; background: #fff; color: var(--ink); }
.button.danger { background: var(--danger); color: #fff; }
.button.danger:hover { background: #7f2f2f; }
.button.wide { width: 100%; }
.button:disabled { cursor: not-allowed; opacity: .45; transform: none; }
.portal { min-height: 100vh; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; height: 68px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); padding: 0 28px; }
.top-actions { display: flex; align-items: center; gap: 13px; }
.header-links { display: flex; align-items: center; gap: 24px; margin-right: 16px; }
.header-links a { color: var(--petrol); font-size: .88rem; font-weight: 700; text-decoration: none; padding: 10px 0; white-space: nowrap; }
.header-links a:hover { text-decoration: underline; text-underline-offset: 5px; }
.header-links a:focus-visible { outline: 2px solid var(--petrol); outline-offset: 4px; }
.environment, .status { display: inline-flex; align-items: center; border-radius: 999px; white-space: nowrap; font-size: .7rem; font-weight: 800; }
.environment { background: #edf2f1; color: #566568; padding: 5px 9px; text-transform: uppercase; letter-spacing: .07em; }
.account-button { display: flex; align-items: center; gap: 8px; border: 0; background: transparent; color: var(--ink); }
.account-button span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 50%; background: var(--petrol); color: #fff; font-size: .72rem; }
.account-button strong { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .82rem; }
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-height: calc(100vh - 68px); }
.sidebar { display: flex; flex-direction: column; justify-content: space-between; border-right: 1px solid var(--line); background: #e9eeec; padding: 24px 14px; }
.sidebar nav { display: grid; gap: 3px; }
.nav-item { min-height: 42px; border: 0; border-left: 3px solid transparent; border-radius: 3px; background: transparent; color: #47565a; padding: 0 15px; text-align: left; font-size: .84rem; font-weight: 680; }
.nav-item:hover { background: #dde6e3; }
.nav-item.active { border-left-color: var(--lime); background: #fff; color: var(--ink); }
.workspace-switcher { display: grid; gap: 7px; padding: 16px; border-top: 1px solid #cfd9d6; }
.workspace-switcher label { gap: 5px; font-size: .68rem; text-transform: uppercase; }
.workspace-switcher select { height: 38px; background: #fff; font-size: .76rem; text-transform: none; }
.workspace-switcher small { color: var(--muted); font-size: .7rem; }
.content { min-width: 0; padding: 38px clamp(22px, 4vw, 64px) 68px; }
.view { display: none; max-width: 1320px; margin: 0 auto; animation: enter .2s ease both; }
.view.active { display: block; }
@keyframes enter { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
.page-head { display: flex; min-height: 92px; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
.page-head p { margin-bottom: 0; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 34px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--surface); }
.metrics > div { display: grid; gap: 4px; min-width: 0; padding: 22px 24px; border-right: 1px solid var(--line); }
.metrics > div:last-child { border-right: 0; }
.metrics span { overflow-wrap: anywhere; font-size: 1.38rem; font-weight: 800; }
.metrics small { color: var(--muted); font-size: .72rem; }
.work-section { margin-bottom: 34px; }
.section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 13px; }
.section-head p { margin: 0; font-size: .84rem; }
.text-action { border: 0; background: transparent; color: var(--petrol); padding: 4px; font-weight: 750; }
.plant-lines { border-top: 1px solid var(--line); }
.plant-line { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(140px, .8fr) minmax(150px, .8fr) auto; align-items: center; gap: 18px; min-height: 76px; border-bottom: 1px solid var(--line); background: #fff; padding: 13px 17px; }
.plant-line > span { display: grid; gap: 4px; min-width: 0; }
.plant-line small { color: var(--muted); font-size: .74rem; }
.plant-line strong { overflow-wrap: anywhere; }
.connection-band { display: grid; grid-template-columns: minmax(260px, .85fr) minmax(330px, 1.15fr); gap: 50px; align-items: center; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 33px 0; }
.connection-band p { margin-bottom: 0; }
.flow-track { display: flex; align-items: center; }
.flow-track span { flex: 0 0 auto; border: 1px solid #bdcac7; background: #fff; padding: 12px 15px; font-size: .78rem; font-weight: 800; }
.flow-track i { min-width: 22px; flex: 1; height: 1px; background: var(--petrol); }
.table-wrap { overflow-x: auto; border-top: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; background: #fff; }
th { background: #edf1f0; color: #5d6c6e; text-align: left; text-transform: uppercase; letter-spacing: .06em; font-size: .68rem; }
th, td { border-bottom: 1px solid var(--line); padding: 14px 16px; vertical-align: middle; }
td { font-size: .83rem; }
td > strong, td > small { display: block; }
td > small { margin-top: 4px; color: var(--muted); }
.status { padding: 5px 9px; }
.status.available { background: #e5f2e8; color: #31643d; }
.status.review { background: #fff1d8; color: var(--amber); }
.status.neutral { border: 1px solid #cdd7d5; background: #fff; color: #59676a; }
.empty-state { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fff; padding: 42px 24px; text-align: center; }
.empty-state strong { display: block; font-size: 1.05rem; }
.empty-state p { margin: 7px auto 18px; }
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 26px; align-items: start; }
.select-line { max-width: 430px; margin-bottom: 22px; }
.manager-summary { margin-bottom: 25px; border-left: 4px solid var(--lime); background: #e8efed; padding: 20px 22px; }
.manager-summary { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.manager-summary > div, .manager-summary > span { display: grid; gap: 5px; }
.manager-summary > .upgrade-option { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 10px; align-items: center; min-width: 280px; color: var(--petrol); }
.upgrade-option input { width: 18px; height: 18px; accent-color: var(--petrol); }
.upgrade-option span { display: grid; gap: 3px; }
.field-label { color: var(--muted); font-size: .72rem; font-weight: 800; text-transform: uppercase; }
.manager-summary > span:last-child { text-align: right; }
.manager-summary small { color: var(--muted); font-size: .75rem; }
.module-row .owned-count { grid-column: 2; color: var(--petrol); font-size: .68rem; }
.module-row .owned-count b { font-size: inherit; }
.module-list { border-top: 1px solid var(--line); }
.module-header, .module-row { display: grid; grid-template-columns: minmax(230px, 1.4fr) 105px 105px 72px; gap: 14px; align-items: center; }
.module-header { min-height: 42px; background: #e8eeec; color: #5d6c6e; padding: 0 14px; font-size: .67rem; font-weight: 800; text-transform: uppercase; }
.module-row { min-height: 72px; border-bottom: 1px solid var(--line); background: #fff; padding: 10px 14px; font-size: .8rem; }
.module-row > span:first-child { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 2px 9px; align-items: center; }
.module-row input[type="checkbox"] { grid-row: 1 / 4; width: 17px; height: 17px; accent-color: var(--petrol); }
.module-row strong, .module-row small { min-width: 0; overflow-wrap: anywhere; }
.module-row small { color: var(--muted); font-weight: 450; }
.module-row .qty { height: 36px; padding: 0 8px; text-align: center; }
.catalog-note { margin: 13px 0 0; font-size: .76rem; }
.order-panel { position: sticky; top: 92px; border: 1px solid #cad5d2; background: #fff; padding: 21px; }
.order-items { display: grid; gap: 10px; margin: 18px 0; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.order-item { display: flex; justify-content: space-between; gap: 14px; font-size: .78rem; }
.order-item span { color: var(--muted); }
.order-panel dl, .connection-panel dl, .detail-list { display: grid; gap: 0; margin: 0 0 20px; }
.order-panel dl > div, .connection-panel dl > div, .detail-list > div { display: flex; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); padding: 10px 0; }
dt { color: var(--muted); font-size: .76rem; }
dd { margin: 0; text-align: right; font-size: .8rem; font-weight: 750; overflow-wrap: anywhere; }
.grand-total { margin-top: 5px; border-top: 2px solid var(--ink); }
.grand-total dd { font-size: 1rem; }
.order-panel > small { display: block; margin-top: 11px; color: var(--muted); line-height: 1.45; }
.forecast-layout, .billing-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.account-layout { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px; }
.forecast-layout > section, .manager-detail, .billing-layout > section, .account-layout > section { border-top: 3px solid var(--petrol); background: #fff; padding: 24px; }
.connection-state { display: flex; align-items: center; gap: 13px; margin: 25px 0 17px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 18px 0; }
.connection-state p { margin: 4px 0 0; font-size: .78rem; }
.signal { width: 11px; height: 11px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 5px rgba(213,223,56,.2); }
.manager-detail { display: flex; flex-direction: column; justify-content: center; min-height: 290px; }
.license-validity { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-top: 18px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 14px 0; }
.license-validity span { color: var(--muted); font-size: .76rem; }
.license-validity strong { color: #31643d; font-size: .84rem; }
.offline-license-warning { margin-top: 16px; border-left: 4px solid var(--amber); background: #fff8ea; padding: 13px 15px; }
.offline-license-warning strong { color: var(--amber); font-size: .82rem; }
.offline-license-warning p { margin: 5px 0 0; color: var(--ink); font-size: .76rem; line-height: 1.5; }
.provider-row { display: grid; grid-template-columns: 1fr 1.4fr auto; gap: 16px; align-items: center; margin: 20px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 17px 0; }
.provider-row span { color: var(--muted); font-size: .8rem; }
.provider-row em { color: var(--amber); font-size: .72rem; font-style: normal; font-weight: 800; }
.billing-orders { margin-top: 30px; }
.compact-form { display: grid; gap: 13px; margin-top: 18px; }
.compact-form .button { justify-self: start; }
.pending-list { display: grid; margin-top: 18px; border-top: 1px solid var(--line); }
.pending-list > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding: 10px 0; }
.pending-list span { display: grid; gap: 3px; }
.pending-list small { color: var(--muted); font-size: .72rem; }
dialog { width: min(610px, calc(100vw - 28px)); max-height: calc(100vh - 28px); overflow: auto; border: 0; border-radius: 7px; padding: 0; color: var(--ink); box-shadow: 0 26px 80px rgba(15, 31, 32, .28); }
dialog::backdrop { background: rgba(11, 27, 29, .64); }
dialog form { display: grid; gap: 17px; padding: 25px; }
.dialog-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.dialog-head h2 { font-size: 1.35rem; }
.icon-button { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: #fff; color: var(--ink); font-size: 1.3rem; }
fieldset { display: grid; gap: 9px; margin: 0; border: 1px solid var(--line); padding: 13px; }
legend { padding: 0 6px; color: #3e4b4e; font-size: .82rem; font-weight: 750; }
.choice-row { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 10px; align-items: center; border: 1px solid var(--line); padding: 11px; }
.choice-row:has(input:checked) { border-color: var(--petrol); background: #eef5f3; }
.choice-row input { width: 17px; height: 17px; accent-color: var(--petrol); }
.choice-row span { display: grid; gap: 3px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; padding-top: 5px; }
.integration-warning { border-left: 3px solid var(--amber); background: #fff8ea; padding: 13px 15px; }
.integration-warning p { margin: 5px 0 0; font-size: .8rem; }
.toast { position: fixed; z-index: 100; right: 20px; bottom: 20px; max-width: min(400px, calc(100vw - 40px)); border-left: 4px solid var(--lime); background: #173f3c; color: #fff; padding: 13px 17px; opacity: 0; pointer-events: none; transform: translateY(12px); transition: .2s ease; }
.toast.show { opacity: 1; transform: none; }
.inline-actions { display: flex; justify-content: center; gap: 10px; }
.mode-switch { display: inline-grid; grid-template-columns: 1fr 1fr; margin-bottom: 24px; border: 1px solid var(--line); background: #fff; }
.mode-switch button { min-height: 42px; border: 0; border-right: 1px solid var(--line); background: transparent; padding: 0 16px; color: var(--muted); font-weight: 750; }
.mode-switch button:last-child { border-right: 0; }
.mode-switch button.active { background: var(--petrol); color: #fff; }
.setup-option { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 11px; align-items: center; margin-top: 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fff; padding: 15px 13px; }
.setup-option input { width: 18px; height: 18px; accent-color: var(--petrol); }
.setup-option span { display: grid; gap: 3px; }
.button.compact { min-height: 32px; padding: 5px 10px; border-color: var(--petrol); background: #fff; color: var(--petrol); font-size: .74rem; }
.code-cell { min-width: 150px; }
.code-cell small, .code-hint { display: block; margin-top: 4px; color: var(--muted); font-size: .68rem; }
.system-configurator { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 28px; align-items: start; }
.config-form { display: grid; gap: 28px; }
.form-section { border-top: 2px solid var(--petrol); background: #fff; padding: 24px; }
.wizard { min-height: 520px; align-content: start; border-top: 3px solid var(--petrol); background: #fff; padding: 24px; }
.wizard-header { display: grid; gap: 20px; border-bottom: 1px solid var(--line); padding-bottom: 20px; }
.wizard-header h2 { font-size: 1.45rem; }
.wizard-progress { display: grid; grid-template-columns: repeat(10, minmax(max-content, 1fr)); overflow-x: auto; margin: 0; padding: 0; list-style: none; }
.wizard-progress li { min-width: 72px; border-top: 3px solid var(--line); padding: 8px 8px 0; color: var(--muted); font-size: .7rem; font-weight: 750; text-align: center; white-space: nowrap; }
.wizard-progress li.active { border-color: var(--lime); color: var(--ink); }
.wizard-progress li.done { border-color: var(--petrol); color: var(--petrol); }
.wizard-step { display: none; min-height: 250px; padding: 28px 0 8px; }
.wizard-step.active { display: block; }
.wizard-question { max-width: 720px; margin: 0 0 24px; color: var(--ink); font-size: 1.15rem; font-weight: 760; }
.wizard-device { display: grid; grid-template-columns: minmax(100px, .35fr) 1fr 1fr; gap: 16px; align-items: end; }
.wizard-review { display: grid; border-top: 1px solid var(--line); }
.wizard-review > div { display: flex; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); padding: 12px 0; }
.wizard-review span { color: var(--muted); }
.wizard-review strong { text-align: right; }
.wizard-actions { display: flex; justify-content: space-between; gap: 10px; border-top: 1px solid var(--line); padding-top: 18px; }
.wizard-actions .primary { margin-left: auto; }
.wizard-actions button { min-width: 112px; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 20px 0; }
.configuration-band { margin-top: 22px; border-top: 1px solid var(--line); padding-top: 18px; }
.configuration-band h3 { margin: 0; font-size: .95rem; }
.config-details { margin-top: 24px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 0; }
.config-details > summary { cursor: pointer; padding: 16px 0; color: var(--petrol); font-weight: 760; }
.config-details > p { margin: 0 0 16px; color: var(--muted); font-size: .78rem; }
.schema-groups { display: grid; gap: 18px; padding-bottom: 20px; }
.schema-group { border-top: 1px solid var(--line); padding-top: 14px; }
.schema-group h4 { margin: 0 0 10px; font-size: .84rem; }
.schema-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
.schema-grid label { min-width: 0; margin: 0; }
.schema-grid label.full-width { grid-column: 1 / -1; }
.schema-grid input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--petrol); }
.schema-toggle { display: grid; grid-template-columns: 18px 1fr; gap: 9px; align-items: center; min-height: 43px; border-bottom: 1px solid var(--line); padding: 4px 0; }
.quantity-line { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 20px; align-items: end; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 16px; }
.quantity-line small { align-self: center; color: var(--muted); }
.device-editor { display: grid; gap: 14px; margin: 16px 0 28px; }
.device-instance { border: 1px solid var(--line); border-radius: 4px; background: #fbfcfc; }
.device-instance > summary { cursor: pointer; padding: 14px 16px; color: var(--ink); font-weight: 760; }
.device-instance-body { display: grid; gap: 18px; border-top: 1px solid var(--line); padding: 16px; }
.structured-editor { border-top: 1px solid var(--line); padding-top: 16px; }
.structured-editor-head, .structured-row-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.structured-editor-head h3, .structured-row-head strong { margin: 0; font-size: .9rem; }
.structured-editor-head p { margin: 4px 0 0; color: var(--muted); font-size: .78rem; }
.structured-row { margin-top: 12px; border-left: 3px solid var(--petrol); background: #f6f9f8; padding: 14px 16px; }
.structured-row .configuration-band { margin-top: 12px; }
.structured-row .schema-groups { padding-bottom: 0; }
.compact-row { padding-bottom: 4px; }
.sensor-band { border-top-color: var(--petrol); }
.integration-status { margin: -4px 0 0; color: var(--muted); font-size: .72rem; }
.device-grid { display: grid; border-top: 1px solid var(--line); }
.device-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) 90px minmax(150px, .8fr) minmax(150px, .8fr); gap: 13px; align-items: end; border-bottom: 1px solid var(--line); padding: 15px 0; }
.device-row > div { display: grid; gap: 4px; align-self: center; }
.device-row small { color: var(--muted); font-size: .72rem; }
.recommendation-panel { position: sticky; top: 92px; border-top: 3px solid var(--lime); background: #173f3c; color: #fff; padding: 23px; }
.recommendation-panel .eyebrow { color: var(--lime); }
.recommendation-panel p { color: #bcd0ce; font-size: .78rem; }
.recommend-list { display: grid; margin: 20px 0; border-top: 1px solid #3b625f; }
.recommend-list > div { display: flex; justify-content: space-between; gap: 15px; border-bottom: 1px solid #3b625f; padding: 11px 0; font-size: .8rem; }
.recommend-list > div > span { display: grid; gap: 3px; }
.recommend-list small { font-size: .68rem; }
.recommend-list .license-covered { color: #cbdc7c; }
.recommend-list .license-missing { color: #ffd291; font-weight: 750; }
.package-result { display: grid; gap: 10px; margin-top: 20px; border-top: 1px solid #5c7b78; padding-top: 18px; }
.package-result .button.secondary { border-color: #78938f; background: transparent; color: #fff; }
#activationDialog { padding: 25px; }
#activationDialog > p { margin: 12px 0 20px; }
.activation-code { display: block; margin-bottom: 16px; border: 1px solid var(--line); background: #edf2f1; padding: 18px 12px; text-align: center; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1.2rem; font-weight: 800; overflow-wrap: anywhere; }
@media (max-width: 940px) {
.auth-screen { grid-template-columns: 1fr; }
.auth-brand { min-height: 290px; padding: 28px; }
.auth-message { padding: 42px 0 10px; }
.auth-message h1 { font-size: clamp(2rem, 8vw, 3.4rem); }
.auth-workspace { min-height: auto; padding: 42px 24px; }
.app-shell { grid-template-columns: 1fr; }
.sidebar { position: sticky; z-index: 15; top: 68px; display: block; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); padding: 7px 12px; }
.sidebar nav { display: flex; width: max-content; }
.nav-item { min-height: 39px; border-left: 0; border-bottom: 3px solid transparent; padding: 0 13px; }
.nav-item.active { border-bottom-color: var(--lime); }
.workspace-switcher { display: none; }
.config-layout { grid-template-columns: 1fr; }
.order-panel { position: static; }
.connection-band { grid-template-columns: 1fr; gap: 24px; }
.system-configurator { grid-template-columns: 1fr; }
.recommendation-panel { position: static; }
.account-layout { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 680px) {
.topbar { height: 62px; padding: 0 15px; }
.top-actions { gap: 10px; }
.header-links { gap: 12px; margin-right: 0; }
.header-links a { font-size: .75rem; }
.brand strong { font-size: 1.05rem; }
.brand-mark { width: 32px; height: 32px; }
.environment, .account-button strong { display: none; }
.sidebar { top: 62px; }
.content { padding: 26px 15px 55px; }
.page-head { display: grid; min-height: 0; margin-bottom: 22px; }
.page-head .button { width: 100%; }
.metrics { grid-template-columns: 1fr 1fr; }
.metrics > div:nth-child(2) { border-right: 0; }
.metrics > div:last-child { grid-column: 1 / -1; border-top: 1px solid var(--line); }
.metrics > div { padding: 17px; }
.plant-line { grid-template-columns: 1fr auto; gap: 11px; }
.plant-line > span:nth-child(2), .plant-line > span:nth-child(3) { grid-column: 1 / -1; }
.plant-line .text-action { grid-column: 2; grid-row: 1; }
.flow-track { overflow-x: auto; }
.manager-summary { display: grid; }
.manager-summary > label, .manager-summary > label select { min-width: 0; width: 100%; }
.manager-summary > span:last-child { text-align: left; }
.module-header { display: none; }
.module-row { grid-template-columns: minmax(0, 1fr) 76px; gap: 8px 12px; padding: 14px 12px; }
.module-row > span:first-child { grid-row: 1 / 4; }
.module-row > span:nth-child(2), .module-row > span:nth-child(3) { font-size: .73rem; text-align: right; }
.module-row .qty { grid-column: 2; }
.forecast-layout, .billing-layout, .account-layout { grid-template-columns: 1fr; }
.field-pair { grid-template-columns: 1fr; }
.field-grid, .schema-grid { grid-template-columns: 1fr; }
.schema-grid label.full-width { grid-column: auto; }
.quantity-line { grid-template-columns: 1fr; gap: 8px; }
.device-instance-body { padding: 14px 12px; }
.device-row { grid-template-columns: 1fr 1fr; align-items: start; }
.device-row > div { grid-column: 1 / -1; }
.wizard { min-height: 480px; padding: 18px; }
.wizard-device { grid-template-columns: 1fr; }
.wizard-review > div { display: grid; gap: 5px; }
.wizard-review strong { text-align: left; }
.wizard-actions button { min-width: 0; flex: 1; }
.mode-switch { display: grid; width: 100%; }
.auth-brand { min-height: 260px; }
.auth-workspace { padding: 34px 18px; }
.provider-row { grid-template-columns: 1fr auto; }
.provider-row span { grid-column: 1 / -1; grid-row: 2; }
.dialog-actions { display: grid; grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
.page-actions { display: flex; align-items: center; gap: 10px; }
.license-list { border-top: 1px solid var(--line); }
.license-row {
display: grid;
grid-template-columns: minmax(170px, 1.2fr) minmax(170px, 1fr) 80px 120px minmax(130px, .8fr) auto;
gap: 18px;
align-items: center;
min-height: 82px;
border-bottom: 1px solid var(--line);
background: #fff;
padding: 14px 16px;
}
.license-row > span { display: grid; gap: 5px; min-width: 0; }
.license-row strong { overflow-wrap: anywhere; font-size: .8rem; }
.license-row small { color: var(--muted); font-size: .68rem; }
.license-main strong { font-size: .92rem; }
.license-row .status { width: max-content; }
.license-actions { display: grid; gap: 7px; min-width: 190px; }
.license-actions .button { width: 100%; }
.license-actions .code-replacement { border-color: #d9b4b4; color: var(--danger); }
.license-actions .destructive-action { border-color: #d9b4b4; color: var(--danger); background: #fff; }
.destructive-link { color: var(--danger); justify-self: center; }
.code-cell { display: grid; gap: 5px; min-width: 170px; }
.code-cell .destructive-link { justify-self: start; }
.replacement-warning { border-left: 4px solid var(--danger); background: #fff1f1; padding: 16px 18px; }
.replacement-warning strong { color: var(--danger); }
.replacement-warning p { margin: 8px 0 0; font-size: .84rem; line-height: 1.55; }
.critical-warning { background: #ffe5e5; border-left-width: 6px; }
.billing-summary { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .75rem; }
.account-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dialog-body { display: grid; gap: 18px; padding: 25px; }
.purchase-paths { display: grid; gap: 10px; margin-top: 4px; }
.purchase-paths > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
min-height: 86px;
border: 1px solid var(--line);
border-radius: 5px;
background: #fff;
color: var(--ink);
padding: 16px;
text-align: left;
transition: border-color .16s ease, background .16s ease, transform .16s ease;
}
.purchase-paths > button:hover { border-color: var(--petrol); background: #eef5f3; transform: translateY(-1px); }
.purchase-paths span { display: grid; gap: 5px; }
.purchase-paths small { color: var(--muted); font-weight: 450; line-height: 1.4; }
.purchase-paths b { color: var(--petrol); font-size: .76rem; }
dialog[open] { animation: dialog-enter .16s ease both; }
@keyframes dialog-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@media (max-width: 1100px) {
.license-row { grid-template-columns: minmax(180px, 1.2fr) 1fr 80px 120px auto; }
.license-row > span:nth-child(5) { grid-column: 2 / 5; }
}
@media (max-width: 680px) {
.page-actions { display: grid; width: 100%; }
.license-row { grid-template-columns: 1fr 1fr; gap: 14px; padding: 17px 14px; }
.license-row .license-main { grid-column: 1 / -1; }
.license-row > span:nth-child(5), .license-actions { grid-column: 1 / -1; }
.license-row .button { width: 100%; }
.billing-summary { align-items: flex-end; flex-direction: column; }
.account-layout { grid-template-columns: 1fr; }
.purchase-paths > button { grid-template-columns: 1fr; gap: 8px; }
}
/* Read-only prognosis workspace */
.forecast-toolbar {
display: grid;
grid-template-columns: minmax(260px, .8fr) minmax(320px, 1.2fr);
gap: 32px;
align-items: end;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 22px 24px;
}
.forecast-toolbar label { margin: 0; }
.forecast-toolbar select { min-width: 0; }
.forecast-toolbar .connection-state { margin: 0; }
.forecast-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--line);
background: #f7faf9;
}
.forecast-metrics > div {
display: grid;
gap: 3px;
min-width: 0;
border-right: 1px solid var(--line);
padding: 18px 24px;
}
.forecast-metrics > div:last-child { border-right: 0; }
.forecast-metrics span {
overflow-wrap: anywhere;
color: var(--ink);
font-size: 1.25rem;
font-weight: 750;
}
.forecast-metrics small { color: var(--muted); }
.forecast-chart-section {
margin-top: 28px;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 24px;
}
.forecast-chart-section .section-head { align-items: flex-start; }
.forecast-state {
display: grid;
min-height: 260px;
place-items: center;
border: 1px dashed var(--line);
color: var(--muted);
padding: 32px;
text-align: center;
}
.forecast-chart {
display: block;
width: 100%;
height: 320px;
overflow: visible;
}
.forecast-series-panel {
display: grid;
gap: 8px;
margin-top: 14px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 12px 0;
}
.forecast-series-panel > strong {
font-size: .86rem;
}
.forecast-series-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 4px 18px;
}
.forecast-series-option {
display: inline-grid;
grid-template-columns: 16px 18px minmax(0, 1fr);
align-items: center;
gap: 6px;
min-height: 30px;
margin: 0;
color: var(--muted);
cursor: pointer;
font-size: .78rem;
}
.forecast-series-option input {
width: 16px;
height: 16px;
margin: 0;
}
.forecast-series-option i,
.forecast-chart-legend-item i {
width: 18px;
height: 3px;
flex: 0 0 auto;
background: var(--series-color);
}
.forecast-series-option i.prediction,
.forecast-chart-legend-item i.prediction {
background: repeating-linear-gradient(
90deg,
var(--series-color) 0 7px,
transparent 7px 11px
);
}
.forecast-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin: 0 0 10px;
border-bottom: 1px solid var(--line);
padding: 0 0 10px;
}
.forecast-chart-legend-item {
display: inline-flex;
align-items: center;
gap: 7px;
color: #455350;
font-size: .78rem;
white-space: nowrap;
}
.forecast-chart-controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
margin: 14px 0 18px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.forecast-chart-controls label {
min-width: 190px;
margin: 0;
}
.forecast-chart-controls input,
.forecast-chart-controls select {
min-height: 40px;
}
.forecast-chart-controls .button {
min-height: 40px;
}
.forecast-chart-wrap { position: relative; }
.forecast-tooltip { position: absolute; z-index: 4; display: grid; gap: 7px; min-width: 220px; max-width: 340px; border: 1px solid #cfdad7; border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgba(24,39,36,.15); padding: 10px 12px; pointer-events: none; font-size: .8rem; }
.forecast-tooltip > strong { margin-bottom: 2px; }
.forecast-tooltip span { display: grid; grid-template-columns: 10px 1fr auto; gap: 7px; align-items: center; }
.forecast-tooltip span i { width: 8px; height: 8px; border-radius: 50%; background: var(--series-color); }
.forecast-tooltip span b { font-weight: 600; }
.forecast-tooltip span em { color: var(--muted); font-style: normal; font-variant-numeric: tabular-nums; }
.forecast-hover-line { stroke: #596461; stroke-width: 1; stroke-dasharray: 3 3; vector-effect: non-scaling-stroke; pointer-events: none; }
.forecast-grid-line {
stroke: #dce5e3;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.forecast-series-line {
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2.25;
}
.forecast-series-prediction {
stroke-dasharray: 8 5;
}
.forecast-now-line {
stroke: #222c2a;
stroke-dasharray: 5 5;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.forecast-axis-label, .forecast-now-label {
fill: #687572;
font-family: inherit;
font-size: 12px;
}
.forecast-now-label { fill: #222c2a; font-weight: 700; }
.forecast-quality-section {
margin-top: 28px;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 24px;
}
.forecast-quality-state {
margin-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
padding: 22px 0 4px;
}
.forecast-quality-section .table-wrap { margin-top: 18px; }
.forecast-quality-section table { min-width: 760px; }
.forecast-layout { margin-top: 28px; }
.forecast-scope { min-height: 290px; }
@media (max-width: 760px) {
.forecast-toolbar { grid-template-columns: 1fr; gap: 20px; padding: 18px; }
.forecast-metrics { grid-template-columns: 1fr 1fr; }
.forecast-metrics > div { padding: 14px 16px; }
.forecast-metrics > div:nth-child(2) { border-right: 0; }
.forecast-metrics > div:last-child {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
}
.forecast-chart-section { padding: 18px 12px 14px; }
.forecast-chart-section .section-head { display: grid; gap: 14px; }
.forecast-quality-section { padding: 18px 12px; }
.forecast-quality-section .section-head { display: grid; gap: 14px; }
.forecast-series-controls { grid-template-columns: 1fr; }
.forecast-chart-legend { justify-content: flex-start; }
.forecast-chart-controls { display: grid; grid-template-columns: 1fr; }
.forecast-chart-controls label { min-width: 0; }
.forecast-chart-controls .button { width: 100%; }
.forecast-chart { height: 270px; }
}
.forecast-config-section {
margin-top: 28px;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 24px;
}
.forecast-config-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 18px;
}
.forecast-config-grid label { margin: 0; }
.forecast-config-block {
margin-top: 28px;
border-top: 1px solid var(--line);
padding-top: 22px;
}
.config-block-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 16px;
}
.config-block-head h3 { margin: 0 0 4px; font-size: 1rem; }
.config-block-head p { margin: 0; color: var(--muted); }
.button.compact { min-height: 38px; padding: 8px 13px; }
.forecast-roofs { display: grid; gap: 10px; }
.forecast-roof-row {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr)) 42px;
gap: 12px;
align-items: end;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
}
.forecast-roof-row label { margin: 0; }
.forecast-roof-row .icon-button {
width: 42px;
height: 42px;
margin-bottom: 1px;
}
.forecast-variants {
display: grid;
grid-template-columns: repeat(3, minmax(140px, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
}
.forecast-variants label {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
margin: 0;
background: #f7faf9;
padding: 10px 14px;
}
.forecast-variants input { width: auto; }
.forecast-config-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.forecast-topology { display: grid; gap: 22px; }
.forecast-topology-summary {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.forecast-topology-summary > div {
display: grid;
gap: 4px;
padding: 14px 16px;
border-right: 1px solid var(--line);
}
.forecast-topology-summary > div:last-child { border-right: 0; }
.forecast-topology-summary span,
.forecast-topology-group span { color: var(--muted); font-size: 0.83rem; }
.forecast-topology-groups {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 24px;
}
.forecast-topology-group h4 { margin: 0 0 10px; font-size: 0.9rem; }
.forecast-topology-group > div {
display: grid;
gap: 3px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.forecast-topology-group p { margin: 0; color: var(--muted); }
.forecast-config-section input:disabled,
.forecast-config-section select:disabled {
color: #34413f;
background: #edf2f1;
opacity: 1;
}
@media (max-width: 980px) {
.forecast-config-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
.forecast-topology-summary { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.forecast-topology-groups { grid-template-columns: 1fr; }
.forecast-variants { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
}
@media (max-width: 680px) {
.forecast-config-section { padding: 18px; }
.forecast-config-grid { grid-template-columns: 1fr; }
.forecast-topology-summary { grid-template-columns: repeat(2, minmax(110px, 1fr)); }
.forecast-topology-summary > div { border-bottom: 1px solid var(--line); }
.config-block-head { display: grid; }
.forecast-roof-row { grid-template-columns: 1fr 1fr; }
.forecast-roof-row label:first-child { grid-column: 1 / -1; }
.forecast-roof-row .icon-button { justify-self: end; }
.forecast-variants { grid-template-columns: 1fr; }
.forecast-config-actions .button { width: 100%; }
}
.fault-toolbar {
display: grid;
grid-template-columns: minmax(220px, 360px) minmax(300px, 1fr);
gap: 32px;
align-items: end;
border-top: 3px solid var(--petrol);
background: #fff;
padding: 22px 24px;
}
.fault-toolbar .connection-state { margin: 0; }
.fault-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 26px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #fff;
}
.fault-metrics > div { display: grid; gap: 4px; padding: 20px 24px; border-right: 1px solid var(--line); }
.fault-metrics > div:last-child { border-right: 0; }
.fault-metrics span { overflow-wrap: anywhere; font-size: 1.28rem; font-weight: 800; }
.fault-metrics small { color: var(--muted); font-size: .72rem; }
.fault-section { margin-top: 26px; border-top: 3px solid var(--petrol); background: #fff; padding: 23px 24px 0; }
.fault-section .table-wrap { margin: 0 -24px; }
.fault-section table { min-width: 760px; }
.fault-state { margin: 0 -24px; border-top: 1px solid var(--line); padding: 28px 24px; color: var(--muted); }
.severity { display: inline-block; min-width: 72px; padding: 5px 8px; text-align: center; font-size: .7rem; font-weight: 800; }
.severity-critical { background: #702634; color: #fff; }
.severity-error { background: #f4d9de; color: #7b2635; }
.severity-warning { background: #fff1d8; color: #865b14; }
.severity-info { background: #dcecf2; color: #245b70; }
.signal.offline { background: #9aa5a3; box-shadow: 0 0 0 5px rgba(154, 165, 163, .18); }
.fault-notification-section { padding-bottom: 22px; }
.fault-recipient-form {
display: grid;
grid-template-columns: minmax(240px, 1fr) auto;
gap: 12px;
align-items: end;
border-top: 1px solid var(--line);
margin: 18px -24px 0;
padding: 20px 24px;
}
.fault-recipient-form label { margin: 0; }
.fault-recipient-form .button { min-height: 46px; }
.fault-recipient-list { border-top: 1px solid var(--line); }
.fault-recipient-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
border-bottom: 1px solid var(--line);
padding: 10px 0;
}
.fault-recipient-row span { display: grid; gap: 3px; overflow-wrap: anywhere; }
.fault-recipient-row small, .fault-recipient-status, .fault-recipient-empty { color: var(--muted); }
.fault-recipient-row .icon-button { flex: 0 0 40px; width: 40px; height: 40px; }
.fault-recipient-empty { padding: 19px 0; }
.fault-recipient-status { margin: 16px 0 0; font-size: .82rem; }
@media (max-width: 760px) {
.fault-toolbar { grid-template-columns: 1fr; gap: 18px; padding: 18px; }
.fault-metrics { grid-template-columns: 1fr; }
.fault-metrics > div { border-right: 0; border-bottom: 1px solid var(--line); }
.fault-metrics > div:last-child { border-bottom: 0; }
.fault-section { padding: 19px 18px 0; }
.fault-section .table-wrap, .fault-state { margin-left: -18px; margin-right: -18px; }
.fault-notification-section { padding-bottom: 18px; }
.fault-recipient-form { grid-template-columns: 1fr; margin-left: -18px; margin-right: -18px; padding: 18px; }
.fault-recipient-form .button { width: 100%; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,253 @@
import { createHmac, timingSafeEqual } from "node:crypto";
const stripeSecretKey = String(process.env.STRIPE_SECRET_KEY || "").trim();
const stripeWebhookSecret = String(process.env.STRIPE_WEBHOOK_SECRET || "").trim();
const stripeApiBase = String(process.env.STRIPE_API_BASE || "https://api.stripe.com").replace(/\/$/, "");
export const catalog = Object.freeze({
manager_standard: { sku: "ENX-MANAGER", name: "Manager ohne Peak Shaving", unitAmount: 30815, setupAmount: 11460, entitlement: true },
manager_peak: { sku: "ENX-MANAGER-PEAK", name: "Manager mit Peak Shaving", unitAmount: 32905, setupAmount: 14180, entitlement: true },
manager_peak_upgrade: { sku: "ENX-MANAGER-PEAK-UPGRADE", name: "Upgrade auf Peak Shaving", unitAmount: 2090, setupAmount: 0, entitlement: true },
battery: { sku: "ENX-BATTERY", name: "Batteriespeicher", unitAmount: 4195, setupAmount: 5455, entitlement: true },
buffer_storage: { sku: "ENX-BUFFER", name: "Pufferspeicher", unitAmount: 2515, setupAmount: 3270, entitlement: true },
ev_charger: { sku: "ENX-EV", name: "EV-Ladestation", unitAmount: 3365, setupAmount: 4375, entitlement: true },
consumer_single: { sku: "ENX-CONSUMER-1", name: "Verbraucher einstufig", unitAmount: 2095, setupAmount: 2725, entitlement: true },
boiler_multi: { sku: "ENX-BOILER-MULTI", name: "Boiler mehrstufig", unitAmount: 2515, setupAmount: 3270, entitlement: true },
heat_pump: { sku: "ENX-HEAT-PUMP", name: "Wärmepumpe", unitAmount: 2095, setupAmount: 2725, entitlement: true },
forecast_pv: { sku: "ENX-FORECAST-PV", name: "PV-Ertragsprognose", unitAmount: 0, setupAmount: 0, entitlement: true, termMonths: 12 },
forecast_load: { sku: "ENX-FORECAST-LOAD", name: "Verbrauchsprognose", unitAmount: 0, setupAmount: 0, entitlement: true, termMonths: 12 },
grid_schedule: { sku: "ENX-GRID-SCHEDULE", name: "Intelligenter Netzfahrplan", unitAmount: 5000, setupAmount: 0, entitlement: true, termMonths: 12 },
cost_report: { sku: "ENX-BILLING", name: "Abrechnungsmanager", unitAmount: 24515, setupAmount: 3270, entitlement: true },
cost_meter_power: { sku: "ENX-BILLING-POWER", name: "Stromzähler Abrechnung", unitAmount: 500, setupAmount: 0, entitlement: true },
cost_meter_aux: { sku: "ENX-BILLING-AUX", name: "Wärme- oder Wasserzähler Abrechnung", unitAmount: 300, setupAmount: 0, entitlement: true }
});
const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi", "heat_pump"];
const forecastKeys = ["forecast_pv", "forecast_load", "grid_schedule"];
const licenseKeys = [...moduleKeys, ...forecastKeys];
export function publicCatalog(priceCatalog = catalog) {
return Object.fromEntries(Object.entries(priceCatalog).map(([key, item]) => [key, {
sku: item.sku,
name: item.name,
unitAmount: item.unitAmount,
setupAmount: item.setupAmount || 0,
termMonths: item.termMonths || null
}]));
}
function quantity(value) {
const number = Number(value || 0);
if (!Number.isInteger(number) || number < 0 || number > 999) throw Object.assign(new Error("invalid_quantity"), { status: 400 });
return number;
}
export function buildOrderLines(
managerVariant,
requestedModules = {},
setupRequest = {},
ownedManagerVariant = null,
priceCatalog = catalog,
vatRate = 0
) {
const manager = priceCatalog[managerVariant];
if (!manager || !["manager_standard", "manager_peak"].includes(managerVariant)) {
throw Object.assign(new Error("invalid_manager"), { status: 400, publicMessage: "Ungültige Manager-Variante." });
}
if (ownedManagerVariant && !["manager_standard", "manager_peak"].includes(ownedManagerVariant)) {
throw Object.assign(new Error("invalid_owned_manager"), { status: 400 });
}
const lines = [];
if (!ownedManagerVariant) {
lines.push({ ...manager, catalogKey: managerVariant, quantity: 1, lineType: "license" });
} else if (ownedManagerVariant === "manager_standard" && managerVariant === "manager_peak") {
lines.push({ ...priceCatalog.manager_peak_upgrade, catalogKey: "manager_peak", quantity: 1, lineType: "license" });
}
if (setupRequest.manager === true && manager.setupAmount > 0) {
lines.push({
sku: `${manager.sku}-SETUP`,
name: `Generator / Ersteinrichtung ${manager.name}`,
unitAmount: manager.setupAmount,
catalogKey: managerVariant,
quantity: 1,
lineType: "setup",
entitlement: false
});
}
for (const key of licenseKeys) {
const count = quantity(requestedModules[key]);
if (!count) continue;
const item = priceCatalog[key];
if (!item || item.available === false) {
throw Object.assign(new Error("catalog_item_unavailable"), { status: 400, publicMessage: "Die gewählte Lizenz ist derzeit nicht verfügbar." });
}
if (key === "grid_schedule" && managerVariant !== "manager_peak") {
throw Object.assign(new Error("grid_schedule_requires_peak"), { status: 400, publicMessage: "Der intelligente Netzfahrplan benötigt den Manager mit Peak Shaving." });
}
lines.push({ ...item, catalogKey: key, quantity: count, lineType: "license" });
}
const setupModules = setupRequest.modules || {};
for (const key of moduleKeys) {
const count = quantity(setupModules[key]);
if (!count) continue;
const item = priceCatalog[key];
lines.push({
sku: `${item.sku}-SETUP`,
name: `Generator / Ersteinrichtung ${item.name}`,
unitAmount: item.setupAmount,
catalogKey: key,
quantity: count,
lineType: "setup",
entitlement: false
});
}
if (!lines.length) {
throw Object.assign(new Error("nothing_to_purchase"), { status: 400, publicMessage: "Für diese Auswahl ist keine Lizenzerweiterung oder Ersteinrichtung erforderlich." });
}
const normalizedVatRate = Number(vatRate);
if (!Number.isFinite(normalizedVatRate) || normalizedVatRate < 0 || normalizedVatRate > 100) {
throw Object.assign(new Error("invalid_vat_rate"), { status: 500 });
}
const netAmount = lines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0);
const vatAmount = Math.round(netAmount * normalizedVatRate / 100);
if (vatAmount > 0) {
lines.push({
sku: "ENX-VAT",
name: `MwSt. ${normalizedVatRate.toFixed(1)} %`,
unitAmount: vatAmount,
catalogKey: "vat",
quantity: 1,
lineType: "tax",
entitlement: false
});
}
return lines;
}
export function buildBillingOrderLines(
requestedMeters = {},
ownedQuantities = {},
setupRequested = false,
priceCatalog = catalog,
vatRate = 0
) {
const lines = [];
const base = priceCatalog.cost_report;
if (quantity(ownedQuantities.cost_report) < 1) {
lines.push({ ...base, catalogKey: "cost_report", quantity: 1, lineType: "license" });
}
for (const key of ["cost_meter_power", "cost_meter_aux"]) {
const count = quantity(requestedMeters[key]);
if (!count) continue;
lines.push({ ...priceCatalog[key], catalogKey: key, quantity: count, lineType: "license" });
}
if (setupRequested && base.setupAmount > 0) {
lines.push({
sku: `${base.sku}-SETUP`,
name: `Generator / Ersteinrichtung ${base.name}`,
unitAmount: base.setupAmount,
catalogKey: "cost_report",
quantity: 1,
lineType: "setup",
entitlement: false
});
}
if (!lines.length) {
throw Object.assign(new Error("nothing_to_purchase"), { status: 400, publicMessage: "Für diese Abrechnungslizenz ist keine Erweiterung ausgewählt." });
}
const normalizedVatRate = Number(vatRate);
if (!Number.isFinite(normalizedVatRate) || normalizedVatRate < 0 || normalizedVatRate > 100) {
throw Object.assign(new Error("invalid_vat_rate"), { status: 500 });
}
const netAmount = lines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0);
const vatAmount = Math.round(netAmount * normalizedVatRate / 100);
if (vatAmount > 0) {
lines.push({
sku: "ENX-VAT",
name: `MwSt. ${normalizedVatRate.toFixed(1)} %`,
unitAmount: vatAmount,
catalogKey: "vat",
quantity: 1,
lineType: "tax",
entitlement: false
});
}
return lines;
}
export function stripeConfigured() {
return /^(sk|rk)_test_/.test(stripeSecretKey) && stripeWebhookSecret.startsWith("whsec_");
}
export async function createCheckoutSession({ orderId, userId, email, lines, successUrl, cancelUrl }) {
if (!stripeConfigured()) throw Object.assign(new Error("stripe_not_configured"), { status: 503 });
const params = new URLSearchParams();
params.set("mode", "payment");
params.set("customer_email", email);
params.set("client_reference_id", orderId);
params.set("metadata[order_id]", orderId);
params.set("metadata[user_id]", String(userId));
params.set("success_url", successUrl);
params.set("cancel_url", cancelUrl);
lines.forEach((line, index) => {
const root = `line_items[${index}]`;
params.set(`${root}[quantity]`, String(line.quantity));
params.set(`${root}[price_data][currency]`, "chf");
params.set(`${root}[price_data][unit_amount]`, String(line.unitAmount));
params.set(`${root}[price_data][product_data][name]`, line.name);
params.set(`${root}[price_data][product_data][metadata][sku]`, line.sku);
});
const response = await fetch(`${stripeApiBase}/v1/checkout/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${stripeSecretKey}`,
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const providerError = payload.error || {};
const error = Object.assign(new Error("stripe_checkout_failed"), {
status: 502,
publicMessage: providerError.message || "Stripe Checkout konnte nicht geöffnet werden.",
diagnostic: {
status: response.status,
type: providerError.type || "unknown",
code: providerError.code || "unknown",
param: providerError.param || null,
requestId: response.headers.get("request-id") || null
}
});
throw error;
}
if (!payload.id || !payload.url) {
throw Object.assign(new Error("stripe_checkout_invalid_response"), {
status: 502,
publicMessage: "Stripe Checkout hat keine gültige Sitzung zurückgegeben."
});
}
return { id: payload.id, url: payload.url, expiresAt: payload.expires_at || null };
}
export function verifyStripeSignature(rawBody, signatureHeader) {
if (!stripeWebhookSecret || !signatureHeader) return false;
const parts = String(signatureHeader).split(",").map((part) => part.split("="));
const timestamp = parts.find(([key]) => key === "t")?.[1];
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value);
if (!/^\d+$/.test(timestamp || "") || !signatures.length) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", stripeWebhookSecret)
.update(`${timestamp}.${rawBody.toString("utf8")}`)
.digest("hex");
return signatures.some((signature) => {
if (!/^[0-9a-f]{64}$/i.test(signature)) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
});
}
@@ -0,0 +1,485 @@
import { normalizeSystemConfiguration, recommendLicenses, systemDeviceTypes } from "./portal-domain.mjs";
const managerModuleId = "{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}";
const shellyModuleId = "{21D82EDB-AC8E-4F3D-9FEE-D20BCAFC1C33}";
const costReportModuleId = "{9DAC29C7-F381-F2A4-7CE2-F391C4123F38}";
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear());
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()
};
}
function zipStore(entries) {
const localParts = [];
const centralParts = [];
let offset = 0;
const stamp = dosDateTime();
for (const entry of entries) {
const name = Buffer.from(entry.name, "utf8");
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, "utf8");
const checksum = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(0, 8); local.writeUInt16LE(stamp.time, 10); local.writeUInt16LE(stamp.date, 12);
local.writeUInt32LE(checksum, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28);
localParts.push(local, name, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(stamp.time, 12);
central.writeUInt16LE(stamp.date, 14); central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32); central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38); central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.length + name.length + data.length;
}
const centralDirectory = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDirectory.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, centralDirectory, end]);
}
function provisioningScript(configuration) {
const encoded = Buffer.from(JSON.stringify(configuration), "utf8").toString("base64");
const modules = Object.fromEntries(Object.entries(systemDeviceTypes).map(([key, item]) => [key, item.moduleId]));
return `<?php
declare(strict_types=1);
$config = json_decode(base64_decode('${encoded}'), true, 512, JSON_THROW_ON_ERROR);
$moduleIDs = json_decode('${JSON.stringify(modules)}', true, 512, JSON_THROW_ON_ERROR);
$managerModuleID = '${managerModuleId}';
$shellyModuleID = '${shellyModuleId}';
$costReportModuleID = '${costReportModuleId}';
$energyPieModuleID = '{F73B8F8D-5E36-4B23-B379-4632A517A1D6}';
$energyFlowModuleID = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}';
$evGatewayModuleID = '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}';
$easeeGatewayModuleID = '{B7552FC9-87BC-49D0-8CCB-076BBB045BBE}';
if (version_compare(IPS_GetKernelVersion(), '8.0', '<') || version_compare(IPS_GetKernelVersion(), '9.0', '>=')) {
throw new RuntimeException('Dieses Einrichtungspaket benoetigt IP-Symcon 8.x.');
}
function enxFind(int $parent, string $ident): int {
$id = @IPS_GetObjectIDByIdent($ident, $parent);
return $id === false ? 0 : (int) $id;
}
function enxCategory(int $parent, string $ident, string $name): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateCategory(); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
return $id;
}
function enxInstance(int $parent, string $ident, string $name, string $moduleID): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateInstance($moduleID); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
if (IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== $moduleID) throw new RuntimeException('Modulkonflikt bei ' . $ident);
IPS_SetName($id, $name);
return $id;
}
function enxVariable(int $parent, string $ident, string $name, string $profile = '~Watt'): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateVariable(2); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
if ($profile !== '' && IPS_VariableProfileExists($profile)) IPS_SetVariableCustomProfile($id, $profile);
return $id;
}
function enxScript(int $parent, string $ident, string $name): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateScript(0); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
return $id;
}
function enxLink(int $parent, string $ident, string $name, int $target): int {
$id = enxFind($parent, $ident);
if ($id === 0) { $id = IPS_CreateLink(); IPS_SetParent($id, $parent); IPS_SetIdent($id, $ident); }
IPS_SetName($id, $name);
IPS_SetLinkTargetID($id, $target);
return $id;
}
function enxShellyBinding(
int $parent,
string $ident,
string $name,
string $topic,
int $channel,
int $target,
string $role,
int $levelIndex,
string $shellyModuleID
): void {
if ($topic === '') throw new RuntimeException('MQTT-Topic fuer ' . $name . ' fehlt.');
$hardware = enxInstance($parent, 'SHELLY_' . $ident, 'Shelly ' . $name, $shellyModuleID);
IPS_SetProperty($hardware, 'UseDeviceTopicFilter', true);
IPS_SetProperty($hardware, 'DeviceTopicPrefix', $topic);
IPS_SetProperty($hardware, 'CreateSwitches', $role !== 'sensor');
IPS_SetProperty($hardware, 'CreatePower', $role !== 'sensor');
IPS_SetProperty($hardware, 'CreateEnergy', $role !== 'sensor');
IPS_SetProperty($hardware, 'CreateTemperature', $role === 'sensor');
IPS_ApplyChanges($hardware);
$binding = base64_encode(json_encode([
'target' => $target,
'hardware' => $hardware,
'topic' => $topic,
'channel' => $channel,
'role' => $role,
'levelIndex' => $levelIndex,
], JSON_THROW_ON_ERROR));
$finalizer = enxScript($parent, 'SHELLY_LINK_' . $ident, 'Shelly-Zuordnung ' . $name);
$script = '<?php' . "\n"
. '$binding = json_decode(base64_decode(' . var_export($binding, true) . '), true, 512, JSON_THROW_ON_ERROR);' . "\n"
. '$key = trim(preg_replace("/[^a-z0-9_]+/", "_", strtolower($binding["topic"])), "_");' . "\n"
. '$key = substr($key === "" ? "device" : $key, 0, 48) . "_" . substr(hash("sha256", $binding["topic"]), 0, 8);' . "\n"
. '$folder = @IPS_GetObjectIDByIdent("folder_" . $key, $binding["hardware"]);' . "\n"
. 'if ($folder === false) return;' . "\n"
. '$variable = false;' . "\n"
. 'if ($binding["role"] === "sensor") {' . "\n"
. ' foreach (IPS_GetChildrenIDs((int) $folder) as $candidate) {' . "\n"
. ' if (!IPS_VariableExists($candidate)) continue;' . "\n"
. ' $meta = IPS_GetVariable($candidate);' . "\n"
. ' if (($meta["VariableCustomProfile"] ?? $meta["VariableProfile"] ?? "") === "~Temperature") { $variable = $candidate; break; }' . "\n"
. ' }' . "\n"
. '} else {' . "\n"
. ' $variable = @IPS_GetObjectIDByIdent($key . "_output_" . $binding["channel"], (int) $folder);' . "\n"
. '}' . "\n"
. 'if ($variable === false) return;' . "\n"
. 'if ($binding["role"] === "consumer") {' . "\n"
. ' IPS_SetProperty($binding["target"], "SchaltkontaktVariableID", (int) $variable);' . "\n"
. ' IPS_SetProperty($binding["target"], "RueckmeldungVariableID", (int) $variable);' . "\n"
. '} elseif ($binding["role"] === "level") {' . "\n"
. ' $levels = json_decode((string) IPS_GetProperty($binding["target"], "LeistungsStufen"), true);' . "\n"
. ' if (!is_array($levels) || !isset($levels[$binding["levelIndex"]])) return;' . "\n"
. ' $levels[$binding["levelIndex"]]["Schaltkontakt_Stufe"] = (int) $variable;' . "\n"
. ' IPS_SetProperty($binding["target"], "LeistungsStufen", json_encode($levels, JSON_THROW_ON_ERROR));' . "\n"
. '} elseif ($binding["role"] === "sensor") {' . "\n"
. ' $property = IPS_GetInstance($binding["target"])["ModuleInfo"]["ModuleID"] === "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" ? "Boilerfuehler_PT1" : "PufferfuehlerVariableID";' . "\n"
. ' IPS_SetProperty($binding["target"], $property, (int) $variable);' . "\n"
. '}' . "\n"
. 'IPS_ApplyChanges($binding["target"]);' . "\n"
. 'IPS_SetScriptTimer($_IPS["SELF"], 0);';
IPS_SetScriptContent($finalizer, $script);
IPS_SetScriptTimer($finalizer, 15);
IPS_RunScript($finalizer);
}
$required = [$managerModuleID];
foreach ($config['devices'] as $key => $device) {
foreach (($device['items'] ?? []) as $item) {
if ($key === 'ev_charger' && ($item['variant'] ?? 'standalone') === 'gateway') {
$required[] = $evGatewayModuleID;
$required[] = $easeeGatewayModuleID;
} else {
$required[] = $moduleIDs[$key];
}
$integrations = [$item['integration'] ?? [], $item['sensor'] ?? []];
foreach (($item['powerLevels'] ?? []) as $level) $integrations[] = $level['integration'] ?? [];
foreach ($integrations as $integration) {
if (str_starts_with((string) ($integration['profile'] ?? ''), 'shelly_')) $required[] = $shellyModuleID;
}
}
}
if (($config['costReport']['enabled'] ?? false) === true) $required[] = $costReportModuleID;
if (($config['manager']['properties']['EnergyPieAnzeigen'] ?? false) === true) $required[] = $energyPieModuleID;
if (($config['manager']['properties']['EnergieflussAnzeigen'] ?? false) === true) $required[] = $energyFlowModuleID;
foreach (array_unique($required) as $moduleID) {
if (!in_array($moduleID, IPS_GetModuleList(), true)) throw new RuntimeException('Erforderliches Modul fehlt: ' . $moduleID);
}
// Validate heat-pump bindings before creating or changing any system objects.
foreach (($config['devices']['heat_pump']['items'] ?? []) as $item) {
if (!in_array($moduleIDs['heat_pump'], IPS_GetModuleList(), true)) {
throw new RuntimeException('Das Enelix-Waermepumpenmodul fehlt.');
}
$properties = $item['properties'];
$contact1 = (int) $properties['Kontakt1VariableID'];
$contact2 = (int) $properties['Kontakt2VariableID'];
if ($contact1 === $contact2 || (int) $properties['Nennleistung'] <= 0) {
throw new RuntimeException('Waermepumpe: zwei unterschiedliche Kontakte und Nennleistung erforderlich.');
}
foreach ([$contact1, $contact2] as $contact) {
if (!IPS_VariableExists($contact)) throw new RuntimeException('Waermepumpe: Kontaktvariable fehlt.');
$variable = IPS_GetVariable($contact);
if ($variable['VariableType'] !== 0 || max($variable['VariableAction'], $variable['VariableCustomAction']) <= 0) {
throw new RuntimeException('Waermepumpe: Kontakte muessen schaltbare Boolean-Variablen sein.');
}
}
$feedback = (int) $properties['IstleistungVariableID'];
if (!IPS_VariableExists($feedback)) throw new RuntimeException('Waermepumpe: Betriebsrueckmeldung fehlt.');
$feedbackType = IPS_GetVariable($feedback)['VariableType'];
$allowedTypes = (int) $properties['Rueckmeldungsart'] === 1 ? [0] : [1, 2];
if (!in_array($feedbackType, $allowedTypes, true)) {
throw new RuntimeException('Waermepumpe: Typ der Betriebsrueckmeldung passt nicht zur Rueckmeldungsart.');
}
}
$root = enxCategory(0, 'ENELIX_System', (string) $config['name']);
$visualisation = enxCategory($root, 'Visualisierung', 'Visualisierung');
$energyView = enxCategory($visualisation, 'Energie', 'Energie und Bilanz');
$consumerView = enxCategory($visualisation, 'Verbraucher', 'Verbraucher und Speicher');
$serviceView = enxCategory($visualisation, 'Service', 'Service und Diagnose');
$manager = enxInstance($root, 'Manager', 'Enelix Manager', $managerModuleID);
enxLink($energyView, 'Manager', 'Enelix Manager', $manager);
foreach (($config['manager']['properties'] ?? []) as $property => $value) {
IPS_SetProperty($manager, (string) $property, $value);
}
if (($config['manager']['properties']['AutomatischeSuche'] ?? true) && (int) ($config['manager']['properties']['SuchbereichID'] ?? 0) === 0) {
IPS_SetProperty($manager, 'SuchbereichID', $root);
}
IPS_ApplyChanges($manager);
$froniusHost = '';
$energyVariables = [];
$froniusProfiles = ['fronius_solar_api_v1', 'fronius_hybrid_storage'];
foreach ([$config['pvSystem'] ?? [], $config['gridMeasurement'] ?? []] as $source) {
if (in_array((string) ($source['profile'] ?? ''), $froniusProfiles, true) && (string) ($source['host'] ?? '') !== '') {
$froniusHost = (string) $source['host'];
break;
}
}
if ($froniusHost === '') {
foreach (($config['devices']['battery']['items'] ?? []) as $item) {
$source = $item['integration'] ?? [];
if (in_array((string) ($source['profile'] ?? ''), $froniusProfiles, true) && (string) ($source['host'] ?? '') !== '') {
$froniusHost = (string) $source['host'];
break;
}
}
}
if ($froniusHost !== '') {
$energy = enxCategory($root, 'FroniusEnergy', 'Fronius Energiefluss');
$energyVariables = [];
$variableDefinitions = [
'GridPower' => ['Netzleistung', '~Watt'],
'PvPower' => ['PV-Leistung', '~Watt'],
'LoadPower' => ['Verbrauch', '~Watt'],
'BatteryPower' => ['Batterieleistung', '~Watt'],
'BatterySoc' => ['Batterie-Ladezustand', '~Intensity.100'],
];
foreach ($variableDefinitions as $ident => [$name, $profile]) {
$energyVariables[$ident] = enxVariable($energy, $ident, $name, $profile);
}
$script = enxScript($energy, 'FroniusPoll', 'Fronius Solar API einlesen');
$scriptContent = '$host = ' . var_export($froniusHost, true) . ";\n" . <<<'ENELIX'
$energy = IPS_GetParent($_IPS['SELF']);
try {
$raw = Sys_GetURLContent('http://' . $host . '/solar_api/v1/GetPowerFlowRealtimeData.fcgi');
$payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
$site = $payload['Body']['Data']['Site'] ?? [];
$inverters = $payload['Body']['Data']['Inverters'] ?? [];
$firstInverter = is_array($inverters) && $inverters !== [] ? reset($inverters) : [];
$values = [
'GridPower' => $site['P_Grid'] ?? null,
'PvPower' => $site['P_PV'] ?? null,
'LoadPower' => isset($site['P_Load']) ? abs((float) $site['P_Load']) : null,
'BatteryPower' => $site['P_Akku'] ?? null,
'BatterySoc' => is_array($firstInverter) ? ($firstInverter['SOC'] ?? null) : null,
];
foreach ($values as $ident => $value) {
$variable = @IPS_GetObjectIDByIdent($ident, $energy);
if ($variable !== false && is_numeric($value)) SetValueFloat((int) $variable, (float) $value);
}
} catch (Throwable $error) {
IPS_LogMessage('Enelix Fronius', $error->getMessage());
}
ENELIX;
IPS_SetScriptContent($script, "<?php\n" . $scriptContent);
IPS_SetScriptTimer($script, 60);
IPS_RunScript($script);
if (in_array((string) ($config['gridMeasurement']['profile'] ?? ''), $froniusProfiles, true)) {
IPS_SetProperty($manager, 'NetzleistungVariableID', $energyVariables['GridPower']);
}
IPS_SetProperty($manager, 'MessungPVLeistungVariableID', $energyVariables['PvPower']);
IPS_SetProperty($manager, 'MessungHausverbrauchLeistungVariableID', $energyVariables['LoadPower']);
IPS_SetProperty($manager, 'MessungBatterieleistungVariableID', $energyVariables['BatteryPower']);
}
if ((int) ($config['gridMeasurement']['sourceVariableId'] ?? 0) > 0) {
IPS_SetProperty($manager, 'NetzleistungVariableID', (int) $config['gridMeasurement']['sourceVariableId']);
}
IPS_ApplyChanges($manager);
foreach ([
'EnergyPie' => 'Energie-Kreisdiagramm',
'Energiefluss' => 'Energiefluss',
'Energiediagramm' => 'Energiediagramme',
'EnergieFunFacts' => 'Energy Facts',
] as $ident => $name) {
$target = enxFind($manager, $ident);
if ($target > 0) enxLink($energyView, $ident, $name, $target);
}
foreach (['Aktiv' => 'Manager aktiv', 'Betriebsart' => 'Betriebsart', 'Netzleistung' => 'Netzleistung'] as $ident => $name) {
$target = enxFind($manager, $ident);
if ($target > 0) enxLink($serviceView, 'Manager_' . $ident, $name, $target);
}
$easeeGateway = 0;
foreach ($config['devices'] as $key => $device) {
foreach (($device['items'] ?? []) as $position => $item) {
$index = $position + 1;
$ident = strtoupper($key) . '_' . $index;
$label = (string) ($item['name'] ?? ($key . ' ' . $index));
$gatewayVariant = $key === 'ev_charger' && ($item['variant'] ?? 'standalone') === 'gateway';
$moduleID = $gatewayVariant ? $evGatewayModuleID : $moduleIDs[$key];
$instance = enxInstance($root, $ident, $label, $moduleID);
if ($gatewayVariant) {
if ($easeeGateway === 0) {
$easeeGateway = enxInstance($root, 'EaseeGateway', 'Easee Gateway', $easeeGatewayModuleID);
IPS_ApplyChanges($easeeGateway);
enxLink($serviceView, 'EaseeGateway', 'Easee Gateway', $easeeGateway);
}
$connection = (int) (IPS_GetInstance($instance)['ConnectionID'] ?? 0);
if ($connection === 0) IPS_ConnectInstance($instance, $easeeGateway);
elseif ($connection !== $easeeGateway) throw new RuntimeException('Unerwartete Easee-Gateway-Verbindung bei ' . $label);
}
foreach (($item['properties'] ?? []) as $property => $value) {
IPS_SetProperty($instance, (string) $property, $value);
}
$integration = $item['integration'] ?? [];
$profile = (string) ($integration['profile'] ?? '');
$sourceVariableID = (int) ($integration['sourceVariableId'] ?? 0);
if ($key === 'consumer_single' && $sourceVariableID > 0) {
IPS_SetProperty($instance, 'SchaltkontaktVariableID', $sourceVariableID);
IPS_SetProperty($instance, 'RueckmeldungVariableID', (int) ($integration['feedbackVariableId'] ?? $sourceVariableID) ?: $sourceVariableID);
}
if ($key === 'battery' && $sourceVariableID > 0) {
IPS_SetProperty($instance, 'IstleistungVariableID', $sourceVariableID);
}
if ($key === 'battery' && $profile === 'fronius_hybrid_storage' && $energyVariables !== []) {
$maxCharge = enxVariable($instance, 'AutoMaxLadeleistung', 'Maximale Ladeleistung');
$maxDischarge = enxVariable($instance, 'AutoMaxEntladeleistung', 'Maximale Entladeleistung');
SetValueFloat($maxCharge, 1000 * (float) ($item['topology']['MaxLadeleistung_kW'] ?? 0));
SetValueFloat($maxDischarge, 1000 * (float) ($item['topology']['MaxEntladeleistung_kW'] ?? 0));
IPS_SetProperty($instance, 'MaxLadeleistungVariableID', $maxCharge);
IPS_SetProperty($instance, 'MaxEntladeleistungVariableID', $maxDischarge);
IPS_SetProperty($instance, 'LadezustandVariableID', $energyVariables['BatterySoc']);
IPS_SetProperty($instance, 'NetzleistungVariableID', $energyVariables['GridPower']);
IPS_SetProperty($instance, 'IstleistungVariableID', $energyVariables['BatteryPower']);
}
$sensor = $item['sensor'] ?? [];
$sensorVariableID = (int) ($sensor['sourceVariableId'] ?? 0);
if ($sensorVariableID > 0 && $key === 'boiler_multi') IPS_SetProperty($instance, 'Boilerfuehler_PT1', $sensorVariableID);
if ($sensorVariableID > 0 && $key === 'buffer_storage') IPS_SetProperty($instance, 'PufferfuehlerVariableID', $sensorVariableID);
IPS_ApplyChanges($instance);
enxLink($consumerView, $ident, $label, $instance);
if ($key === 'consumer_single' && str_starts_with($profile, 'shelly_')) {
enxShellyBinding($instance, 'MAIN', $label, (string) $integration['topicPrefix'], (int) ($integration['channel'] ?? 0), $instance, 'consumer', 0, $shellyModuleID);
}
foreach (($item['powerLevels'] ?? []) as $levelIndex => $level) {
$levelIntegration = $level['integration'] ?? [];
if (str_starts_with((string) ($levelIntegration['profile'] ?? ''), 'shelly_')) {
enxShellyBinding($instance, 'LEVEL_' . ($levelIndex + 1), $label . ' Stufe ' . ($levelIndex + 1), (string) $levelIntegration['topicPrefix'], (int) ($levelIntegration['channel'] ?? 0), $instance, 'level', $levelIndex, $shellyModuleID);
}
}
if (str_starts_with((string) ($sensor['profile'] ?? ''), 'shelly_')) {
enxShellyBinding($instance, 'SENSOR', $label . ' Temperatur', (string) $sensor['topicPrefix'], 0, $instance, 'sensor', 0, $shellyModuleID);
}
}
}
if (($config['costReport']['enabled'] ?? false) === true) {
$report = enxInstance($root, 'Verbrauchskostenreport', 'Verbrauchskostenreport', $costReportModuleID);
IPS_SetProperty($report, 'PropertyText', (string) $config['name']);
IPS_ApplyChanges($report);
enxLink($energyView, 'Verbrauchskostenreport', 'Verbrauchskostenreport', $report);
}
echo 'Enelix-System wurde angelegt. Bitte Lizenzcodes, Variablenzuordnungen, MQTT-Verbindung und Geraetezugriffe kontrollieren.';
`;
}
function integrationChecklist(configuration) {
const lines = [
"Enelix Geräteintegrationen",
"",
"Automatisch bedeutet, dass das Bootstrap-Skript die lokale Auslesung oder Zuordnung vorbereitet.",
"Modellspezifisch bedeutet, dass Modell, Firmware und Registerstand vor der Inbetriebnahme geprüft werden müssen.",
""
];
const sources = [
["Netzmessung", configuration.gridMeasurement],
["PV-Wechselrichter", configuration.pvSystem?.enabled ? configuration.pvSystem : null]
];
for (const [label, source] of sources) {
if (!source) continue;
lines.push(label + ": " + source.manufacturer + " / " + source.protocol + " / " + source.setup);
if (source.host) lines.push(" Adresse: " + source.host + ":" + source.port + ", Geräte-ID " + source.unitId);
}
for (const [key, device] of Object.entries(configuration.devices)) {
for (const [index, item] of (device.items || []).entries()) {
const source = item.integration || {};
lines.push((item.name || key + " " + (index + 1)) + ": " + (source.manufacturer || "Geräte-API") + " / " + (source.protocol || source.profile || "manuell") + " / " + (source.setup || "manuell"));
if (source.host) lines.push(" Adresse: " + source.host + ":" + source.port + ", Geräte-ID " + source.unitId);
if (source.topicPrefix) lines.push(" MQTT: " + source.topicPrefix + ", Ausgang " + source.channel);
for (const level of item.powerLevels || []) {
const actuator = level.integration || {};
lines.push(" Stufe " + level.Stufe + ": " + level.Leistung + " W / " + (actuator.profile || "manuell"));
if (actuator.topicPrefix) lines.push(" MQTT: " + actuator.topicPrefix + ", Ausgang " + actuator.channel);
}
const sensor = item.sensor || {};
if (sensor.profile && sensor.profile !== "manual") lines.push(" Temperaturfühler: " + sensor.profile + " / MQTT " + sensor.topicPrefix);
}
}
return lines.join("\n") + "\n";
}
export function buildSymconPackage(configuration, recommendation) {
const normalizedConfiguration = normalizeSystemConfiguration(configuration);
const normalizedRecommendation = recommendLicenses(normalizedConfiguration);
const readme = `Enelix Symcon-Einrichtungspaket
Dieses Paket ist kein blind einspielbares Vollbackup. Es enthaelt ein idempotentes
Bootstrap-Skript fuer die IP-Symcon-Schnellausfuehrung und die normalisierte
Systemkonfiguration. Das ist sicherer, weil Objekt-IDs, Zugangsdaten und lokale
Schnittstellen erst auf der Zielinstallation zugeordnet werden koennen. Beim Lauf
werden Manager, Verbraucher, Geraeteanbindungen und die Visualisierungsstruktur
mit Energiefluss, Kreisdiagramm, Diagrammen und Verbraucherlinks angelegt.
Voraussetzungen:
- IP-Symcon 8.x
- Enelix EMS Module aus dem develop-Stand installiert
- Vor Ausfuehrung ein Backup der Zielinstallation erstellen
Ausfuehrung:
1. bootstrap.php in der IP-Symcon-Schnellausfuehrung oeffnen.
2. Inhalt ausfuehren.
3. Angelegte Instanzen, Variablenzuordnungen und Geraetezugriffe pruefen.
4. Den nach dem Kauf erzeugten Aktivierungscode im Enelix Manager eingeben.
5. Bei gewähltem Verbrauchskostenreport dessen separaten Lizenzcode in der Report-Instanz eintragen.
6. Bei Shelly Gen2+ den automatisch verbundenen MQTT Server prüfen und dasselbe Topic-Präfix am Gerät konfigurieren.
7. Schaltkontakte, Rückmeldungen, Leistungsstufen und Shelly-Temperaturfühler werden nach dem ersten MQTT-Datenpunkt automatisch zugeordnet.
8. INTEGRATIONEN.txt prüfen. Fronius Solar API und Shelly MQTT werden automatisch vorbereitet.
9. Bei Huawei, GoodWe, Sigenergy und modellabhängigen SunSpec-Geräten Modell, Firmware und Registerstand vor der Freigabe verifizieren.
`;
return zipStore([
{ name: "bootstrap.php", data: provisioningScript(normalizedConfiguration) },
{ name: "configuration.json", data: JSON.stringify({
configuration: normalizedConfiguration,
recommendation: normalizedRecommendation
}, null, 2) },
{ name: "INTEGRATIONEN.txt", data: integrationChecklist(normalizedConfiguration) },
{ name: "README.txt", data: readme }
]);
}
@@ -0,0 +1,35 @@
{
"date": "2026-10-06",
"host": "enelix-services",
"status": "tested, not deployed",
"files": {
"stripe.mjs": {
"before": "66fccada6f48e1a15c3c418ae8ae99ed26b723866d6a6b911b25321c7a208571",
"after": "5060b88265436a8e60ba9eeca11175809a20c9c925631e468602cbb1d5c7065b"
},
"portal-domain.mjs": {
"before": "63a2f443b02aaa2e6b5ab85179cb5e5b199e3795f48d69606723c766c2ec0935",
"after": "13986aaecb4a427269c8f0aefc373faab662269318074a2623524b04fbbb8d1c"
},
"server.mjs": {
"before": "e1a3ede87638717a03e1a2505eb87ecc32caa22d7611c84b80ddb1aa99a6daff",
"after": "e1e785f837872022186b80a571fdf80554647221d59cb778b1f37725547a82c0"
},
"symcon-package.mjs": {
"before": "dc2444dc72bd68c1369e861bbb0a080e7edd5344412c2e87f68523bbb30b18ed",
"after": "10da57f396d7be369f2410518432b2606286e86b8954204234bdb33bc1ac6d8e"
},
"public/index.html": {
"before": "ec9bba90ccca3d57abd9c37a99b7efda27272050cb6013b540bc7cd0cec29c87",
"after": "d800a160f6daf1be1839c6cf787cd9020d2c83b11be0b3713c7d35aeae99bba5"
},
"public/app.js": {
"before": "f0d88f6daaa6ea6135821ace6c36f515053afff17ee08bc7c5d814dab4263330",
"after": "b4828a1bd58a5cc29a3970475c8eebadf89a3fda788c269d6951572603fa5dd9"
},
"public/styles.css": {
"before": "aea743390d4c873fb5b95d97fefabf3b4e35abdcef268dc9fa6f7df0eecbd082",
"after": "507a028a5deec9bf33150786c587262ecdd158bfd6cee217e14417839cf42325"
}
}
}
@@ -0,0 +1,342 @@
--- a/stripe.mjs
+++ b/stripe.mjs
@@ -22,7 +22,7 @@
cost_meter_aux: { sku: "ENX-BILLING-AUX", name: "Wärme- oder Wasserzähler Abrechnung", unitAmount: 300, setupAmount: 0, entitlement: true }
});
-const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi"];
+const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi", "heat_pump"];
const forecastKeys = ["forecast_pv", "forecast_load", "grid_schedule"];
const licenseKeys = [...moduleKeys, ...forecastKeys];
--- a/portal-domain.mjs
+++ b/portal-domain.mjs
@@ -5,7 +5,8 @@
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}" }
+ 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({
@@ -210,6 +211,23 @@
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 }),
@@ -253,6 +271,12 @@
"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"
@@ -455,8 +479,8 @@
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"].includes(key) ? "actuator" : null);
- const integration = kind ? normalizeIntegration(item.integration, kind) : {
+ 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),
@@ -588,6 +612,14 @@
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;
--- a/server.mjs
+++ b/server.mjs
@@ -19,6 +19,7 @@
createActivationCode,
normalizeActivationCode,
normalizeSystemConfiguration,
+ licenseCoverage,
publicWizardCatalog,
recommendLicenses
} from "./portal-domain.mjs";
@@ -2126,8 +2127,9 @@
const setup = ownedSetupState(plantId);
const modules = {};
const setupModules = {};
+ const coverage = licenseCoverage(recommendation.modules, owned.quantities);
for (const [key, requested] of Object.entries(recommendation.modules || {})) {
- modules[key] = Math.max(0, Number(requested || 0) - Number(owned.quantities[key] || 0));
+ modules[key] = Math.max(0, Number(requested || 0) - Number(coverage[key] || 0));
setupModules[key] = Math.max(0, Number(requested || 0) - Number(setup.quantities[key] || 0));
}
let managerAction = null;
--- a/symcon-package.mjs
+++ b/symcon-package.mjs
@@ -207,6 +207,33 @@
if (!in_array($moduleID, IPS_GetModuleList(), true)) throw new RuntimeException('Erforderliches Modul fehlt: ' . $moduleID);
}
+// Validate heat-pump bindings before creating or changing any system objects.
+foreach (($config['devices']['heat_pump']['items'] ?? []) as $item) {
+ if (!in_array($moduleIDs['heat_pump'], IPS_GetModuleList(), true)) {
+ throw new RuntimeException('Das Enelix-Waermepumpenmodul fehlt.');
+ }
+ $properties = $item['properties'];
+ $contact1 = (int) $properties['Kontakt1VariableID'];
+ $contact2 = (int) $properties['Kontakt2VariableID'];
+ if ($contact1 === $contact2 || (int) $properties['Nennleistung'] <= 0) {
+ throw new RuntimeException('Waermepumpe: zwei unterschiedliche Kontakte und Nennleistung erforderlich.');
+ }
+ foreach ([$contact1, $contact2] as $contact) {
+ if (!IPS_VariableExists($contact)) throw new RuntimeException('Waermepumpe: Kontaktvariable fehlt.');
+ $variable = IPS_GetVariable($contact);
+ if ($variable['VariableType'] !== 0 || max($variable['VariableAction'], $variable['VariableCustomAction']) <= 0) {
+ throw new RuntimeException('Waermepumpe: Kontakte muessen schaltbare Boolean-Variablen sein.');
+ }
+ }
+ $feedback = (int) $properties['IstleistungVariableID'];
+ if (!IPS_VariableExists($feedback)) throw new RuntimeException('Waermepumpe: Betriebsrueckmeldung fehlt.');
+ $feedbackType = IPS_GetVariable($feedback)['VariableType'];
+ $allowedTypes = (int) $properties['Rueckmeldungsart'] === 1 ? [0] : [1, 2];
+ if (!in_array($feedbackType, $allowedTypes, true)) {
+ throw new RuntimeException('Waermepumpe: Typ der Betriebsrueckmeldung passt nicht zur Rueckmeldungsart.');
+ }
+}
+
$root = enxCategory(0, 'ENELIX_System', (string) $config['name']);
$visualisation = enxCategory($root, 'Visualisierung', 'Visualisierung');
$energyView = enxCategory($visualisation, 'Energie', 'Energie und Bilanz');
--- a/public/index.html
+++ b/public/index.html
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Enelix EMS Portal für Systeme, Lizenzen und Prognosemanager">
<title>Enelix EMS Portal</title>
- <link rel="stylesheet" href="/styles.css?v=20260929-3">
+ <link rel="stylesheet" href="/styles.css?v=20261006-heat-pump">
<link rel="stylesheet" href="/docs-portal.css?v=20261006">
</head>
<body>
@@ -65,7 +65,7 @@
<div id="portalApp" class="portal hidden">
<header class="topbar">
<a class="brand" href="#overview"><span class="brand-mark">E</span><span><strong>enelix</strong><small>EMS Portal</small></span></a>
- <div class="top-actions"><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
+ <div class="top-actions"><nav class="header-links" aria-label="Dokumentation und Preise"><a href="/docs/index.html">Dokumentation</a><a href="/docs/preise.html">Preise</a></nav><span class="environment">Entwicklung</span><button class="account-button" id="accountButton" type="button"><span id="topInitials">EN</span><strong id="topName">Konto</strong></button></div>
</header>
<div class="app-shell">
@@ -75,8 +75,6 @@
<button class="nav-item" data-view="forecast">Prognose</button>
<button class="nav-item" data-view="faults">Störüberwachung</button>
<button class="nav-item" data-view="account">Konto</button>
- <a class="nav-item public-doc-link" href="/docs/index.html">Dokumentation</a>
- <a class="nav-item public-doc-link" href="/docs/preise.html">Preise</a>
</nav>
<div class="workspace-switcher"><label>Organisation<select id="workspaceSelect"></select></label><small id="workspaceRole">Eigentümer</small></div>
</aside>
@@ -100,7 +98,8 @@
<label class="module-row" data-key="battery" data-name="Batteriespeicher" data-price="41.95" data-setup="54.55"><span><input type="checkbox" class="module-check"><strong>Batteriespeicher</strong><small>Batterie</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 41.95</span><span>CHF 54.55</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="buffer_storage" data-name="Pufferspeicher" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Pufferspeicher</strong><small>VerbraucherPufferspeicher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="ev_charger" data-name="EV-Ladestation" data-price="33.65" data-setup="43.75"><span><input type="checkbox" class="module-check"><strong>EV-Ladestation</strong><small>Stand-Alone oder Gateway</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 33.65</span><span>CHF 43.75</span><input class="qty" type="number" min="1" max="999" value="1"></label>
- <label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>z. B. Wärmepumpe</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
+ <label class="module-row" data-key="consumer_single" data-name="Verbraucher einstufig" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Verbraucher einstufig</strong><small>Ein schaltbarer Verbraucher</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
+ <label class="module-row" data-key="heat_pump" data-name="Wärmepumpe" data-price="20.95" data-setup="27.25"><span><input type="checkbox" class="module-check"><strong>Wärmepumpe</strong><small>Sperre / Erhöhung oder SG Ready</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 20.95</span><span>CHF 27.25</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<label class="module-row" data-key="boiler_multi" data-name="Boiler mehrstufig" data-price="25.15" data-setup="32.70"><span><input type="checkbox" class="module-check"><strong>Boiler mehrstufig</strong><small>Warmwassererwärmer</small><small class="owned-count">Vorhanden: <b>0</b></small></span><span>CHF 25.15</span><span>CHF 32.70</span><input class="qty" type="number" min="1" max="999" value="1"></label>
<div class="module-header"><span>Prognoselizenz</span><span>Jahrespreis</span><span>Einrichtung</span><span>Menge</span></div>
<label class="module-row" data-key="forecast_pv" data-name="PV-Ertragsprognose" data-price="0" data-setup="0" data-term-months="12"><span><input type="checkbox" class="module-check"><strong>PV-Ertragsprognose</strong><small>Jahreslizenz</small><small class="owned-count">Nicht aktiviert</small></span><span>CHF 0.00 / Jahr</span><span>–</span><input class="qty" type="number" min="1" max="1" value="1"></label>
@@ -117,13 +116,13 @@
<form id="systemForm" class="system-configurator">
<section class="config-form wizard">
<header class="wizard-header">
- <div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 9</p><h2 id="wizardStepTitle">Anlage</h2></div>
+ <div><p class="eyebrow" id="wizardStepCount">Schritt 1 von 10</p><h2 id="wizardStepTitle">Anlage</h2></div>
<ol class="wizard-progress" aria-label="Fortschritt">
<li class="active" data-wizard-marker="0">Anlage</li><li data-wizard-marker="1">Energiequellen</li>
<li data-wizard-marker="2">Batterie</li><li data-wizard-marker="3">Pufferspeicher</li>
<li data-wizard-marker="4">Ladestation</li><li data-wizard-marker="5">Verbraucher</li>
- <li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Abrechnung</li>
- <li data-wizard-marker="8">Prüfen</li>
+ <li data-wizard-marker="6">Boiler</li><li data-wizard-marker="7">Wärmepumpe</li>
+ <li data-wizard-marker="8">Abrechnung</li><li data-wizard-marker="9">Prüfen</li>
</ol>
</header>
<section class="wizard-step active" data-wizard-step="0">
@@ -190,11 +189,16 @@
<div class="device-editor" data-device-editor="boiler_multi"></div>
</section>
<section class="wizard-step" data-wizard-step="7">
+ <p class="wizard-question">Welche Wärmepumpen sollen eingerichtet werden?</p>
+ <div class="quantity-line"><label>Anzahl<input name="heatPumpQuantity" data-device-quantity="heat_pump" type="number" min="0" max="50" value="0"></label></div>
+ <div class="device-editor" data-device-editor="heat_pump"></div>
+ </section>
+ <section class="wizard-step" data-wizard-step="8">
<p class="wizard-question">Soll im selben Symcon-System ein Verbrauchskostenreport eingerichtet werden?</p>
<label class="setup-option"><input name="costReportEnabled" type="checkbox"><span><strong>Verbrauchskostenreport hinzufügen</strong><small>Wird als eigene Instanz eingerichtet und verwendet einen separaten Lizenzcode.</small></span></label>
<div class="field-grid"><label>Stromzähler<input name="costPowerMeters" type="number" min="0" max="999" value="0"></label><label>Wärme- oder Wasserzähler<input name="costAuxMeters" type="number" min="0" max="999" value="0"></label></div>
</section>
- <section class="wizard-step" data-wizard-step="8">
+ <section class="wizard-step" data-wizard-step="9">
<p class="wizard-question">Bitte prüfe die Angaben vor dem Speichern.</p>
<div id="wizardReview" class="wizard-review"></div>
</section>
@@ -367,6 +371,6 @@
<dialog id="deletePaidDialog" class="warning-dialog"><div class="dialog-body"><div class="dialog-head"><div><p class="eyebrow">Zweite Bestätigung</p><h2>Auch Lizenz löschen?</h2></div><button class="icon-button" type="button" data-close-dialog aria-label="Schließen">×</button></div><div class="replacement-warning critical-warning"><strong>Achtung: Für diese Anlage besteht eine bezahlte Lizenz.</strong><p>Sollen <b id="deletePaidSystem">die Anlage</b> und die zugehörigen Lizenzcodes, Berechtigungen, Bestellungen und Sandbox-Zahlungsdaten wirklich endgültig gelöscht werden?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p></div><p class="form-error" id="deletePaidError"></p><div class="dialog-actions"><button class="button secondary" type="button" data-close-dialog>Abbrechen</button><button class="button danger" id="confirmDeletePaid" type="button">Anlage und Lizenz löschen</button></div></div></dialog>
- <script src="/app.js?v=20260929-3"></script>
+ <script src="/app.js?v=20261006-heat-pump"></script>
</body>
</html>
--- a/public/app.js
+++ b/public/app.js
@@ -7,7 +7,7 @@
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" };
+ 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); };
@@ -437,6 +437,14 @@
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;
@@ -445,6 +453,7 @@
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);
@@ -455,7 +464,7 @@
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 - ownedQuantity);
+ 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;
@@ -1877,7 +1886,7 @@
renderPvSurfaceEditor(pvSurfaceQuantity, pvSurfaces);
updatePvTopologyVisibility(form);
renderManagerFields(configuration.manager?.properties || {});
- var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity" };
+ var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" };
Object.keys(deviceLabels).forEach(function (key) {
var source = configuration.devices[key] || {};
var quantity = Math.max(Number(source.quantity || 0), Number(owned[key] || 0));
@@ -1908,7 +1917,7 @@
function systemPayload(form) {
var values = Object.fromEntries(new FormData(form).entries());
- var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity" };
+ var quantityNames = { battery: "batteryQuantity", buffer_storage: "bufferQuantity", ev_charger: "evQuantity", consumer_single: "consumerQuantity", boiler_multi: "boilerQuantity", heat_pump: "heatPumpQuantity" };
var devices = {};
Object.keys(deviceLabels).forEach(function (key) {
devices[key] = { quantity: Number(values[quantityNames[key]] || 0), items: collectDeviceItems(key) };
@@ -1932,7 +1941,7 @@
};
}
- var wizardTitles = ["Anlage", "Manager, Netz und PV", "Batteriespeicher", "Pufferspeicher", "EV-Ladestation", "Einstufige Verbraucher", "Boiler", "Verbrauchskostenreport", "Angaben prüfen"];
+ var wizardTitles = ["Anlage", "Manager, Netz und PV", "Batteriespeicher", "Pufferspeicher", "EV-Ladestation", "Einstufige Verbraucher", "Boiler", "Wärmepumpe", "Verbrauchskostenreport", "Angaben prüfen"];
function updateRecommendation() {
var payload = systemPayload(byId("systemForm"));
@@ -1941,9 +1950,11 @@
var selectedDevices = Object.keys(deviceLabels).filter(function (key) { return payload.devices[key].quantity > 0; });
var configuredPlant = state.plants.find(function (plant) { return plant.id === byId("systemForm").dataset.plantId; });
var owned = configuredPlant ? paidLicenseSummary(configuredPlant.id) : {};
+ var requestedModules = Object.fromEntries(Object.entries(payload.devices).map(function (entry) { return [entry[0], entry[1].quantity]; }));
+ var coverage = licenseCoverage(requestedModules, owned);
var rows = selectedDevices.map(function (key) {
var requested = Number(payload.devices[key].quantity || 0);
- var licensed = Number(owned[key] || 0);
+ var licensed = Number(coverage[key] || 0);
var missing = Math.max(0, requested - licensed);
var licenseText = missing
? licensed + " lizenziert · " + missing + " zusätzliche " + (missing === 1 ? "Lizenz" : "Lizenzen") + " erforderlich"
--- a/public/styles.css
+++ b/public/styles.css
@@ -85,6 +85,10 @@
.portal { min-height: 100vh; }
.topbar { position: sticky; z-index: 20; top: 0; display: flex; height: 68px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); padding: 0 28px; }
.top-actions { display: flex; align-items: center; gap: 13px; }
+.header-links { display: flex; align-items: center; gap: 24px; margin-right: 16px; }
+.header-links a { color: var(--petrol); font-size: .88rem; font-weight: 700; text-decoration: none; padding: 10px 0; white-space: nowrap; }
+.header-links a:hover { text-decoration: underline; text-underline-offset: 5px; }
+.header-links a:focus-visible { outline: 2px solid var(--petrol); outline-offset: 4px; }
.environment, .status { display: inline-flex; align-items: center; border-radius: 999px; white-space: nowrap; font-size: .7rem; font-weight: 800; }
.environment { background: #edf2f1; color: #566568; padding: 5px 9px; text-transform: uppercase; letter-spacing: .07em; }
.account-button { display: flex; align-items: center; gap: 8px; border: 0; background: transparent; color: var(--ink); }
@@ -239,8 +243,8 @@
.wizard { min-height: 520px; align-content: start; border-top: 3px solid var(--petrol); background: #fff; padding: 24px; }
.wizard-header { display: grid; gap: 20px; border-bottom: 1px solid var(--line); padding-bottom: 20px; }
.wizard-header h2 { font-size: 1.45rem; }
-.wizard-progress { display: grid; grid-template-columns: repeat(9, minmax(72px, 1fr)); overflow-x: auto; margin: 0; padding: 0; list-style: none; }
-.wizard-progress li { min-width: 72px; border-top: 3px solid var(--line); padding: 8px 5px 0; color: var(--muted); font-size: .7rem; font-weight: 750; text-align: center; }
+.wizard-progress { display: grid; grid-template-columns: repeat(10, minmax(max-content, 1fr)); overflow-x: auto; margin: 0; padding: 0; list-style: none; }
+.wizard-progress li { min-width: 72px; border-top: 3px solid var(--line); padding: 8px 8px 0; color: var(--muted); font-size: .7rem; font-weight: 750; text-align: center; white-space: nowrap; }
.wizard-progress li.active { border-color: var(--lime); color: var(--ink); }
.wizard-progress li.done { border-color: var(--petrol); color: var(--petrol); }
.wizard-step { display: none; min-height: 250px; padding: 28px 0 8px; }
@@ -325,6 +329,9 @@
@media (max-width: 680px) {
.topbar { height: 62px; padding: 0 15px; }
+ .top-actions { gap: 10px; }
+ .header-links { gap: 12px; margin-right: 0; }
+ .header-links a { font-size: .75rem; }
.brand strong { font-size: 1.05rem; }
.brand-mark { width: 32px; height: 32px; }
.environment, .account-button strong { display: none; }
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { publicWizardCatalog, normalizeSystemConfiguration, recommendLicenses } from '../portal-domain.mjs';
import { publicCatalog } from '../stripe.mjs';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const require = createRequire(process.env.BROWSER_PACKAGE || import.meta.url);
const { chromium } = require('playwright');
const options = { headless: true };
if (process.env.BROWSER_EXECUTABLE) {
options.executablePath = process.env.BROWSER_EXECUTABLE;
const loaded = require('@sparticuz/chromium');
const standalone = loaded.default || loaded;
options.args = standalone.args.filter(arg => !['--single-process', '--disable-web-security', '--allow-running-insecure-content', '--disable-site-isolation-trials'].includes(arg) && !arg.startsWith('--disable-features='));
}
const browser = await chromium.launch(options);
let plants = [];
let submitted;
const errors = [];
const json = (route, value) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(value) });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'de-CH' });
page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.pathname === '/api/catalog') return json(route, { items: publicCatalog(), vatRate: 8.1 });
if (url.pathname === '/api/wizard-catalog') return json(route, publicWizardCatalog());
if (url.pathname === '/api/session') return json(route, { authenticated: true, user: { name: 'Testkonto', email: 'portal-test@example.invalid' }, organization: { type: 'personal', role: 'owner', name: 'Testbereich' }, organizations: [] });
if (url.pathname === '/healthz') return json(route, { stripe: 'not_configured' });
if (url.pathname === '/api/plants') return json(route, { plants });
if (url.pathname === '/api/orders') return json(route, { orders: [] });
if (url.pathname === '/api/configurations') {
submitted = normalizeSystemConfiguration(route.request().postDataJSON());
const recommendation = recommendLicenses(submitted);
plants = [{ id: 'test-plant', name: submitted.name, location: submitted.location, type: submitted.siteType, manager_variant: 'manager_standard', configuration_id: 'test-config', configuration_json: JSON.stringify(submitted), recommendation_json: JSON.stringify(recommendation) }];
return json(route, { plant: plants[0], configurationId: 'test-config', packageReady: false, purchaseProposal: { required: true } });
}
const file = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
if (!['index.html', 'styles.css', 'docs-portal.css', 'app.js'].includes(file)) return route.fulfill({ status: 404, body: 'Not found' });
return route.fulfill({ contentType: file.endsWith('.js') ? 'text/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html', body: await fs.readFile(path.join(root, 'public', file)) });
});
await page.goto('http://portal.test/');
await page.locator('#portalApp:not(.hidden)').waitFor();
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
const layout = await page.locator('.topbar').evaluate(element => {
const nodes = [...element.querySelectorAll('.brand, .header-links a, .account-button')].map(node => {
const box = node.getBoundingClientRect(); return { x: box.x, right: box.right, top: box.top, bottom: box.bottom };
});
return { nodes, viewport: innerWidth, width: document.documentElement.scrollWidth };
});
assert.ok(layout.nodes.every(box => box.x >= 0 && box.right <= width), JSON.stringify({ width, layout }));
for (let i = 1; i < layout.nodes.length; i++) assert.ok(layout.nodes[i - 1].right <= layout.nodes[i].x, JSON.stringify({ width, layout }));
assert.equal(await page.locator('.sidebar a[href^="/docs/"]').count(), 0);
await page.screenshot({ path: path.join(root, `header-${width}.png`), clip: { x: 0, y: 0, width, height: 180 } });
}
await page.setViewportSize({ width: 1440, height: 1000 });
await page.locator('[data-open-new-license]').first().click();
await page.locator('#newLicenseDialog [data-goto="configurator"]').click();
await page.locator('#systemForm [name="name"]').fill('Testanlage');
await page.locator('#systemForm [name="location"]').fill('Teststandort');
for (let i = 0; i < 7; i++) await page.locator('#wizardNext').click();
assert.equal(await page.locator('#wizardStepTitle').textContent(), 'Wärmepumpe');
assert.equal(await page.locator('.wizard-progress li').evaluateAll(nodes => nodes.every(node => node.scrollWidth <= node.clientWidth)), true);
await page.locator('[name="heatPumpQuantity"]').fill('1');
const editor = page.locator('[data-device-editor="heat_pump"]');
await editor.locator('[data-device-property="Kontaktart"]').selectOption('1');
for (const [field, value] of Object.entries({ Kontakt1VariableID: 10001, Kontakt2VariableID: 10002, IstleistungVariableID: 10003, Nennleistung: 4200, Mindestlaufzeit: 1500 })) {
await editor.locator(`[data-device-property="${field}"]`).fill(String(value));
}
await editor.locator('[data-device-property="Rueckmeldungsart"]').selectOption('1');
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: path.join(root, 'wizard-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: path.join(root, 'wizard-mobile.png'), fullPage: true });
await page.locator('#wizardNext').click();
await page.locator('#wizardNext').click();
assert.match(await page.locator('#wizardReview').textContent(), /Wärmepumpe/);
await page.locator('#wizardSubmit').click();
await page.locator('#view-licenses.active').waitFor();
assert.equal(submitted.devices.heat_pump.items[0].properties.Mindestlaufzeit, 1500);
assert.equal(submitted.devices.heat_pump.items[0].properties.Kontaktart, 1);
assert.equal(await page.locator('.module-row[data-key="heat_pump"] .module-check').isChecked(), true);
assert.match(await page.locator('#orderItems').textContent(), /Wärmepumpe/);
await page.locator('[data-configure-current]').click();
for (let i = 0; i < 7; i++) await page.locator('#wizardNext').click();
assert.equal(await editor.locator('[data-device-property="Kontakt1VariableID"]').inputValue(), '10001');
assert.equal(await editor.locator('[data-device-property="Mindestlaufzeit"]').inputValue(), '1500');
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, headerWidths: [1440, 768, 390, 320], wizard: 'configure, save, license proposal, reopen', browserErrors: errors }));
} finally { await browser.close(); }
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import { buildOrderLines, catalog } from "../stripe.mjs";
import { consumerFields, licenseCoverage, normalizeSystemConfiguration, publicWizardCatalog, recommendLicenses, systemDeviceTypes } from "../portal-domain.mjs";
import { buildSymconPackage } from "../symcon-package.mjs";
const configuration = () => ({
name: "Testsystem", location: "Teststandort", devices: {
buffer_storage: { quantity: 0 },
heat_pump: { quantity: 1, items: [{ name: "WP", properties: {
Kontaktart: 1, Kontakt1VariableID: 10001, Kontakt2VariableID: 10002,
Nennleistung: 4200, Rueckmeldungsart: 1, IstleistungVariableID: 10003,
Kontakt2Invertiert: true, Mindestlaufzeit: 1500
} }] }
}
});
test("heat-pump order includes separate licenses and setup at existing prices", () => {
const result = buildOrderLines("manager_standard", { heat_pump: 2 }, { modules: { heat_pump: 2 } }, "manager_standard");
assert.equal(result.length, 2);
assert.deepEqual(result.map(line => [line.catalogKey, line.lineType, line.quantity, line.unitAmount, line.entitlement]), [
["heat_pump", "license", 2, 2095, true], ["heat_pump", "setup", 2, 2725, false]
]);
});
test("heat-pump checkout respects catalog availability and quantities", () => {
const disabled = { ...catalog, heat_pump: { ...catalog.heat_pump, available: false } };
assert.throws(() => buildOrderLines("manager_standard", { heat_pump: 1 }, {}, "manager_standard", disabled), /catalog_item_unavailable/);
for (const quantity of [-1, 0.5, 1000]) {
assert.throws(() => buildOrderLines("manager_standard", { heat_pump: quantity }), /invalid_quantity/);
}
});
test("wizard publishes two-contact heat-pump fields without duplicates", () => {
const fields = publicWizardCatalog().consumerFields.heat_pump;
assert.equal(new Set(consumerFields.heat_pump.map(field => field.key)).size, consumerFields.heat_pump.length);
for (const key of ["Kontaktart", "Kontakt1VariableID", "Kontakt2VariableID", "Rueckmeldungsart", "IstleistungVariableID", "MaximaleSperrzeit24h"]) {
assert.ok(fields.some(field => field.key === key), key);
}
assert.equal(systemDeviceTypes.heat_pump.moduleId, "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}");
});
test("heat-pump settings survive normalize, save and recommendation", () => {
const result = normalizeSystemConfiguration(configuration());
assert.deepEqual(normalizeSystemConfiguration(result), result);
const properties = result.devices.heat_pump.items[0].properties;
assert.equal(properties.Kontaktart, 1);
assert.equal(properties.Kontakt1VariableID, 10001);
assert.equal(properties.Kontakt2VariableID, 10002);
assert.equal(properties.IstleistungVariableID, 10003);
assert.equal(properties.Rueckmeldungsart, 1);
assert.equal(properties.Kontakt2Invertiert, true);
assert.equal(properties.Mindestlaufzeit, 1500);
assert.equal(properties.MaximaleSperrzeit, 7200);
assert.equal(recommendLicenses(result).modules.heat_pump, 1);
});
test("older configurations gain no heat-pump device and retain consumers", () => {
const input = configuration();
delete input.devices.heat_pump;
input.devices.consumer_single = { quantity: 1, items: [{ name: "Bestand", properties: { Nennleistung: 2200 } }] };
const result = normalizeSystemConfiguration(input);
assert.deepEqual(result.devices.heat_pump, { quantity: 0, items: [] });
assert.equal(result.devices.consumer_single.items[0].properties.Nennleistung, 2200);
});
test("legacy coverage reserves single-consumer licenses without double counting", () => {
assert.equal(licenseCoverage({ heat_pump: 2, consumer_single: 1 }, { consumer_single: 3 }).heat_pump, 2);
assert.equal(licenseCoverage({ heat_pump: 2, consumer_single: 2 }, { consumer_single: 3 }).heat_pump, 1);
assert.equal(licenseCoverage({ heat_pump: 2, consumer_single: 3 }, { consumer_single: 3 }).heat_pump, 0);
assert.equal(licenseCoverage({ heat_pump: 2 }, { heat_pump: 1, consumer_single: 3 }).heat_pump, 2);
const source = readFileSync(new URL("../public/app.js", import.meta.url), "utf8");
const functionSource = source.match(/ function licenseCoverage\(requested, owned\) \{[\s\S]*?\n \}/)[0];
const browserCoverage = vm.runInNewContext(`(${functionSource})`);
for (let requested = 0; requested < 5; requested++) {
for (let consumers = 0; consumers < 5; consumers++) {
const demand = { heat_pump: requested, consumer_single: consumers };
const owned = { heat_pump: 1, consumer_single: 3 };
assert.equal(JSON.stringify(browserCoverage(demand, owned)), JSON.stringify(licenseCoverage(demand, owned)));
}
}
});
test("package contains heat-pump configuration and preflight before writes", () => {
const archive = buildSymconPackage(configuration());
const files = {};
let offset = 0;
while (archive.readUInt32LE(offset) === 0x04034b50) {
const length = archive.readUInt32LE(offset + 18);
const nameLength = archive.readUInt16LE(offset + 26);
const extraLength = archive.readUInt16LE(offset + 28);
const name = archive.subarray(offset + 30, offset + 30 + nameLength).toString();
const start = offset + 30 + nameLength + extraLength;
files[name] = archive.subarray(start, start + length).toString();
offset = start + length;
}
const bootstrap = files["bootstrap.php"];
assert.match(bootstrap, /A31C9274-54F7-4BD2-9804-7AF23E86C4D1/);
assert.ok(bootstrap.indexOf("// Validate heat-pump bindings") < bootstrap.indexOf("$root = enxCategory"));
assert.match(bootstrap, /\$contact1 === \$contact2/);
assert.match(bootstrap, /Typ der Betriebsrueckmeldung/);
assert.match(bootstrap, /IPS_SetProperty\(\$instance, \(string\) \$property, \$value\)/);
});
test("header has both links, sidebar has neither, and wizard has ten steps", () => {
const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8");
const header = html.match(/<header class="topbar">([\s\S]*?)<\/header>/)[1];
const sidebar = html.match(/<aside class="sidebar">([\s\S]*?)<\/aside>/)[1];
assert.match(header, /href="\/docs\/index.html">Dokumentation/);
assert.match(header, /href="\/docs\/preise.html">Preise/);
assert.doesNotMatch(sidebar, /Dokumentation|Preise/);
assert.deepEqual([...html.matchAll(/data-wizard-step="(\d+)"/g)].map(match => Number(match[1])), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
assert.equal([...html.matchAll(/data-wizard-marker="/g)].length, 10);
assert.match(html, /data-device-quantity="heat_pump"/);
assert.match(html, /data-key="heat_pump"/);
});
@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
consumerFields,
normalizeSystemConfiguration,
publicWizardCatalog
} from "../portal-domain.mjs";
import { buildSymconPackage } from "../symcon-package.mjs";
const emptyDevices = () => ({
battery: { quantity: 0, items: [] },
buffer_storage: { quantity: 0, items: [] },
ev_charger: { quantity: 0, items: [] },
consumer_single: { quantity: 0, items: [] },
boiler_multi: { quantity: 0, items: [] },
heat_pump: { quantity: 0, items: [] }
});
const baseConfiguration = () => ({
name: "Testsystem",
location: "Zürich",
manager: { properties: {} },
gridMeasurement: { profile: "manual", sourceVariableId: 42 },
pvSystem: { enabled: false },
devices: emptyDevices()
});
test("wizard catalog contains complete manager, consumer and integration schemas", () => {
const catalog = publicWizardCatalog();
assert.equal(catalog.schemaVersion, 4);
assert.equal(catalog.managerFields.length, 11);
assert.deepEqual(Object.keys(catalog.consumerFields).sort(), Object.keys(emptyDevices()).sort());
assert.equal(catalog.evVariants.length, 2);
assert.ok(catalog.integrations.energy.some(profile => profile.id === "fronius_solar_api_v1"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "huawei_sun2000_modbus"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "sma_sunspec_modbus"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "goodwe_et_modbus"));
assert.ok(catalog.integrations.energy.some(profile => profile.id === "solaredge_sunspec_modbus"));
assert.ok(catalog.integrations.storage.some(profile => profile.id === "sigenergy_sigenstor"));
assert.ok(catalog.integrations.actuator.some(profile => profile.id === "shelly_pro_4pm"));
assert.ok(consumerFields.battery.length > 20);
});
test("normalizes individual device properties and integration metadata", () => {
const configuration = baseConfiguration();
configuration.manager.properties.MesswertMaxAlter = 15;
configuration.devices.consumer_single = {
quantity: 1,
items: [{
name: "Wärmepumpe",
integration: { profile: "shelly_pro_4pm", topicPrefix: "shellypro4pm-test", channel: 3 },
properties: { Nennleistung: 4200, PrioritaetPV: 2 }
}]
};
const result = normalizeSystemConfiguration(configuration);
assert.equal(result.manager.properties.MesswertMaxAlter, 15);
assert.equal(result.devices.consumer_single.items[0].integration.channel, 3);
assert.equal(result.devices.consumer_single.items[0].properties.Nennleistung, 4200);
assert.equal(result.devices.consumer_single.items[0].properties.PrioritaetPV, 2);
});
test("derives battery type and enables charging by default", () => {
const configuration = baseConfiguration();
configuration.devices.battery = {
quantity: 2,
items: [{
name: "Hausspeicher",
integration: { profile: "goodwe_lynx", host: "192.0.2.10" },
properties: { Batterietyp: 0 }
}, {
name: "Gewerbespeicher",
integration: { profile: "sigenergy_sigenstor", host: "192.0.2.11" },
properties: { Batterietyp: 0 }
}]
};
configuration.devices.ev_charger = {
quantity: 2,
items: [{ name: "Direkt", variant: "standalone", properties: {} },
{ name: "Gateway", variant: "gateway", properties: {} }]
};
const result = normalizeSystemConfiguration(configuration);
assert.equal(result.devices.battery.items[0].properties.Batterietyp, 2);
assert.equal(result.devices.battery.items[1].properties.Batterietyp, 4);
assert.equal(result.devices.ev_charger.items[0].properties.Ladefreigabe, true);
assert.equal(result.devices.ev_charger.items[1].properties.Ladefreigabe, true);
});
test("package wires gateway, Shelly feedback, levels, sensors and visualisation", () => {
const configuration = baseConfiguration();
configuration.devices.ev_charger = {
quantity: 1,
items: [{ name: "Garage", variant: "gateway", properties: { Ladestationskennung: "EH123" } }]
};
configuration.devices.consumer_single = {
quantity: 1,
items: [{ name: "Heizstab", integration: { profile: "shelly_plus_1pm", topicPrefix: "shelly/heizstab" }, properties: {} }]
};
configuration.devices.boiler_multi = {
quantity: 1,
items: [{
name: "Boiler",
properties: {},
powerLevels: [{ Stufe: 1, Leistung: 3000, integration: { profile: "shelly_pro_2pm", topicPrefix: "shelly/boiler", channel: 1 } }],
schedule: [{ Uhrzeit: "06:30", Solltemperatur: 55 }],
sensor: { profile: "shelly_addon_temperature", topicPrefix: "shelly/boiler" }
}]
};
const archive = buildSymconPackage(configuration).toString("utf8");
assert.match(archive, /RueckmeldungVariableID/);
assert.match(archive, /Boilerfuehler_PT1/);
assert.match(archive, /IPS_ConnectInstance/);
assert.match(archive, /Energie-Kreisdiagramm/);
});
test("requires a host for network profiles", () => {
const configuration = baseConfiguration();
configuration.gridMeasurement = { profile: "sma_sunspec_modbus" };
assert.throws(
() => normalizeSystemConfiguration(configuration),
error => error.message === "integration_host_missing"
);
});
test("requires an MQTT topic for Shelly profiles", () => {
const configuration = baseConfiguration();
configuration.devices.consumer_single = {
quantity: 1,
items: [{
name: "Heizstab",
integration: { profile: "shelly_plus_1pm" },
properties: {}
}]
};
assert.throws(
() => normalizeSystemConfiguration(configuration),
error => error.message === "integration_topic_missing"
);
});