feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s

Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
This commit is contained in:
dh
2026-10-06 14:22:16 +00:00
207 changed files with 20340 additions and 11 deletions
+140
View File
@@ -0,0 +1,140 @@
<?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 $session=[];
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 GetNetzfahrplanV4Diagnose() { return json_encode(['operation'=>$this->op]); }
private function aktiveVerbraucherIDs() {return [];}
private function v4ManagerTestSession() {return $this->session;}
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');
$source=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php');
check(!str_contains($source, 'netzfahrplanV4EmpfangFormular'), 'receiver no longer owns a separate form panel');
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');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();
check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===10000,'refresh has an independent timer');
$h->session=['kind'=>'local_active_test_consent'];$h->receive();$saved=$h->attrs['NetzfahrplanV4Empfang'];
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+45,'active refresh due after 45 seconds');
advance($h,45);$GLOBALS['getCode']=503;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']===$saved,'temporary error retains exact original active envelope');
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+15,'temporary error retries after 15 seconds');
advance($h,46);$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}','active envelope older than 90 seconds is discarded');
$h->stop();check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===0,'stop disables independent refresh timer');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->session=['kind'=>'local_active_test_consent'];$h->receive();
$future=json_decode($h->attrs['NetzfahrplanV4Empfang'],true);$future['receivedAt']=$clock+120;
$h->attrs['NetzfahrplanV4Empfang']=json_encode($future);advance($h,45);$GLOBALS['getCode']=503;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}','future receipt cannot survive failed refresh');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['pending']=['sequence'=>1];$GLOBALS['response']['fresh']=false;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+15 && $h->attrs['NetzfahrplanV4Empfang']==='{}',
'pending startup retries after 15 seconds without creating a usable envelope');
$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check(($v['reception']['status']??null)==='rejected_or_unavailable','unavailable preview preserves receiver rejection detail');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['getCode']=429;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+60,'startup rate-limit response retains ordinary backoff');
echo "Total: $count checks. Mocked IPS and HTTP only.\n";
}