feat(manager): receive and preview V4 shadow plans without dispatch

This commit is contained in:
ENELIX Agent
2026-10-02 13:01:04 +00:00
parent fc4c4632ce
commit b5c1fa6be2
9 changed files with 847 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4EmpfangTest extends TestCase
{
/** @dataProvider scripts */
public function testIsolatedReceiverChecks(string $script): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Receiver/' . $script],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
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($process), $out . $err);
self::assertStringContainsString('Total:', $out);
}
public function scripts(): array
{
return [['protocol_checks.php'], ['receiver_checks.php']];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
function receiverFixture(): array
{
$now = strtotime('2026-10-02T12:00:00Z');
$id = 'e3a08f9e-af12-4695-99bd-8b51c0520021';
$operation = ['eventId' => 'op-original', 'observedAt' => gmdate('c', $now), 'gridW' => -3000.0,
'batteries' => [['id' => 'b', 'capacityKwh' => 20.0, 'socPercent' => 50.0, 'minSocPercent' => 15.0,
'maxSocPercent' => 90.0, 'physicalMinSocPercent' => 3.0, 'rearmSocPercent' => 17.0,
'gridCharging' => true, 'dischargeBlocked' => false, 'maxChargeW' => 5000.0, 'maxDischargeW' => 4000.0,
'measuredAt' => gmdate('c', $now)]],
'limits' => ['importW' => 25000.0, 'exportW' => 25000.0, 'managerMonthLimitsW' => []]];
$points = [];
for ($i = 0; $i < 6; $i++) $points[] = ['time' => gmdate('c', $now + $i*300), 'validUntil' => gmdate('c', $now + ($i+1)*300),
'baselineGridW' => -3000.0, 'gridTargetW' => 0.0, 'batteryTargetW' => 3000.0, 'pvCurtailmentW' => 0.0,
'assetTargetsW' => ['b' => 3000.0], 'socEndPercent' => ['b' => 50.0 + $i], 'intent' => 'pvCharge',
'importLimitW' => 25000.0, 'exportLimitW' => 25000.0];
$plan = ['schemaVersion' => 2, 'planId' => '10000000-0000-4000-8000-000000000001', 'installationId' => $id,
'configRevision' => 1, 'sourceFamily' => '3', 'runMode' => 'shadow', 'liveEnabled' => false, 'executable' => true,
'status' => 'optimal', 'generatedAt' => gmdate('c', $now), 'validFrom' => gmdate('c', $now),
'validUntil' => gmdate('c', $now + 1800), 'pricesKnownUntil' => gmdate('c', $now + 1800),
'controlContext' => \Belevo\EnelixEMS\NetzfahrplanV4Planpruefung::kontext($operation),
'inputRefs' => ['operation' => 'op-original', 'forecast' => 'fc-original', 'tariffs' => 'tar-original'],
'points' => $points, 'warnings' => ['Estimated peak; shadow only']];
$response = ['receiverProtocolVersion' => 1, 'installationId' => $id, 'checkedAt' => gmdate('c', $now),
'liveEnabled' => false, 'fresh' => true, 'pending' => null,
'settings' => ['revision' => 1, 'family' => '3', 'runMode' => 'shadow', 'roundtripEfficiency' => .9],
'families' => [['key' => '3'], ['key' => '13'], ['key' => '23']], 'plan' => $plan,
'lastRun' => ['status' => 'optimal', 'detail' => ['planId' => $plan['planId']]]];
return [$response, $id, $operation, $now];
}
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../../libs/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/fixture.php';
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as P;
$count = 0;
function check($ok, string $label): void { global $count; if (!$ok) throw new RuntimeException('FAIL ' . $label); $count++;echo 'PASS ' . $label . "\n"; }
function invalid(string $name, callable $change): void {
[$r,$id,$op,$now] = receiverFixture();$receipt=$now;
$change($r,$op,$now,$receipt);
try { P::pruefen($r,$id,$op,$now,$receipt); } catch (Throwable $e) { check(true,$name);return; }
check(false,$name);
}
[$r,$id,$op,$now]=receiverFixture();
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0.0],$now);
check($v['previewBatteryW']===3000.0 && $v['canDispatch']===false && $v['liveEnabled']===false,'zero grid uses actual surplus, never permission');
invalid('wrong installation',fn(&$r)=>$r['installationId']='00000000-0000-4000-8000-000000000001');
invalid('foreign plan',fn(&$r)=>$r['plan']['installationId']='00000000-0000-4000-8000-000000000001');
invalid('old protocol',function(&$r){unset($r['receiverProtocolVersion']);});
invalid('revision mismatch',fn(&$r)=>$r['plan']['configRevision']=2);
invalid('boolean revision',fn(&$r)=>$r['plan']['configRevision']=true);
invalid('live response',fn(&$r)=>$r['liveEnabled']=true);
invalid('live plan',fn(&$r)=>$r['plan']['runMode']='live');
invalid('fake executable',fn(&$r)=>$r['plan']['executable']='true');
invalid('stale server flag',fn(&$r)=>$r['fresh']=false);
invalid('pending computation',fn(&$r)=>$r['pending']=['reason'=>'new price']);
invalid('last run different plan',fn(&$r)=>$r['lastRun']['detail']['planId']='wrong');
invalid('last run invalid',fn(&$r)=>$r['lastRun']['status']='invalid_inputs');
invalid('family mismatch',fn(&$r)=>$r['settings']['family']='23');
invalid('unknown family',fn(&$r)=>$r['plan']['sourceFamily']='unregistered');
invalid('receipt stale',function(&$r,&$op,&$now,&$receipt){$receipt-=91;});
invalid('future receipt',function(&$r,&$op,&$now,&$receipt){$receipt++;});
invalid('stale server response',function(&$r)use($now){$r['checkedAt']=gmdate('c',$now-91);});
invalid('future plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now+10);});
invalid('stale plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now-901);});
invalid('price horizon too short',function(&$r)use($now){$r['plan']['pricesKnownUntil']=gmdate('c',$now+300);});
invalid('stale local operation',function(&$r,&$op)use($now){$op['observedAt']=gmdate('c',$now-61);});
invalid('changed local reserve',fn(&$r,&$op)=>$op['batteries'][0]['minSocPercent']=30.0);
invalid('changed local grid limit',fn(&$r,&$op)=>$op['limits']['importW']=2000.0);
invalid('typed context required',fn(&$r)=>$r['plan']['controlContext']['batteries']['b']['capacityKwh']='20');
invalid('input provenance missing',function(&$r){unset($r['plan']['inputRefs']);});
invalid('unknown assets',function(&$r){$r['plan']['points'][0]['assetTargetsW']=['c'=>3000];});
invalid('overlap',function(&$r){$r['plan']['points'][1]['time']=$r['plan']['points'][0]['time'];});
invalid('gap',function(&$r)use($now){$r['plan']['points'][1]['time']=gmdate('c',$now+310);});
invalid('bool target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']=false);
invalid('string target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']='0');
invalid('NaN target',fn(&$r)=>$r['plan']['points'][0]['batteryTargetW']=NAN);
invalid('broken balance',fn(&$r)=>$r['plan']['points'][0]['baselineGridW']=10000.0);
invalid('unknown intent',fn(&$r)=>$r['plan']['points'][0]['intent']='discharge_all');
invalid('no timezone',fn(&$r)=>$r['plan']['points'][0]['time']='2026-10-02T12:00:00');
invalid('bad calendar date',fn(&$r)=>$r['plan']['points'][0]['time']='2026-02-30T12:00:00Z');
invalid('missing step',fn(&$r)=>$r['plan']['points']=[]);
invalid('negative curtailment',fn(&$r)=>$r['plan']['points'][0]['pvCurtailmentW']=-100.0);
[$r,$id,$op,$now]=receiverFixture();$r['checkedAt']=$op['observedAt']=gmdate('c',$now+300);$op['batteries'][0]['measuredAt']=$op['observedAt'];
$x=P::pruefen($r,$id,$op,$now+300,$now+300);
check($x['point']['time']===gmdate('c',$now+300),'half-open boundary picks current, not interpolated');
[$r,$id,$op,$now]=receiverFixture();$r['plan']['sourceFamily']=$r['settings']['family']='future-family';$r['families'][]=['key'=>'future-family'];
check(P::pruefen($r,$id,$op,$now,$now)['plan']['sourceFamily']==='future-family','registry is extensible');
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['dischargeBlocked']=true;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
check($v['previewBatteryW']===0.0,'local hysteresis prevents preview discharge');
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['socPercent']=15.001;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
check($v['previewBatteryW']>-3 && $v['previewBatteryW']<0,'remaining-step energy cannot cross reserve');
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['socPercent']=90;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.0,'full battery cannot charge');
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['maxChargeW']=0;$op['limits']['exportW']=0.;$r['plan']['controlContext']=P::kontext($op);
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['unmetGridLimitW']===3000.0 && !$v['canDispatch'],'zero export limit reports unmet constraint');
[$r,$id,$op,$now]=receiverFixture();$x=P::pruefen($r,$id,$op,$now,$now);$op['gridW']=2000;$op['batteries'][0]['gridCharging']=false;
$x['point']['gridTargetW']=8000.;$x['point']['intent']='gridCharge';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'grid charging opt-in enforced');
$op['batteries'][0]['gridCharging']=true;$x['point']['intent']='hold';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'nonzero hold does not accidentally charge');
$x['point']['gridTargetW']=0.;$x['point']['intent']='discharge';$op['gridW']=0.;$v=P::vorschau($x,$op,['b'=>-2000],$now);check($v['previewBatteryW']===-2000.,'uses actual battery feedback, not incremental zero');
try { P::dekodieren(str_repeat('x',P::MAX_RESPONSE_BYTES+1));check(false,'body limit'); } catch (InvalidArgumentException $e) {check(true,'body limit');}
echo "Total: $count checks. Synthetic data only.\n";
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Belevo\EnelixEMS {
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
class Lizenzpruefung { public const NETZFAHRPLAN = 'grid_schedule'; }
foreach (['CURLOPT_CUSTOMREQUEST','CURLOPT_FOLLOWLOCATION','CURLOPT_CONNECTTIMEOUT','CURLOPT_TIMEOUT',
'CURLOPT_SSL_VERIFYPEER','CURLOPT_SSL_VERIFYHOST','CURLOPT_PROTOCOLS','CURLOPT_HTTPHEADER',
'CURLOPT_WRITEFUNCTION','CURLOPT_POSTFIELDS','CURLINFO_HTTP_CODE','CURLPROTO_HTTPS'] as $i=>$name) {
if (!defined($name)) define($name,50000+$i);
}
function time() { return $GLOBALS['clock']; }
function IPS_SemaphoreEnter($name,$wait) { if ($GLOBALS['lock']) return false;$GLOBALS['lock']=true;return true; }
function IPS_SemaphoreLeave($name) { $GLOBALS['lock']=false; }
function IPS_VariableExists($id) {return $id===100;}
function IPS_GetVariable($id) {return ['VariableUpdated'=>$GLOBALS['clock']];}
function GetValue($id) {return $GLOBALS['actual'];}
function curl_init($url) { return (object)['url'=>$url,'options'=>[],'code'=>0]; }
function curl_setopt_array($h,$options) { $h->options=$options;return true; }
function curl_getinfo($h,$key) { return $h->code; }
function curl_close($h) {}
function curl_exec($h) {
$GLOBALS['requests'][]=['url'=>$h->url,'options'=>$h->options];
$ack=str_ends_with($h->url,'/ack');
$h->code=$ack?$GLOBALS['ackCode']:$GLOBALS['getCode'];
$body=$ack?['status'=>'recorded']:$GLOBALS['response'];
$json=json_encode($body,JSON_THROW_ON_ERROR);
$callback=$h->options[CURLOPT_WRITEFUNCTION];
return $callback($h,$json)===strlen($json);
}
final class ReceiverHarness {
use ManagerNetzfahrplanV4EmpfangTrait;
public int $InstanceID=17004;
public array $props=[],$attrs=[],$vars=[],$timers=[];
public bool $licensed=true;
public array $op=[];
public function __construct($id,$op) {
$this->op=$op;
$this->props=['NetzfahrplanV4EmpfangAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,
'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,
'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>100,'Leistungsfaktor'=>1]])];
$this->attrs=['LizenzInstallationID'=>$id,'PrognoseInstallationsToken'=>str_repeat('x',43)];
$this->registriereNetzfahrplanV4Empfang();
}
public function receive() {$this->empfangeNetzfahrplanV4();}
public function update() {$this->aktualisiereNetzfahrplanV4Vorschau();}
public function stop() {$this->stoppeNetzfahrplanV4Vorschau();}
public function configure() {$this->konfiguriereNetzfahrplanV4Vorschau();}
public function form() {return $this->netzfahrplanV4EmpfangFormular();}
public function GetNetzfahrplanV4Diagnose() { return json_encode(['operation'=>$this->op]); }
private function berechtigungLizenziert($key) {return $this->licensed;}
private function ReadPropertyBoolean($key) {return (bool)$this->props[$key];}
private function ReadPropertyString($key) {return $this->props[$key];}
private function ReadPropertyInteger($key) {return (int)$this->props[$key];}
private function ReadAttributeString($key) {return $this->attrs[$key]??'';}
private function ReadAttributeInteger($key) {return (int)($this->attrs[$key]??0);}
private function WriteAttributeString($key,$val) {
if (!str_starts_with($key,'NetzfahrplanV4')) throw new \RuntimeException('Unexpected external attribute');
$this->attrs[$key]=$val;
}
private function WriteAttributeInteger($key,$val) { $this->attrs[$key]=$val; }
private function RegisterPropertyBoolean($key,$default) {$this->props[$key]??=$default;}
private function RegisterAttributeString($key,$default) {$this->attrs[$key]??=$default;}
private function RegisterAttributeInteger($key,$default) {$this->attrs[$key]??=$default;}
private function RegisterVariableString($key,$name,$profile,$pos) {$this->vars[$key]='';}
private function RegisterTimer($key,$interval,$script) {$this->timers[$key]=$interval;}
private function SetTimerInterval($key,$interval) {$this->timers[$key]=$interval;}
private function SetValue($key,$value) {
if (!in_array($key,['NetzfahrplanV4Vorschau','NetzfahrplanV4VorschauJSON'],true)) throw new \RuntimeException('Actuator/foreign variable write');
$this->vars[$key]=$value;
}
}
}
namespace {
require_once __DIR__.'/fixture.php';
use Belevo\EnelixEMS\ReceiverHarness;
$count=0;
function check($ok,$name) {global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";}
function fixture() {
[$r,$id,$op,$now]=receiverFixture();
$GLOBALS['response']=$r;$GLOBALS['clock']=$now;$GLOBALS['actual']=0.;$GLOBALS['requests']=[];
$GLOBALS['lock']=false;$GLOBALS['getCode']=200;$GLOBALS['ackCode']=200;
return new ReceiverHarness($id,$op);
}
function advance($h,$seconds) {
$GLOBALS['clock']+=$seconds;$GLOBALS['response']['checkedAt']=gmdate('c',$GLOBALS['clock']);
$h->op['observedAt']=gmdate('c',$GLOBALS['clock']);$h->op['batteries'][0]['measuredAt']=$h->op['observedAt'];
}
$h=fixture();$h->receive();check(count($requests)===0,'default disabled no network');
$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();check($h->timers['NetzfahrplanV4VorschauAktualisieren']===10000,'watchdog without network');
$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check($v['status']==='preview_only' && $v['previewBatteryW']===3000 && $v['canDispatch']===false,'read and show only');
check(count($requests)===2 && str_ends_with($requests[1]['url'],'/ack'),'one read and one acknowledgement');
$body=json_decode($requests[1]['options'][CURLOPT_POSTFIELDS],true);check($body['status']==='shadow_seen' && $body['step']===$response['plan']['points'][0]['time'],'ack is shadow_seen, never applied');
$h->receive();check(count($requests)===2,'repeated click throttled');
advance($h,60);$h->receive();check(count($requests)===3,'same plan step not acknowledged twice');
advance($h,91);$h->update();$v=json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true);
check($v['status']==='unavailable' && $v['previewBatteryW']===null,'expired cache clears numeric targets, not fake zero');
check(!str_contains($h->vars['NetzfahrplanV4Vorschau'],'3000'),'expired HTML clears previous setpoint');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();advance($h,61);$GLOBALS['getCode']=429;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}' && json_decode($h->GetNetzfahrplanV4Vorschau(),true)['previewBatteryW']===null,'429 invalidates cache');
check(!str_contains(json_encode($h->vars),str_repeat('x',43)),'no credentials in diagnosis');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->props['NetzfahrplanAktiv']=true;$h->receive();check(count($requests)===0,'legacy execution on blocks preview acquisition');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['installationId']='00000000-0000-4000-8000-000000000001';$h->receive();
check(count($requests)===1 && $h->attrs['NetzfahrplanV4Empfang']==='{}','foreign response not acknowledged');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['ackCode']=503;$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check($v['status']==='preview_only' && !$v['reception']['acknowledged'] && isset($v['reception']['acknowledgementError']),'ack failure is visible, no false execution confirmation');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->op['batteries'][0]['minSocPercent']=30.;$h->update();
check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'local policy change invalidates cache');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->stop();check($h->attrs['NetzfahrplanV4Empfang']==='{}' && $h->timers['NetzfahrplanV4VorschauAktualisieren']===0,'apply/restart invalidates cached plan');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->licensed=false;$h->receive();check(count($requests)===0,'no license no read');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['lock']=true;$h->receive();check(count($requests)===0,'concurrent receiver does not send');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->props['NetzfahrplanV4EmpfangAktiv']=false;$h->update();check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'disable clears displayed target');
$form=$h->form();check($form['items'][1]['name']==='NetzfahrplanV4EmpfangAktiv','separate dynamic form field');
$source=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php');
check(!preg_match('/\b(?:SendDataToChildren|SendDataToParent|sendeManagerdaten|regeln|SetStatus|EnableAction|IPS_SetProperty|IPS_ApplyChanges)\s*\(/',$source),'no control or external configuration call in receiver');
check(!str_contains($source,"'status' => 'applied'"),'never acknowledges applied');
echo "Total: $count checks. Mocked IPS and HTTP only.\n";
}