This commit is contained in:
@@ -19,6 +19,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
private const VERBRAUCHER_MODUL_IDS = [
|
||||
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
|
||||
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
||||
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
||||
];
|
||||
private const MONATSNAMEN = [
|
||||
1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||||
|
||||
@@ -5,8 +5,9 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
|
||||
## Status
|
||||
|
||||
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
|
||||
Verbraucherbasis, der Manager und der mehrstufige Warmwassererwaermer sind
|
||||
implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert.
|
||||
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer und der
|
||||
1-stufige Verbraucher sind implementiert. Die weiteren Module sind als
|
||||
Diskussionsentwürfe dokumentiert.
|
||||
|
||||
## Geplante Module
|
||||
|
||||
@@ -14,7 +15,7 @@ implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert.
|
||||
- Batterie
|
||||
- Wassererwaermer (implementiert)
|
||||
- Pufferspeicher
|
||||
- Verbraucher 1-Stufig
|
||||
- Verbraucher 1-Stufig (implementiert)
|
||||
- Waermepumpe
|
||||
- Ladestation Stand-Alone
|
||||
- Ladestation Gateway
|
||||
@@ -51,6 +52,8 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
|
||||
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
|
||||
- [Manager-Modul](docs/module/Manager/README.md)
|
||||
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
|
||||
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
|
||||
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
|
||||
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
|
||||
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
|
||||
- [Modulübersicht](docs/module/README.md)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Manager und Zeitverhalten",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "PrioritaetPV",
|
||||
"caption": "Prioritaet PV",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "PrioritaetPeak",
|
||||
"caption": "Prioritaet Peak",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Meldeintervall",
|
||||
"caption": "Meldeintervall",
|
||||
"suffix": " s",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "VorgabeTimeout",
|
||||
"caption": "Vorgabe-Timeout",
|
||||
"suffix": " s",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Umschaltabstand",
|
||||
"caption": "Zeit bis zum naechsten Lastwechsel",
|
||||
"suffix": " s",
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Verbraucher",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Nennleistung",
|
||||
"caption": "Nennleistung",
|
||||
"suffix": " W",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "SchaltkontaktVariableID",
|
||||
"caption": "Schaltkontakt",
|
||||
"validVariableTypes": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "SchaltkontaktInvertiert",
|
||||
"caption": "Schaltkontakt invertieren"
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "RueckmeldungVariableID",
|
||||
"caption": "Optionale Schaltzustands-Rueckmeldung",
|
||||
"validVariableTypes": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Mindestlaufzeit",
|
||||
"caption": "Taegliche Mindestlaufzeit",
|
||||
"suffix": " s",
|
||||
"minimum": 0,
|
||||
"maximum": 86400
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Darstellung und Diagnose",
|
||||
"items": [
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EinstellungenInVisu",
|
||||
"caption": "Lokale Einstellungen in der Visualisierung anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "DiagnosevariablenAnzeigen",
|
||||
"caption": "Diagnosevariablen anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "LoggingEin",
|
||||
"caption": "Diagnoseprotokoll aktivieren"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "{15879A4E-D0C2-4495-83DE-46E1E462591E}",
|
||||
"name": "VerbraucherEinStufig",
|
||||
"type": 3,
|
||||
"vendor": "Enelix",
|
||||
"aliases": [
|
||||
"Verbraucher 1-Stufig"
|
||||
],
|
||||
"parentRequirements": [],
|
||||
"childRequirements": [],
|
||||
"implemented": [],
|
||||
"prefix": "ENELIX",
|
||||
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/VerbraucherEinStufig"
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
|
||||
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
|
||||
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
|
||||
require_once __DIR__ . '/../libs/EinStufigRegler.php';
|
||||
|
||||
use Belevo\EnelixEMS\EinStufigRegler;
|
||||
use Belevo\EnelixEMS\Nachrichtenvertrag;
|
||||
use Belevo\EnelixEMS\VerbraucherBasisTrait;
|
||||
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
|
||||
|
||||
class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
|
||||
{
|
||||
use VerbraucherBasisTrait;
|
||||
|
||||
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
|
||||
private const STATUS_AKTIV = 102;
|
||||
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
|
||||
private const STATUS_SCHALTFEHLER = 202;
|
||||
private const VM_UPDATE = 10603;
|
||||
|
||||
/** @var list<string> */
|
||||
private const DIAGNOSE_VARIABLEN = [
|
||||
'Istleistung',
|
||||
'Leistungsquelle',
|
||||
'Sollleistung',
|
||||
'SollwertGueltig',
|
||||
'Verfuegbar',
|
||||
'AenderungMoeglich',
|
||||
'Stoerung',
|
||||
'Stoertext',
|
||||
'Rueckmeldefehler',
|
||||
];
|
||||
|
||||
public function Create(): void
|
||||
{
|
||||
parent::Create();
|
||||
|
||||
$this->registriereVerbraucherBasis();
|
||||
$this->RegisterPropertyInteger('Nennleistung', 0);
|
||||
$this->RegisterPropertyInteger('SchaltkontaktVariableID', 0);
|
||||
$this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false);
|
||||
$this->RegisterPropertyInteger('RueckmeldungVariableID', 0);
|
||||
$this->RegisterPropertyInteger('Mindestlaufzeit', 0);
|
||||
$this->RegisterPropertyInteger('Umschaltabstand', 5);
|
||||
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
||||
|
||||
$this->RegisterVariableBoolean('Schaltzustand', 'Schaltzustand', '~Switch', 100);
|
||||
$this->RegisterVariableInteger('Tageslaufzeit', 'Tageslaufzeit', '', 110);
|
||||
|
||||
$this->RegisterAttributeInteger('RegistrierterSchaltkontakt', 0);
|
||||
$this->RegisterAttributeInteger('RegistrierteRueckmeldung', 0);
|
||||
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
|
||||
$this->RegisterAttributeInteger('LetzteManagerID', 0);
|
||||
$this->RegisterAttributeInteger('GesperrtBis', 0);
|
||||
$this->RegisterAttributeInteger('LaufzeitStandZeit', 0);
|
||||
$this->RegisterAttributeString('LaufzeitTag', '');
|
||||
$this->RegisterAttributeString('Leistungsangebot', '[0]');
|
||||
$this->RegisterAttributeBoolean('Initialisiert', false);
|
||||
$this->RegisterAttributeBoolean('SchaltvorgangLaeuft', false);
|
||||
$this->RegisterAttributeBoolean('SchaltpruefungAusstehend', false);
|
||||
$this->RegisterAttributeBoolean('Schaltziel', false);
|
||||
$this->RegisterAttributeString('Schaltfehler', '');
|
||||
|
||||
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
|
||||
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
|
||||
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
|
||||
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
|
||||
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
|
||||
$this->RegisterAttributeBoolean('ZustandStoerung', false);
|
||||
$this->RegisterAttributeString('ZustandStoertext', '');
|
||||
$this->RegisterAttributeBoolean('ZustandSchaltzustand', false);
|
||||
$this->RegisterAttributeInteger('ZustandTageslaufzeit', 0);
|
||||
$this->RegisterAttributeBoolean('ZustandRueckmeldefehler', false);
|
||||
|
||||
$this->RegisterTimer(
|
||||
'Meldezyklus',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'RueckmeldungVerzoegert',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'Umschaltfreigabe',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Umschaltfreigabe', 0);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'VorgabeTimeout',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', 0);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'Tagesplanung',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Tagesplanung', 0);"
|
||||
);
|
||||
}
|
||||
|
||||
public function ApplyChanges(): void
|
||||
{
|
||||
parent::ApplyChanges();
|
||||
|
||||
$this->aktualisiereVariablen();
|
||||
$this->registriereMeldungen();
|
||||
|
||||
try {
|
||||
$this->pruefeKonfiguration();
|
||||
} catch (Throwable $fehler) {
|
||||
$this->deaktiviereTimer();
|
||||
$this->setzeZustand('Verfuegbar', false);
|
||||
$this->setzeZustand('AenderungMoeglich', false);
|
||||
$this->setzeStoerung($fehler->getMessage(), false);
|
||||
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
|
||||
$this->protokolliere('Konfiguration', $fehler->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
|
||||
$this->initialisiereZustand();
|
||||
$this->aktualisiere(true);
|
||||
}
|
||||
|
||||
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
|
||||
{
|
||||
if ((int) $nachricht !== self::VM_UPDATE || $this->ReadAttributeBoolean('SchaltvorgangLaeuft')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relevanteIDs = array_filter([
|
||||
$this->ReadPropertyInteger('SchaltkontaktVariableID'),
|
||||
$this->ReadPropertyInteger('RueckmeldungVariableID'),
|
||||
]);
|
||||
if (in_array((int) $senderID, $relevanteIDs, true)) {
|
||||
$this->aktualisiere(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function RequestAction($ident, $wert): void
|
||||
{
|
||||
switch ($ident) {
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'Melden':
|
||||
if ((bool) $wert) {
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
|
||||
}
|
||||
$this->aktualisiere(false);
|
||||
$this->sendeVerbraucherdaten();
|
||||
return;
|
||||
|
||||
case 'Umschaltfreigabe':
|
||||
case 'VorgabeTimeout':
|
||||
case 'Tagesplanung':
|
||||
$this->SetTimerInterval((string) $ident, 0);
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'ManagerdatenEmpfangen':
|
||||
if (!is_string($wert)) {
|
||||
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
|
||||
}
|
||||
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($daten)) {
|
||||
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
|
||||
}
|
||||
$this->ManagerdatenEmpfangen($daten);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $daten */
|
||||
public function ManagerdatenEmpfangen(array $daten): void
|
||||
{
|
||||
Nachrichtenvertrag::pruefeManagerdaten($daten);
|
||||
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
|
||||
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
|
||||
}
|
||||
|
||||
$managerID = $daten['Kopf']['AbsenderID'];
|
||||
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
|
||||
throw new InvalidArgumentException('Der Manager hat diesen Verbraucher nicht aktiv zugeordnet.');
|
||||
}
|
||||
|
||||
$this->aktualisiere(false);
|
||||
$sollleistung = $daten['Sollleistung_W'];
|
||||
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
|
||||
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
|
||||
$this->setzeZustand('Sollleistung', $sollleistung);
|
||||
$this->setzeZustand('SollwertGueltig', true);
|
||||
$this->planeVorgabeTimeout(time());
|
||||
$this->aktualisiere(false);
|
||||
$this->planeMeldung();
|
||||
}
|
||||
|
||||
private function aktualisiere(bool $meldungPlanen): void
|
||||
{
|
||||
try {
|
||||
$jetzt = time();
|
||||
$this->aktualisiereTageslaufzeit($jetzt);
|
||||
$vorherigerSchaltzustand = (bool) $this->leseZustand('Schaltzustand');
|
||||
$schaltzustand = $this->leseSchaltzustand();
|
||||
|
||||
if ($schaltzustand !== $vorherigerSchaltzustand) {
|
||||
$this->setzeSchaltzustand($schaltzustand);
|
||||
if ($this->ReadAttributeBoolean('Initialisiert')
|
||||
&& !$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
|
||||
) {
|
||||
$this->starteUmschaltsperre($jetzt);
|
||||
}
|
||||
}
|
||||
|
||||
$this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt);
|
||||
$this->pruefeVorgabeTimeout($jetzt);
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array(
|
||||
(int) $this->leseZustand('Sollleistung'),
|
||||
[0, $this->ReadPropertyInteger('Nennleistung')],
|
||||
true
|
||||
)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
}
|
||||
|
||||
$aktiv = (bool) $this->GetValue('Aktiv');
|
||||
$fehlerfrei = $this->ReadAttributeString('Schaltfehler') === ''
|
||||
&& !(bool) $this->leseZustand('Rueckmeldefehler');
|
||||
$verfuegbar = $aktiv && $fehlerfrei;
|
||||
$mindestlaufzeitErzwungen = $verfuegbar
|
||||
&& $this->mussMindestlaufzeitErzwingen($jetzt);
|
||||
$gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis');
|
||||
|
||||
$ziel = 0;
|
||||
if ($verfuegbar) {
|
||||
if ($mindestlaufzeitErzwungen) {
|
||||
$ziel = $this->ReadPropertyInteger('Nennleistung');
|
||||
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
|
||||
$ziel = (int) $this->leseZustand('Sollleistung');
|
||||
}
|
||||
}
|
||||
|
||||
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
|
||||
if ($ziel !== $istleistung && (!$aktiv || (!$gesperrt && $fehlerfrei))) {
|
||||
$this->schalte($ziel > 0, $jetzt);
|
||||
$schaltzustand = $this->leseSchaltzustand();
|
||||
$this->setzeSchaltzustand($schaltzustand);
|
||||
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
|
||||
$gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis');
|
||||
}
|
||||
|
||||
$this->setzeZustand('Sollleistung', $ziel);
|
||||
$this->setzeZustand('Verfuegbar', $verfuegbar);
|
||||
$aenderungMoeglich = $verfuegbar
|
||||
&& !$mindestlaufzeitErzwungen
|
||||
&& !$gesperrt
|
||||
&& !$this->ReadAttributeBoolean('SchaltpruefungAusstehend');
|
||||
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
|
||||
|
||||
$angebot = EinStufigRegler::leistungsangebot(
|
||||
$this->ReadPropertyInteger('Nennleistung'),
|
||||
$istleistung,
|
||||
$verfuegbar,
|
||||
$aenderungMoeglich,
|
||||
$mindestlaufzeitErzwungen
|
||||
);
|
||||
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
|
||||
$this->aktualisiereStoerung();
|
||||
$this->planeTimer($jetzt);
|
||||
$this->SetStatus(
|
||||
$this->ReadAttributeString('Schaltfehler') === ''
|
||||
&& !(bool) $this->leseZustand('Rueckmeldefehler')
|
||||
? self::STATUS_AKTIV
|
||||
: self::STATUS_SCHALTFEHLER
|
||||
);
|
||||
|
||||
if ($meldungPlanen) {
|
||||
$this->planeMeldung();
|
||||
}
|
||||
} catch (Throwable $fehler) {
|
||||
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
|
||||
$this->setzeZustand('Verfuegbar', false);
|
||||
$this->setzeZustand('AenderungMoeglich', false);
|
||||
$this->setzeStoerung($fehler->getMessage(), false);
|
||||
$this->SetStatus(self::STATUS_SCHALTFEHLER);
|
||||
$this->protokolliere('Aktualisierung', $fehler->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function schalte(bool $einschalten, int $jetzt): void
|
||||
{
|
||||
$kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID');
|
||||
$aktorwert = $einschalten !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert');
|
||||
|
||||
$this->WriteAttributeBoolean('Schaltziel', $einschalten);
|
||||
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', true);
|
||||
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', true);
|
||||
try {
|
||||
RequestAction($kontaktID, $aktorwert);
|
||||
} catch (Throwable $fehler) {
|
||||
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
|
||||
throw new RuntimeException('Schaltkontakt konnte nicht gesetzt werden: ' . $fehler->getMessage());
|
||||
} finally {
|
||||
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', false);
|
||||
}
|
||||
|
||||
$this->starteUmschaltsperre($jetzt);
|
||||
$istzustand = $this->leseSchaltzustand();
|
||||
if ($istzustand === $einschalten) {
|
||||
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
|
||||
$this->setzeZustand('Rueckmeldefehler', false);
|
||||
} elseif ($this->ReadPropertyInteger('RueckmeldungVariableID') <= 0) {
|
||||
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
|
||||
throw new RuntimeException('Schaltkontakt hat den angeforderten Zustand nicht uebernommen.');
|
||||
} elseif ($this->ReadPropertyInteger('Umschaltabstand') === 0) {
|
||||
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
|
||||
$this->setzeZustand('Rueckmeldefehler', true);
|
||||
}
|
||||
|
||||
$this->WriteAttributeString('Schaltfehler', '');
|
||||
$this->protokolliere('Lastwechsel', ['Eingeschaltet' => $einschalten]);
|
||||
}
|
||||
|
||||
private function pruefeAusstehendenSchaltvorgang(bool $schaltzustand, int $jetzt): void
|
||||
{
|
||||
if (!$this->ReadAttributeBoolean('SchaltpruefungAusstehend')) {
|
||||
if ((bool) $this->leseZustand('Rueckmeldefehler')
|
||||
&& $schaltzustand === $this->ReadAttributeBoolean('Schaltziel')
|
||||
) {
|
||||
$this->setzeZustand('Rueckmeldefehler', false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ($schaltzustand === $this->ReadAttributeBoolean('Schaltziel')) {
|
||||
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
|
||||
$this->setzeZustand('Rueckmeldefehler', false);
|
||||
return;
|
||||
}
|
||||
if ($jetzt >= $this->ReadAttributeInteger('GesperrtBis')) {
|
||||
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
|
||||
$this->setzeZustand('Rueckmeldefehler', true);
|
||||
}
|
||||
}
|
||||
|
||||
private function starteUmschaltsperre(int $jetzt): void
|
||||
{
|
||||
$gesperrtBis = $jetzt + $this->ReadPropertyInteger('Umschaltabstand');
|
||||
$this->WriteAttributeInteger('GesperrtBis', $gesperrtBis);
|
||||
}
|
||||
|
||||
private function aktualisiereTageslaufzeit(int $jetzt): void
|
||||
{
|
||||
$werte = EinStufigRegler::aktualisiereTageslaufzeit(
|
||||
$this->ReadAttributeString('LaufzeitTag'),
|
||||
(int) $this->leseZustand('Tageslaufzeit'),
|
||||
$this->ReadAttributeInteger('LaufzeitStandZeit'),
|
||||
$jetzt,
|
||||
(bool) $this->leseZustand('Schaltzustand'),
|
||||
$this->zeitzone()
|
||||
);
|
||||
|
||||
$this->WriteAttributeString('LaufzeitTag', $werte['Tag']);
|
||||
$this->WriteAttributeInteger('LaufzeitStandZeit', $werte['StandZeit']);
|
||||
$this->setzeZustand('Tageslaufzeit', $werte['Sekunden']);
|
||||
}
|
||||
|
||||
private function mussMindestlaufzeitErzwingen(int $jetzt): bool
|
||||
{
|
||||
return EinStufigRegler::mussMindestlaufzeitErzwingen(
|
||||
(int) $this->leseZustand('Tageslaufzeit'),
|
||||
$this->ReadPropertyInteger('Mindestlaufzeit'),
|
||||
EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone())
|
||||
);
|
||||
}
|
||||
|
||||
private function pruefeVorgabeTimeout(int $jetzt): void
|
||||
{
|
||||
if (!(bool) $this->leseZustand('SollwertGueltig')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit');
|
||||
if ($letzteVorgabe <= 0
|
||||
|| $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout')
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function planeTimer(int $jetzt): void
|
||||
{
|
||||
$gesperrtBis = $this->ReadAttributeInteger('GesperrtBis');
|
||||
$this->SetTimerInterval(
|
||||
'Umschaltfreigabe',
|
||||
$gesperrtBis > $jetzt ? max(1, ($gesperrtBis - $jetzt) * 1000) : 0
|
||||
);
|
||||
$this->planeVorgabeTimeout($jetzt);
|
||||
|
||||
$bisTagesende = EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone());
|
||||
$naechstePruefung = EinStufigRegler::sekundenBisNaechsteTagespruefung(
|
||||
(int) $this->leseZustand('Tageslaufzeit'),
|
||||
$this->ReadPropertyInteger('Mindestlaufzeit'),
|
||||
$bisTagesende,
|
||||
(bool) $this->leseZustand('Schaltzustand')
|
||||
);
|
||||
$this->SetTimerInterval('Tagesplanung', $naechstePruefung * 1000);
|
||||
}
|
||||
|
||||
private function planeVorgabeTimeout(int $jetzt): void
|
||||
{
|
||||
if (!(bool) $this->leseZustand('SollwertGueltig')) {
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
return;
|
||||
}
|
||||
|
||||
$ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit')
|
||||
+ $this->ReadPropertyInteger('VorgabeTimeout');
|
||||
$this->SetTimerInterval('VorgabeTimeout', max(1, ($ablauf - $jetzt) * 1000));
|
||||
}
|
||||
|
||||
private function planeMeldung(): void
|
||||
{
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
}
|
||||
|
||||
private function initialisiereZustand(): void
|
||||
{
|
||||
$jetzt = time();
|
||||
$schaltzustand = $this->leseSchaltzustand();
|
||||
|
||||
if (!$this->ReadAttributeBoolean('Initialisiert')) {
|
||||
$this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt));
|
||||
$this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt);
|
||||
$this->setzeZustand('Tageslaufzeit', 0);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->setzeZustand('Rueckmeldefehler', false);
|
||||
$this->WriteAttributeBoolean('Initialisiert', true);
|
||||
}
|
||||
$this->setzeSchaltzustand($schaltzustand);
|
||||
}
|
||||
|
||||
private function setzeSchaltzustand(bool $eingeschaltet): void
|
||||
{
|
||||
$this->setzeZustand('Schaltzustand', $eingeschaltet);
|
||||
$this->setzeZustand('Istleistung', (float) $this->leistungFuerSchaltzustand($eingeschaltet));
|
||||
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
|
||||
}
|
||||
|
||||
private function leseSchaltzustand(): bool
|
||||
{
|
||||
$rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID');
|
||||
if ($rueckmeldungID > 0) {
|
||||
return (bool) GetValue($rueckmeldungID);
|
||||
}
|
||||
|
||||
$kontaktwert = (bool) GetValue($this->ReadPropertyInteger('SchaltkontaktVariableID'));
|
||||
|
||||
return $kontaktwert !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert');
|
||||
}
|
||||
|
||||
private function leistungFuerSchaltzustand(bool $eingeschaltet): int
|
||||
{
|
||||
return $eingeschaltet ? $this->ReadPropertyInteger('Nennleistung') : 0;
|
||||
}
|
||||
|
||||
private function aktualisiereStoerung(): void
|
||||
{
|
||||
$texte = [];
|
||||
if ($this->ReadAttributeString('Schaltfehler') !== '') {
|
||||
$texte[] = $this->ReadAttributeString('Schaltfehler');
|
||||
}
|
||||
if ((bool) $this->leseZustand('Rueckmeldefehler')) {
|
||||
$texte[] = 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.';
|
||||
}
|
||||
$this->setzeStoerung(implode("\n", $texte), $texte === []);
|
||||
}
|
||||
|
||||
private function setzeStoerung(string $text, bool $fehlerfrei): void
|
||||
{
|
||||
$this->setzeZustand('Stoerung', !$fehlerfrei);
|
||||
$this->setzeZustand('Stoertext', $text);
|
||||
}
|
||||
|
||||
private function sendeVerbraucherdaten(): void
|
||||
{
|
||||
foreach ($this->zugeordneteManagerIDs() as $managerID) {
|
||||
$daten = $this->baueVerbraucherdaten($managerID);
|
||||
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
|
||||
try {
|
||||
IPS_RequestAction(
|
||||
$managerID,
|
||||
'VerbraucherdatenEmpfangen',
|
||||
json_encode($daten, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
} catch (Throwable $fehler) {
|
||||
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function baueVerbraucherdaten(int $managerID): array
|
||||
{
|
||||
return [
|
||||
'Kopf' => [
|
||||
'Version' => Nachrichtenvertrag::VERSION,
|
||||
'AbsenderID' => $this->InstanceID,
|
||||
'EmpfaengerID' => $managerID,
|
||||
'Zeitpunkt' => time(),
|
||||
],
|
||||
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
|
||||
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
|
||||
'Leistungswerte_W' => $this->leseLeistungsangebot(),
|
||||
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
|
||||
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
|
||||
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
|
||||
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
|
||||
'Zustand' => [
|
||||
[
|
||||
'Kennung' => 'Sollleistung_W',
|
||||
'Art' => 'Sollwert',
|
||||
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
|
||||
? (int) $this->leseZustand('Sollleistung')
|
||||
: null,
|
||||
'Einheit' => 'W',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Schaltzustand',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (bool) $this->leseZustand('Schaltzustand'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Tageslaufzeit_s',
|
||||
'Art' => 'Istwert',
|
||||
'Wert' => (int) $this->leseZustand('Tageslaufzeit'),
|
||||
'Einheit' => 's',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Rueckmeldefehler',
|
||||
'Art' => 'Stoerung',
|
||||
'Wert' => (bool) $this->leseZustand('Rueckmeldefehler'),
|
||||
'Einheit' => '',
|
||||
'Text' => (bool) $this->leseZustand('Rueckmeldefehler')
|
||||
? 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.'
|
||||
: '',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function zugeordneteManagerIDs(): array
|
||||
{
|
||||
$ergebnis = [];
|
||||
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
|
||||
try {
|
||||
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
|
||||
$property = $automatisch
|
||||
? 'AutomatischeVerbraucherZuordnung'
|
||||
: 'VerbraucherZuordnung';
|
||||
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
|
||||
if ($automatisch && $zuordnung === []) {
|
||||
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
|
||||
}
|
||||
} catch (Throwable $fehler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($zuordnung as $eintrag) {
|
||||
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
|
||||
if ($instanzID === $this->InstanceID) {
|
||||
$ergebnis[] = (int) $managerID;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ergebnis));
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
private function leseManagerZuordnung(int $managerID, string $property): array
|
||||
{
|
||||
$zuordnung = json_decode(
|
||||
(string) IPS_GetProperty($managerID, $property),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
|
||||
return is_array($zuordnung) ? $zuordnung : [];
|
||||
}
|
||||
|
||||
private function pruefeKonfiguration(): void
|
||||
{
|
||||
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
|
||||
if ($this->ReadPropertyInteger($property) < 0) {
|
||||
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
|
||||
}
|
||||
}
|
||||
foreach (['Meldeintervall', 'VorgabeTimeout'] as $property) {
|
||||
if ($this->ReadPropertyInteger($property) <= 0) {
|
||||
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
|
||||
}
|
||||
}
|
||||
if ($this->ReadPropertyInteger('Nennleistung') <= 0) {
|
||||
throw new InvalidArgumentException('Nennleistung muss groesser als 0 sein.');
|
||||
}
|
||||
if ($this->ReadPropertyInteger('Mindestlaufzeit') < 0
|
||||
|| $this->ReadPropertyInteger('Mindestlaufzeit') > 86400
|
||||
) {
|
||||
throw new InvalidArgumentException('Mindestlaufzeit muss zwischen 0 und 86400 Sekunden liegen.');
|
||||
}
|
||||
if ($this->ReadPropertyInteger('Umschaltabstand') < 0) {
|
||||
throw new InvalidArgumentException('Umschaltabstand darf nicht negativ sein.');
|
||||
}
|
||||
|
||||
$kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID');
|
||||
$this->pruefeBooleanVariable($kontaktID, 'Schaltkontakt', true);
|
||||
|
||||
$rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID');
|
||||
if ($rueckmeldungID > 0) {
|
||||
$this->pruefeBooleanVariable($rueckmeldungID, 'Rueckmeldung', false);
|
||||
}
|
||||
}
|
||||
|
||||
private function pruefeBooleanVariable(int $variableID, string $bezeichnung, bool $aktionErforderlich): void
|
||||
{
|
||||
if ($variableID <= 0 || !IPS_VariableExists($variableID)) {
|
||||
throw new InvalidArgumentException($bezeichnung . ' ist nicht eingerichtet.');
|
||||
}
|
||||
$variable = IPS_GetVariable($variableID);
|
||||
if ((int) $variable['VariableType'] !== 0) {
|
||||
throw new InvalidArgumentException($bezeichnung . ' muss eine Booleanvariable sein.');
|
||||
}
|
||||
if ($aktionErforderlich
|
||||
&& (int) $variable['VariableAction'] <= 0
|
||||
&& (int) $variable['VariableCustomAction'] <= 0
|
||||
) {
|
||||
throw new InvalidArgumentException($bezeichnung . ' benoetigt eine Standard- oder benutzerdefinierte Aktion.');
|
||||
}
|
||||
}
|
||||
|
||||
private function registriereMeldungen(): void
|
||||
{
|
||||
$alteIDs = array_unique(array_filter([
|
||||
$this->ReadAttributeInteger('RegistrierterSchaltkontakt'),
|
||||
$this->ReadAttributeInteger('RegistrierteRueckmeldung'),
|
||||
]));
|
||||
$neueIDs = array_unique(array_filter([
|
||||
$this->ReadPropertyInteger('SchaltkontaktVariableID'),
|
||||
$this->ReadPropertyInteger('RueckmeldungVariableID'),
|
||||
]));
|
||||
|
||||
foreach ($alteIDs as $alteID) {
|
||||
if (!in_array($alteID, $neueIDs, true)) {
|
||||
$this->UnregisterMessage($alteID, self::VM_UPDATE);
|
||||
}
|
||||
}
|
||||
foreach ($neueIDs as $neueID) {
|
||||
if (IPS_VariableExists($neueID)) {
|
||||
$this->RegisterMessage($neueID, self::VM_UPDATE);
|
||||
}
|
||||
}
|
||||
|
||||
$this->WriteAttributeInteger(
|
||||
'RegistrierterSchaltkontakt',
|
||||
$this->ReadPropertyInteger('SchaltkontaktVariableID')
|
||||
);
|
||||
$this->WriteAttributeInteger(
|
||||
'RegistrierteRueckmeldung',
|
||||
$this->ReadPropertyInteger('RueckmeldungVariableID')
|
||||
);
|
||||
}
|
||||
|
||||
private function aktualisiereVariablen(): void
|
||||
{
|
||||
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||
if ($ident === 'Leistungsquelle') {
|
||||
continue;
|
||||
}
|
||||
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
||||
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
|
||||
$this->setzeZustand($ident, GetValue($variablenID));
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
|
||||
$this->registriereVerbraucherDiagnose();
|
||||
$this->RegisterVariableBoolean('Rueckmeldefehler', 'Rueckmeldefehler', '~Alert', 200);
|
||||
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||
$wert = $ident === 'Leistungsquelle'
|
||||
? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET
|
||||
: $this->leseZustand($ident);
|
||||
$this->setzeZustand($ident, $wert);
|
||||
}
|
||||
} else {
|
||||
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
||||
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
|
||||
$this->UnregisterVariable($ident);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param bool|int|float|string $wert */
|
||||
private function setzeZustand(string $ident, $wert): void
|
||||
{
|
||||
$booleanAttribute = [
|
||||
'SollwertGueltig' => 'ZustandSollwertGueltig',
|
||||
'Verfuegbar' => 'ZustandVerfuegbar',
|
||||
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
|
||||
'Stoerung' => 'ZustandStoerung',
|
||||
'Schaltzustand' => 'ZustandSchaltzustand',
|
||||
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
|
||||
];
|
||||
$integerAttribute = [
|
||||
'Sollleistung' => 'ZustandSollleistung',
|
||||
'Tageslaufzeit' => 'ZustandTageslaufzeit',
|
||||
];
|
||||
$floatAttribute = [
|
||||
'Istleistung' => 'ZustandIstleistung',
|
||||
];
|
||||
$stringAttribute = [
|
||||
'Stoertext' => 'ZustandStoertext',
|
||||
];
|
||||
|
||||
if (isset($booleanAttribute[$ident])) {
|
||||
$wert = (bool) $wert;
|
||||
$this->WriteAttributeBoolean($booleanAttribute[$ident], $wert);
|
||||
} elseif (isset($integerAttribute[$ident])) {
|
||||
$wert = (int) $wert;
|
||||
$this->WriteAttributeInteger($integerAttribute[$ident], $wert);
|
||||
} elseif (isset($floatAttribute[$ident])) {
|
||||
$wert = (float) $wert;
|
||||
$this->WriteAttributeFloat($floatAttribute[$ident], $wert);
|
||||
} elseif (isset($stringAttribute[$ident])) {
|
||||
$wert = (string) $wert;
|
||||
$this->WriteAttributeString($stringAttribute[$ident], $wert);
|
||||
} elseif ($ident !== 'Leistungsquelle') {
|
||||
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
|
||||
}
|
||||
|
||||
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
||||
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
|
||||
SetValue($variablenID, $wert);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return bool|int|float|string */
|
||||
private function leseZustand(string $ident)
|
||||
{
|
||||
$booleanAttribute = [
|
||||
'SollwertGueltig' => 'ZustandSollwertGueltig',
|
||||
'Verfuegbar' => 'ZustandVerfuegbar',
|
||||
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
|
||||
'Stoerung' => 'ZustandStoerung',
|
||||
'Schaltzustand' => 'ZustandSchaltzustand',
|
||||
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
|
||||
];
|
||||
$integerAttribute = [
|
||||
'Sollleistung' => 'ZustandSollleistung',
|
||||
'Tageslaufzeit' => 'ZustandTageslaufzeit',
|
||||
];
|
||||
$floatAttribute = [
|
||||
'Istleistung' => 'ZustandIstleistung',
|
||||
];
|
||||
$stringAttribute = [
|
||||
'Stoertext' => 'ZustandStoertext',
|
||||
];
|
||||
|
||||
if (isset($booleanAttribute[$ident])) {
|
||||
return $this->ReadAttributeBoolean($booleanAttribute[$ident]);
|
||||
}
|
||||
if (isset($integerAttribute[$ident])) {
|
||||
return $this->ReadAttributeInteger($integerAttribute[$ident]);
|
||||
}
|
||||
if (isset($floatAttribute[$ident])) {
|
||||
return $this->ReadAttributeFloat($floatAttribute[$ident]);
|
||||
}
|
||||
if (isset($stringAttribute[$ident])) {
|
||||
return $this->ReadAttributeString($stringAttribute[$ident]);
|
||||
}
|
||||
|
||||
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function leseLeistungsangebot(): array
|
||||
{
|
||||
$angebot = json_decode(
|
||||
$this->ReadAttributeString('Leistungsangebot'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
if (!is_array($angebot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('intval', $angebot));
|
||||
}
|
||||
|
||||
private function zeitzone(): DateTimeZone
|
||||
{
|
||||
return new DateTimeZone(date_default_timezone_get());
|
||||
}
|
||||
|
||||
private function deaktiviereTimer(): void
|
||||
{
|
||||
foreach ([
|
||||
'Meldezyklus',
|
||||
'RueckmeldungVerzoegert',
|
||||
'Umschaltfreigabe',
|
||||
'VorgabeTimeout',
|
||||
'Tagesplanung',
|
||||
] as $timer) {
|
||||
$this->SetTimerInterval($timer, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param mixed $daten */
|
||||
private function protokolliere(string $bezeichnung, $daten): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('LoggingEin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SendDebug(
|
||||
$bezeichnung,
|
||||
is_string($daten) ? $daten : json_encode($daten, JSON_THROW_ON_ERROR),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# ADR 0002: Verbraucher 1-Stufig arbeitet ereignisbasiert
|
||||
|
||||
## Status
|
||||
|
||||
Akzeptiert.
|
||||
|
||||
## Kontext
|
||||
|
||||
Das Enelix-1-Modul berechnete seinen Zustand in einem festen Intervall und
|
||||
bildete die Lastwechselsperre ueber `Interval`, `IdleCounterMax` und weitere
|
||||
zyklusabhaengige Zaehler ab. Dieses Verhalten passt nicht zur
|
||||
ereignisbasierten Enelix-2-Kommunikation und machte reale Zeitabstaende von
|
||||
mehreren Properties abhaengig.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der neue `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung.
|
||||
Schaltkontakt, Rueckmeldung, Manager-Vorgabe, Freigabe, Vorgabeablauf und
|
||||
Tagesplanung loesen die Regelung direkt aus.
|
||||
|
||||
Als einziger modulspezifischer Wert fuer die Lastwechselbegrenzung wird
|
||||
`Umschaltabstand` in Sekunden registriert. Der Standardwert ist 5 Sekunden.
|
||||
Das Ende der Sperre wird durch einen einmaligen Timer ausgeloest.
|
||||
|
||||
Das gemeinsame `Meldeintervall` bleibt erhalten, weil Vertrag `3.0`
|
||||
zusaetzlich zu Ereignismeldungen eine periodische Vollmeldung fordert. Dieser
|
||||
Timer ist kein Regelzyklus.
|
||||
|
||||
Die Tagesmindestlaufzeit wird in realen Sekunden und lokaler Symcon-Zeitzone
|
||||
gezaehlt. Das Modul erzwingt den Betrieb erst zum spaetestmoeglichen Zeitpunkt,
|
||||
an dem die fehlende Laufzeit vor Mitternacht noch erreicht werden kann.
|
||||
|
||||
## Alternativen
|
||||
|
||||
- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Sperrzeit
|
||||
wieder voneinander abhaengen wuerden.
|
||||
- Getrennte Ein- und Ausschaltzeiten wurden verworfen, weil sie fuer diese
|
||||
Adaption nicht freigegeben sind.
|
||||
- Die feste Nachtphase aus Enelix 1 wurde verworfen, weil sie weder
|
||||
konfigurierbar noch Teil der Enelix-2-Spezifikation ist.
|
||||
|
||||
## Folgen
|
||||
|
||||
- Lastwechsel reagieren ohne Polling auf relevante Ereignisse.
|
||||
- `Interval` und `IdleCounterMax` entfallen.
|
||||
- Der Manager sieht waehrend einer Sperre nur die aktuelle Leistung und
|
||||
`AenderungMoeglich=false`.
|
||||
- Ein optionaler Rueckmeldekontakt muss innerhalb des Umschaltabstands folgen.
|
||||
- Tageslaufzeit und Mindestlaufzeit sind unabhaengig von einer Zyklusdauer.
|
||||
- Das Modul bleibt vollstaendig im Repository Enelix EMS; Enelix Utils erhaelt
|
||||
keine geraetespezifische Logik.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- Eine spaetere sperrbare Variante wird separat spezifiziert.
|
||||
- Unterschiedliche Ein- und Ausschaltabstaende benoetigen eine neue
|
||||
Architekturentscheidung.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Migration von Verbraucher_1_Stufig
|
||||
|
||||
Das Enelix-1-Modul wird nicht in-place aktualisiert. Das neue Modul verwendet
|
||||
eine neue Modul-ID, den Vertrag `3.0` und eine ereignisbasierte Regelung.
|
||||
|
||||
## Eigenschaften
|
||||
|
||||
| Enelix 1 | Enelix 2 | Migration |
|
||||
| --- | --- | --- |
|
||||
| `BoilerLeistung` | `Nennleistung` | Wert in ganzen Watt uebernehmen. |
|
||||
| `Schaltkontakt1` | `SchaltkontaktVariableID` | Boolean-Aktor uebernehmen und Aktion pruefen. |
|
||||
| nicht vorhanden | `SchaltkontaktInvertiert` | Nur bei umgekehrter Aktorlogik aktivieren. |
|
||||
| nicht vorhanden | `RueckmeldungVariableID` | Optional eine echte Boolean-Rueckmeldung zuordnen. |
|
||||
| `Mindesttlaufzeit` | `Mindestlaufzeit` | Alter Wert war in Minuten gedacht; fuer gleiches Verhalten mit 60 multiplizieren und als Sekunden eintragen. |
|
||||
| `Zeit_Zwischen_Zustandswechseln` | `Umschaltabstand` | Alte Minuten nicht uebernehmen. Neuen Sekundenwert bewusst festlegen; Standard ist 5 s. |
|
||||
| `Interval` | entfaellt | Keine zyklische Regelberechnung mehr. |
|
||||
| `IdleCounterMax` | entfaellt | Keine zyklusbasierte Idle-Erkennung mehr. |
|
||||
|
||||
## Variablen und Verhalten
|
||||
|
||||
| Enelix 1 | Enelix 2 |
|
||||
| --- | --- |
|
||||
| `DailyOnTime` | `Tageslaufzeit` in echten Sekunden |
|
||||
| `IstNacht` | entfaellt; Mindestlaufzeit wird am spaetestmoeglichen Start des lokalen Kalendertags erzwungen |
|
||||
| `IsTimerActive` | entfaellt; interne einmalige Timer |
|
||||
| `Aktuelle_Leistung` | gemeinsames `Istleistung` |
|
||||
| `Power` | gemeinsames `Sollleistung` |
|
||||
| `PowerSteps` | Vertragsfeld `Leistungswerte_W` |
|
||||
| `PV_Prio` | Property `PrioritaetPV` |
|
||||
| `Sperre_Prio` | Property `PrioritaetPeak` |
|
||||
| `Is_Peak_Shaving` | entfaellt; Betriebsart liegt im Manager |
|
||||
| `Leistung_Delta` | entfaellt; Leistungsverteilung liegt im Manager |
|
||||
| `Idle`, `IdleCounter` | entfallen |
|
||||
|
||||
Die feste Enelix-1-Nachtzeit von 22:00 bis 07:00 wird nicht uebernommen.
|
||||
Enelix 2 plant nur den Zeitpunkt, ab dem die verbleibende Mindestlaufzeit vor
|
||||
dem lokalen Tagesende zwingend eingeschaltet werden muss.
|
||||
|
||||
## Inbetriebnahme
|
||||
|
||||
1. Bestehende Instanzkonfiguration und aktuelle Aktorlogik dokumentieren.
|
||||
2. Neue Instanz **Verbraucher 1-Stufig** anlegen.
|
||||
3. Leistung, Aktor, Invertierung und optional Rueckmeldung konfigurieren.
|
||||
4. Mindestlaufzeit von Minuten in Sekunden umrechnen.
|
||||
5. `Umschaltabstand` neu in Sekunden festlegen; nicht den alten Minutenwert
|
||||
kopieren.
|
||||
6. Neue Instanz im Enelix-2-Manager aktiv zuordnen.
|
||||
7. Bei ausgeschalteter lokaler EMS-Freigabe Ein/Aus und Rueckmeldung
|
||||
beaufsichtigt pruefen.
|
||||
8. Alte Instanz erst deaktivieren, danach `Aktiv` an der neuen Instanz
|
||||
einschalten.
|
||||
9. Mindestens einen Timeout, einen verhinderten Lastwechsel und einen
|
||||
Tageswechsel kontrollieren.
|
||||
|
||||
## Rueckkehr
|
||||
|
||||
Solange die alte Instanz nicht geloescht wurde, kann zurueckgekehrt werden,
|
||||
indem die neue Instanz im Manager deaktiviert und ausgeschaltet wird. Danach
|
||||
darf die alte Instanz wieder aktiviert werden. Beide Module duerfen nie
|
||||
gleichzeitig denselben Aktor steuern.
|
||||
@@ -1,7 +1,7 @@
|
||||
# EMS-Module und Modulentwürfe
|
||||
|
||||
> Manager und Warmwassererwaermer sind als installierbare IP-Symcon-Module
|
||||
> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
|
||||
> Manager, Warmwassererwaermer und Verbraucher 1-Stufig sind als installierbare
|
||||
> IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
|
||||
|
||||
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
|
||||
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
|
||||
@@ -13,7 +13,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
|
||||
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers |
|
||||
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
|
||||
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
|
||||
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher |
|
||||
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
|
||||
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
|
||||
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung |
|
||||
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
|
||||
|
||||
@@ -1,36 +1,156 @@
|
||||
# Verbraucher 1-Stufig
|
||||
|
||||
> Status: Diskussionsentwurf.
|
||||
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`.
|
||||
|
||||
Schaltet eine elektrische Last. Das Leistungsangebot ist üblicherweise
|
||||
`[0, Nennleistung]`.
|
||||
Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand
|
||||
meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waerend der
|
||||
Umschaltsperre und bei lokal erzwungener Mindestlaufzeit wird nur der aktuell
|
||||
zulaessige Leistungswert angeboten.
|
||||
|
||||
## Zusätzliche Variablen
|
||||
## Architekturentscheidung
|
||||
|
||||
Die Regelung ist ereignisbasiert. Neuberechnungen werden ausgeloest durch:
|
||||
|
||||
- eine neue Manager-Vorgabe,
|
||||
- eine Aenderung an Schaltkontakt oder optionaler Rueckmeldung,
|
||||
- das Ein- oder Ausschalten der lokalen EMS-Freigabe,
|
||||
- das Ende des Umschaltabstands,
|
||||
- das Ablaufen einer Manager-Vorgabe,
|
||||
- den naechsten relevanten Zeitpunkt der Tageslaufzeitplanung.
|
||||
|
||||
Es gibt keinen Regelzyklus, kein `Interval` und keinen `IdleCounterMax`.
|
||||
`Meldeintervall` bleibt als vertraglich geforderte periodische Vollmeldung
|
||||
bestehen und steuert keine Regelberechnung.
|
||||
|
||||
## Properties
|
||||
|
||||
Zu den sechs gemeinsamen Verbraucher-Properties aus
|
||||
[`Schnittstelle.md`](../../Schnittstelle.md) kommen sieben
|
||||
Modul-Properties hinzu.
|
||||
|
||||
| Ident | Typ | Standard | Beschreibung |
|
||||
| --- | --- | ---: | --- |
|
||||
| `Nennleistung` | Integer | `0` | Positive elektrische Leistung in W. |
|
||||
| `SchaltkontaktVariableID` | Integer | `0` | Boolean-Aktor mit Standard- oder benutzerdefinierter Aktion. |
|
||||
| `SchaltkontaktInvertiert` | Boolean | `false` | Kehrt die Ein-/Aus-Semantik des Aktors um. |
|
||||
| `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet eingeschaltet. |
|
||||
| `Mindestlaufzeit` | Integer | `0` | Geforderte Laufzeit pro lokalem Kalendertag in Sekunden, maximal 86400. |
|
||||
| `Umschaltabstand` | Integer | `5` | Sekunden bis zum naechsten erlaubten Lastwechsel. |
|
||||
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame Diagnosevariablen und `Rueckmeldefehler` an. |
|
||||
|
||||
`EinstellungenInVisu` ist Teil der gemeinsamen Verbraucherbasis. Das Modul
|
||||
besitzt derzeit keine zusaetzlichen bedienbaren Einstellvariablen und wertet
|
||||
diese Property deshalb noch nicht weiter aus.
|
||||
|
||||
## Variablen
|
||||
|
||||
Immer sichtbar:
|
||||
|
||||
| Ident | Typ / Zugriff | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `Schaltzustand` | Boolean / Anzeige | Rückgemeldeter oder berechneter Kontaktzustand. |
|
||||
| `Tageslaufzeit` | Integer / Anzeige | Laufzeit seit lokalem Tagesbeginn in Sekunden; persistent mit Tageskennung. |
|
||||
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Startwert `false`. |
|
||||
| `Schaltzustand` | Boolean / Anzeige | Rueckgemeldeter oder aus dem Aktor berechneter Zustand. |
|
||||
| `Tageslaufzeit` | Integer / Anzeige | Laufzeit des lokalen Kalendertags in Sekunden. |
|
||||
|
||||
## Zusätzliche Properties
|
||||
Mit `DiagnosevariablenAnzeigen` werden diese Variablen zusaetzlich angelegt:
|
||||
|
||||
| Ident | Typ | Standard / Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `Nennleistung` | Float | `0*` W; grösser 0. |
|
||||
| `SchaltkontaktVariableID` | Integer | `0`; erforderlicher Boolean-Aktor mit Action. |
|
||||
| `SchaltkontaktInvertiert` | Boolean | `false`; Ein-/Aus-Semantik des Aktors. |
|
||||
| `RueckmeldungVariableID` | Integer | `0`; optionale echte Rückmeldung. |
|
||||
| `Mindestlaufzeit` | Integer | `0` s. |
|
||||
| `Umschaltabstand` | Integer | `60` s; Mindestabstand zwischen Änderungen. |
|
||||
- `Istleistung`
|
||||
- `Leistungsquelle`
|
||||
- `Sollleistung`
|
||||
- `SollwertGueltig`
|
||||
- `Verfuegbar`
|
||||
- `AenderungMoeglich`
|
||||
- `Stoerung`
|
||||
- `Stoertext`
|
||||
- `Rueckmeldefehler`
|
||||
|
||||
Der frühere Ident `BoilerLeistung` wird durch `Nennleistung` ersetzt.
|
||||
Die Regellogik arbeitet mit persistenten Attributen und ist nicht von der
|
||||
Sichtbarkeit der Diagnosevariablen abhaengig.
|
||||
|
||||
## Zustand
|
||||
## Zeit- und Schaltverhalten
|
||||
|
||||
`Schaltzustand` als Status, `Tageslaufzeit_s` als Istwert und
|
||||
`Rueckmeldefehler` als Störung.
|
||||
Nach jedem Lastwechsel wird `AenderungMoeglich=false` gemeldet und das
|
||||
Leistungsangebot auf den aktuellen Wert eingeschraenkt. Nach
|
||||
`Umschaltabstand` Sekunden gibt ein einmaliger Timer den naechsten
|
||||
Lastwechsel frei. Der Standardwert betraegt 5 Sekunden.
|
||||
|
||||
## Offene Punkte
|
||||
Die Tageslaufzeit wird mit der in IP-Symcon eingestellten lokalen Zeitzone
|
||||
gefuehrt. Sommer- und Winterzeit sowie der Wechsel um Mitternacht werden
|
||||
beruecksichtigt. Ist die konfigurierte Mindestlaufzeit noch nicht erreicht,
|
||||
wird der Verbraucher erst zum spaetestmoeglichen Zeitpunkt lokal
|
||||
eingeschaltet, an dem die Restlaufzeit bis Mitternacht noch erfuellt werden
|
||||
kann. In dieser Phase meldet er `AenderungMoeglich=false`.
|
||||
|
||||
- Sperrbare Variante später integrieren und vollständig überarbeiten.
|
||||
- Unterschiedliche Ein- und Ausschaltzeiten erst nach Freigabe ergänzen.
|
||||
Bei Aktivierung sehr kurz vor Tagesende kann eine unmoeglich hohe Restlaufzeit
|
||||
naturgemaess nicht mehr vollstaendig nachgeholt werden. Der Verbraucher wird
|
||||
dann sofort eingeschaltet und die tatsaechliche Laufzeit erfasst.
|
||||
|
||||
## Rueckmeldung und Fehler
|
||||
|
||||
Ohne separate Rueckmeldung wird der Schaltzustand aus der Aktorvariable
|
||||
berechnet. Mit `RueckmeldungVariableID` wartet das Modul bis zum Ende des
|
||||
Umschaltabstands auf den angeforderten Zustand. Liegt er dann nicht an, werden
|
||||
`Rueckmeldefehler=true`, `Stoerung=true` und `Verfuegbar=false` gemeldet.
|
||||
|
||||
Die Istleistung ist immer berechnet: `0` oder `Nennleistung`. Deshalb wird
|
||||
`Leistungsquelle=1` gemeldet, auch wenn der Boolean-Schaltzustand ueber eine
|
||||
physische Rueckmeldung erfasst wird.
|
||||
|
||||
## Managerkommunikation
|
||||
|
||||
Das Modul implementiert
|
||||
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
|
||||
ausschliesslich den Vertrag `3.0`. Der Verbraucher besitzt keine
|
||||
Manager-ID-Property. Er akzeptiert nur Manager, in deren manueller oder
|
||||
automatischer Verbraucherzuordnung seine Instanz aktiv eingetragen ist.
|
||||
|
||||
Rueckmeldungen erfolgen bei relevanten Ereignissen, kurz verzoegert nach einer
|
||||
Manager-Vorgabe und zusaetzlich alle `Meldeintervall` Sekunden. Eine Vorgabe
|
||||
wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig.
|
||||
|
||||
## Installation und Inbetriebnahme
|
||||
|
||||
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
|
||||
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
|
||||
aktualisieren.
|
||||
2. Unter **Instanz hinzufuegen** nach **Verbraucher 1-Stufig** suchen und eine
|
||||
Instanz anlegen.
|
||||
3. `Nennleistung` in ganzen Watt eintragen.
|
||||
4. Als `Schaltkontakt` eine Booleanvariable mit funktionsfaehiger Aktion
|
||||
auswaehlen. Bei umgekehrter Aktorlogik `SchaltkontaktInvertiert`
|
||||
aktivieren.
|
||||
5. Optional eine separate Boolean-Rueckmeldung auswaehlen. Dort muss
|
||||
`true` dem physisch eingeschalteten Verbraucher entsprechen.
|
||||
6. Prioritaeten, `Mindestlaufzeit`, `Umschaltabstand`,
|
||||
`Meldeintervall` und `VorgabeTimeout` einstellen und die Konfiguration
|
||||
uebernehmen.
|
||||
7. Die neue Instanz im Manager manuell aktiv zuordnen oder bei automatischer
|
||||
Suche in der gefundenen Liste aktivieren.
|
||||
8. Fuer die Erstpruefung `DiagnosevariablenAnzeigen` und bei Bedarf
|
||||
`LoggingEin` einschalten.
|
||||
9. Unter Aufsicht `Aktiv` einschalten und ueber den Manager je eine Ein- und
|
||||
Aus-Vorgabe pruefen.
|
||||
|
||||
## Abnahmecheckliste
|
||||
|
||||
- `Aktiv=false` schaltet den Ausgang aus.
|
||||
- `Aktiv=true` und eine Manager-Vorgabe `Nennleistung` schalten den Ausgang
|
||||
ein.
|
||||
- Direkt nach einem Wechsel ist `AenderungMoeglich=false`; nach 5 Sekunden
|
||||
wird es mit Standardkonfiguration wieder `true`.
|
||||
- Eine Gegenanforderung waehrend der Sperre wird vom Leistungsangebot
|
||||
ausgeschlossen.
|
||||
- Die optionale Rueckmeldung folgt dem Ausgang innerhalb des
|
||||
Umschaltabstands; andernfalls erscheint eine Stoerung.
|
||||
- `Tageslaufzeit` steigt nur im eingeschalteten Zustand und beginnt am
|
||||
lokalen Tageswechsel wieder bei 0.
|
||||
- Nach Ablauf von `VorgabeTimeout` wird ohne neue Manager-Vorgabe
|
||||
ausgeschaltet, sobald der Umschaltabstand dies zulaesst.
|
||||
- Nach Neustart oder erneuter Konfiguration wird der reale Schaltzustand
|
||||
eingelesen; alte Manager-Vorgaben werden nicht ungeprueft fortgesetzt.
|
||||
|
||||
## Migration
|
||||
|
||||
Die Zuordnung der Enelix-1-Felder, Einheiten und bewusst entfallenen
|
||||
Kommunikationsvariablen steht in
|
||||
[`docs/migration/Verbraucher-1-Stufig.md`](../../migration/Verbraucher-1-Stufig.md).
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EinStufigRegler
|
||||
{
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function leistungsangebot(
|
||||
int $nennleistung,
|
||||
int $istleistung,
|
||||
bool $verfuegbar,
|
||||
bool $aenderungMoeglich,
|
||||
bool $mindestlaufzeitErzwungen
|
||||
): array {
|
||||
if ($nennleistung <= 0) {
|
||||
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
|
||||
}
|
||||
if (!in_array($istleistung, [0, $nennleistung], true)) {
|
||||
throw new InvalidArgumentException('Die Istleistung muss 0 oder der Nennleistung entsprechen.');
|
||||
}
|
||||
|
||||
if (!$verfuegbar || !$aenderungMoeglich || $mindestlaufzeitErzwungen) {
|
||||
return [$istleistung];
|
||||
}
|
||||
|
||||
return [0, $nennleistung];
|
||||
}
|
||||
|
||||
public static function mussMindestlaufzeitErzwingen(
|
||||
int $tageslaufzeit,
|
||||
int $mindestlaufzeit,
|
||||
int $sekundenBisTagesende
|
||||
): bool {
|
||||
self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende);
|
||||
|
||||
$restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit);
|
||||
|
||||
return $restlaufzeit > 0 && $sekundenBisTagesende <= $restlaufzeit;
|
||||
}
|
||||
|
||||
public static function sekundenBisNaechsteTagespruefung(
|
||||
int $tageslaufzeit,
|
||||
int $mindestlaufzeit,
|
||||
int $sekundenBisTagesende,
|
||||
bool $eingeschaltet
|
||||
): int {
|
||||
self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende);
|
||||
|
||||
$restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit);
|
||||
if ($restlaufzeit === 0) {
|
||||
return max(1, $sekundenBisTagesende);
|
||||
}
|
||||
if ($eingeschaltet) {
|
||||
return max(1, min($restlaufzeit, $sekundenBisTagesende));
|
||||
}
|
||||
|
||||
return max(1, $sekundenBisTagesende - $restlaufzeit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{Tag: string, Sekunden: int, StandZeit: int}
|
||||
*/
|
||||
public static function aktualisiereTageslaufzeit(
|
||||
string $gespeicherterTag,
|
||||
int $gespeicherteSekunden,
|
||||
int $standZeit,
|
||||
int $jetzt,
|
||||
bool $warEingeschaltet,
|
||||
DateTimeZone $zeitzone
|
||||
): array {
|
||||
if ($gespeicherteSekunden < 0 || $standZeit < 0 || $jetzt < 0) {
|
||||
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
|
||||
}
|
||||
|
||||
$jetztDatum = self::datum($jetzt, $zeitzone);
|
||||
$aktuellerTag = $jetztDatum->format('Y-m-d');
|
||||
if ($standZeit <= 0 || $standZeit > $jetzt) {
|
||||
return [
|
||||
'Tag' => $aktuellerTag,
|
||||
'Sekunden' => $gespeicherterTag === $aktuellerTag ? $gespeicherteSekunden : 0,
|
||||
'StandZeit' => $jetzt,
|
||||
];
|
||||
}
|
||||
|
||||
$standDatum = self::datum($standZeit, $zeitzone);
|
||||
$sekunden = $gespeicherteSekunden;
|
||||
if ($gespeicherterTag !== $aktuellerTag || $standDatum->format('Y-m-d') !== $aktuellerTag) {
|
||||
$sekunden = 0;
|
||||
if ($warEingeschaltet) {
|
||||
$tagesbeginn = $jetztDatum->setTime(0, 0)->getTimestamp();
|
||||
$sekunden = max(0, $jetzt - max($standZeit, $tagesbeginn));
|
||||
}
|
||||
} elseif ($warEingeschaltet) {
|
||||
$sekunden += $jetzt - $standZeit;
|
||||
}
|
||||
|
||||
$tagesbeginn = $jetztDatum->setTime(0, 0);
|
||||
$tagesdauer = $tagesbeginn->modify('+1 day')->getTimestamp() - $tagesbeginn->getTimestamp();
|
||||
|
||||
return [
|
||||
'Tag' => $aktuellerTag,
|
||||
'Sekunden' => min($sekunden, $tagesdauer),
|
||||
'StandZeit' => $jetzt,
|
||||
];
|
||||
}
|
||||
|
||||
public static function sekundenBisTagesende(int $jetzt, DateTimeZone $zeitzone): int
|
||||
{
|
||||
if ($jetzt < 0) {
|
||||
throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.');
|
||||
}
|
||||
|
||||
$datum = self::datum($jetzt, $zeitzone);
|
||||
$tagesende = $datum->setTime(0, 0)->modify('+1 day');
|
||||
|
||||
return max(1, $tagesende->getTimestamp() - $jetzt);
|
||||
}
|
||||
|
||||
private static function pruefeLaufzeiten(
|
||||
int $tageslaufzeit,
|
||||
int $mindestlaufzeit,
|
||||
int $sekundenBisTagesende
|
||||
): void {
|
||||
if ($tageslaufzeit < 0 || $mindestlaufzeit < 0 || $mindestlaufzeit > 86400) {
|
||||
throw new InvalidArgumentException('Laufzeiten sind ausserhalb des gueltigen Bereichs.');
|
||||
}
|
||||
if ($sekundenBisTagesende <= 0) {
|
||||
throw new InvalidArgumentException('Sekunden bis Tagesende muss groesser als 0 sein.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function datum(int $zeitpunkt, DateTimeZone $zeitzone): DateTimeImmutable
|
||||
{
|
||||
return (new DateTimeImmutable('@' . $zeitpunkt))->setTimezone($zeitzone);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ final class DokumentationsstrukturTest extends TestCase
|
||||
return [
|
||||
['Batterie'],
|
||||
['Pufferspeicher'],
|
||||
['Verbraucher-1-Stufig'],
|
||||
['Waermepumpe'],
|
||||
['Ladestation-Stand-Alone'],
|
||||
['Ladestation-Gateway'],
|
||||
@@ -49,6 +48,15 @@ final class DokumentationsstrukturTest extends TestCase
|
||||
self::assertStringContainsString('Verteilalgorithmus', $inhalt);
|
||||
}
|
||||
|
||||
public function testVerbraucherEinStufigDokumentiertDenImplementiertenStand(): void
|
||||
{
|
||||
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Verbraucher-1-Stufig/README.md');
|
||||
self::assertNotFalse($inhalt);
|
||||
self::assertStringContainsString('Status: implementiert', $inhalt);
|
||||
self::assertStringContainsString('Umschaltabstand', $inhalt);
|
||||
self::assertStringContainsString('Installation und Inbetriebnahme', $inhalt);
|
||||
}
|
||||
|
||||
public function testWarmwassererwaermerDokumentiertDenImplementiertenStand(): void
|
||||
{
|
||||
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Wassererwaermer/README.md');
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\EinStufigRegler;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EinStufigReglerTest extends TestCase
|
||||
{
|
||||
public function testFreiesLeistungsangebotEnthaeltAusUndEin(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[0, 3500],
|
||||
EinStufigRegler::leistungsangebot(3500, 0, true, true, false)
|
||||
);
|
||||
}
|
||||
|
||||
public function testGesperrtesOderErzwungenesAngebotEnthaeltNurIstleistung(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[3500],
|
||||
EinStufigRegler::leistungsangebot(3500, 3500, true, false, false)
|
||||
);
|
||||
self::assertSame(
|
||||
[0],
|
||||
EinStufigRegler::leistungsangebot(3500, 0, true, true, true)
|
||||
);
|
||||
self::assertSame(
|
||||
[3500],
|
||||
EinStufigRegler::leistungsangebot(3500, 3500, false, true, false)
|
||||
);
|
||||
}
|
||||
|
||||
public function testUngueltigeIstleistungWirdAbgewiesen(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
EinStufigRegler::leistungsangebot(3500, 1200, true, true, false);
|
||||
}
|
||||
|
||||
public function testMindestlaufzeitWirdErstZumSpaetestmoeglichenStartErzwungen(): void
|
||||
{
|
||||
self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 7200));
|
||||
self::assertTrue(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 3600));
|
||||
self::assertTrue(EinStufigRegler::mussMindestlaufzeitErzwingen(1800, 3600, 1200));
|
||||
self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(3600, 3600, 1));
|
||||
}
|
||||
|
||||
public function testNaechsteTagespruefungIstEreignisbasiert(): void
|
||||
{
|
||||
self::assertSame(
|
||||
3600,
|
||||
EinStufigRegler::sekundenBisNaechsteTagespruefung(0, 3600, 7200, false)
|
||||
);
|
||||
self::assertSame(
|
||||
1800,
|
||||
EinStufigRegler::sekundenBisNaechsteTagespruefung(1800, 3600, 7200, true)
|
||||
);
|
||||
self::assertSame(
|
||||
7200,
|
||||
EinStufigRegler::sekundenBisNaechsteTagespruefung(3600, 3600, 7200, false)
|
||||
);
|
||||
}
|
||||
|
||||
public function testLaufzeitWirdInnerhalbEinesTagesFortgeschrieben(): void
|
||||
{
|
||||
$zone = new DateTimeZone('Europe/Zurich');
|
||||
$von = (new DateTimeImmutable('2026-09-17 10:00:00', $zone))->getTimestamp();
|
||||
$bis = (new DateTimeImmutable('2026-09-17 10:02:30', $zone))->getTimestamp();
|
||||
|
||||
self::assertSame(
|
||||
['Tag' => '2026-09-17', 'Sekunden' => 250, 'StandZeit' => $bis],
|
||||
EinStufigRegler::aktualisiereTageslaufzeit(
|
||||
'2026-09-17',
|
||||
100,
|
||||
$von,
|
||||
$bis,
|
||||
true,
|
||||
$zone
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testLaufzeitWirdAnLokalerTagesgrenzeGetrennt(): void
|
||||
{
|
||||
$zone = new DateTimeZone('Europe/Zurich');
|
||||
$von = (new DateTimeImmutable('2026-09-16 23:59:50', $zone))->getTimestamp();
|
||||
$bis = (new DateTimeImmutable('2026-09-17 00:00:10', $zone))->getTimestamp();
|
||||
|
||||
self::assertSame(
|
||||
['Tag' => '2026-09-17', 'Sekunden' => 10, 'StandZeit' => $bis],
|
||||
EinStufigRegler::aktualisiereTageslaufzeit(
|
||||
'2026-09-16',
|
||||
1000,
|
||||
$von,
|
||||
$bis,
|
||||
true,
|
||||
$zone
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testTagesendeBeruecksichtigtSommerzeit(): void
|
||||
{
|
||||
$zone = new DateTimeZone('Europe/Zurich');
|
||||
$zeitpunkt = (new DateTimeImmutable('2026-03-29 00:00:00', $zone))->getTimestamp();
|
||||
|
||||
self::assertSame(23 * 3600, EinStufigRegler::sekundenBisTagesende($zeitpunkt, $zone));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class VerbraucherEinStufigModulstrukturTest extends TestCase
|
||||
{
|
||||
private const MODULPFAD = __DIR__ . '/../VerbraucherEinStufig/';
|
||||
|
||||
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
|
||||
{
|
||||
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
|
||||
self::assertFileExists(self::MODULPFAD . $datei);
|
||||
}
|
||||
|
||||
$metadaten = json_decode(
|
||||
(string) file_get_contents(self::MODULPFAD . 'module.json'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
self::assertSame('VerbraucherEinStufig', $metadaten['name']);
|
||||
self::assertSame('Enelix', $metadaten['vendor']);
|
||||
self::assertSame(['Verbraucher 1-Stufig'], $metadaten['aliases']);
|
||||
self::assertSame(3, $metadaten['type']);
|
||||
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
|
||||
}
|
||||
|
||||
public function testPropertiesSindRegistriertUndImFormularVorhanden(): void
|
||||
{
|
||||
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
preg_match_all(
|
||||
'/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/',
|
||||
$basis . $modul,
|
||||
$treffer
|
||||
);
|
||||
$properties = array_values(array_unique($treffer[1]));
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
'PrioritaetPV',
|
||||
'PrioritaetPeak',
|
||||
'Meldeintervall',
|
||||
'VorgabeTimeout',
|
||||
'EinstellungenInVisu',
|
||||
'LoggingEin',
|
||||
'Nennleistung',
|
||||
'SchaltkontaktVariableID',
|
||||
'SchaltkontaktInvertiert',
|
||||
'RueckmeldungVariableID',
|
||||
'Mindestlaufzeit',
|
||||
'Umschaltabstand',
|
||||
'DiagnosevariablenAnzeigen',
|
||||
],
|
||||
$properties
|
||||
);
|
||||
|
||||
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
|
||||
foreach ($properties as $property) {
|
||||
self::assertStringContainsString('"name": "' . $property . '"', $formular, $property);
|
||||
}
|
||||
}
|
||||
|
||||
public function testNurUmschaltabstandErsetztAlteZyklusProperties(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyInteger('Umschaltabstand', 5)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringNotContainsString("RegisterPropertyInteger('Interval'", $modul);
|
||||
self::assertStringNotContainsString("RegisterPropertyInteger('IdleCounterMax'", $modul);
|
||||
self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul);
|
||||
}
|
||||
|
||||
public function testRegelungReagiertAufEreignisseUndEinmalTimer(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString('public function MessageSink', $modul);
|
||||
self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul);
|
||||
foreach (['Umschaltfreigabe', 'VorgabeTimeout', 'Tagesplanung'] as $timer) {
|
||||
self::assertStringContainsString("'$timer'", $modul);
|
||||
}
|
||||
self::assertStringContainsString(
|
||||
"SetTimerInterval('Meldezyklus', \$this->ReadPropertyInteger('Meldeintervall') * 1000)",
|
||||
$modul
|
||||
);
|
||||
}
|
||||
|
||||
public function testModulVerwendetAusschliesslichDenVertrag30(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
|
||||
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
|
||||
self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $modul);
|
||||
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
|
||||
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
|
||||
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
|
||||
}
|
||||
|
||||
public function testFachvariablenUndRueckmeldestoerungSindEnthalten(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableBoolean('Schaltzustand'",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableInteger('Tageslaufzeit'",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString("'Kennung' => 'Rueckmeldefehler'", $modul);
|
||||
self::assertStringContainsString("'Kennung' => 'Tageslaufzeit_s'", $modul);
|
||||
}
|
||||
|
||||
public function testDiagnosevariablenSindOptional(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $modul);
|
||||
self::assertStringContainsString('registriereVerbraucherDiagnose', $modul);
|
||||
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user