Tests / test (push) Successful in 1m1s
Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
295 lines
18 KiB
PHP
295 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Belevo\EnelixEMS;
|
|
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
require_once __DIR__ . '/NetzfahrplanV4Einrichtung.php';
|
|
require_once __DIR__ . '/NetzfahrplanV4Regelung.php';
|
|
require_once __DIR__ . '/NetzfahrplanV4Geraeteabruf.php';
|
|
require_once __DIR__ . '/NetzfahrplanV4RueckmeldungEinrichtung.php';
|
|
|
|
/** One learning consent and one local control request. No server-side actuator authority. */
|
|
trait ManagerPrognoseWorkflowTrait
|
|
{
|
|
private function registrierePrognoseWorkflow(): void
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|
|
}
|