feat(portal): prepare heat pump licensing and header navigation
Tests / test (push) Failing after 49s
Tests / test (push) Failing after 49s
This commit is contained in:
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user