diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 705c2b3..ecd1504 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -21,6 +21,9 @@ jobs:
- name: Abhaengigkeiten installieren
run: composer install --no-interaction --no-progress --prefer-dist
- - name: Pruefungen ausfuehren
+ - name: Verbraucher 1-Stufig separat pruefen
+ run: composer check:verbraucher-einstufig
+
+ - name: Gesamttest ausfuehren
run: composer check
diff --git a/README.md b/README.md
index 8aed29d..d5957f2 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,13 @@ composer install
composer check
```
+Der Gesamttest schliesst alle Modulsuiten ein. Der einstufige Verbraucher kann
+zusaetzlich unabhaengig ausgefuehrt werden:
+
+```bash
+composer check:verbraucher-einstufig
+```
+
Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt. Die CI-Pruefung muss vor der Uebernahme in einen Freigabebranch erfolgreich sein.
| Git-Branch | IP-Symcon-Kanal |
diff --git a/VerbraucherEinStufig/form.json b/VerbraucherEinStufig/form.json
index a6a0d4a..a6160bd 100644
--- a/VerbraucherEinStufig/form.json
+++ b/VerbraucherEinStufig/form.json
@@ -33,8 +33,15 @@
},
{
"type": "NumberSpinner",
- "name": "Umschaltabstand",
- "caption": "Zeit bis zum naechsten Lastwechsel",
+ "name": "Mindesteinschaltdauer",
+ "caption": "Mindest-Einschaltdauer",
+ "suffix": " s",
+ "minimum": 0
+ },
+ {
+ "type": "NumberSpinner",
+ "name": "Mindestausschaltdauer",
+ "caption": "Mindest-Ausschaltdauer",
"suffix": " s",
"minimum": 0
}
diff --git a/VerbraucherEinStufig/module.php b/VerbraucherEinStufig/module.php
index 1858320..258ffd2 100644
--- a/VerbraucherEinStufig/module.php
+++ b/VerbraucherEinStufig/module.php
@@ -33,6 +33,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
'Stoerung',
'Stoertext',
'Rueckmeldefehler',
+ 'SchaltbefehlAusstehend',
+ 'RestMindestzeit',
];
public function Create(): void
@@ -45,7 +47,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false);
$this->RegisterPropertyInteger('RueckmeldungVariableID', 0);
$this->RegisterPropertyInteger('Mindestlaufzeit', 0);
- $this->RegisterPropertyInteger('Umschaltabstand', 5);
+ $this->RegisterPropertyInteger('Mindesteinschaltdauer', 5);
+ $this->RegisterPropertyInteger('Mindestausschaltdauer', 5);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableBoolean('Schaltzustand', 'Schaltzustand', '~Switch', 100);
@@ -55,7 +58,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->RegisterAttributeInteger('RegistrierteRueckmeldung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
- $this->RegisterAttributeInteger('GesperrtBis', 0);
+ $this->RegisterAttributeInteger('BestaetigtSeit', 0);
+ $this->RegisterAttributeInteger('FreigabeAb', 0);
$this->RegisterAttributeInteger('LaufzeitStandZeit', 0);
$this->RegisterAttributeString('LaufzeitTag', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
@@ -75,6 +79,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->RegisterAttributeBoolean('ZustandSchaltzustand', false);
$this->RegisterAttributeInteger('ZustandTageslaufzeit', 0);
$this->RegisterAttributeBoolean('ZustandRueckmeldefehler', false);
+ $this->RegisterAttributeBoolean('ZustandSchaltbefehlAusstehend', false);
+ $this->RegisterAttributeInteger('ZustandRestMindestzeit', 0);
$this->RegisterTimer(
'Meldezyklus',
@@ -86,10 +92,12 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
+ // Bestehende Instanzen behalten den alten Timer deaktiviert.
+ $this->RegisterTimer('Umschaltfreigabe', 0, '');
$this->RegisterTimer(
- 'Umschaltfreigabe',
+ 'MindestzeitAbgelaufen',
0,
- "IPS_RequestAction(\$_IPS['TARGET'], 'Umschaltfreigabe', 0);"
+ "IPS_RequestAction(\$_IPS['TARGET'], 'MindestzeitAbgelaufen', 0);"
);
$this->RegisterTimer(
'VorgabeTimeout',
@@ -162,7 +170,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->sendeVerbraucherdaten();
return;
- case 'Umschaltfreigabe':
+ case 'MindestzeitAbgelaufen':
case 'VorgabeTimeout':
case 'Tagesplanung':
$this->SetTimerInterval((string) $ident, 0);
@@ -223,14 +231,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$schaltzustand = $this->leseSchaltzustand();
if ($schaltzustand !== $vorherigerSchaltzustand) {
- $this->setzeSchaltzustand($schaltzustand);
- if ($this->ReadAttributeBoolean('Initialisiert')
- && !$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
- ) {
- $this->starteUmschaltsperre($jetzt);
- }
+ $this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
}
-
$this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt);
$this->pruefeVorgabeTimeout($jetzt);
if ((bool) $this->leseZustand('SollwertGueltig')
@@ -244,12 +246,23 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
}
$aktiv = (bool) $this->GetValue('Aktiv');
+ $wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
+ $rueckmeldefehler = $this->ReadPropertyInteger('RueckmeldungVariableID') > 0
+ && !$wartetAufRueckmeldung
+ && $schaltzustand !== $this->leseAktorSchaltzustand();
+ $this->setzeZustand('Rueckmeldefehler', $rueckmeldefehler);
+
$fehlerfrei = $this->ReadAttributeString('Schaltfehler') === ''
- && !(bool) $this->leseZustand('Rueckmeldefehler');
+ && !$rueckmeldefehler;
$verfuegbar = $aktiv && $fehlerfrei;
$mindestlaufzeitErzwungen = $verfuegbar
&& $this->mussMindestlaufzeitErzwingen($jetzt);
- $gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis');
+ $restMindestzeit = $wartetAufRueckmeldung
+ ? 0
+ : EinStufigRegler::restMindestzeit(
+ $this->ReadAttributeInteger('FreigabeAb'),
+ $jetzt
+ );
$ziel = 0;
if ($verfuegbar) {
@@ -261,20 +274,37 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
}
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
- if ($ziel !== $istleistung && (!$aktiv || (!$gesperrt && $fehlerfrei))) {
- $this->schalte($ziel > 0, $jetzt);
+ $zielzustand = $ziel > 0;
+ $befehlNoetig = $ziel !== $istleistung
+ || ($wartetAufRueckmeldung
+ && $zielzustand !== $this->ReadAttributeBoolean('Schaltziel'));
+ // Die lokale Deaktivierung bleibt der einzige bewusste Mindestzeit-Override.
+ $sicheresAusschalten = !$zielzustand && !$aktiv;
+ if ($befehlNoetig
+ && ($sicheresAusschalten
+ || ($restMindestzeit === 0 && !$wartetAufRueckmeldung && $fehlerfrei))
+ ) {
+ $this->schalte($zielzustand, $jetzt);
$schaltzustand = $this->leseSchaltzustand();
- $this->setzeSchaltzustand($schaltzustand);
+ $this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt);
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
- $gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis');
+ $wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
+ $restMindestzeit = $wartetAufRueckmeldung
+ ? 0
+ : EinStufigRegler::restMindestzeit(
+ $this->ReadAttributeInteger('FreigabeAb'),
+ $jetzt
+ );
}
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
+ $this->setzeZustand('SchaltbefehlAusstehend', $wartetAufRueckmeldung);
+ $this->setzeZustand('RestMindestzeit', $restMindestzeit);
$aenderungMoeglich = $verfuegbar
&& !$mindestlaufzeitErzwungen
- && !$gesperrt
- && !$this->ReadAttributeBoolean('SchaltpruefungAusstehend');
+ && $restMindestzeit === 0
+ && !$wartetAufRueckmeldung;
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$angebot = EinStufigRegler::leistungsangebot(
@@ -314,58 +344,77 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->WriteAttributeBoolean('Schaltziel', $einschalten);
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', true);
+ $this->setzeZustand('SchaltbefehlAusstehend', true);
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', true);
try {
RequestAction($kontaktID, $aktorwert);
} catch (Throwable $fehler) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
+ $this->setzeZustand('SchaltbefehlAusstehend', 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);
+ $this->bestaetigeZustandswechsel($istzustand, $jetzt);
} elseif ($this->ReadPropertyInteger('RueckmeldungVariableID') <= 0) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
+ $this->setzeZustand('SchaltbefehlAusstehend', false);
throw new RuntimeException('Schaltkontakt hat den angeforderten Zustand nicht uebernommen.');
- } elseif ($this->ReadPropertyInteger('Umschaltabstand') === 0) {
- $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
- $this->setzeZustand('Rueckmeldefehler', true);
+ } else {
+ // Die physische Rueckmeldung bestimmt Zustand und Beginn der Mindestzeit.
+ $this->setzeZustand('Rueckmeldefehler', false);
}
$this->WriteAttributeString('Schaltfehler', '');
- $this->protokolliere('Lastwechsel', ['Eingeschaltet' => $einschalten]);
+ $this->protokolliere('Schaltbefehl', ['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);
+ if ($this->ReadAttributeBoolean('SchaltpruefungAusstehend')
+ && $schaltzustand === $this->ReadAttributeBoolean('Schaltziel')
+ ) {
+ $this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
}
+
+ $this->setzeZustand(
+ 'SchaltbefehlAusstehend',
+ $this->ReadAttributeBoolean('SchaltpruefungAusstehend')
+ );
}
- private function starteUmschaltsperre(int $jetzt): void
+ private function bestaetigeZustandswechsel(bool $schaltzustand, int $jetzt): void
{
- $gesperrtBis = $jetzt + $this->ReadPropertyInteger('Umschaltabstand');
- $this->WriteAttributeInteger('GesperrtBis', $gesperrtBis);
+ $vorherigerZustand = (bool) $this->leseZustand('Schaltzustand');
+ $zustandGeaendert = $schaltzustand !== $vorherigerZustand
+ || $this->ReadAttributeInteger('BestaetigtSeit') <= 0;
+
+ $this->setzeSchaltzustand($schaltzustand);
+ if ($zustandGeaendert) {
+ $this->WriteAttributeInteger('BestaetigtSeit', $jetzt);
+ $this->WriteAttributeInteger(
+ 'FreigabeAb',
+ EinStufigRegler::freigabezeitpunkt(
+ $schaltzustand,
+ $jetzt,
+ $this->ReadPropertyInteger('Mindesteinschaltdauer'),
+ $this->ReadPropertyInteger('Mindestausschaltdauer')
+ )
+ );
+ }
+
+ if ($schaltzustand === $this->ReadAttributeBoolean('Schaltziel')) {
+ $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
+ }
+ $this->setzeZustand(
+ 'SchaltbefehlAusstehend',
+ $this->ReadAttributeBoolean('SchaltpruefungAusstehend')
+ );
+ $this->setzeZustand('Rueckmeldefehler', false);
+ $this->protokolliere('Schaltzustand bestaetigt', ['Eingeschaltet' => $schaltzustand]);
}
private function aktualisiereTageslaufzeit(int $jetzt): void
@@ -410,10 +459,14 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
private function planeTimer(int $jetzt): void
{
- $gesperrtBis = $this->ReadAttributeInteger('GesperrtBis');
+ $this->SetTimerInterval('Umschaltfreigabe', 0);
+ $freigabeAb = $this->ReadAttributeInteger('FreigabeAb');
+ $wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$this->SetTimerInterval(
- 'Umschaltfreigabe',
- $gesperrtBis > $jetzt ? max(1, ($gesperrtBis - $jetzt) * 1000) : 0
+ 'MindestzeitAbgelaufen',
+ !$wartetAufRueckmeldung && $freigabeAb > $jetzt
+ ? max(1, ($freigabeAb - $jetzt) * 1000)
+ : 0
);
$this->planeVorgabeTimeout($jetzt);
@@ -457,7 +510,19 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->setzeZustand('Rueckmeldefehler', false);
$this->WriteAttributeBoolean('Initialisiert', true);
}
- $this->setzeSchaltzustand($schaltzustand);
+
+ if ($this->ReadAttributeInteger('BestaetigtSeit') <= 0) {
+ // Migration bestehender Instanzen ohne bestaetigten Zeitbezug.
+ $this->WriteAttributeBoolean('Schaltziel', $schaltzustand);
+ $this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
+ $this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
+ } else {
+ $this->setzeSchaltzustand($schaltzustand);
+ $this->setzeZustand(
+ 'SchaltbefehlAusstehend',
+ $this->ReadAttributeBoolean('SchaltpruefungAusstehend')
+ );
+ }
}
private function setzeSchaltzustand(bool $eingeschaltet): void
@@ -474,6 +539,11 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
return (bool) GetValue($rueckmeldungID);
}
+ return $this->leseAktorSchaltzustand();
+ }
+
+ private function leseAktorSchaltzustand(): bool
+ {
$kontaktwert = (bool) GetValue($this->ReadPropertyInteger('SchaltkontaktVariableID'));
return $kontaktwert !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert');
@@ -551,6 +621,24 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
'Wert' => (bool) $this->leseZustand('Schaltzustand'),
'Einheit' => '',
],
+ [
+ 'Kennung' => 'SchaltbefehlAusstehend',
+ 'Art' => 'Status',
+ 'Wert' => (bool) $this->leseZustand('SchaltbefehlAusstehend'),
+ 'Einheit' => '',
+ ],
+ [
+ 'Kennung' => 'Schaltbereit',
+ 'Art' => 'Status',
+ 'Wert' => (bool) $this->leseZustand('AenderungMoeglich'),
+ 'Einheit' => '',
+ ],
+ [
+ 'Kennung' => 'RestMindestzeit_s',
+ 'Art' => 'Istwert',
+ 'Wert' => (int) $this->leseZustand('RestMindestzeit'),
+ 'Einheit' => 's',
+ ],
[
'Kennung' => 'Tageslaufzeit_s',
'Art' => 'Istwert',
@@ -636,8 +724,10 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
) {
throw new InvalidArgumentException('Mindestlaufzeit muss zwischen 0 und 86400 Sekunden liegen.');
}
- if ($this->ReadPropertyInteger('Umschaltabstand') < 0) {
- throw new InvalidArgumentException('Umschaltabstand darf nicht negativ sein.');
+ foreach (['Mindesteinschaltdauer', 'Mindestausschaltdauer'] as $property) {
+ if ($this->ReadPropertyInteger($property) < 0) {
+ throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
+ }
}
$kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID');
@@ -713,6 +803,13 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableBoolean('Rueckmeldefehler', 'Rueckmeldefehler', '~Alert', 200);
+ $this->RegisterVariableBoolean(
+ 'SchaltbefehlAusstehend',
+ 'Schaltbefehl ausstehend',
+ '~Switch',
+ 210
+ );
+ $this->RegisterVariableInteger('RestMindestzeit', 'Restliche Mindestzeit', '', 220);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$wert = $ident === 'Leistungsquelle'
? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET
@@ -739,10 +836,12 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
'Stoerung' => 'ZustandStoerung',
'Schaltzustand' => 'ZustandSchaltzustand',
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
+ 'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
+ 'RestMindestzeit' => 'ZustandRestMindestzeit',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
@@ -783,10 +882,12 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
'Stoerung' => 'ZustandStoerung',
'Schaltzustand' => 'ZustandSchaltzustand',
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
+ 'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
+ 'RestMindestzeit' => 'ZustandRestMindestzeit',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
@@ -838,6 +939,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
'Meldezyklus',
'RueckmeldungVerzoegert',
'Umschaltfreigabe',
+ 'MindestzeitAbgelaufen',
'VorgabeTimeout',
'Tagesplanung',
] as $timer) {
diff --git a/composer.json b/composer.json
index dd2af8a..f7f3172 100644
--- a/composer.json
+++ b/composer.json
@@ -20,10 +20,16 @@
},
"scripts": {
"lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l",
+ "lint:verbraucher-einstufig": "find VerbraucherEinStufig libs/EinStufigRegler.php tests/VerbraucherEinStufig -name '*.php' -print0 | xargs -0 -n1 php -l",
"test": "phpunit",
+ "test:verbraucher-einstufig": "phpunit --configuration phpunit.verbraucher-einstufig.xml",
"check": [
"@lint",
"@test"
+ ],
+ "check:verbraucher-einstufig": [
+ "@lint:verbraucher-einstufig",
+ "@test:verbraucher-einstufig"
]
},
"config": {
diff --git a/docs/adr/0002-verbraucher-einstufig-eventbasiert.md b/docs/adr/0002-verbraucher-einstufig-eventbasiert.md
index 7f5db7b..d471df3 100644
--- a/docs/adr/0002-verbraucher-einstufig-eventbasiert.md
+++ b/docs/adr/0002-verbraucher-einstufig-eventbasiert.md
@@ -2,56 +2,73 @@
## Status
-Akzeptiert.
+Akzeptiert, am 17. September 2026 um getrennte Mindestzeiten erweitert.
## 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.
+bildete Lastwechselsperren ueber `Interval`, `IdleCounterMax` und weitere
+zyklusabhaengige Zaehler ab. Ein erster Enelix-2-Stand ersetzte dies durch
+einen allgemeinen `Umschaltabstand`. Dieser konnte unterschiedliche
+Geraeteanforderungen fuer Ein- und Aus-Zustand nicht ausdruecken.
+
+Bei einem asynchron schaltenden Geraet kann ausserdem der Aktorbefehl nicht als
+physische Zustandsbestaetigung gelten.
## Entscheidung
-Der neue `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung.
+Der `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.
+Der allgemeine `Umschaltabstand` entfaellt. An seine Stelle treten:
+
+- `Mindesteinschaltdauer` in Sekunden, Standard 5,
+- `Mindestausschaltdauer` in Sekunden, Standard 5.
+
+Die passende Mindestzeit beginnt mit dem bestaetigten Zustandswechsel. Ohne
+separate Rueckmeldung bestaetigt die Aktorvariable den Wechsel unmittelbar.
+Mit separater Rueckmeldung beginnt die Mindestzeit erst, wenn deren Wert den
+Zielzustand erreicht.
+
+Bis dahin bleiben Istleistung und Leistungsangebot beim rueckgemeldeten
+Zustand. `SchaltbefehlAusstehend=true`,
+`AenderungMoeglich=false` und `Schaltbereit=false` machen die laufende
+Umschaltung fuer den Manager sichtbar.
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.
+gezaehlt. Bei separater Rueckmeldung zaehlt ausschliesslich der bestaetigte
+Ein-Zustand.
## Alternativen
-- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Sperrzeit
+- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Zeitregeln
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.
+- Ein allgemeiner Umschaltabstand wurde verworfen, weil Ein- und Aus-Zustand
+ unterschiedliche Mindestzeiten benoetigen koennen.
+- Der Aktorbefehl als sofortige physische Bestaetigung wurde bei vorhandener
+ Rueckmeldung verworfen.
+- Ein zusaetzlicher Rueckmelde-Timeout wurde nicht eingefuehrt. Ein
+ ausstehender Befehl bleibt transparent sichtbar, bis ein neues Ereignis den
+ Zustand klaert.
## 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.
+- `Interval`, `IdleCounterMax` und `Umschaltabstand` entfallen.
+- Waerend Mindestzeiten und ausstehenden Rueckmeldungen wird nur die
+ bestaetigte Istleistung angeboten.
+- Der Manager erhaelt Status und Restmindestzeit als Zustandseintraege.
+- Eine unerwartete Abweichung zwischen Aktor und Rueckmeldung ausserhalb eines
+ laufenden Schaltvorgangs wird als Stoerung gemeldet.
+- Tageslaufzeit und Mindestzeiten sind unabhaengig von einer Zyklusdauer.
+- Das Modul bleibt vollstaendig im Repository Enelix EMS.
## Offene Punkte
- Eine spaetere sperrbare Variante wird separat spezifiziert.
-- Unterschiedliche Ein- und Ausschaltabstaende benoetigen eine neue
- Architekturentscheidung.
+- Ein konfigurierbarer Rueckmelde-Timeout benoetigt eine eigene Entscheidung.
diff --git a/docs/migration/Verbraucher-1-Stufig.md b/docs/migration/Verbraucher-1-Stufig.md
index 8b60ead..3b35788 100644
--- a/docs/migration/Verbraucher-1-Stufig.md
+++ b/docs/migration/Verbraucher-1-Stufig.md
@@ -1,27 +1,28 @@
# 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.
+eine neue Modul-ID, Vertrag `3.0` und eine ereignisbasierte Regelung.
## Eigenschaften
-| Enelix 1 | Enelix 2 | Migration |
+| Enelix 1 / bisheriges Enelix 2 | Aktuelles 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. |
+| `Mindesttlaufzeit` | `Mindestlaufzeit` | Alten Minutenwert fuer gleiches Verhalten mit 60 multiplizieren. |
+| `Zeit_Zwischen_Zustandswechseln` | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Alte Minuten nicht ungeprueft uebernehmen; beide Sekundenwerte geraetespezifisch setzen. |
+| `Umschaltabstand` aus fruehem Enelix 2 | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Der alte Wert wird nicht automatisch uebertragen; beide Werte haben Standard 5 s. |
+| `Interval` | entfaellt | Keine zyklische Regelberechnung. |
+| `IdleCounterMax` | entfaellt | Keine zyklusbasierte Idle-Erkennung. |
## Variablen und Verhalten
-| Enelix 1 | Enelix 2 |
+| Alt | Aktuell |
| --- | --- |
-| `DailyOnTime` | `Tageslaufzeit` in echten Sekunden |
-| `IstNacht` | entfaellt; Mindestlaufzeit wird am spaetestmoeglichen Start des lokalen Kalendertags erzwungen |
+| `DailyOnTime` | `Tageslaufzeit` in bestaetigten echten Sekunden |
+| `IstNacht` | entfaellt; Tagesmindestlaufzeit wird spaetestmoeglich erzwungen |
| `IsTimerActive` | entfaellt; interne einmalige Timer |
| `Aktuelle_Leistung` | gemeinsames `Istleistung` |
| `Power` | gemeinsames `Sollleistung` |
@@ -32,25 +33,25 @@ eine neue Modul-ID, den Vertrag `3.0` und eine ereignisbasierte Regelung.
| `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.
+Bei konfigurierter Rueckmeldung bestimmt nicht mehr der ausgegebene
+Aktorbefehl, sondern erst der rueckgemeldete Zustand die Istleistung, die
+Tageslaufzeit und den Start der jeweiligen Mindestzeit.
## Inbetriebnahme
-1. Bestehende Instanzkonfiguration und aktuelle Aktorlogik dokumentieren.
-2. Neue Instanz **Verbraucher 1-Stufig** anlegen.
+1. Bestehende Instanzkonfiguration und Aktorlogik dokumentieren.
+2. Neue Instanz **Verbraucher 1-Stufig** anlegen oder die bestehende
+ Enelix-2-Instanz aktualisieren.
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.
+4. Tagesmindestlaufzeit gegebenenfalls von Minuten in Sekunden umrechnen.
+5. Mindest-Ein- und Mindest-Aus-Zeit getrennt anhand der Geraeteanforderungen
+ setzen.
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.
+7. Ein- und Ausschalten zuerst ohne reale Last oder unter Aufsicht pruefen.
+8. Bei separater Rueckmeldung kontrollieren, dass
+ `SchaltbefehlAusstehend` erst mit deren Zustandswechsel verschwindet.
+9. Mindestens je einen verhinderten Ein- und Ausschaltvorgang testen.
+10. Tageslaufzeit und Tageswechsel kontrollieren.
## Rueckkehr
diff --git a/docs/module/Verbraucher-1-Stufig/README.md b/docs/module/Verbraucher-1-Stufig/README.md
index 7454db4..73b63fd 100644
--- a/docs/module/Verbraucher-1-Stufig/README.md
+++ b/docs/module/Verbraucher-1-Stufig/README.md
@@ -3,9 +3,10 @@
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`.
Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand
-meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waehrend der
-Umschaltsperre und bei lokal erzwungener Mindestlaufzeit wird nur der aktuell
-zulaessige Leistungswert angeboten.
+meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waerend einer
+Mindest-Ein- oder Mindest-Aus-Zeit, waehrend einer ausstehenden Rueckmeldung
+und bei lokal erzwungener Tagesmindestlaufzeit wird nur der aktuelle
+bestaetigte Leistungswert angeboten.
## Architekturentscheidung
@@ -14,18 +15,19 @@ 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 Ende der zustandsabhaengigen Mindestzeit,
- 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.
+Es gibt keinen Regelzyklus, kein `Interval`, keinen `IdleCounterMax` und
+keinen allgemeinen `Umschaltabstand`. `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
+[`Schnittstelle.md`](../../Schnittstelle.md) kommen acht
Modul-Properties hinzu.
| Ident | Typ | Standard | Beschreibung |
@@ -33,14 +35,19 @@ Modul-Properties hinzu.
| `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. |
+| `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet physisch 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. |
+| `Mindesteinschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Ein-Zustand gehalten wird. |
+| `Mindestausschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Aus-Zustand gehalten wird. |
+| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame und modulspezifische Diagnosevariablen an. |
+
+Beide Mindestzeiten duerfen `0` sein. Die lokale Aktion `Aktiv=false`
+bleibt der einzige bewusste Override und schaltet sicher aus, auch wenn die
+Mindesteinschaltdauer noch laeuft.
`EinstellungenInVisu` ist Teil der gemeinsamen Verbraucherbasis. Das Modul
besitzt derzeit keine zusaetzlichen bedienbaren Einstellvariablen und wertet
-diese Property deshalb noch nicht weiter aus.
+diese Property deshalb nicht weiter aus.
## Variablen
@@ -49,10 +56,10 @@ Immer sichtbar:
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `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. |
+| `Schaltzustand` | Boolean / Anzeige | Bestaetigter oder aus dem Aktor berechneter Zustand. |
+| `Tageslaufzeit` | Integer / Anzeige | Bestaetigte Laufzeit des lokalen Kalendertags in Sekunden. |
-Mit `DiagnosevariablenAnzeigen` werden diese Variablen zusaetzlich angelegt:
+Mit `DiagnosevariablenAnzeigen` werden zusaetzlich angelegt:
- `Istleistung`
- `Leistungsquelle`
@@ -63,47 +70,83 @@ Mit `DiagnosevariablenAnzeigen` werden diese Variablen zusaetzlich angelegt:
- `Stoerung`
- `Stoertext`
- `Rueckmeldefehler`
+- `SchaltbefehlAusstehend`
+- `RestMindestzeit`
Die Regellogik arbeitet mit persistenten Attributen und ist nicht von der
Sichtbarkeit der Diagnosevariablen abhaengig.
-## Zeit- und Schaltverhalten
+## Mindest-Ein- und Mindest-Aus-Zeit
-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.
+Nach einer bestaetigten Einschaltung wird fuer
+`Mindesteinschaltdauer` Sekunden kein regulaeres Ausschalten angeboten. Nach
+einer bestaetigten Ausschaltung blockiert `Mindestausschaltdauer` ein
+regulaeres Einschalten.
+
+Waerend einer Mindestzeit gilt:
+
+- `AenderungMoeglich=false`,
+- `Schaltbereit=false`,
+- `RestMindestzeit_s` enthaelt die verbleibenden Sekunden,
+- `Leistungswerte_W` enthaelt nur die bestaetigte Istleistung.
+
+Ein einmaliger Timer loest am Ende der Mindestzeit genau eine Neuberechnung
+aus. Der fruehere allgemeine `Umschaltabstand` ist vollstaendig entfallen.
+
+## Rueckmeldung und Schaltbereitschaft
+
+Ohne separate Rueckmeldung wird der logische Zustand der Aktorvariable als
+sofortige Schaltbestaetigung verwendet.
+
+Mit `RueckmeldungVariableID` ist ausschliesslich deren Booleanwert fuer
+`Schaltzustand`, `Istleistung_W`, Tageslaufzeit und den Beginn der
+Mindestzeit massgeblich. Nach einem Aktorbefehl und vor der passenden
+Rueckmeldung meldet das Modul:
+
+- die bisherige bestaetigte Istleistung,
+- `SchaltbefehlAusstehend=true`,
+- `Schaltbereit=false`,
+- `AenderungMoeglich=false`,
+- nur den bisherigen Leistungswert in `Leistungswerte_W`.
+
+Sobald die Rueckmeldung den Zielzustand bestaetigt, beginnt die passende
+Mindest-Ein- oder Mindest-Aus-Zeit. Es gibt bewusst keinen zusaetzlichen
+zyklischen Rueckmelde-Timeout. Bleibt die Rueckmeldung aus, bleibt der
+Schaltbefehl sichtbar ausstehend.
+
+Weichen Aktor- und Rueckmeldezustand ohne ausstehenden Schaltbefehl voneinander
+ab, wird `Rueckmeldefehler=true`, `Verfuegbar=false` und eine Stoerung
+gemeldet.
+
+## Tagesmindestlaufzeit
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`.
+beruecksichtigt. Nur der bestaetigte Ein-Zustand zaehlt.
-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.
+Ist die konfigurierte Tagesmindestlaufzeit noch nicht erreicht, wird der
+Verbraucher zum spaetestmoeglichen Zeitpunkt angefordert, an dem die
+Restlaufzeit bis Mitternacht noch erfuellt werden kann. Eine noch aktive
+Mindestausschaltdauer hat dabei Vorrang; eine unmoegliche Restlaufzeit kann
+nicht rueckwirkend nachgeholt werden.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
-ausschliesslich den Vertrag `3.0`. Der Verbraucher besitzt keine
+ausschliesslich 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.
+Neben den gemeinsamen Vertragsfeldern werden diese Zustaende gemeldet:
+
+- `Schaltzustand`
+- `SchaltbefehlAusstehend`
+- `Schaltbereit`
+- `RestMindestzeit_s`
+- `Tageslaufzeit_s`
+- `Rueckmeldefehler`
+
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.
@@ -116,16 +159,14 @@ wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig.
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.
+4. Als Schaltkontakt eine Booleanvariable mit funktionsfaehiger Aktion
+ auswaehlen und bei Bedarf `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.
+6. Tagesmindestlaufzeit, Mindest-Einschaltdauer und Mindest-Ausschaltdauer
+ passend zum angeschlossenen Geraet festlegen.
+7. Die 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
@@ -134,23 +175,37 @@ wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig.
## 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.
+- Eine Ein-Vorgabe setzt zuerst den Aktor; mit separater Rueckmeldung bleibt
+ `SchaltbefehlAusstehend=true`, bis diese einschaltet.
+- Die Mindesteinschaltdauer beginnt erst mit der Ein-Rueckmeldung.
+- Vor Ablauf der Mindesteinschaltdauer wird kein Ausschalten angeboten.
+- Nach bestaetigter Ausschaltung beginnt die Mindestausschaltdauer.
+- Vor Ablauf der Mindestausschaltdauer wird kein Einschalten angeboten.
+- `RestMindestzeit_s` erreicht 0 und `Schaltbereit` wird danach true.
+- `Tageslaufzeit` steigt nur bei bestaetigtem Ein-Zustand.
+- Ein unerwarteter Unterschied zwischen Aktor und Rueckmeldung erzeugt eine
+ Stoerung.
+- Nach einem Neustart wird keine alte Manager-Vorgabe ungeprueft fortgesetzt.
+
+## Tests
+
+Nur dieses Modul:
+
+```bash
+composer check:verbraucher-einstufig
+```
+
+Gesamtes Repository inklusive dieser Modulsuite:
+
+```bash
+composer check
+```
+
+Die separate Suite und ihre Struktur sind unter
+[`tests/VerbraucherEinStufig`](../../../tests/VerbraucherEinStufig/README.md)
+dokumentiert.
## Migration
-Die Zuordnung der Enelix-1-Felder, Einheiten und bewusst entfallenen
-Kommunikationsvariablen steht in
+Die Zuordnung der Enelix-1- und bisherigen Enelix-2-Zeitwerte steht in
[`docs/migration/Verbraucher-1-Stufig.md`](../../migration/Verbraucher-1-Stufig.md).
diff --git a/libs/EinStufigRegler.php b/libs/EinStufigRegler.php
index 74563f1..31d7d5a 100644
--- a/libs/EinStufigRegler.php
+++ b/libs/EinStufigRegler.php
@@ -34,6 +34,28 @@ final class EinStufigRegler
return [0, $nennleistung];
}
+ public static function freigabezeitpunkt(
+ bool $eingeschaltet,
+ int $bestaetigtSeit,
+ int $mindesteinschaltdauer,
+ int $mindestausschaltdauer
+ ): int {
+ if ($bestaetigtSeit < 0 || $mindesteinschaltdauer < 0 || $mindestausschaltdauer < 0) {
+ throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
+ }
+
+ return $bestaetigtSeit + ($eingeschaltet ? $mindesteinschaltdauer : $mindestausschaltdauer);
+ }
+
+ public static function restMindestzeit(int $freigabezeitpunkt, int $jetzt): int
+ {
+ if ($freigabezeitpunkt < 0 || $jetzt < 0) {
+ throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
+ }
+
+ return max(0, $freigabezeitpunkt - $jetzt);
+ }
+
public static function mussMindestlaufzeitErzwingen(
int $tageslaufzeit,
int $mindestlaufzeit,
diff --git a/phpunit.verbraucher-einstufig.xml b/phpunit.verbraucher-einstufig.xml
new file mode 100644
index 0000000..b36f2fc
--- /dev/null
+++ b/phpunit.verbraucher-einstufig.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ tests/VerbraucherEinStufig
+
+
+
diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php
index 5cc692d..2cdb8f3 100644
--- a/tests/ManagerModulstrukturTest.php
+++ b/tests/ManagerModulstrukturTest.php
@@ -35,7 +35,7 @@ final class ManagerModulstrukturTest extends TestCase
512,
JSON_THROW_ON_ERROR
);
- $json = json_encode($formular, JSON_THROW_ON_ERROR);
+ $json = json_encode($formular, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
foreach ([
'NetzleistungVariableID',
diff --git a/tests/EinStufigReglerTest.php b/tests/VerbraucherEinStufig/EinStufigReglerTest.php
similarity index 84%
rename from tests/EinStufigReglerTest.php
rename to tests/VerbraucherEinStufig/EinStufigReglerTest.php
index c8f8945..ba20a3c 100644
--- a/tests/EinStufigReglerTest.php
+++ b/tests/VerbraucherEinStufig/EinStufigReglerTest.php
@@ -43,6 +43,25 @@ final class EinStufigReglerTest extends TestCase
EinStufigRegler::leistungsangebot(3500, 1200, true, true, false);
}
+ public function testFreigabeVerwendetZustandsabhaengigeMindestdauer(): void
+ {
+ self::assertSame(1010, EinStufigRegler::freigabezeitpunkt(true, 1000, 10, 20));
+ self::assertSame(1020, EinStufigRegler::freigabezeitpunkt(false, 1000, 10, 20));
+ }
+
+ public function testRestMindestzeitWirdNieNegativ(): void
+ {
+ self::assertSame(7, EinStufigRegler::restMindestzeit(1007, 1000));
+ self::assertSame(0, EinStufigRegler::restMindestzeit(1000, 1007));
+ }
+
+ public function testNegativeMindestdauerWirdAbgewiesen(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ EinStufigRegler::freigabezeitpunkt(true, 1000, -1, 0);
+ }
+
public function testMindestlaufzeitWirdErstZumSpaetestmoeglichenStartErzwungen(): void
{
self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 7200));
diff --git a/tests/VerbraucherEinStufigModulstrukturTest.php b/tests/VerbraucherEinStufig/ModulstrukturTest.php
similarity index 68%
rename from tests/VerbraucherEinStufigModulstrukturTest.php
rename to tests/VerbraucherEinStufig/ModulstrukturTest.php
index 1d24f13..3b80a1b 100644
--- a/tests/VerbraucherEinStufigModulstrukturTest.php
+++ b/tests/VerbraucherEinStufig/ModulstrukturTest.php
@@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase;
final class VerbraucherEinStufigModulstrukturTest extends TestCase
{
- private const MODULPFAD = __DIR__ . '/../VerbraucherEinStufig/';
+ private const MODULPFAD = __DIR__ . '/../../VerbraucherEinStufig/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
@@ -43,7 +43,7 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
public function testPropertiesSindRegistriertUndImFormularVorhanden(): void
{
- $basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
+ $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)\(\'([^\']+)\'/',
@@ -65,7 +65,8 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
'SchaltkontaktInvertiert',
'RueckmeldungVariableID',
'Mindestlaufzeit',
- 'Umschaltabstand',
+ 'Mindesteinschaltdauer',
+ 'Mindestausschaltdauer',
'DiagnosevariablenAnzeigen',
],
$properties
@@ -77,17 +78,21 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
}
}
- public function testNurUmschaltabstandErsetztAlteZyklusProperties(): void
+ public function testGetrennteMindestzeitenErsetzenUmschaltabstand(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
- "RegisterPropertyInteger('Umschaltabstand', 5)",
+ "RegisterPropertyInteger('Mindesteinschaltdauer', 5)",
$modul
);
+ self::assertStringContainsString(
+ "RegisterPropertyInteger('Mindestausschaltdauer', 5)",
+ $modul
+ );
+ self::assertStringNotContainsString("RegisterPropertyInteger('Umschaltabstand'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('Interval'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('IdleCounterMax'", $modul);
- self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul);
}
public function testRegelungReagiertAufEreignisseUndEinmalTimer(): void
@@ -96,13 +101,33 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
self::assertStringContainsString('public function MessageSink', $modul);
self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul);
- foreach (['Umschaltfreigabe', 'VorgabeTimeout', 'Tagesplanung'] as $timer) {
+ foreach (['MindestzeitAbgelaufen', 'VorgabeTimeout', 'Tagesplanung'] as $timer) {
self::assertStringContainsString("'$timer'", $modul);
}
self::assertStringContainsString(
"SetTimerInterval('Meldezyklus', \$this->ReadPropertyInteger('Meldeintervall') * 1000)",
$modul
);
+ self::assertStringNotContainsString("case 'Umschaltfreigabe':", $modul);
+ self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul);
+ }
+
+ public function testRueckmeldungBestimmtZustandUndSchaltbereitschaft(): void
+ {
+ $modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
+
+ self::assertStringContainsString(
+ "ReadPropertyInteger('RueckmeldungVariableID')",
+ $modul
+ );
+ self::assertStringContainsString('bestaetigeZustandswechsel', $modul);
+ self::assertStringContainsString("'Kennung' => 'SchaltbefehlAusstehend'", $modul);
+ self::assertStringContainsString("'Kennung' => 'Schaltbereit'", $modul);
+ self::assertStringContainsString("'Kennung' => 'RestMindestzeit_s'", $modul);
+ self::assertStringContainsString(
+ 'setzeZustand(\'AenderungMoeglich\', $aenderungMoeglich)',
+ $modul
+ );
}
public function testModulVerwendetAusschliesslichDenVertrag30(): void
@@ -117,25 +142,19 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
}
- public function testFachvariablenUndRueckmeldestoerungSindEnthalten(): void
+ public function testFachvariablenUndRueckmeldestatusSindEnthalten(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
- self::assertStringContainsString(
- "RegisterVariableBoolean('Schaltzustand'",
- $modul
- );
- self::assertStringContainsString(
- "RegisterVariableInteger('Tageslaufzeit'",
- $modul
- );
+ self::assertStringContainsString("RegisterVariableBoolean('Schaltzustand'", $modul);
+ self::assertStringContainsString("RegisterVariableInteger('Tageslaufzeit'", $modul);
self::assertStringContainsString("'Kennung' => 'Rueckmeldefehler'", $modul);
self::assertStringContainsString("'Kennung' => 'Tageslaufzeit_s'", $modul);
}
public function testManagerKenntDieModulIDGenauEinmal(): void
{
- $manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
+ $manager = (string) file_get_contents(__DIR__ . '/../../Manager/module.php');
$modulID = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
self::assertSame(1, substr_count($manager, $modulID));
@@ -149,4 +168,20 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
self::assertStringContainsString('registriereVerbraucherDiagnose', $modul);
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
}
+
+ public function testSeparateSuiteIstImGesamttestEnthalten(): void
+ {
+ self::assertFileExists(__DIR__ . '/../../phpunit.verbraucher-einstufig.xml');
+
+ $composer = json_decode(
+ (string) file_get_contents(__DIR__ . '/../../composer.json'),
+ true,
+ 512,
+ JSON_THROW_ON_ERROR
+ );
+ self::assertArrayHasKey('check:verbraucher-einstufig', $composer['scripts']);
+
+ $gesamt = (string) file_get_contents(__DIR__ . '/../../phpunit.xml.dist');
+ self::assertStringContainsString('tests', $gesamt);
+ }
}
diff --git a/tests/VerbraucherEinStufig/README.md b/tests/VerbraucherEinStufig/README.md
new file mode 100644
index 0000000..5936329
--- /dev/null
+++ b/tests/VerbraucherEinStufig/README.md
@@ -0,0 +1,16 @@
+# Tests fuer Verbraucher 1-Stufig
+
+Die Tests des Moduls koennen unabhaengig vom restlichen Repository ausgefuehrt
+werden:
+
+```bash
+composer check:verbraucher-einstufig
+```
+
+Der Befehl prueft nur den Modulcode, die reine Regellogik und diese Tests.
+Der allgemeine Befehl bleibt der externe Gesamteinstieg und schliesst die
+Modulsuite durch das rekursive Verzeichnis `tests` automatisch ein:
+
+```bash
+composer check
+```