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

This commit is contained in:
dh
2026-10-06 14:35:17 +00:00
parent faa905ab09
commit 5687596799
14 changed files with 9125 additions and 0 deletions
@@ -0,0 +1,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 }
]);
}