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 `=')) { 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 = ' $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, " 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 } ]); }