--- a/stripe.mjs +++ b/stripe.mjs @@ -22,7 +22,7 @@ cost_meter_aux: { sku: "ENX-BILLING-AUX", name: "Wärme- oder Wasserzähler Abrechnung", unitAmount: 300, setupAmount: 0, entitlement: true } }); -const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi"]; +const moduleKeys = ["battery", "buffer_storage", "ev_charger", "consumer_single", "boiler_multi", "heat_pump"]; const forecastKeys = ["forecast_pv", "forecast_load", "grid_schedule"]; const licenseKeys = [...moduleKeys, ...forecastKeys]; --- a/portal-domain.mjs +++ b/portal-domain.mjs @@ -5,7 +5,8 @@ buffer_storage: { name: "Pufferspeicher", catalogKey: "buffer_storage", moduleId: "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}" }, ev_charger: { name: "EV-Ladestation", catalogKey: "ev_charger", moduleId: "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}", gatewayModuleId: "{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}" }, consumer_single: { name: "Verbraucher einstufig", catalogKey: "consumer_single", moduleId: "{15879A4E-D0C2-4495-83DE-46E1E462591E}" }, - boiler_multi: { name: "Boiler mehrstufig", catalogKey: "boiler_multi", moduleId: "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" } + boiler_multi: { name: "Boiler mehrstufig", catalogKey: "boiler_multi", moduleId: "{B7C54AF4-AD7D-4FE4-B75D-203693906251}" }, + heat_pump: { name: "Wärmepumpe", catalogKey: "heat_pump", moduleId: "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}" } }); export const integrationProfiles = Object.freeze({ @@ -210,6 +211,23 @@ f("Mindesteinschaltdauer", "Mindest-Einschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 }), f("Mindestausschaltdauer", "Mindest-Ausschaltdauer (s)", "integer", 5, "Verbraucher", { min: 0 }) ], + heat_pump: [...commonConsumerFields, + f("Kontaktart", "Kontaktart", "select", 0, "Kontakte", { min: 0, max: 1, options: [{ value: 0, label: "Sperre und Erhöhung" }, { value: 1, label: "SG Ready" }] }), + f("Kontakt1VariableID", "Kontakt 1: Sperre / SG Ready 1 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }), + f("Kontakt2VariableID", "Kontakt 2: Erhöhung / SG Ready 2 (Variable-ID)", "integer", 0, "Kontakte", { min: 0 }), + f("Kontakt1Invertiert", "Kontakt 1 invertieren", "boolean", false, "Kontakte"), + f("Kontakt2Invertiert", "Kontakt 2 invertieren", "boolean", false, "Kontakte"), + f("Nennleistung", "Elektrische Nennleistung (W)", "integer", 0, "Wärmepumpe", { min: 0 }), + f("Rueckmeldungsart", "Rückmeldungsart", "select", 0, "Betriebsrückmeldung", { min: 0, max: 1, options: [{ value: 0, label: "Gemessene Leistung" }, { value: 1, label: "Boolean-Betriebsstatus" }] }), + f("IstleistungVariableID", "Leistung oder Betriebsstatus (Variable-ID)", "integer", 0, "Betriebsrückmeldung", { min: 0 }), + f("Laufschwelle", "Laufschwelle bei Leistungsmessung (W)", "number", 100, "Betriebsrückmeldung", { min: 0 }), + f("Anlaufwartezeit", "Anlaufwartezeit (s)", "integer", 30, "Schutzzeiten", { min: 0 }), + f("Wiederholsperre", "Wiederholsperre nach erfolglosem Anlauf (s)", "integer", 300, "Schutzzeiten", { min: 0 }), + f("Mindestlaufzeit", "Mindestlaufzeit nach bestätigtem Start (s)", "integer", 1200, "Schutzzeiten", { min: 0 }), + f("Mindestsperrzeit", "Mindest-Sperrzeit (s)", "integer", 1200, "Schutzzeiten", { min: 0 }), + f("MaximaleSperrzeit", "Maximale Sperrzeit am Stück (s; 0 = aus)", "integer", 7200, "Schutzzeiten", { min: 0 }), + f("MaximaleSperrzeit24h", "Maximale Sperrzeit in 24 Stunden (s; 0 = aus)", "integer", 21600, "Schutzzeiten", { min: 0, max: 86400 }) + ], boiler_multi: [...commonConsumerFields, f("LeistungsStufen", "Leistungsstufen (JSON)", "json", "[]", "Warmwassererwärmer"), f("Boilerfuehler_PT1", "Boilerfühler Variable-ID", "integer", 0, "Warmwassererwärmer", { min: 0 }), @@ -253,6 +271,12 @@ "Nennleistung", "SchaltkontaktInvertiert", "Mindestlaufzeit", "Mindesteinschaltdauer", "Mindestausschaltdauer" ]), + heat_pump: new Set([ + "Kontaktart", "Kontakt1VariableID", "Kontakt2VariableID", "Kontakt1Invertiert", + "Kontakt2Invertiert", "Nennleistung", "Rueckmeldungsart", "IstleistungVariableID", + "Laufschwelle", "Anlaufwartezeit", "Wiederholsperre", "Mindestlaufzeit", + "Mindestsperrzeit", "MaximaleSperrzeit", "MaximaleSperrzeit24h" + ]), boiler_multi: new Set([ "Boilervolumen", "Hysterese", "LegionellenfunktionAktiv", "LegionellenMinimalintervallTage", "LegionellenMaximalintervallTage" @@ -455,8 +479,8 @@ while (rawItems.length < requested) rawItems.push(legacyItem(key, source, rawItems.length)); const items = rawItems.map((item, index) => { const variant = key === "ev_charger" && item.variant === "gateway" ? "gateway" : "standalone"; - const kind = key === "battery" ? "storage" : (["buffer_storage", "consumer_single", "boiler_multi"].includes(key) ? "actuator" : null); - const integration = kind ? normalizeIntegration(item.integration, kind) : { + const kind = key === "battery" ? "storage" : (["buffer_storage", "consumer_single", "boiler_multi", "heat_pump"].includes(key) ? "actuator" : null); + const integration = kind ? normalizeIntegration(key === "heat_pump" ? { profile: "manual" } : item.integration, kind) : { profile: text(item.integration?.profile || "device_api", 60), host: text(item.integration?.host, 120), port: finite(item.integration?.port, 0, 0, 65535, true), @@ -588,6 +612,14 @@ return result; } +export function licenseCoverage(requested = {}, owned = {}) { + const coverage = { ...owned }; + const spareConsumers = Math.max(0, Number(owned.consumer_single || 0) - Number(requested.consumer_single || 0)); + coverage.heat_pump = Number(owned.heat_pump || 0) + Math.min(spareConsumers, + Math.max(0, Number(requested.heat_pump || 0) - Number(owned.heat_pump || 0))); + return coverage; +} + export function recommendLicenses(configuration) { const modules = {}; for (const key of Object.keys(systemDeviceTypes)) modules[key] = configuration.devices[key].quantity; --- a/server.mjs +++ b/server.mjs @@ -19,6 +19,7 @@ createActivationCode, normalizeActivationCode, normalizeSystemConfiguration, + licenseCoverage, publicWizardCatalog, recommendLicenses } from "./portal-domain.mjs"; @@ -2126,8 +2127,9 @@ const setup = ownedSetupState(plantId); const modules = {}; const setupModules = {}; + const coverage = licenseCoverage(recommendation.modules, owned.quantities); for (const [key, requested] of Object.entries(recommendation.modules || {})) { - modules[key] = Math.max(0, Number(requested || 0) - Number(owned.quantities[key] || 0)); + modules[key] = Math.max(0, Number(requested || 0) - Number(coverage[key] || 0)); setupModules[key] = Math.max(0, Number(requested || 0) - Number(setup.quantities[key] || 0)); } let managerAction = null; --- a/symcon-package.mjs +++ b/symcon-package.mjs @@ -207,6 +207,33 @@ if (!in_array($moduleID, IPS_GetModuleList(), true)) throw new RuntimeException('Erforderliches Modul fehlt: ' . $moduleID); } +// Validate heat-pump bindings before creating or changing any system objects. +foreach (($config['devices']['heat_pump']['items'] ?? []) as $item) { + if (!in_array($moduleIDs['heat_pump'], IPS_GetModuleList(), true)) { + throw new RuntimeException('Das Enelix-Waermepumpenmodul fehlt.'); + } + $properties = $item['properties']; + $contact1 = (int) $properties['Kontakt1VariableID']; + $contact2 = (int) $properties['Kontakt2VariableID']; + if ($contact1 === $contact2 || (int) $properties['Nennleistung'] <= 0) { + throw new RuntimeException('Waermepumpe: zwei unterschiedliche Kontakte und Nennleistung erforderlich.'); + } + foreach ([$contact1, $contact2] as $contact) { + if (!IPS_VariableExists($contact)) throw new RuntimeException('Waermepumpe: Kontaktvariable fehlt.'); + $variable = IPS_GetVariable($contact); + if ($variable['VariableType'] !== 0 || max($variable['VariableAction'], $variable['VariableCustomAction']) <= 0) { + throw new RuntimeException('Waermepumpe: Kontakte muessen schaltbare Boolean-Variablen sein.'); + } + } + $feedback = (int) $properties['IstleistungVariableID']; + if (!IPS_VariableExists($feedback)) throw new RuntimeException('Waermepumpe: Betriebsrueckmeldung fehlt.'); + $feedbackType = IPS_GetVariable($feedback)['VariableType']; + $allowedTypes = (int) $properties['Rueckmeldungsart'] === 1 ? [0] : [1, 2]; + if (!in_array($feedbackType, $allowedTypes, true)) { + throw new RuntimeException('Waermepumpe: Typ der Betriebsrueckmeldung passt nicht zur Rueckmeldungsart.'); + } +} + $root = enxCategory(0, 'ENELIX_System', (string) $config['name']); $visualisation = enxCategory($root, 'Visualisierung', 'Visualisierung'); $energyView = enxCategory($visualisation, 'Energie', 'Energie und Bilanz'); --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@