Verbraucher 1-Stufig eventbasiert implementieren
Tests / test (push) Failing after 39s

This commit is contained in:
dh
2026-09-17 09:32:17 +00:00
parent 29cfd8ceaa
commit d4828f1ee5
13 changed files with 1652 additions and 29 deletions
+1
View File
@@ -19,6 +19,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const VERBRAUCHER_MODUL_IDS = [ private const VERBRAUCHER_MODUL_IDS = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}', '{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}', '{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
]; ];
private const MONATSNAMEN = [ private const MONATSNAMEN = [
1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
+6 -3
View File
@@ -5,8 +5,9 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status ## Status
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
Verbraucherbasis, der Manager und der mehrstufige Warmwassererwaermer sind Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer und der
implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert. 1-stufige Verbraucher sind implementiert. Die weiteren Module sind als
Diskussionsentwürfe dokumentiert.
## Geplante Module ## Geplante Module
@@ -14,7 +15,7 @@ implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert.
- Batterie - Batterie
- Wassererwaermer (implementiert) - Wassererwaermer (implementiert)
- Pufferspeicher - Pufferspeicher
- Verbraucher 1-Stufig - Verbraucher 1-Stufig (implementiert)
- Waermepumpe - Waermepumpe
- Ladestation Stand-Alone - Ladestation Stand-Alone
- Ladestation Gateway - Ladestation Gateway
@@ -51,6 +52,8 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md) - [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Manager-Modul](docs/module/Manager/README.md) - [Manager-Modul](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/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 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) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md) - [Modulübersicht](docs/module/README.md)
+108
View File
@@ -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"
}
]
}
]
}
+14
View File
@@ -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"
}
+861
View File
@@ -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.
+60
View File
@@ -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.
+3 -3
View File
@@ -1,7 +1,7 @@
# EMS-Module und Modulentwürfe # EMS-Module und Modulentwürfe
> Manager und Warmwassererwaermer sind als installierbare IP-Symcon-Module > Manager, Warmwassererwaermer und Verbraucher 1-Stufig sind als installierbare
> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. > IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb [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 | | [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers |
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) | | [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve | | [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 | | [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung | | [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway | | [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
+142 -22
View File
@@ -1,36 +1,156 @@
# Verbraucher 1-Stufig # 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 Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand
`[0, Nennleistung]`. 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 | | Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `Schaltzustand` | Boolean / Anzeige | Rückgemeldeter oder berechneter Kontaktzustand. | | `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Startwert `false`. |
| `Tageslaufzeit` | Integer / Anzeige | Laufzeit seit lokalem Tagesbeginn in Sekunden; persistent mit Tageskennung. | | `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 | - `Istleistung`
| --- | --- | --- | - `Leistungsquelle`
| `Nennleistung` | Float | `0*` W; grösser 0. | - `Sollleistung`
| `SchaltkontaktVariableID` | Integer | `0`; erforderlicher Boolean-Aktor mit Action. | - `SollwertGueltig`
| `SchaltkontaktInvertiert` | Boolean | `false`; Ein-/Aus-Semantik des Aktors. | - `Verfuegbar`
| `RueckmeldungVariableID` | Integer | `0`; optionale echte Rückmeldung. | - `AenderungMoeglich`
| `Mindestlaufzeit` | Integer | `0` s. | - `Stoerung`
| `Umschaltabstand` | Integer | `60` s; Mindestabstand zwischen Änderungen. | - `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 Nach jedem Lastwechsel wird `AenderungMoeglich=false` gemeldet und das
`Rueckmeldefehler` als Störung. 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. Bei Aktivierung sehr kurz vor Tagesende kann eine unmoeglich hohe Restlaufzeit
- Unterschiedliche Ein- und Ausschaltzeiten erst nach Freigabe ergänzen. 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).
+144
View File
@@ -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);
}
}
+9 -1
View File
@@ -14,7 +14,6 @@ final class DokumentationsstrukturTest extends TestCase
return [ return [
['Batterie'], ['Batterie'],
['Pufferspeicher'], ['Pufferspeicher'],
['Verbraucher-1-Stufig'],
['Waermepumpe'], ['Waermepumpe'],
['Ladestation-Stand-Alone'], ['Ladestation-Stand-Alone'],
['Ladestation-Gateway'], ['Ladestation-Gateway'],
@@ -49,6 +48,15 @@ final class DokumentationsstrukturTest extends TestCase
self::assertStringContainsString('Verteilalgorithmus', $inhalt); 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 public function testWarmwassererwaermerDokumentiertDenImplementiertenStand(): void
{ {
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Wassererwaermer/README.md'); $inhalt = file_get_contents(__DIR__ . '/../docs/module/Wassererwaermer/README.md');
+115
View File
@@ -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);
}
}