RegisterPropertyBoolean('NetzfahrplanRegelungAktiv', false); $this->RegisterAttributeString('PrognoseV4Einrichtung', '{}'); $this->RegisterAttributeString('PrognoseV4Serverstatus', '{}'); $this->RegisterAttributeString('PrognoseV4Regelstatus', '{}'); $this->RegisterAttributeString('PrognoseV4Einrichtungsfehler', ''); $this->RegisterAttributeString('PrognoseV4Verbindungsfehler', ''); $this->RegisterAttributeInteger('PrognoseV4EingerichtetAm', 0); $this->RegisterTimer('PrognoseWorkflow', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseWorkflow', true);"); } private function prognoseLernenAktiv(): bool { return $this->ReadPropertyBoolean('PrognoseAktiv') && $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN); } private function prognoseV4Datensatz(): string { $custom = $this->ReadPropertyString('NetzfahrplanV4Datensatz'); if ($custom !== '') return $custom; return $this->leseJsonAttribut('PrognoseV4Einrichtung')['datasetId'] ?? ''; } private function prognoseV4Messkonfiguration(): string { $custom = $this->ReadPropertyString('NetzfahrplanV4Messkonfiguration'); if (!in_array(trim($custom), ['', '{}'], true)) return $custom; return json_encode($this->leseJsonAttribut('PrognoseV4Einrichtung')['capture'] ?? new \stdClass(), JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); } /** Passive configuration lookup for the battery sampler; no device reads or writes. */ public function GetV4RueckmeldungKonfiguration(int $batteryID): string { try { if (!$this->prognoseLernenAktiv() || $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') !== '' || !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true)) return '{}'; $setup = $this->leseJsonAttribut('PrognoseV4Einrichtung'); $capture = $setup['capture'] ?? []; foreach (($capture['accounting']['sources'] ?? []) as $source) { if (!IPS_VariableExists($source['variableId'])) return '{}'; $o = IPS_GetObject($source['variableId']); if ($o['ParentID'] !== $source['parentId'] || $o['ObjectIdent'] !== $source['ident'] || !IPS_InstanceExists($o['ParentID'])) return '{}'; $module = IPS_GetInstance($o['ParentID'])['ModuleInfo']['ModuleID']; if (!in_array($module, [NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true)) return '{}'; if ($source['role'] === 'grid' && ($source['variableId'] !== $this->ReadPropertyInteger('NetzleistungVariableID') || $source['factorToW'] != $this->ReadPropertyFloat('Netzleistungsfaktor'))) return '{}'; } $controllers = []; foreach ($this->aktiveVerbraucherIDs() as $id) { if (!IPS_InstanceExists($id) || IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') continue; $controller = ['InstanzID' => $id]; foreach (['IstleistungVariableID', 'LadezustandVariableID', 'NetzleistungVariableID', 'MesswertMaxAlter'] as $key) { $controller[$key] = IPS_GetProperty($id, $key); } $controllers[] = $controller; } $config = NetzfahrplanV4RueckmeldungEinrichtung::erstellen($capture, $this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $batteryID, json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR), $controllers); return json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); } catch (Throwable) { return '{}'; } } private function vorbereitePrognoseWorkflow(): void { $this->WriteAttributeString('PrognoseV4Regelstatus', '{}'); $this->WriteAttributeString('PrognoseV4Serverstatus', '{}'); $this->WriteAttributeInteger('PrognoseV4EingerichtetAm', 0); $this->WriteAttributeString('PrognoseV4Einrichtungsfehler', ''); $this->WriteAttributeString('PrognoseV4Verbindungsfehler', ''); $this->SetTimerInterval('PrognoseWorkflow', 30000); if (!$this->ReadPropertyBoolean('PrognoseAktiv')) return; try { $custom = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true); if ($custom) { // Specialist mappings and their server-side dataset are already commissioned. if ($this->ReadPropertyString('NetzfahrplanV4Datensatz') === '') { throw new RuntimeException('Individuelle Messzuordnung benoetigt einen eigenen Datensatz.'); } $this->v4DatenKonfiguration(); return; } if ($this->ReadPropertyString('NetzfahrplanV4Datensatz') !== '') { throw new RuntimeException('Bestehender Datensatz ohne Messzuordnung; Einrichtung vervollstaendigen.'); } $manager = [ 'NetzleistungVariableID' => $this->ReadPropertyInteger('NetzleistungVariableID'), 'Netzleistungsfaktor' => $this->ReadPropertyFloat('Netzleistungsfaktor'), 'MesswertMaxAlter' => $this->ReadPropertyInteger('MesswertMaxAlter'), 'SDLAktiv' => $this->ReadPropertyBoolean('SDLAktiv'), 'SDLLeistungVariableID' => $this->ReadPropertyInteger('SDLLeistungVariableID'), ]; $reader = function (int $id): array { if (!IPS_VariableExists($id)) throw new RuntimeException('Messquelle fehlt.'); $o = IPS_GetObject($id);$v = IPS_GetVariable($id); if (($o['ObjectType'] ?? null) !== 2 || !IPS_InstanceExists($o['ParentID'])) { throw new RuntimeException('Direkte Geraetemessung erforderlich.'); } $module = IPS_GetInstance($o['ParentID'])['ModuleInfo']['ModuleID']; // A different library alone does not prove physical metering: gateways // can publish virtual allocations from a completely separate repository. $physical = in_array($module, [NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true); return ['parentID' => $o['ParentID'], 'ident' => $o['ObjectIdent'], 'type' => $v['VariableType'], 'virtual' => !$physical]; }; $configuration = NetzfahrplanV4Einrichtung::erstellen($this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $manager, json_decode($this->ReadPropertyString('AnlagenWechselrichter'), true, 64, JSON_THROW_ON_ERROR), json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR), $reader); $previous = $this->leseJsonAttribut('PrognoseV4Einrichtung'); if ($previous !== [] && ($previous['mappingSha256'] ?? '') !== $configuration['mappingSha256']) { throw new RuntimeException('Messzuordnung geaendert; bestehende Lerndaten bleiben erhalten. Datensatzmigration erforderlich.'); } $this->WriteAttributeString('PrognoseV4Einrichtung', json_encode($configuration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); } catch (Throwable $e) { $this->WriteAttributeString('PrognoseV4Einrichtungsfehler', substr($e->getMessage(), 0, 300)); } } /** Network traffic belongs to this slow timer, never to the actuator loop. */ private function aktualisierePrognoseWorkflow(): void { $lock = 'ENELIX.V4.Workflow.' . $this->InstanceID; if (!IPS_SemaphoreEnter($lock, 0)) return; try { if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) return; $setup = $this->leseJsonAttribut('PrognoseV4Einrichtung'); $custom = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true); if (!$custom && $this->prognoseLernenAktiv() && $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') === '') { if (isset($setup['dataset']) && time() - $this->ReadAttributeInteger('PrognoseV4EingerichtetAm') > 300) { $answer = $this->prognoseWorkflowHttp('/setup', ['version' => 1, 'installationId' => $this->ReadAttributeString('LizenzInstallationID'), 'dataset' => $setup['dataset'], 'learningEnabled' => true]); if (($answer['datasetId'] ?? null) !== $setup['datasetId'] || ($answer['selectedDatasetId'] ?? null) !== $setup['datasetId']) { throw new RuntimeException('Server verwendet einen anderen Datensatz; vorhandene Auswahl bleibt erhalten.'); } $this->WriteAttributeInteger('PrognoseV4EingerichtetAm', time()); } } $s = $this->prognoseWorkflowZustand(); $this->prognoseWorkflowHttp('/workflow', ['version' => 1, 'installationId' => $this->ReadAttributeString('LizenzInstallationID'), 'observedAt' => gmdate('Y-m-d\TH:i:s\Z'), 'learningEnabled' => $this->prognoseLernenAktiv(), 'controlRequested' => $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv'), 'controlActive' => $s['controlActive'], 'reason' => substr($s['reason'], 0, 300)]); $this->WriteAttributeString('PrognoseV4Verbindungsfehler', ''); } catch (Throwable $e) { $this->WriteAttributeString('PrognoseV4Verbindungsfehler', substr($e->getMessage(), 0, 300)); } finally { IPS_SemaphoreLeave($lock); $this->aktualisierePrognoseBedienung(); } } private function prognoseWorkflowHttp(string $suffix, array $body): array { if (!in_array($suffix, ['/setup', '/workflow'], true)) throw new RuntimeException('Unzulaessige Workflowaktion.'); $id = $this->ReadAttributeString('LizenzInstallationID'); $token = $this->ReadAttributeString('PrognoseInstallationsToken'); if (!preg_match('/^[0-9a-f-]{36}$/D', $id) || !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) { throw new RuntimeException('Lizenz mit dieser Installation verbinden.'); } $h = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix); if ($h === false) throw new RuntimeException('Prognoseverbindung nicht verfuegbar.'); $response = ''; try { curl_setopt_array($h, [CURLOPT_POST => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token], CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION), CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int { if (strlen($response) + strlen($chunk) > 65536) return 0; $response .= $chunk;return strlen($chunk); }]); $ok = curl_exec($h);$status = (int) curl_getinfo($h, CURLINFO_HTTP_CODE); if ($ok === false || $status !== 200) throw new RuntimeException('Prognoseeinrichtung HTTP ' . $status . '.'); $answer = json_decode($response, true, 32, JSON_THROW_ON_ERROR); if (!is_array($answer)) throw new RuntimeException('Ungueltige Workflowantwort.'); return $answer; } finally { curl_close($h); } } private function prognoseWorkflowZustand(): array { $requested = $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv'); $base = ['state' => 'disabled', 'reason' => 'Datenerfassung ausgeschaltet.', 'ready' => false, 'controlRequested' => $requested, 'controlActive' => false]; if (!$this->ReadPropertyBoolean('PrognoseAktiv')) return $base; $base['state'] = 'collecting';$base['reason'] = 'Messdaten werden gesammelt.'; if (!$this->prognoseLernenAktiv()) { return array_replace($base, ['state' => 'interrupted', 'reason' => 'Netzfahrplanlizenz mit der Installation verbinden.']); } $error = $this->ReadAttributeString('PrognoseV4Einrichtungsfehler'); if ($error === '') $error = $this->ReadAttributeString('PrognoseV4Verbindungsfehler'); if ($error !== '') return array_replace($base, ['state' => 'interrupted', 'reason' => $error]); $server = $this->leseJsonAttribut('PrognoseV4Serverstatus'); $at = $server['receivedAt'] ?? 0; if (!is_int($at) || $at > time() || time() - $at > 90) { return array_replace($base, ['state' => 'interrupted', 'reason' => 'Aktuellen Serverstatus abwarten.']); } $workflow = $server['workflow'] ?? []; if (($workflow['planningReady'] ?? false) !== true) { return array_replace($base, ['state' => $requested ? 'interrupted' : (($workflow['state'] ?? '') === 'learning' ? 'learning' : 'collecting'), 'reason' => (string) ($workflow['reason'] ?? 'Modell und gueltiger Plan werden vorbereitet.')]); } try { $this->prognoseV4Regelbereitschaft(); $base = array_replace($base, ['ready' => true, 'state' => 'ready', 'reason' => 'Bereit. Netzfahrplan kann eingeschaltet werden.']); } catch (Throwable $e) { return array_replace($base, ['state' => 'interrupted', 'reason' => substr($e->getMessage(), 0, 300)]); } $local = $this->leseJsonAttribut('PrognoseV4Regelstatus'); $active = $requested && ($local['controlActive'] ?? false) === true && ($local['at'] ?? 0) <= time() && time() - ($local['at'] ?? 0) <= 10; if ($requested) { $base['state'] = $active ? 'active' : 'interrupted'; $base['reason'] = $active ? 'Manager korrigiert die Batterie auf das V4-Netzziel.' : ($local['reason'] ?? 'Naechsten gueltigen Regelzyklus abwarten.'); $base['controlActive'] = $active; } return $base; } private function prognoseV4Regelbereitschaft(): array { if (!$this->prognoseLernenAktiv() || !$this->GetValue('Aktiv') || IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102) throw new RuntimeException('Manager nicht regelbereit.'); if ($this->v4ManagerTestSession() !== [] || $this->GetValue('NetzfahrplanV4Aktivtest')) { throw new RuntimeException('Separater Inbetriebnahmetest aktiv; keine zweite Batteriesteuerung.'); } [$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte(); if (count($operation['batteries']) !== 1) throw new RuntimeException('V4 regelt derzeit genau eine Batterie.'); $asset = $operation['batteries'][0]['id']; $feedback = $operation['localFeedback'][$asset] ?? []; $battery = $feedback['batteryInstanceId'] ?? 0; if (!is_int($battery) || !IPS_InstanceExists($battery) || !in_array($battery, $this->aktiveVerbraucherIDs(), true)) throw new RuntimeException('Bestaetigte Batterierueckmeldung fehlt.'); $binding = ['batteryInstanceId' => $battery, 'assetId' => $asset, 'watchdogEvidence' => IPS_GetProperty($battery, 'NetzfahrplanV4GeraeteWatchdogNachweis')]; NetzfahrplanV4Regelung::pruefen($this->leseJsonAttribut('NetzfahrplanV4Empfang'), $this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $operation, $actual, $binding, time()); return [$operation, $actual, $binding]; } /** Changes only a bound battery allocation. The ordinary battery module keeps device ownership. */ private function korrigierePrognoseBatterie(array &$allocation, array $consumers, bool $synchronized): ?array { $this->WriteAttributeString('PrognoseV4Regelstatus', '{}'); if (!$this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')) return null; try { $s = $this->prognoseWorkflowZustand(); if (!$s['ready']) throw new RuntimeException($s['reason']); [$operation, $actual, $binding] = $this->prognoseV4Regelbereitschaft(); $result = NetzfahrplanV4Regelung::korrigieren($this->leseJsonAttribut('NetzfahrplanV4Empfang'), $this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $operation, $actual, $allocation, $consumers, $synchronized, $binding, time()); $allocation = $result['allocation']; return $result['report']; } catch (Throwable $e) { $this->WriteAttributeString('PrognoseV4Regelstatus', json_encode(['at' => time(), 'controlActive' => false, 'reason' => substr($e->getMessage(), 0, 300)], JSON_THROW_ON_ERROR)); return null; } } }