feat(control): connect physical feedback to bounded V4 command and fallback paths
This commit is contained in:
+18
-2
@@ -9,6 +9,7 @@ require_once __DIR__ . '/../libs/BatterieRegler.php';
|
|||||||
require_once __DIR__ . '/../libs/BatterieNetzfahrplanV4TestTrait.php';
|
require_once __DIR__ . '/../libs/BatterieNetzfahrplanV4TestTrait.php';
|
||||||
|
|
||||||
use Belevo\EnelixEMS\BatterieRegler;
|
use Belevo\EnelixEMS\BatterieRegler;
|
||||||
|
use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung;
|
||||||
use Belevo\EnelixEMS\Nachrichtenvertrag;
|
use Belevo\EnelixEMS\Nachrichtenvertrag;
|
||||||
use Belevo\EnelixEMS\VerbraucherBasisTrait;
|
use Belevo\EnelixEMS\VerbraucherBasisTrait;
|
||||||
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
|
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
|
||||||
@@ -197,6 +198,10 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
|||||||
!$this->ReadPropertyBoolean('NachladenMitMaximalleistung')
|
!$this->ReadPropertyBoolean('NachladenMitMaximalleistung')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$formular['elements'][]=['type'=>'ExpansionPanel','caption'=>'V4 physische Rueckmeldung (keine Freigabe)', 'items'=>[
|
||||||
|
['type'=>'Label','caption'=>'Quellenzuordnung fuer Vorschau und separaten Regeltest. Eine Modellaufteilung ist keine separate EV-/SDL-Messung.'],
|
||||||
|
['type'=>'ValidationTextBox','name'=>'NetzfahrplanV4RueckmeldungKonfiguration','caption'=>'Versionierte Quellenzuordnung (JSON)']
|
||||||
|
]];
|
||||||
return json_encode($formular, JSON_THROW_ON_ERROR);
|
return json_encode($formular, JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +397,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
|||||||
$this->aktualisiereEnergie($jetzt);
|
$this->aktualisiereEnergie($jetzt);
|
||||||
$messwerte = $this->leseMesswerte();
|
$messwerte = $this->leseMesswerte();
|
||||||
$messwerteGueltig = $messwerte['Gueltig'];
|
$messwerteGueltig = $messwerte['Gueltig'];
|
||||||
|
$this->SetBuffer('V4FeedbackEstimated', ($messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0');
|
||||||
|
|
||||||
$this->setzeZustand('Ladezustand', $messwerte['Ladezustand']);
|
$this->setzeZustand('Ladezustand', $messwerte['Ladezustand']);
|
||||||
$this->setzeZustand('LadezustandGueltig', $messwerteGueltig);
|
$this->setzeZustand('LadezustandGueltig', $messwerteGueltig);
|
||||||
@@ -402,7 +408,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
|||||||
$this->setzeSichtbareVariable(
|
$this->setzeSichtbareVariable(
|
||||||
'Leistungsquelle',
|
'Leistungsquelle',
|
||||||
$messwerteGueltig
|
$messwerteGueltig
|
||||||
? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
|
? (($messwerte['V4FeedbackEstimated'] ?? false) ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN)
|
||||||
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
|
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
|
||||||
);
|
);
|
||||||
$this->aktualisiereLadestatus(
|
$this->aktualisiereLadestatus(
|
||||||
@@ -527,6 +533,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
|||||||
|
|
||||||
$gueltig = true;
|
$gueltig = true;
|
||||||
foreach ($zuordnung as $property => $schluessel) {
|
foreach ($zuordnung as $property => $schluessel) {
|
||||||
|
if ($this->v4BatterieTestAktiv() && in_array($schluessel,['Netzleistung','Istleistung'],true)) continue;
|
||||||
$variablenID = $this->ReadPropertyInteger($property);
|
$variablenID = $this->ReadPropertyInteger($property);
|
||||||
if (!$this->istMesswertAktuell($variablenID)) {
|
if (!$this->istMesswertAktuell($variablenID)) {
|
||||||
$gueltig = false;
|
$gueltig = false;
|
||||||
@@ -535,6 +542,15 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
|||||||
$werte[$schluessel] = (float) GetValue($variablenID);
|
$werte[$schluessel] = (float) GetValue($variablenID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->v4BatterieTestAktiv()) {
|
||||||
|
try {
|
||||||
|
$f=$this->v4BatterieRueckmeldung();
|
||||||
|
NetzfahrplanV4Rueckmeldung::requireTrial($f,$this->v4BatterieTestSitzung(),time());
|
||||||
|
$werte['Istleistung']=$f['batteryW'];$werte['Netzleistung']=$f['gridW'];
|
||||||
|
$werte['V4FeedbackEstimated']=$f['estimated'];
|
||||||
|
} catch (Throwable $e) { $gueltig=false; }
|
||||||
|
}
|
||||||
|
|
||||||
if ($werte['MaxLaden'] <= 0.0
|
if ($werte['MaxLaden'] <= 0.0
|
||||||
|| $werte['MaxEntladen'] <= 0.0
|
|| $werte['MaxEntladen'] <= 0.0
|
||||||
|| $werte['Ladezustand'] < 0.0
|
|| $werte['Ladezustand'] < 0.0
|
||||||
@@ -714,7 +730,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
|||||||
? (float) $this->leseZustand('Istleistung')
|
? (float) $this->leseZustand('Istleistung')
|
||||||
: null,
|
: null,
|
||||||
'Leistungsquelle' => $messwerteGueltig
|
'Leistungsquelle' => $messwerteGueltig
|
||||||
? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
|
? ($this->GetBuffer('V4FeedbackEstimated') === '1' ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN)
|
||||||
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN,
|
: Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN,
|
||||||
'Zustand' => [
|
'Zustand' => [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -724,7 +724,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
|
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
|
||||||
if (!IPS_SemaphoreEnter($lock, 0)) return;
|
if (!IPS_SemaphoreEnter($lock, 0)) return;
|
||||||
try {
|
try {
|
||||||
|
$trialBefore=(bool)$this->v4ManagerTestSession();
|
||||||
$this->regelnIntern($erneuern);
|
$this->regelnIntern($erneuern);
|
||||||
|
if ($trialBefore && !$this->v4ManagerTestSession() && $this->GetValue('Aktiv')
|
||||||
|
&& IPS_GetInstance($this->InstanceID)['InstanceStatus'] === self::STATUS_AKTIV) {
|
||||||
|
$this->WriteAttributeString('LetzteSollwerte','{}');
|
||||||
|
$this->regelnIntern(true); // exactly one fresh fallback, no cached pre-trial target
|
||||||
|
}
|
||||||
if ($this->v4ManagerTestSession()
|
if ($this->v4ManagerTestSession()
|
||||||
&& (!$this->GetValue('Aktiv') || IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== self::STATUS_AKTIV)) {
|
&& (!$this->GetValue('Aktiv') || IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== self::STATUS_AKTIV)) {
|
||||||
$this->v4ManagerTestAbbrechen('Manager nicht mehr betriebsbereit');
|
$this->v4ManagerTestAbbrechen('Manager nicht mehr betriebsbereit');
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# V4 corrected physical feedback -> local bounded control
|
||||||
|
|
||||||
|
## Scope of this delivery
|
||||||
|
|
||||||
|
The shared physical feedback reader is now connected to the actual Manager preview,
|
||||||
|
the existing explicit bounded-trial command path, and an independent reread in the
|
||||||
|
Battery module immediately before hardware output. This is not a new observer.
|
||||||
|
The installer leaves both local trial permissions disabled and cannot arm a server
|
||||||
|
trial. Normal operation continues through the existing local controller.
|
||||||
|
|
||||||
|
Lihrenmoos binding: Manager 17004, Battery 44234, virtual asset
|
||||||
|
`anlage01-virtual-ev`. Effective 161.44 kWh / 39 kW unchanged. Source definitions
|
||||||
|
come from the existing versioned capture config and current saved topology, not
|
||||||
|
from new guessed device models. No old histories, sender cursors, virtual energy
|
||||||
|
accounts, SDL requests, archive policies or inverter polling are changed.
|
||||||
|
|
||||||
|
## Feedback and meaning
|
||||||
|
|
||||||
|
`NetzfahrplanV4Rueckmeldung` checks original value timestamps and object identities
|
||||||
|
in two read passes. Live max age is bounded to 60s and inter-source skew to 30s.
|
||||||
|
History interpolation/publication estimates are not accepted as live feedback.
|
||||||
|
The mapping fingerprint normalizes numbers and ordering, so a JSON property round
|
||||||
|
trip does not create a new identity. A mapping change cancels an active session.
|
||||||
|
|
||||||
|
Two adapters exist: sum of explicitly assigned physical meters, and a virtual
|
||||||
|
EV/SDL partition model. For the latter, gateway state and current requests are
|
||||||
|
read but unchanged zero commands are not mistaken for failed sensor heartbeats.
|
||||||
|
When SDL request is exactly zero, current physical battery power is used rather
|
||||||
|
than the filtered virtual EV display. A new EV request does not invent an immediate
|
||||||
|
physical response or force the measured sign toward the desired sign.
|
||||||
|
|
||||||
|
When SDL is nonzero, source timestamps must cover the latest request change and
|
||||||
|
physical tracking error must remain within the configured small tolerance. The
|
||||||
|
partition is explicitly estimated, not separately measured. Opposing EV/SDL flows
|
||||||
|
cannot be uniquely identified; they remain ineligible for trial control. Unknown
|
||||||
|
or stale physical sources never fall back to held filtered EV values.
|
||||||
|
|
||||||
|
`allowEstimatedForTrial` is false in the prepared Lihrenmoos config. Configuring
|
||||||
|
feedback or seeing a valid preview therefore does NOT accept the estimate for a
|
||||||
|
trial and does NOT satisfy accounting/device-watchdog evidence or other gates.
|
||||||
|
This adapter does not claim to resolve arbitrary simultaneous SDL activity.
|
||||||
|
|
||||||
|
## Actual control integration
|
||||||
|
|
||||||
|
The same coherent grid/battery snapshot is used in the Manager. Battery command
|
||||||
|
is a TOTAL power, not a delta added to the existing command. Planned changes in
|
||||||
|
other consumers are counted once and remain explicitly separate from measured grid.
|
||||||
|
|
||||||
|
A separate explicit server authority, both local consents, accounting evidence,
|
||||||
|
device-watchdog evidence, a deliberate session start and fresh plan remain required.
|
||||||
|
The existing trial is limited to 1800s / 5000W per direction; individual command
|
||||||
|
leases remain <=10s and cannot be renewed by replay or ordinary manager messages.
|
||||||
|
The local feedback mapping fingerprint and grid caps are bound to the session.
|
||||||
|
|
||||||
|
Immediately before writing, Battery rereads physical feedback and applies current
|
||||||
|
SOC, reserve, hysteresis, availability and change-lock constraints. Its resulting
|
||||||
|
command must still meet the bound grid limits; an unreachable target revokes the
|
||||||
|
trial rather than claiming that the planned grid value was achieved. It cannot
|
||||||
|
claim physical performance merely because a register call returned successfully.
|
||||||
|
|
||||||
|
Abort revokes the lease and attempts zero, and the Manager wrapper then runs at
|
||||||
|
most one fresh ordinary allocation, clearing cached pre-trial targets. It does
|
||||||
|
not recurse forever or revive an old plan. A failed stop stays explicitly failed.
|
||||||
|
The watchdog still requires a functioning kernel. Independent hardware failure
|
||||||
|
behavior is NOT certified by software tests.
|
||||||
|
|
||||||
|
Outside a V4 session the existing battery power path is unchanged. The compatible
|
||||||
|
BatterieRegler library includes the already developed optional reserve charging
|
||||||
|
limit; with its default (maximal charge) 13,824 old/new ordinary-offer test cases
|
||||||
|
match exactly. That comparison is not an on-device dynamics test.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
A single prepared Symcon script is the next runtime action:
|
||||||
|
|
||||||
|
require '/srv/agent/netplan-v4-feedback-stage/install.php';
|
||||||
|
|
||||||
|
It hash-checks nine changed files and all version-matched dependencies, backs up
|
||||||
|
existing source, installs dependencies before modules and reloads the ENELIX
|
||||||
|
library. Reload/ApplyChanges may execute existing initialization routines; this
|
||||||
|
is not promised to be a zero-effect library reload. It configures only the new
|
||||||
|
feedback mapping on Battery. No server redeployment is required. No shared classes
|
||||||
|
are included from staging, avoiding the earlier duplicate-class problem.
|
||||||
|
|
||||||
|
A delayed module-registration result requests one repeat. A valid installation
|
||||||
|
can still report an unavailable feedback sample; that is not reinterpreted as zero.
|
||||||
|
Any preexisting trial permission, unsaved change, or concurrent source change stops
|
||||||
|
the installer. Source write failure rolls back its own changed files; reload errors
|
||||||
|
are reported for review and do not automatically authorize anything.
|
||||||
|
|
||||||
|
## Validation and remaining acceptance
|
||||||
|
|
||||||
|
125 isolated PHP functional checks cover the reader, real receiver, actual trial
|
||||||
|
traits with a simulated register driver, the extracted actual Manager fallback
|
||||||
|
wrapper, and the real Battery message builder with optional diagnostic variables absent.
|
||||||
|
Source quality is stored internally; a last partition estimate is not relabelled as a
|
||||||
|
measured value just because a trial ends or a diagnostic variable is hidden. Four full module linkage checks and ten isolated installer scenarios also
|
||||||
|
pass. Full PHP syntax and dependency hashes are checked. Neither these fixtures nor
|
||||||
|
the ordinary-offer comparison run the real Symcon kernel or an inverter.
|
||||||
|
|
||||||
|
Evidence: test host `/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`,
|
||||||
|
`TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`.
|
||||||
|
|
||||||
|
Runtime installation, real feedback reception, and a separately authorized field
|
||||||
|
trial remain outstanding. This finishes the code connection for bounded control;
|
||||||
|
it is NOT an unrestricted continuous-production controller or a hardware
|
||||||
|
commissioning certificate. Corrected model selection and actual real-world savings
|
||||||
|
must be checked against their own data. Existing safety gates are not bypassed.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"scope": "corrected_feedback_trial_disabled",
|
||||||
|
"managerId": 17004,
|
||||||
|
"batteryInstanceId": 44234,
|
||||||
|
"files": {
|
||||||
|
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
|
||||||
|
"before": null,
|
||||||
|
"after": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7"
|
||||||
|
},
|
||||||
|
"libs/BatterieNetzfahrplanV4TestTrait.php": {
|
||||||
|
"before": null,
|
||||||
|
"after": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2"
|
||||||
|
},
|
||||||
|
"libs/BatterieRegler.php": {
|
||||||
|
"before": "035988c54d4d0cc3a59d234f1c08a8edbf5fcac42e09a6bcea61d3dd8270771b",
|
||||||
|
"after": "beecc9c817b722336367e1c574c1a38afc8f3f5346442170d67c101b980adf7b"
|
||||||
|
},
|
||||||
|
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": {
|
||||||
|
"before": "d083cfa6ae8e8026c134d2b53f34bb6fbe342fb7332a3e7da1b881de33e13e97",
|
||||||
|
"after": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66"
|
||||||
|
},
|
||||||
|
"libs/ManagerNetzfahrplanV4TestTrait.php": {
|
||||||
|
"before": null,
|
||||||
|
"after": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18"
|
||||||
|
},
|
||||||
|
"libs/NetzfahrplanV4Regeltest.php": {
|
||||||
|
"before": null,
|
||||||
|
"after": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249"
|
||||||
|
},
|
||||||
|
"libs/NetzfahrplanV4Rueckmeldung.php": {
|
||||||
|
"before": null,
|
||||||
|
"after": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631"
|
||||||
|
},
|
||||||
|
"Batterie/module.php": {
|
||||||
|
"before": "34503645f7e21dd1e5bf0a3f754f6592175dd67f9a263bf431f3801209555401",
|
||||||
|
"after": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe"
|
||||||
|
},
|
||||||
|
"Manager/module.php": {
|
||||||
|
"before": "83e80883c8f9b817c0af5e239b20bcb019c7cf7f7943ee5bcc6493ac5a743532",
|
||||||
|
"after": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurationSha256": "f66cfb9ebf5e02259b902f876a4c78756a51b3f2f2c240fd92c0f01161268c56",
|
||||||
|
"dependencies": {
|
||||||
|
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4",
|
||||||
|
"libs/Nachrichtenvertrag.php": "215ad7d4c5240b39b48cb7672f5057afa532aafdb34039862a1a22985a9ee839",
|
||||||
|
"libs/VerbraucherBasisTrait.php": "10fd9f54283c378104bce11d623d5581558355d26fc2f85bf04b316542874507",
|
||||||
|
"libs/VerbraucherSchnittstelle.php": "6329d423dca7b551f01fd884204be45be43f92cd6da6e4faedf2c3c9b4654a15",
|
||||||
|
"libs/ManagerNetzfahrplanV4Trait.php": "e27a3595e4ed8cdb62e2561149ef6a50adb9194e0239eb25c1216aee2716fa88",
|
||||||
|
"libs/ManagerNetzfahrplanV4DatenTrait.php": "59ae0ae283839ca82c58db9c071dcf28d0ab95e44f7a74c6473aca837ce416c4",
|
||||||
|
"libs/NetzfahrplanV4Datenarchiv.php": "57ef7a60c273c5270b3e93ff5528d7d294e708725c9edfe81ac7ed819587f983",
|
||||||
|
"libs/NetzfahrplanV4Messaufnahme.php": "ebe873bb714041e505a4f500b34c9bae3a5a0b93ac459161584ced758507a6e9",
|
||||||
|
"libs/NetzfahrplanV4Bilanzierung.php": "ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199",
|
||||||
|
"libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
|
||||||
|
"libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
|
||||||
|
"libs/ManagerEnergieTrait.php": "2f89b0ad31ee6dd99180a7225173036eb6212b2ea7157e57fee23f0f8b441a51",
|
||||||
|
"libs/EnergieMessung.php": "16c64e1cbefde81c25f913aaca6546f0d30cc8f2fddecea0768a3760a98e97d2",
|
||||||
|
"libs/StoerungsSnapshot.php": "c871c1dd58d7fb1ef6c06985de066518688ef080f3cbdadda192d66cedcf49e1",
|
||||||
|
"libs/Anlagentopologie.php": "1b7e95d103a3048ed38c464a424f2f12821a147b269e149fd6b89bca84878c47",
|
||||||
|
"libs/Lizenzpruefung.php": "cb93cb49b93ed3ebb2060dba28b5b2dec2f29548d6ccff1afd9c7cb4624ad77e",
|
||||||
|
"libs/EinspeiseRegler.php": "8b982be5b95275c6dd6611a2bf2b1f89b991ba53cf94e46df0eed3249add65e0",
|
||||||
|
"libs/ManagerRegler.php": "608a4abebbe45da37a16316a530fa3f5ab21cb880bb341acbdcd3a9aaa93c6ee",
|
||||||
|
"libs/ManagerSchnittstelle.php": "0aae8c8c85eb252c577fcb0b5a50a12f3247e4a0bb083579122854f6c35878b3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Recreate a reviewed Lihrenmoos package from this commit; never install in Symcon."""
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib,json,sys
|
||||||
|
here=Path(__file__).resolve().parent;repo=here.parents[1]
|
||||||
|
if len(sys.argv)!=2:raise SystemExit('Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY')
|
||||||
|
target=Path(sys.argv[1]).resolve()
|
||||||
|
allowed=[Path('/srv/agent'),Path('/home/agent/services/qa')]
|
||||||
|
if not any(target!=p and target.is_relative_to(p) for p in allowed):raise SystemExit('Use a private agent staging directory, not live module paths')
|
||||||
|
if target.exists():raise SystemExit('Destination must not exist')
|
||||||
|
m=json.loads((here/'MANIFEST.json').read_text());contents={}
|
||||||
|
for n,v in m['files'].items():
|
||||||
|
p=repo/n
|
||||||
|
if p.is_symlink() or not p.resolve().is_relative_to(repo):raise SystemExit('Unsafe source')
|
||||||
|
data=p.read_bytes()
|
||||||
|
if hashlib.sha256(data).hexdigest()!=v['after']:raise SystemExit('Reviewed source changed: '+n)
|
||||||
|
contents['source/'+n]=data
|
||||||
|
for n,v in m['dependencies'].items():
|
||||||
|
if hashlib.sha256((repo/n).read_bytes()).hexdigest()!=v:raise SystemExit('Dependency changed: '+n)
|
||||||
|
for n in ['install.php','file_installer.php','MANIFEST.json','feedback-config.json']:contents[n]=(here/n).read_bytes()
|
||||||
|
if hashlib.sha256(contents['feedback-config.json']).hexdigest()!=m['configurationSha256']:raise SystemExit('Configuration changed')
|
||||||
|
target.mkdir(parents=True,mode=0o700)
|
||||||
|
for n,data in contents.items():
|
||||||
|
p=target/n;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(0o640)
|
||||||
|
(target/'PACKAGE_HASHES.json').write_text(json.dumps({n:hashlib.sha256(v).hexdigest() for n,v in contents.items()},indent=2)+'\n')
|
||||||
|
print('Private staging package created:',target,'; no running module, settings or control changes.')
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
|
||||||
|
"assetId": "anlage01-virtual-ev",
|
||||||
|
"managerId": 17004,
|
||||||
|
"batteryInstanceId": 44234,
|
||||||
|
"mode": "virtual_split",
|
||||||
|
"allowEstimatedForTrial": false,
|
||||||
|
"maxSkewSeconds": 30,
|
||||||
|
"maxAgeSeconds": 60,
|
||||||
|
"idleToleranceW": 50.0,
|
||||||
|
"trackingToleranceW": 200.0,
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"key": "grid",
|
||||||
|
"variableId": 40348,
|
||||||
|
"parentId": 11490,
|
||||||
|
"ident": "Power_8",
|
||||||
|
"factorToW": 1000,
|
||||||
|
"role": "grid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "physical_goodwe1",
|
||||||
|
"variableId": 47725,
|
||||||
|
"parentId": 19742,
|
||||||
|
"ident": "A_6_3_35182",
|
||||||
|
"factorToW": -1,
|
||||||
|
"role": "physical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "physical_goodwe2",
|
||||||
|
"variableId": 35724,
|
||||||
|
"parentId": 57658,
|
||||||
|
"ident": "A_6_3_35182",
|
||||||
|
"factorToW": -1,
|
||||||
|
"role": "physical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "physical_solaredge",
|
||||||
|
"variableId": 21447,
|
||||||
|
"parentId": 30789,
|
||||||
|
"ident": "Value",
|
||||||
|
"factorToW": 1,
|
||||||
|
"role": "physical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ev_requested",
|
||||||
|
"variableId": 19651,
|
||||||
|
"parentId": 58448,
|
||||||
|
"ident": "Nennleistung_Soll_EV",
|
||||||
|
"factorToW": 1.0,
|
||||||
|
"role": "ev_request"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sdl_requested",
|
||||||
|
"variableId": 38943,
|
||||||
|
"parentId": 58448,
|
||||||
|
"ident": "Nennleistung_Soll_SDL",
|
||||||
|
"factorToW": 1.0,
|
||||||
|
"role": "sdl_request"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gateway_active",
|
||||||
|
"role": "gateway_active",
|
||||||
|
"variableId": 23483,
|
||||||
|
"parentId": 58448,
|
||||||
|
"ident": "State",
|
||||||
|
"factorToW": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** File-only installation helpers. No module classes loaded from the staging tree. */
|
||||||
|
function v4FeedbackWrite(string $path, string $content, int $mode=0644): void
|
||||||
|
{
|
||||||
|
if (is_link($path)) throw new RuntimeException('Symlink wird nicht ersetzt.');
|
||||||
|
$temp=tempnam(dirname($path),'.v4-feedback-');
|
||||||
|
if ($temp===false) throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
|
||||||
|
try {
|
||||||
|
if (file_put_contents($temp,$content,LOCK_EX)!==strlen($content) || !chmod($temp,$mode) || !rename($temp,$path)) throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
|
||||||
|
} finally { if(is_file($temp))unlink($temp); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function v4FeedbackFiles(string $stage,string $target): array
|
||||||
|
{
|
||||||
|
$allowed=['libs/NetzfahrplanV4Rueckmeldung.php','libs/BatterieNetzfahrplanV4RueckmeldungTrait.php','libs/NetzfahrplanV4Regeltest.php',
|
||||||
|
'libs/BatterieNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4EmpfangTrait.php','libs/BatterieRegler.php','Batterie/module.php','Manager/module.php'];
|
||||||
|
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
if (($m['scope']??null)!=='corrected_feedback_trial_disabled' || count($m['files']??[])!==count($allowed) || array_diff(array_keys($m['files']),$allowed)) throw new RuntimeException('Unerwarteter Dateiumfang.');
|
||||||
|
if (realpath($target)!==$target || is_link($target)) throw new RuntimeException('Modulpfad unerwartet.');
|
||||||
|
foreach($m['dependencies'] as $name=>$hash) {
|
||||||
|
if (!preg_match('~^libs/[A-Za-z0-9]+\.php$~D',$name) || is_link($target.'/'.$name) || hash_file('sha256',$target.'/'.$name)!==$hash) throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: '.$name);
|
||||||
|
}
|
||||||
|
$before=[];$source=[];$already=true;
|
||||||
|
// Explicit dependency order. Both top-level modules are replaced only after all dependencies.
|
||||||
|
foreach($allowed as$name) {
|
||||||
|
$path=$target.'/'.$name;$candidate=$stage.'/source/'.$name;
|
||||||
|
if (is_link($path)||is_link($candidate)||realpath(dirname($path))!==$target.'/'.dirname($name)) throw new RuntimeException('Unerwarteter Dateipfad.');
|
||||||
|
$source[$name]=(string)file_get_contents($candidate);
|
||||||
|
if (hash('sha256',$source[$name])!==$m['files'][$name]['after']) throw new RuntimeException('Paketpruefsumme geaendert: '.$name);
|
||||||
|
token_get_all($source[$name],TOKEN_PARSE);
|
||||||
|
$before[$name]=is_file($path)?file_get_contents($path):null;
|
||||||
|
$hash=$before[$name]===null?null:hash('sha256',$before[$name]);
|
||||||
|
if (!in_array($hash,[$m['files'][$name]['before'],$m['files'][$name]['after']],true)) throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: '.$name);
|
||||||
|
if($hash!==$m['files'][$name]['after'])$already=false;
|
||||||
|
}
|
||||||
|
if($already)return ['status'=>'already_installed','backup'=>null];
|
||||||
|
$backup=$stage.'/backups/'.gmdate('Ymd\THis\Z').'-'.bin2hex(random_bytes(4));
|
||||||
|
if(!mkdir($backup,0700,true))throw new RuntimeException('Sicherung fehlgeschlagen.');
|
||||||
|
foreach($allowed as$name) {
|
||||||
|
if($before[$name]!==null) {
|
||||||
|
$dest=$backup.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);
|
||||||
|
v4FeedbackWrite($dest,$before[$name],0600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v4FeedbackWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600);
|
||||||
|
$written=[];
|
||||||
|
try {
|
||||||
|
foreach($allowed as$name) {
|
||||||
|
if((is_file($target.'/'.$name)?file_get_contents($target.'/'.$name):null)!==$before[$name])throw new RuntimeException('Parallele Aenderung waehrend Installation.');
|
||||||
|
if($before[$name]===$source[$name])continue;
|
||||||
|
v4FeedbackWrite($target.'/'.$name,$source[$name]);$written[]=$name;
|
||||||
|
}
|
||||||
|
} catch(Throwable $e) {
|
||||||
|
foreach(array_reverse($written)as$name) {
|
||||||
|
$p=$target.'/'.$name;
|
||||||
|
if(!is_link($p)&&is_file($p)&&hash_file('sha256',$p)===$m['files'][$name]['after']) {
|
||||||
|
if($before[$name]===null)unlink($p);else v4FeedbackWrite($p,$before[$name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
return ['status'=>'installed','backup'=>$backup,'filesChanged'=>$written];
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Execute only in Lihrenmoos Symcon. Installs code and readonly feedback mapping; never starts a trial.
|
||||||
|
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
|
||||||
|
require_once __DIR__.'/file_installer.php';
|
||||||
|
$manager=17004;$battery=44234;$locked=false;$changedMapping=false;$oldMapping=null;
|
||||||
|
$report=['scope'=>'corrected_feedback_trial_disabled','startedAt'=>gmdate('c'),'actuatorPermissionGranted'=>false,'serverUpdateRequired'=>false];
|
||||||
|
try {
|
||||||
|
$hashes=json_decode((string)file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
foreach($hashes as$name=>$hash) {
|
||||||
|
if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Vorbereitetes Paket veraendert.');
|
||||||
|
}
|
||||||
|
foreach([$manager=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',$battery=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']as$id=>$module) {
|
||||||
|
if(!IPS_InstanceExists($id)||IPS_GetInstance($id)['ModuleInfo']['ModuleID']!==$module)throw new RuntimeException('Modulzuordnung ungueltig.');
|
||||||
|
if(IPS_HasChanges($id))throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
|
||||||
|
}
|
||||||
|
$beforeM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
|
||||||
|
$beforeB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
|
||||||
|
if(($beforeM['NetzfahrplanAktiv']??null)!==false || ($beforeM['NetzfahrplanV4RegeltestErlaubt']??false)!==false || ($beforeB['NetzfahrplanV4RegeltestErlaubt']??false)!==false)throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
|
||||||
|
$c=json_decode((string)file_get_contents(__DIR__.'/feedback-config.json'),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
$manifest=json_decode((string)file_get_contents(__DIR__.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
if(($c['managerId']??null)!==$manager || ($c['batteryInstanceId']??null)!==$battery || ($c['allowEstimatedForTrial']??null)!==false
|
||||||
|
|| hash_file('sha256',__DIR__.'/feedback-config.json')!==$manifest['configurationSha256'])throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
|
||||||
|
$desired=json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION);
|
||||||
|
$oldMapping=$beforeB['NetzfahrplanV4RueckmeldungKonfiguration']??'{}';
|
||||||
|
if(!in_array($oldMapping,['','{}',$desired],true))throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
|
||||||
|
$controls=[];foreach(IPS_GetModuleList()as$module)if((IPS_GetModule($module)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($module));
|
||||||
|
if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.');
|
||||||
|
$locked=IPS_SemaphoreEnter('ENELIX.V4.FeedbackInstall',3000);if(!$locked)throw new RuntimeException('Installation bereits aktiv.');
|
||||||
|
$report['files']=v4FeedbackFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS');
|
||||||
|
if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
|
||||||
|
$afterM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
|
||||||
|
$afterB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
|
||||||
|
foreach([[$beforeM,$afterM],[$beforeB,$afterB]]as$pair)foreach($pair[0]as$key=>$value)if(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value)throw new RuntimeException('Bestehender Parameter beim Reload veraendert: '.$key);
|
||||||
|
if(!array_key_exists('NetzfahrplanV4RueckmeldungKonfiguration',$afterB)||!array_key_exists('NetzfahrplanV4RegeltestErlaubt',$afterM)
|
||||||
|
||!function_exists('ENELIX_GetV4BatterieRueckmeldung')||!function_exists('ENELIX_GetV4ManagerTestStatus')) {
|
||||||
|
$report['status']='waiting_for_registration';
|
||||||
|
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
|
||||||
|
} else {
|
||||||
|
if($afterM['NetzfahrplanV4RegeltestErlaubt']!==false||$afterB['NetzfahrplanV4RegeltestErlaubt']!==false)throw new RuntimeException('Unerwartete Testfreigabe.');
|
||||||
|
if($afterB['NetzfahrplanV4RueckmeldungKonfiguration']!==$desired) {
|
||||||
|
IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$desired);$changedMapping=true;IPS_ApplyChanges($battery);
|
||||||
|
}
|
||||||
|
$finalM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR);
|
||||||
|
$finalB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR);
|
||||||
|
foreach([[$beforeM,$finalM],[$beforeB,$finalB]]as$pair)foreach($pair[0]as$key=>$value)if($key!=='NetzfahrplanV4RueckmeldungKonfiguration'&&(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value))throw new RuntimeException('Bestehender Parameter unerwartet veraendert: '.$key);
|
||||||
|
if(($finalM['NetzfahrplanAktiv']??null)!==false||($finalM['NetzfahrplanV4RegeltestErlaubt']??null)!==false||($finalB['NetzfahrplanV4RegeltestErlaubt']??null)!==false)throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
|
||||||
|
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($battery),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
$report['feedback']=array_intersect_key($f,array_flip(['status','kind','method','reason','reasons','estimated','batteryW','gridW','sourceOldestAt','checkedAt','configHash','usableForTrial','canDispatch']));
|
||||||
|
$report['status']='corrected_feedback_installed_trial_disabled';$report['originalParametersPreserved']=true;
|
||||||
|
echo "OK: Korrigierte physische Rueckmeldung mit Manager-Vorschau und Batterietreiber verbunden.\n";
|
||||||
|
echo 'Rueckmeldung: '.($f['status']??'available').'; Modellanteil: '.(($f['estimated']??false)?'ja':'nein').".\n";
|
||||||
|
if(isset($f['reason']))echo 'Aktueller Hinweis: '.$f['reason']."\n";
|
||||||
|
echo "Beide Regeltestfreigaben AUS. Keine V4-Ausgabe aktiviert; vorhandene Regelung, Energiekonten und Datensender bleiben bestehen.\n";
|
||||||
|
}
|
||||||
|
} catch(Throwable $e) {
|
||||||
|
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,260);
|
||||||
|
if($changedMapping && $oldMapping!==null) {
|
||||||
|
try{IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$oldMapping);IPS_ApplyChanges($battery);$report['mappingRestored']=true;}catch(Throwable $ignored){$report['mappingRestored']=false;}
|
||||||
|
}
|
||||||
|
echo 'FEHLER: '.$report['reason']."\nKeine V4-Stellfreigabe erteilt.\n";
|
||||||
|
} finally {
|
||||||
|
$report['finishedAt']=gmdate('c');v4FeedbackWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644);
|
||||||
|
if($locked)IPS_SemaphoreLeave('ENELIX.V4.FeedbackInstall');
|
||||||
|
echo 'FEEDBACK INSTALL REPORT: '.__DIR__."/INSTALL_RESULT.json\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace Belevo\EnelixEMS;
|
||||||
|
|
||||||
|
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
|
||||||
|
|
||||||
|
/** Shared physical reading used by receiver and battery immediately before a trial command. */
|
||||||
|
trait BatterieNetzfahrplanV4RueckmeldungTrait
|
||||||
|
{
|
||||||
|
private function registriereV4Rueckmeldung(): void
|
||||||
|
{
|
||||||
|
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function v4BatterieRueckmeldung(): array
|
||||||
|
{
|
||||||
|
$json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||||
|
if (strlen($json)>65536) throw new \RuntimeException('feedback_configuration_too_large');
|
||||||
|
$c=json_decode($json,true,32,JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid');
|
||||||
|
if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid');
|
||||||
|
return NetzfahrplanV4Rueckmeldung::read($c,static function(int $id):array {
|
||||||
|
if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing');
|
||||||
|
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id);
|
||||||
|
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
|
||||||
|
},time());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read only. A returned current value is NOT actuator permission. */
|
||||||
|
public function GetV4BatterieRueckmeldung(): string
|
||||||
|
{
|
||||||
|
try { $r=$this->v4BatterieRueckmeldung(); }
|
||||||
|
catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200),
|
||||||
|
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false]; }
|
||||||
|
return json_encode($r,JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,15 @@ use RuntimeException;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
|
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
|
||||||
|
require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php';
|
||||||
|
|
||||||
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
|
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
|
||||||
trait BatterieNetzfahrplanV4TestTrait
|
trait BatterieNetzfahrplanV4TestTrait
|
||||||
{
|
{
|
||||||
|
use BatterieNetzfahrplanV4RueckmeldungTrait;
|
||||||
private function registriereV4BatterieTest(): void
|
private function registriereV4BatterieTest(): void
|
||||||
{
|
{
|
||||||
|
$this->registriereV4Rueckmeldung();
|
||||||
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
|
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
|
||||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
|
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
|
||||||
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
|
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
|
||||||
@@ -69,6 +72,7 @@ trait BatterieNetzfahrplanV4TestTrait
|
|||||||
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
|
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
|
||||||
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
|
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
|
||||||
}
|
}
|
||||||
|
NetzfahrplanV4Rueckmeldung::requireTrial($this->v4BatterieRueckmeldung(), $s, $now);
|
||||||
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
|
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
|
||||||
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
|
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
|
||||||
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
|
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
|
||||||
@@ -157,6 +161,8 @@ trait BatterieNetzfahrplanV4TestTrait
|
|||||||
$s = $this->v4BatterieTestSitzung();$mono = hrtime(true) / 1e9;
|
$s = $this->v4BatterieTestSitzung();$mono = hrtime(true) / 1e9;
|
||||||
try {
|
try {
|
||||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Lokale Testfreigabe entzogen.');
|
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Lokale Testfreigabe entzogen.');
|
||||||
|
$feedback = $this->v4BatterieRueckmeldung();
|
||||||
|
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
|
||||||
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
|
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
|
||||||
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
|
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
|
||||||
if (!$c) {
|
if (!$c) {
|
||||||
@@ -175,6 +181,13 @@ trait BatterieNetzfahrplanV4TestTrait
|
|||||||
if ($last * $w < 0) $w = 0;
|
if ($last * $w < 0) $w = 0;
|
||||||
elseif (abs($w) > abs($last)) $w = $last;
|
elseif (abs($w) > abs($last)) $w = $last;
|
||||||
}
|
}
|
||||||
|
// Final hardware-policy/ramp clipping must not invalidate the binding or grid limits.
|
||||||
|
if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed');
|
||||||
|
$expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w;
|
||||||
|
if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1)
|
||||||
|
|| ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) {
|
||||||
|
throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit');
|
||||||
|
}
|
||||||
$s['lastWallClockAt'] = $now;
|
$s['lastWallClockAt'] = $now;
|
||||||
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
||||||
return $w;
|
return $w;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use RuntimeException;
|
|||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||||
|
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||||
|
|
||||||
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
|
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
|
||||||
trait ManagerNetzfahrplanV4EmpfangTrait
|
trait ManagerNetzfahrplanV4EmpfangTrait
|
||||||
@@ -158,6 +159,31 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
|||||||
$topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR);
|
$topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR);
|
||||||
$actual = [];$now = time();
|
$actual = [];$now = time();
|
||||||
foreach ($topology as $asset) {
|
foreach ($topology as $asset) {
|
||||||
|
$controllers=[];
|
||||||
|
foreach ($this->aktiveVerbraucherIDs() as $controller) {
|
||||||
|
if (IPS_InstanceExists($controller)
|
||||||
|
&& IPS_GetInstance($controller)['ModuleInfo']['ModuleID']==='{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}'
|
||||||
|
&& IPS_GetProperty($controller,'IstleistungVariableID')===($asset['LeistungVariableID']??null)
|
||||||
|
&& IPS_GetProperty($controller,'LadezustandVariableID')===($asset['SOCVariableID']??null)) $controllers[]=$controller;
|
||||||
|
}
|
||||||
|
if (count($controllers)>1) throw new RuntimeException('feedback_controller_ambiguous');
|
||||||
|
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
|
||||||
|
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
|
||||||
|
if (is_string($configured) && !in_array(trim($configured),['','{}'],true)) {
|
||||||
|
if (count($topology)!==1) throw new RuntimeException('feedback_multi_asset_adapter_required');
|
||||||
|
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($controller),true,32,JSON_THROW_ON_ERROR);
|
||||||
|
if (($f['installationId']??null)!==$this->ReadAttributeString('LizenzInstallationID')
|
||||||
|
|| ($f['assetId']??null)!==$asset['ID'] || ($f['managerId']??null)!==$this->InstanceID
|
||||||
|
|| ($f['batteryInstanceId']??null)!==$controller || !is_numeric($f['batteryW']??null)
|
||||||
|
|| !is_numeric($f['gridW']??null) || !is_int($f['checkedAt']??null) || $now-$f['checkedAt']>2 || $f['checkedAt']>$now) {
|
||||||
|
throw new RuntimeException('feedback_invalid_no_legacy_fallback');
|
||||||
|
}
|
||||||
|
$actual[$asset['ID']]=$f['batteryW'];$d['operation']['gridW']=$f['gridW'];
|
||||||
|
$d['operation']['localFeedback'][$asset['ID']]=$f;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy value remains display-only until an explicit physical feedback mapping exists.
|
||||||
$id = (int) ($asset['LeistungVariableID'] ?? 0);
|
$id = (int) ($asset['LeistungVariableID'] ?? 0);
|
||||||
if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.');
|
if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.');
|
||||||
$before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id);
|
$before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id);
|
||||||
@@ -189,6 +215,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
|||||||
$checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'],
|
$checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'],
|
||||||
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']);
|
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']);
|
||||||
$out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
$out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
||||||
|
$out['localFeedback'] = $operation['localFeedback'] ?? [];
|
||||||
|
$out['feedbackMeaning'] = isset($operation['localFeedback']) ? 'fresh_physical_or_explicit_partition_model' : 'legacy_display_only_not_for_trial';
|
||||||
$out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true);
|
$out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true);
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
$out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300)];
|
$out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300)];
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ trait ManagerNetzfahrplanV4TestTrait
|
|||||||
'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 5000),
|
'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 5000),
|
||||||
'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 5000),
|
'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 5000),
|
||||||
'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'),
|
'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'),
|
||||||
|
'feedbackConfigHash' => $op['localFeedback'][$a['assetId']]['configHash'] ?? '',
|
||||||
|
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($op)['limits'],
|
||||||
'sequence' => 1, 'lastWallClockAt' => $now,
|
'sequence' => 1, 'lastWallClockAt' => $now,
|
||||||
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now];
|
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now];
|
||||||
// Validate everything BEFORE arming either module. This rejects all normal shadow replies.
|
// Validate everything BEFORE arming either module. This rejects all normal shadow replies.
|
||||||
@@ -156,6 +158,7 @@ trait ManagerNetzfahrplanV4TestTrait
|
|||||||
}
|
}
|
||||||
if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.');
|
if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.');
|
||||||
$op['gridW'] += $otherDelta;
|
$op['gridW'] += $otherDelta;
|
||||||
|
$op['localPredictedOtherDeltaW']=$otherDelta;
|
||||||
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
|
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
|
||||||
throw new RuntimeException('Kein gueltiger empfangener Testkontext.');
|
throw new RuntimeException('Kein gueltiger empfangener Testkontext.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Belevo\EnelixEMS;
|
|||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||||
|
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||||
|
|
||||||
/** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */
|
/** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */
|
||||||
final class NetzfahrplanV4Regeltest
|
final class NetzfahrplanV4Regeltest
|
||||||
@@ -87,6 +88,11 @@ final class NetzfahrplanV4Regeltest
|
|||||||
if (($a['controlContext'] ?? null) != $plan['controlContext']) {
|
if (($a['controlContext'] ?? null) != $plan['controlContext']) {
|
||||||
throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.');
|
throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.');
|
||||||
}
|
}
|
||||||
|
$feedback = $operation['localFeedback'][$a['assetId']] ?? [];
|
||||||
|
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $local, $now);
|
||||||
|
if (abs(self::number($actual[$a['assetId']] ?? null,'Batterie Ist',-1e9,1e9) - self::number($feedback['batteryW'],'Rueckmeldung',-1e9,1e9)) > 0.000001) throw new InvalidArgumentException('feedback_power_not_from_verified_snapshot');
|
||||||
|
$otherDelta=self::number($operation['localPredictedOtherDeltaW']??0.0,'Andere Verbraucherabweichung',-1e9,1e9);
|
||||||
|
if (abs($operation['gridW']-$feedback['gridW']-$otherDelta)>0.001) throw new InvalidArgumentException('feedback_grid_not_from_same_snapshot');
|
||||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
|
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
|
||||||
$charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, self::MAX_TEST_POWER_W),
|
$charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, self::MAX_TEST_POWER_W),
|
||||||
self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, self::MAX_TEST_POWER_W));
|
self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, self::MAX_TEST_POWER_W));
|
||||||
@@ -109,6 +115,9 @@ final class NetzfahrplanV4Regeltest
|
|||||||
'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge,
|
'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge,
|
||||||
'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'],
|
'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'],
|
||||||
'revision' => $plan['configRevision'], 'expectedGridW' => $grid,
|
'revision' => $plan['configRevision'], 'expectedGridW' => $grid,
|
||||||
|
'feedbackConfigHash' => $feedback['configHash'], 'feedbackEstimated' => $feedback['estimated'],
|
||||||
|
'feedbackSourceOldestAt' => $feedback['sourceOldestAt'],
|
||||||
|
'importLimitW' => $p['importLimitW'], 'exportLimitW' => $p['exportLimitW'],
|
||||||
'executionMeaning' => 'separate_local_control_trial_not_shadow_plan_execution'];
|
'executionMeaning' => 'separate_local_control_trial_not_shadow_plan_execution'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +148,23 @@ final class NetzfahrplanV4Regeltest
|
|||||||
if ($c > $session['maxChargeW'] || $d > $session['maxDischargeW'] || $w > $c || $w < -$d) {
|
if ($c > $session['maxChargeW'] || $d > $session['maxDischargeW'] || $w > $c || $w < -$d) {
|
||||||
throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.');
|
throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.');
|
||||||
}
|
}
|
||||||
|
if (!is_string($command['feedbackConfigHash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $command['feedbackConfigHash'])
|
||||||
|
|| $command['feedbackConfigHash'] !== ($session['feedbackConfigHash'] ?? null)) throw new InvalidArgumentException('feedback_command_binding_invalid');
|
||||||
|
foreach (['importLimitW','exportLimitW'] as $limit) {
|
||||||
|
if (!array_key_exists($limit,$command)) throw new InvalidArgumentException('feedback_command_limits_missing');
|
||||||
|
if ($command[$limit] !== null) self::number($command[$limit],$limit,0,1e9);
|
||||||
|
}
|
||||||
|
$l=$session['gridLimits'] ?? null;
|
||||||
|
if (!is_array($l) || !array_key_exists('importW',$l) || !array_key_exists('exportW',$l) || !is_array($l['managerMonthLimitsW']??null)) throw new InvalidArgumentException('session_grid_limits_missing');
|
||||||
|
$month=(int)(new \DateTimeImmutable('@'.$now))->setTimezone(new \DateTimeZone('Europe/Zurich'))->format('n');
|
||||||
|
$import=$l['importW'];$monthly=$l['managerMonthLimitsW'][$month]??null;
|
||||||
|
if ($monthly!==null) $import=$import===null?$monthly:min($import,$monthly);
|
||||||
|
foreach (['importLimitW'=>$import,'exportLimitW'=>$l['exportW']] as $key=>$limit) {
|
||||||
|
if ($limit!==null) {
|
||||||
|
self::number($limit,$key,0,1e9);
|
||||||
|
if ($command[$key]===null || $command[$key]>$limit) throw new InvalidArgumentException('command_relaxes_local_grid_limit');
|
||||||
|
}
|
||||||
|
}
|
||||||
self::uuid($command['sourceShadowPlanId'] ?? null);
|
self::uuid($command['sourceShadowPlanId'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace Belevo\EnelixEMS;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/** Fresh physical feedback. Pure reader: no writes, history reconstruction or actuator authority. */
|
||||||
|
final class NetzfahrplanV4Rueckmeldung
|
||||||
|
{
|
||||||
|
private static function number($v, float $lo, float $hi): float
|
||||||
|
{
|
||||||
|
if ((!is_int($v) && !is_float($v)) || !is_finite((float)$v) || $v<$lo || $v>$hi) {
|
||||||
|
throw new InvalidArgumentException('feedback_numeric_invalid');
|
||||||
|
}
|
||||||
|
return (float)$v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ordered(array $v): array
|
||||||
|
{
|
||||||
|
if (!array_is_list($v)) ksort($v, SORT_STRING);
|
||||||
|
foreach ($v as &$x) if (is_array($x)) $x=self::ordered($x);
|
||||||
|
unset($x);return $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function configuration(array $c): array
|
||||||
|
{
|
||||||
|
$keys=['version','installationId','assetId','managerId','batteryInstanceId','mode','allowEstimatedForTrial',
|
||||||
|
'maxSkewSeconds','maxAgeSeconds','idleToleranceW','trackingToleranceW','sources'];
|
||||||
|
if (count($c)!==count($keys) || array_diff($keys,array_keys($c)) || $c['version']!==1
|
||||||
|
|| !is_string($c['installationId']) || !preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/D',$c['installationId'])
|
||||||
|
|| !is_string($c['assetId']) || $c['assetId']==='' || strlen($c['assetId'])>100
|
||||||
|
|| !in_array($c['mode'],['physical_sum','virtual_split'],true) || !is_bool($c['allowEstimatedForTrial'])) {
|
||||||
|
throw new InvalidArgumentException('feedback_configuration_invalid');
|
||||||
|
}
|
||||||
|
foreach (['managerId','batteryInstanceId'] as $k) if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>99999) throw new InvalidArgumentException('feedback_binding_invalid');
|
||||||
|
if ($c['managerId']===$c['batteryInstanceId']) throw new InvalidArgumentException('feedback_binding_invalid');
|
||||||
|
foreach (['maxSkewSeconds'=>30,'maxAgeSeconds'=>60] as $k=>$max) {
|
||||||
|
if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>$max) throw new InvalidArgumentException('feedback_freshness_invalid');
|
||||||
|
}
|
||||||
|
$c['idleToleranceW']=self::number($c['idleToleranceW'],0,100);
|
||||||
|
$c['trackingToleranceW']=self::number($c['trackingToleranceW'],0,500);
|
||||||
|
if (!is_array($c['sources']) || !array_is_list($c['sources']) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid');
|
||||||
|
$ids=[];$names=[];$roles=[];
|
||||||
|
foreach ($c['sources'] as &$s) {
|
||||||
|
$fields=['key','role','variableId','parentId','ident','factorToW'];
|
||||||
|
if (!is_array($s) || count($s)!==count($fields) || array_diff($fields,array_keys($s))
|
||||||
|
|| !is_string($s['key']) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D',$s['key'])
|
||||||
|
|| isset($names[$s['key']]) || !in_array($s['role'],['grid','physical','ev_request','sdl_request','gateway_active'],true)
|
||||||
|
|| !is_int($s['variableId']) || $s['variableId']<1 || $s['variableId']>99999 || isset($ids[$s['variableId']])
|
||||||
|
|| !is_int($s['parentId']) || $s['parentId']<1 || !is_string($s['ident']) || strlen($s['ident'])>100) throw new InvalidArgumentException('feedback_source_identity_invalid');
|
||||||
|
$s['factorToW']=self::number($s['factorToW'],-1e6,1e6);
|
||||||
|
if ($s['factorToW']==0 || ($s['role']==='gateway_active' && $s['factorToW']!==1.0)) throw new InvalidArgumentException('feedback_factor_invalid');
|
||||||
|
$ids[$s['variableId']]=true;$names[$s['key']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
|
||||||
|
}
|
||||||
|
unset($s);
|
||||||
|
if (($roles['grid']??0)!==1 || ($roles['physical']??0)<1) throw new InvalidArgumentException('feedback_measurements_required');
|
||||||
|
foreach (['ev_request','sdl_request','gateway_active'] as $role) {
|
||||||
|
if (($roles[$role]??0)!==($c['mode']==='virtual_split'?1:0)) throw new InvalidArgumentException('feedback_partition_sources_required');
|
||||||
|
}
|
||||||
|
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));
|
||||||
|
return self::ordered($c);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fingerprint(array $c): string
|
||||||
|
{
|
||||||
|
return hash('sha256',json_encode(self::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reader supplies value, original updated/changed, parentID, ident. Never trusts collector timestamp alone. */
|
||||||
|
public static function read(array $config, callable $reader, int $now): array
|
||||||
|
{
|
||||||
|
$c=self::configuration($config);
|
||||||
|
if ($now<1) throw new InvalidArgumentException('feedback_time_invalid');
|
||||||
|
$first=[];$values=[];$times=[];$physicalTimes=[];$changed=[];
|
||||||
|
foreach ($c['sources'] as $s) {
|
||||||
|
$r=$reader($s['variableId']);
|
||||||
|
if (!is_array($r) || ($r['parentID']??null)!==$s['parentId'] || ($r['ident']??null)!==$s['ident']
|
||||||
|
|| !is_int($r['updated']??null) || $r['updated']<1 || $r['updated']>$now
|
||||||
|
|| !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
|
||||||
|
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident']));
|
||||||
|
if ($s['role']==='gateway_active') {
|
||||||
|
if (($r['value']??null)!==true) throw new InvalidArgumentException('feedback_gateway_inactive');
|
||||||
|
} else {
|
||||||
|
$w=self::number($r['value']??null,-1e9,1e9)*$s['factorToW'];
|
||||||
|
$values[$s['role']]=($values[$s['role']]??0)+self::number($w,-1e9,1e9);
|
||||||
|
}
|
||||||
|
// An unchanged command is held STATE, not proof of a fresh sensor or an actuator watchdog.
|
||||||
|
if (in_array($s['role'],['grid','physical'],true)) {
|
||||||
|
if ($now-$r['updated']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
|
||||||
|
$times[]=$r['updated'];if ($s['role']==='physical') $physicalTimes[]=$r['updated'];
|
||||||
|
} else $changed[$s['role']]=$r['changed'];
|
||||||
|
}
|
||||||
|
foreach ($c['sources'] as $s) {
|
||||||
|
$second=$reader($s['variableId']);
|
||||||
|
if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident']))!==$first[$s['key']]) {
|
||||||
|
throw new InvalidArgumentException('feedback_changed_during_read:'.$s['key']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (max($times)-min($times)>$c['maxSkewSeconds']) throw new InvalidArgumentException('feedback_source_skew');
|
||||||
|
$physical=(float)$values['physical'];$power=$physical;$external=0.0;$estimated=$c['mode']==='virtual_split';$reasons=[];$tracking=null;
|
||||||
|
if ($estimated) {
|
||||||
|
if ($changed['gateway_active']>min($physicalTimes) || $changed['sdl_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_external_transition_not_observed');
|
||||||
|
$ev=(float)$values['ev_request'];$sdl=(float)$values['sdl_request'];$tracking=$physical-$ev-$sdl;
|
||||||
|
if ($sdl===0.0) {
|
||||||
|
// No external commanded contribution. Physical response may lag a NEW EV command.
|
||||||
|
$power=$physical;
|
||||||
|
if ($ev===0.0 && abs($physical)>$c['idleToleranceW']) throw new InvalidArgumentException('feedback_unassigned_idle_power');
|
||||||
|
} else {
|
||||||
|
if ($changed['ev_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_ev_transition_not_observed');
|
||||||
|
if (abs($tracking)>$c['trackingToleranceW']) throw new InvalidArgumentException('feedback_partition_not_tracking');
|
||||||
|
$power=$ev+$tracking*abs($ev)/(abs($ev)+abs($sdl));$external=$physical-$power;
|
||||||
|
if ($ev*$sdl<0) $reasons[]='opposing_virtual_flows_not_identifiable';
|
||||||
|
}
|
||||||
|
if (!$c['allowEstimatedForTrial']) $reasons[]='modelled_feedback_not_accepted_for_trial';
|
||||||
|
}
|
||||||
|
return ['version'=>1,'kind'=>'local_physical_feedback','installationId'=>$c['installationId'],'assetId'=>$c['assetId'],
|
||||||
|
'managerId'=>$c['managerId'],'batteryInstanceId'=>$c['batteryInstanceId'],'checkedAt'=>$now,
|
||||||
|
'sourceOldestAt'=>min($times),'sourceNewestAt'=>max($times),'physicalOldestAt'=>min($physicalTimes),
|
||||||
|
'sourceSkewSeconds'=>max($times)-min($times),'maxAgeSeconds'=>$c['maxAgeSeconds'],
|
||||||
|
'configHash'=>self::fingerprint($c),'gridW'=>(float)$values['grid'],'physicalStorageW'=>$physical,
|
||||||
|
'batteryW'=>$power,'externalModelW'=>$external,'trackingErrorW'=>$tracking,
|
||||||
|
'estimated'=>$estimated,'separatelyMeasured'=>!$estimated,'usableForTrial'=>$reasons===[],
|
||||||
|
'reasons'=>$reasons,'method'=>$estimated?'physical_request_partition_unfiltered':'physical_sum',
|
||||||
|
'canDispatch'=>false,'gatewayWatchdogVerified'=>false,'heldFilterUsed'=>false];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function requireTrial(array $f, array $binding, int $now): void
|
||||||
|
{
|
||||||
|
foreach (['installationId','assetId','managerId','batteryInstanceId'] as $key) {
|
||||||
|
if (!array_key_exists($key,$binding) || ($f[$key]??null)!==$binding[$key]) throw new InvalidArgumentException('feedback_wrong_binding');
|
||||||
|
}
|
||||||
|
if (($f['kind']??null)!=='local_physical_feedback' || ($f['version']??null)!==1 || ($f['usableForTrial']??null)!==true
|
||||||
|
|| !is_int($f['checkedAt']??null) || $f['checkedAt']>$now || $now-$f['checkedAt']>2
|
||||||
|
|| !is_int($f['sourceOldestAt']??null) || !is_int($f['maxAgeSeconds']??null)
|
||||||
|
|| $f['maxAgeSeconds']<1 || $f['maxAgeSeconds']>60 || $f['sourceOldestAt']>$now || $now-$f['sourceOldestAt']>$f['maxAgeSeconds']
|
||||||
|
|| !is_string($f['configHash']??null) || !preg_match('/^[a-f0-9]{64}$/D',$f['configHash'])) throw new InvalidArgumentException('feedback_not_usable_for_trial');
|
||||||
|
if (isset($binding['feedbackConfigHash']) && $binding['feedbackConfigHash']!==$f['configHash']) throw new InvalidArgumentException('feedback_configuration_changed');
|
||||||
|
self::number($f['batteryW']??null,-1e9,1e9);self::number($f['gridW']??null,-1e9,1e9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,6 @@ final class NetzfahrplanV4BilanzierungTest extends TestCase
|
|||||||
$p=proc_open([PHP_BINARY,__DIR__.'/V4Accounting/checks.php'],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
$p=proc_open([PHP_BINARY,__DIR__.'/V4Accounting/checks.php'],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
||||||
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
||||||
fclose($pipes[1]);fclose($pipes[2]);self::assertSame(0,proc_close($p),$out.$err);self::assertSame('',$err);
|
fclose($pipes[1]);fclose($pipes[2]);self::assertSame(0,proc_close($p),$out.$err);self::assertSame('',$err);
|
||||||
self::assertStringContainsString('TOTAL 34 checks passed',$out);
|
self::assertStringContainsString('TOTAL 42 checks passed',$out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,6 @@ final class NetzfahrplanV4DatenTest extends TestCase
|
|||||||
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
||||||
fclose($pipes[1]);fclose($pipes[2]);
|
fclose($pipes[1]);fclose($pipes[2]);
|
||||||
self::assertSame(0,proc_close($process),$out.$err);
|
self::assertSame(0,proc_close($process),$out.$err);
|
||||||
self::assertStringContainsString('TOTAL 21 application-data checks passed',$out);
|
self::assertStringContainsString('TOTAL 27 application-data checks passed',$out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class NetzfahrplanV4RegeltestTest extends TestCase
|
|||||||
fclose($pipes[1]);fclose($pipes[2]);
|
fclose($pipes[1]);fclose($pipes[2]);
|
||||||
self::assertSame(0, proc_close($process), $out . $err);
|
self::assertSame(0, proc_close($process), $out . $err);
|
||||||
self::assertSame('', $err);
|
self::assertSame('', $err);
|
||||||
self::assertStringContainsString('TOTAL 59 checks passed.', $out);
|
self::assertStringContainsString('TOTAL 71 checks passed.', $out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class NetzfahrplanV4RueckmeldungTest extends TestCase
|
||||||
|
{
|
||||||
|
private function checkScript(string $name): void
|
||||||
|
{
|
||||||
|
$pipes=[];
|
||||||
|
$p=proc_open([PHP_BINARY,__DIR__.'/V4Feedback/'.$name],
|
||||||
|
[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
||||||
|
self::assertIsResource($p);fclose($pipes[0]);
|
||||||
|
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]);fclose($pipes[2]);
|
||||||
|
self::assertSame(0,proc_close($p),$out.$err);
|
||||||
|
self::assertSame('',$err);self::assertStringContainsString('TOTAL ',$out);
|
||||||
|
}
|
||||||
|
public function testPhysicalFeedback(): void {$this->checkScript('checks.php');}
|
||||||
|
public function testActualReceiverIntegration(): void {$this->checkScript('receiver_checks.php');}
|
||||||
|
public function testActualFallbackWrapper(): void {$this->checkScript('fallback_checks.php');}
|
||||||
|
public function testMessageWithoutVisibleDiagnostics(): void {$this->checkScript('message_checks.php');}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ namespace Belevo\EnelixEMS {
|
|||||||
function IPS_GetInstance($id) { return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
|
function IPS_GetInstance($id) { return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
|
||||||
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']]; }
|
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']]; }
|
||||||
function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; }
|
function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; }
|
||||||
|
function IPS_VariableExists($id) { return in_array($id,[50001,50002],true); }
|
||||||
|
function IPS_GetVariable($id) { $t=$GLOBALS['feedbackAt'][$id]??$GLOBALS['clock'];return ['VariableUpdated'=>$t,'VariableChanged'=>$t]; }
|
||||||
|
function IPS_GetObject($id) { return ['ParentID'=>200,'ObjectIdent'=>'source'.$id]; }
|
||||||
|
function GetValue($id) { return $id===50001?$GLOBALS['objects'][17004]->op['gridW']:$GLOBALS['objects'][17004]->actual['b']; }
|
||||||
|
|
||||||
function IPS_RequestAction($id,$key,$value) {
|
function IPS_RequestAction($id,$key,$value) {
|
||||||
$GLOBALS['calls'][]=[$id,$key];$b=$GLOBALS['objects'][$id];
|
$GLOBALS['calls'][]=[$id,$key];$b=$GLOBALS['objects'][$id];
|
||||||
if ($key==='NetzfahrplanV4TestStart') $b->V4BatterieTestStarten($value);
|
if ($key==='NetzfahrplanV4TestStart') $b->V4BatterieTestStarten($value);
|
||||||
@@ -76,7 +81,10 @@ namespace Belevo\EnelixEMS {
|
|||||||
private function berechtigungLizenziert($k) { return $this->licensed; }
|
private function berechtigungLizenziert($k) { return $this->licensed; }
|
||||||
private function actieveDummy() {}
|
private function actieveDummy() {}
|
||||||
private function aktiveVerbraucherIDs() { return [44234]; }
|
private function aktiveVerbraucherIDs() { return [44234]; }
|
||||||
private function netzfahrplanV4LokaleVorschauwerte() { return [$this->op,$this->actual]; }
|
private function netzfahrplanV4LokaleVorschauwerte() {
|
||||||
|
$op=$this->op;$f=json_decode($GLOBALS['objects'][44234]->GetV4BatterieRueckmeldung(),true);
|
||||||
|
$op['localFeedback']=['b'=>$f];return [$op,$this->actual];
|
||||||
|
}
|
||||||
private function regeln($renew) { $this->fallback++; }
|
private function regeln($renew) { $this->fallback++; }
|
||||||
public function tick(&$allocation,$consumers=null,$sync=true) {
|
public function tick(&$allocation,$consumers=null,$sync=true) {
|
||||||
$this->v4ManagerTestUebergabe($allocation,$consumers??[['InstanzID'=>44234,'Daten'=>[]]],$sync);
|
$this->v4ManagerTestUebergabe($allocation,$consumers??[['InstanzID'=>44234,'Daten'=>[]]],$sync);
|
||||||
@@ -105,7 +113,14 @@ namespace {
|
|||||||
'maxChargeW'=>5000.,'maxDischargeW'=>5000.,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog',
|
'maxChargeW'=>5000.,'maxDischargeW'=>5000.,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog',
|
||||||
'accountingEvidenceId'=>'synthetic-meter-boundary-v1','acceptEstimatedPeak'=>true,'controlContext'=>$r['plan']['controlContext']];
|
'accountingEvidenceId'=>'synthetic-meter-boundary-v1','acceptEstimatedPeak'=>true,'controlContext'=>$r['plan']['controlContext']];
|
||||||
$r['controlledTrial']=$serverAuthority?$a:null;
|
$r['controlledTrial']=$serverAuthority?$a:null;
|
||||||
$m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b];
|
$m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b];$GLOBALS['feedbackAt']=[];
|
||||||
|
$fc=['version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,
|
||||||
|
'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,
|
||||||
|
'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
|
||||||
|
foreach ([50001=>'grid',50002=>'physical'] as $vid=>$role) $fc['sources'][]=['key'=>'source'.$vid,'role'=>$role,'variableId'=>$vid,'parentId'=>200,'ident'=>'source'.$vid,'factorToW'=>1.0];
|
||||||
|
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($fc);
|
||||||
|
$op['localFeedback']=['b'=>json_decode($b->GetV4BatterieRueckmeldung(),true)];
|
||||||
|
|
||||||
$m->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;$b->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;
|
$m->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;$b->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;
|
||||||
$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='synthetic-hardware-watchdog';
|
$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='synthetic-hardware-watchdog';
|
||||||
$start=['confirmation'=>'START_BOUNDED_V4_CONTROL_TRIAL','sessionId'=>$a['sessionId'],'durationSeconds'=>300,'maxChargeW'=>5000.,'maxDischargeW'=>5000.];
|
$start=['confirmation'=>'START_BOUNDED_V4_CONTROL_TRIAL','sessionId'=>$a['sessionId'],'durationSeconds'=>300,'maxChargeW'=>5000.,'maxDischargeW'=>5000.];
|
||||||
@@ -157,5 +172,6 @@ namespace {
|
|||||||
$bad=$r;$bad['controlledTrial']['validUntil']=gmdate('c',$now+120);rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'oversized server authorization lease');
|
$bad=$r;$bad['controlledTrial']['validUntil']=gmdate('c',$now+120);rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'oversized server authorization lease');
|
||||||
$bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance');
|
$bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance');
|
||||||
require __DIR__ . '/additional_checks.php';
|
require __DIR__ . '/additional_checks.php';
|
||||||
|
require __DIR__ . '/feedback_checks.php';
|
||||||
echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n";
|
echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
// Run through actual Manager/Battery trial traits, fresh read helper, and simulated register driver.
|
||||||
|
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RueckmeldungKonfiguration']='{}';
|
||||||
|
rejects(fn()=>start($m,$s),'trial cannot use old filtered feedback when mapping missing');check($b->writes===[],'missing corrected feedback sends no command');
|
||||||
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock-61;
|
||||||
|
$a=tick($m);check(!$m->active()&&!$b->active()&&isset($a['Sollwerte'][44234]),'physical feedback expiry returns battery to normal allocation');
|
||||||
|
check(end($b->writes)===0,'expired physical feedback revokes prior command');
|
||||||
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock-61;$b->watchdog();
|
||||||
|
check(!$b->active()&&end($b->writes)===0,'independent battery read catches staleness without manager tick');
|
||||||
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=json_decode($b->props['NetzfahrplanV4RueckmeldungKonfiguration'],true);$c['trackingToleranceW']=250;
|
||||||
|
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($c);$b->watchdog();
|
||||||
|
check(!$b->active()&&end($b->writes)===0,'mapping revision change revokes active battery session');
|
||||||
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['feedbackConfigHash']=str_repeat('f',64);
|
||||||
|
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'changed feedback command binding rejected');
|
||||||
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();check(isset($c['feedbackConfigHash'],$c['feedbackSourceOldestAt'])&&array_key_exists('importLimitW',$c),'command includes source provenance and limits');
|
||||||
|
$c['sequence']++;unset($c['exportLimitW']);rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'missing explicit grid limit not interpreted as unlimited');
|
||||||
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['importLimitW']=1000.;$c['watts']=0;
|
||||||
|
$m->op['gridW']=1500.;$b->measurements['Ladezustand']=15.;
|
||||||
|
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'final device clipping cannot silently violate accepted grid boundary');
|
||||||
|
check(!$b->active()&&end($b->writes)===0,'unreachable final limit aborts software trial');
|
||||||
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock+1;tick($m);
|
||||||
|
check(!$m->active(),'future physical sample is not smoothed into control');
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
// Offline only, no actual Symcon calls.
|
||||||
|
declare(strict_types=1);
|
||||||
|
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only');
|
||||||
|
require_once __DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php';
|
||||||
|
use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung as F;
|
||||||
|
$count=0;
|
||||||
|
function check($b,$s){global $count;if(!$b)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
|
||||||
|
function fails($f,$s){try{$f();}catch(Throwable $e){check(true,$s);return;}throw new RuntimeException('FAIL no rejection '.$s);}
|
||||||
|
function fx($mode='physical_sum') {
|
||||||
|
$now=1791032400;$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234,
|
||||||
|
'mode'=>$mode,'allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
|
||||||
|
$roles=['grid','physical','physical'];if($mode==='virtual_split')$roles=array_merge($roles,['ev_request','sdl_request','gateway_active']);$values=[3000.0,-2000.0,-1000.0,-3000.0,0.0,true];$r=[];
|
||||||
|
foreach($roles as$i=>$role){$id=100+$i;$c['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'q'.$id,'factorToW'=>1];$r[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-60,'parentID'=>200,'ident'=>'q'.$id];}
|
||||||
|
return [$c,$r,$now];
|
||||||
|
}
|
||||||
|
function readit($c,$r,$n){return F::read($c,fn($id)=>$r[$id],$n);}
|
||||||
|
[$c,$r,$n]=fx();$f=readit($c,$r,$n);
|
||||||
|
check($f['batteryW']===-3000.0&&$f['gridW']===3000.0,'physical total and grid signs retained');
|
||||||
|
check($f['usableForTrial']&&!$f['estimated']&&!$f['canDispatch'],'usable feedback alone never grants dispatch');
|
||||||
|
F::requireTrial($f,$c,$n);check(true,'matching trial binding accepted');
|
||||||
|
check(F::fingerprint($c)===F::fingerprint(json_decode(json_encode($c),true)),'configuration hash stable over numeric property roundtrip');
|
||||||
|
$d=$c;$d['sources']=array_reverse($d['sources']);check(F::fingerprint($c)===F::fingerprint($d),'source list order canonical');
|
||||||
|
$d['trackingToleranceW']=201;check(F::fingerprint($c)!==F::fingerprint($d),'changed tolerance invalidates session hash');
|
||||||
|
foreach([true,'1',NAN,INF,null]as$bad){$x=$r;$x[101]['value']=$bad;fails(fn()=>readit($c,$x,$n),'invalid physical number '.gettype($bad));}
|
||||||
|
$x=$r;$x[101]['updated']=$n-61;$x[101]['changed']=$n-90;fails(fn()=>readit($c,$x,$n),'physical age not refreshed by read');
|
||||||
|
$x=$r;$x[101]['updated']=$n-31;fails(fn()=>readit($c,$x,$n),'incoherent grid battery timestamps rejected');
|
||||||
|
$x=$r;$x[101]['updated']=$n+1;fails(fn()=>readit($c,$x,$n),'future original sample rejected');
|
||||||
|
$x=$r;$x[101]['parentID']=201;fails(fn()=>readit($c,$x,$n),'reused variable wrong parent rejected');
|
||||||
|
$x=$r;$x[101]['ident']='other';fails(fn()=>readit($c,$x,$n),'reused variable wrong ident rejected');
|
||||||
|
$d=$c;$d['sources'][1]['variableId']=$d['sources'][0]['variableId'];fails(fn()=>F::configuration($d),'duplicate physical source rejected');
|
||||||
|
$d=$c;$d['maxAgeSeconds']=300;fails(fn()=>F::configuration($d),'historical freshness not accepted for live control');
|
||||||
|
$calls=[];fails(function()use($c,$r,$n,&$calls){F::read($c,function($id)use($r,&$calls){$calls[$id]=($calls[$id]??0)+1;$x=$r[$id];if($calls[$id]>1)$x['value']+=1;return$x;},$n);},'same-second change during double read rejected');
|
||||||
|
$d=$c;$d['sources'][1]['factorToW']=-1;check(readit($d,$r,$n)['batteryW']===1000.0,'vendor sign conversion once');
|
||||||
|
$f=readit($c,$r,$n);fails(fn()=>F::requireTrial($f,$c,$n+3),'old validated snapshot not reused');
|
||||||
|
$d=$c;$d['managerId']=20000;fails(fn()=>F::requireTrial($f,$d,$n),'different manager cannot use feedback');
|
||||||
|
$d=$c;$d['feedbackConfigHash']=str_repeat('a',64);fails(fn()=>F::requireTrial($f,$d,$n),'changed mapping aborts trial');
|
||||||
|
[$c,$r,$n]=fx('virtual_split');$r[103]['updated']=$r[104]['updated']=$r[105]['updated']=$n-86400;$r[103]['changed']=$r[104]['changed']=$r[105]['changed']=$n-172800;
|
||||||
|
$r[101]['value']=$r[102]['value']=$r[103]['value']=$r[104]['value']=0.0;$f=readit($c,$r,$n);
|
||||||
|
check($f['batteryW']===0.0&&$f['estimated'],'unchanged idle commands need no artificial heartbeat');
|
||||||
|
check(!$f['usableForTrial'],'virtual estimate requires explicit acceptance');
|
||||||
|
$c['allowEstimatedForTrial']=true;$f=readit($c,$r,$n);check($f['usableForTrial']&&!$f['gatewayWatchdogVerified'],'idle acceptance is not gateway watchdog proof');
|
||||||
|
$r[101]['value']=20.0;$f=readit($c,$r,$n);check($f['batteryW']===20.0,'idle residual not clipped to zero');
|
||||||
|
$r[101]['value']=60.0;fails(fn()=>readit($c,$r,$n),'unexplained idle power refused');
|
||||||
|
$r[101]['value']=-2000.0;$r[102]['value']=-1000.0;$r[103]['value']=2000.0;$r[103]['updated']=$n;$r[103]['changed']=$n;
|
||||||
|
$f=readit($c,$r,$n);check($f['batteryW']===-3000.0,'single EV feedback follows physics not desired sign or held filter');
|
||||||
|
$r[104]['value']=1000.0;$r[104]['updated']=$n;$r[104]['changed']=$n;$r[101]['updated']=$n-1;
|
||||||
|
fails(fn()=>readit($c,$r,$n),'SDL transition after physical sample cannot be guessed');
|
||||||
|
[$c,$r,$n]=fx('virtual_split');$c['allowEstimatedForTrial']=true;$r[103]['value']=3000.0;$r[104]['value']=1000.0;$r[101]['value']=1900.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n);
|
||||||
|
check($f['batteryW']===2925.0&&$f['externalModelW']===975.0,'small tracking error allocated but explicitly estimated');
|
||||||
|
check(!$f['separatelyMeasured']&&$f['heldFilterUsed']===false,'partition never labelled separately measured');
|
||||||
|
$r[101]['value']=1000.0;fails(fn()=>readit($c,$r,$n),'large tracking error prevents stale virtual correction');
|
||||||
|
$r[103]['value']=42000.0;$r[104]['value']=-40000.0;$r[101]['value']=0.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n);
|
||||||
|
check(!$f['usableForTrial']&&in_array('opposing_virtual_flows_not_identifiable',$f['reasons'],true),'counterflow cannot be independently attributed');
|
||||||
|
$r[105]['value']=false;fails(fn()=>readit($c,$r,$n),'disabled gateway blocks feedback');
|
||||||
|
$src=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php');
|
||||||
|
check(!preg_match('/\b(?:SetValue|IPS_RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$src),'pure feedback has no control side effects');
|
||||||
|
echo "TOTAL $count physical-feedback checks passed. Synthetic source readings only.\n";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
// Exercise the EXACT serialized fallback wrapper from Manager/module.php in an isolated harness.
|
||||||
|
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline only');
|
||||||
|
$source=file_get_contents(__DIR__.'/../../Manager/module.php');
|
||||||
|
$a=strpos($source,' private function regeln(bool $erneuern): void');$b=strpos($source,' private function regelnIntern(bool $erneuern): void',$a);
|
||||||
|
if($a===false||$b===false)throw new RuntimeException('Manager wrapper source changed');
|
||||||
|
$method=substr($source,$a,$b-$a);$calls=[];$locked=false;
|
||||||
|
function IPS_SemaphoreEnter($k,$t){global$locked;if($locked)return false;$locked=true;return true;}
|
||||||
|
function IPS_SemaphoreLeave($k){global$locked;$locked=false;}
|
||||||
|
function IPS_GetInstance($id){return ['InstanceStatus'=>$GLOBALS['instance']->status];}
|
||||||
|
eval('class FallbackHarness { const STATUS_AKTIV=102; public $InstanceID=17004,$trial=true,$active=true,$status=102,$abortFirst=true,$throwFirst=false,$passes=[],$attributes=[],$stops=[];'.
|
||||||
|
'public function run(){ $this->regeln(false); } private function v4ManagerTestSession(){return $this->trial?[1]:[];} private function GetValue($k){return $this->active;}'.
|
||||||
|
'private function WriteAttributeString($k,$v){$this->attributes[$k]=$v;} private function v4ManagerTestAbbrechen($s){$this->stops[]=$s;$this->trial=false;}'.
|
||||||
|
'private function regelnIntern(bool $r):void { $this->passes[]=$r;if(count($this->passes)==1){if($this->throwFirst)throw new RuntimeException("synthetic_failure");if($this->abortFirst)$this->trial=false;}}'. $method.'}');
|
||||||
|
$count=0;function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
|
||||||
|
$instance=new FallbackHarness();$instance->run();ck($instance->passes===[false,true],'trial abort performs exactly one fresh ordinary recomputation');
|
||||||
|
ck($instance->attributes['LetzteSollwerte']==='{}','pre-trial cached target is discarded before fallback');ck(!$locked,'control semaphore released after fallback');
|
||||||
|
$instance=new FallbackHarness();$instance->trial=false;$instance->run();ck($instance->passes===[false],'ordinary control path does not gain extra passes');
|
||||||
|
$instance=new FallbackHarness();$instance->abortFirst=false;$instance->run();ck($instance->passes===[false],'valid trial not overridden by ordinary pass');
|
||||||
|
$instance=new FallbackHarness();$instance->status=201;$instance->run();ck($instance->passes===[false],'invalid measurement status prevents forced ordinary recomputation');
|
||||||
|
$instance=new FallbackHarness();$instance->throwFirst=true;try{$instance->run();}catch(RuntimeException $e){}
|
||||||
|
ck(!$locked&&!$instance->trial&&count($instance->stops)===1,'exception revokes trial and releases semaphore without endless retries');
|
||||||
|
echo "TOTAL $count actual manager fallback-wrapper checks passed. No real IPS runtime.\n";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
// Exact Battery message builder with no optional visible diagnostic variables.
|
||||||
|
if(function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Offline only');
|
||||||
|
class IPSModule {
|
||||||
|
public int $InstanceID=44234;public array $buffers=['V4FeedbackEstimated'=>'1'];public bool $valid=true;
|
||||||
|
protected function GetBuffer($k){return $this->buffers[$k]??'';}
|
||||||
|
protected function GetValue($k){throw new RuntimeException('No diagnostic variables exist: '.$k);}
|
||||||
|
protected function ReadAttributeBoolean($k){return $k==='ZustandLadezustandGueltig'?$this->valid:false;}
|
||||||
|
protected function ReadAttributeInteger($k){return 0;}
|
||||||
|
protected function ReadAttributeFloat($k){return 0.0;}
|
||||||
|
protected function ReadAttributeString($k){return $k==='Leistungsangebot'?'[0]':($k==='Betriebsart'?'Auto':'');}
|
||||||
|
protected function ReadPropertyInteger($k){return 1;}
|
||||||
|
}
|
||||||
|
require __DIR__.'/../../Batterie/module.php';
|
||||||
|
use Belevo\EnelixEMS\Nachrichtenvertrag as N;
|
||||||
|
$b=new Batterie();$read=new ReflectionMethod(Batterie::class,'baueVerbraucherdaten');$count=0;
|
||||||
|
function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
|
||||||
|
$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'partition source quality independent of hidden diagnostic variables');
|
||||||
|
$b->buffers['V4FeedbackEstimated']='0';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_GEMESSEN,'ordinary physical source marked measured');
|
||||||
|
$b->valid=false;$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_NICHT_VORHANDEN&&$d['Istleistung_W']===null,'invalid sample never published as measured');
|
||||||
|
$b->valid=true;$b->buffers['V4FeedbackEstimated']='1';$b->buffers['V4BatteryTrialSession']='{}';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'last estimate not relabelled measured immediately after trial ends');
|
||||||
|
$source=file_get_contents(__DIR__.'/../../Batterie/module.php');ck(str_contains($source,"SetBuffer('V4FeedbackEstimated', (\$messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0')"),'fresh measurement updates internal source quality');
|
||||||
|
echo "TOTAL $count actual message-builder checks passed. No real variables or writes.\n";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
namespace Belevo\EnelixEMS {
|
||||||
|
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('CLI test only');
|
||||||
|
require_once __DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
|
||||||
|
class Lizenzpruefung {const NETZFAHRPLAN='synthetic';}
|
||||||
|
function time(){return $GLOBALS['now'];}
|
||||||
|
function IPS_InstanceExists($id){return $id===44234;}
|
||||||
|
function IPS_GetInstance($id){return ['ModuleInfo'=>['ModuleID'=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']];}
|
||||||
|
function IPS_GetProperty($id,$k){return ['IstleistungVariableID'=>1002,'LadezustandVariableID'=>1001,'NetzfahrplanV4RueckmeldungKonfiguration'=>$GLOBALS['configured']?'configured':'{}'][$k];}
|
||||||
|
function IPS_VariableExists($id){return $id===1002;}
|
||||||
|
function IPS_GetVariable($id){return ['VariableUpdated'=>$GLOBALS['now']];}
|
||||||
|
function GetValue($id){return -9000.;} // deliberately held/incorrect legacy virtual display
|
||||||
|
class FeedbackReceiver {
|
||||||
|
use ManagerNetzfahrplanV4EmpfangTrait;
|
||||||
|
public int $InstanceID=17004;public array $props,$attrs,$op;
|
||||||
|
public function __construct($r,$op){$this->op=$op;$this->props=['NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>1002,'SOCVariableID'=>1001,'Leistungsfaktor'=>1]])];$this->attrs=['LizenzInstallationID'=>$r['installationId'],'NetzfahrplanV4Empfang'=>json_encode(['response'=>$r,'receivedAt'=>time()]),'NetzfahrplanV4EmpfangStatus'=>'{}'];}
|
||||||
|
private function ReadPropertyBoolean($k){return $this->props[$k];}
|
||||||
|
private function ReadPropertyString($k){return $this->props[$k];}
|
||||||
|
private function ReadPropertyInteger($k){return $this->props[$k];}
|
||||||
|
private function ReadAttributeString($k){return $this->attrs[$k];}
|
||||||
|
private function aktiveVerbraucherIDs(){return [44234];}
|
||||||
|
private function berechtigungLizenziert($k){return true;}
|
||||||
|
public function GetNetzfahrplanV4Diagnose(){return json_encode(['operation'=>$this->op]);}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace {
|
||||||
|
function ENELIX_GetV4BatterieRueckmeldung($id){return json_encode($GLOBALS['feedback']);}
|
||||||
|
require_once __DIR__.'/../V4Receiver/fixture.php';
|
||||||
|
use Belevo\EnelixEMS\FeedbackReceiver;
|
||||||
|
[$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0;
|
||||||
|
$feedback=['kind'=>'local_physical_feedback','version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,'batteryW'=>0.,'gridW'=>$op['gridW'],'checkedAt'=>$now,'estimated'=>true,'usableForTrial'=>false,'canDispatch'=>false,'configHash'=>str_repeat('a',64)];
|
||||||
|
function ck($v,$t){global$count;if(!$v)throw new RuntimeException('FAIL '.$t);$count++;echo "PASS $t\n";}
|
||||||
|
$m=new FeedbackReceiver($response,$op);$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
|
||||||
|
ck($p['status']==='preview_only'&&$p['actualBatteryW']==0,'real receiver uses corrected physical feedback rather than held -9kW');
|
||||||
|
ck($p['previewBatteryW']==3000,'net target translated once to total battery demand');
|
||||||
|
ck($p['canDispatch']===false&&$p['localFeedback']['b']['estimated']===true,'model feedback remains marked and preview grants no dispatch');
|
||||||
|
$feedback=['status'=>'unavailable','batteryW'=>null,'gridW'=>null];$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
|
||||||
|
ck($p['status']==='unavailable'&&$p['previewBatteryW']===null,'configured feedback failure never falls back to held virtual display');
|
||||||
|
$configured=false;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
|
||||||
|
ck($p['status']==='preview_only'&&$p['feedbackMeaning']==='legacy_display_only_not_for_trial','unconfigured old preview stays compatible but explicitly not for control');
|
||||||
|
ck($p['canDispatch']===false,'legacy display cannot gain actuation permission');
|
||||||
|
echo "TOTAL $count actual receiver checks passed; synthetic read-only controller API.\n";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user