feat(data): add passive versioned raw capture independent of control

This commit is contained in:
ENELIX Agent
2026-10-02 14:55:43 +00:00
parent e0e52d2454
commit 76f8b067cc
7 changed files with 603 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4MessaufnahmeTest extends TestCase
{
/** @dataProvider scripts */
public function testPassiveCapture(string $name): void
{
$pipes=[];$p=proc_open([PHP_BINARY,__DIR__.'/V4DataCapture/'.$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::assertStringContainsString('TOTAL',$out);
}
public function scripts(): array { return [['checks.php'],['native_checks.php']]; }
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Messaufnahme.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
$n = 0;
function ck(bool $ok, string $label): void { global $n; if (!$ok) throw new RuntimeException('FAIL ' . $label); $n++; echo 'PASS ' . $label . "\n"; }
function refuses(callable $f, string $label): void { try { $f(); } catch (Throwable $e) { ck(true,$label); return; } throw new RuntimeException('Expected refusal: '.$label); }
$now = 1790952000;
$roles=['grid','pv','physical_storage','controlled_virtual','external_virtual'];
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,
'reportedInventorySha256'=>str_repeat('a',64),'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]],
'extraSources'=>[['key'=>'soc','variableId'=>106,'parentId'=>200,'ident'=>'soc','unit'=>'percent']]];
$values=[3000,5000,2000,1500,500]; $readings=[];
foreach($roles as $i=>$role){
$key='s'.$i;$id=101+$i;
$c['accounting']['sources'][]=['key'=>$key,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>in_array($i,[3,4],true)?['s2']:[]];
$readings[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];
}
$readings[106]=['value'=>0.,'updated'=>$now,'changed'=>0,'parentID'=>200,'ident'=>'soc'];
function take(array $c,array $r,int $now): array { return Capture::capture($c,static fn(int $id):array=>$r[$id],static fn():int=>$now); }
$r=take($c,$readings,$now);
ck($r['assessment']['baseLoadCandidateW']===6000.,'physical battery subtracted exactly once');
ck($r['raw']['soc']['value']===0. && $r['raw']['soc']['sourceChangedAt']===0,'real zero and unchanged timestamp retained');
ck(!$r['trainingEligible'] && !$r['controlEligible'] && !$r['measurementBoundaryVerified'],'algebra never enables training or control');
ck($r['futureSdl']==='unknown_no_assumption','no fabricated future SDL');
ck(count($r['raw'])===6,'raw extra sources retained separately');
$a=$readings;$a[103]['updated']=$now-90;$a[104]['updated']=$now;
$x=take($c,$a,$now);ck($x['assessment']['status']==='quality_hold','old physical measurement quarantines candidate');
ck($x['raw']['s2']['sourceUpdatedAt']===$now-90,'original old timestamp preserved');
ck(in_array('stale_origin:s3',$x['assessment']['issues'],true),'fresh virtual write cannot mask old dependency');
$a=$readings;$a[101]['value']=null;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && $x['assessment']===null,'missing source not zero');
$a=$readings;$a[101]['value']='sensitive-test-text';$x=take($c,$a,$now);ck(!str_contains(json_encode($x),'sensitive-test-text'),'unexpected strings never logged');
foreach([true,NAN,INF,1.0e20] as $v){$a=$readings;$a[101]['value']=$v;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && is_string(json_encode($x,JSON_THROW_ON_ERROR)),'invalid numeric represented safely');}
$a=$readings;$a[101]['parentID']=999;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && $x['assessment']===null,'reused ID not recorded as correct measurement');
$a=$readings;$a[102]['updated']=$now+1;$x=take($c,$a,$now);ck($x['assessment']===null && in_array('s1:future_source',$x['issues'],true),'future timestamp marked');
$a=$readings;$a[101]['updated']=0;$x=take($c,$a,$now);ck($x['assessment']===null,'absent timestamp not invented');
$counts=[];$x=Capture::capture($c,static function($id)use(&$counts,$readings){$r=$readings[$id];$counts[$id]=($counts[$id]??0)+1;if($id===101 && $counts[$id]===2)$r['value']++;return $r;},static fn()=>$now);
ck($x['assessment']===null && $x['raw']['s0']['secondValue']===3001,'same-second concurrent change visible');
$x=Capture::capture($c,static function($id)use($readings){if($id===101)throw new RuntimeException('do-not-emit-secret');return $readings[$id];},static fn()=>$now);
ck(!str_contains(json_encode($x),'do-not-emit-secret') && $x['assessment']===null,'source errors bounded without secret messages');
$a=$readings;$a[106]['value']=null;$x=take($c,$a,$now);ck($x['assessment']!==null && !$x['trainingEligible'],'optional SOC error does not destroy available raw powers');
$a=$readings;$a[101]['value']=-10000;$x=take($c,$a,$now);ck($x['assessment']['baseLoadCandidateW']<0 && $x['assessment']['status']==='quality_hold','negative candidate not clipped');
$a=$c;$a['extraSources'][]=$a['extraSources'][0];refuses(fn()=>take($a,$readings,$now),'duplicate source rejected');
$a=$c;$a['accounting']['sources']=array_reverse($a['accounting']['sources']);ck(take($a,$readings,$now)['mappingSha256']===$r['mappingSha256'],'mapping identity stable');
$t=0;refuses(fn()=>Capture::capture($c,static fn($id)=>$readings[$id],static function()use(&$t,$now){return $now-$t++;}),'backwards clock rejected');
$base=sys_get_temp_dir().'/v4-raw-test-'.bin2hex(random_bytes(5));mkdir($base,0700);$dir=$base.'/data';mkdir($dir,0700);
try{
$file=Capture::append($dir,$r);$first=file_get_contents($file);Capture::append($dir,$r);
ck(file_get_contents($file)===$first.$first,'append retains existing observations');
ck(count(file($file))===2,'one record per line');
ck((fileperms($file)&0777)===0640,'data permissions restricted');
$old=file_get_contents($file);refuses(fn()=>Capture::append($dir,$r,1),'quota refuses without deleting data');ck(file_get_contents($file)===$old,'quota leaves journal unchanged');
file_put_contents($file,'partial',FILE_APPEND);refuses(fn()=>Capture::append($dir,$r),'crash-truncated tail not silently extended');ck(str_ends_with(file_get_contents($file),'partial'),'incomplete record retained for review');
unlink($file);file_put_contents($base.'/foreign','untouched');symlink($base.'/foreign',$file);refuses(fn()=>Capture::append($dir,$r),'journal symlink refused');ck(file_get_contents($base.'/foreign')==='untouched','unrelated file preserved');unlink($file);
$lock=fopen($dir.'/.writer.lock','c+b');flock($lock,LOCK_EX);refuses(fn()=>Capture::append($dir,$r),'parallel writer refused');flock($lock,LOCK_UN);fclose($lock);
$a=$r;$a['blob']=str_repeat('x',300000);refuses(fn()=>Capture::append($dir,$a),'oversized record refused');
}finally{foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);}
$source=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Messaufnahme.php');
ck(!preg_match('/\b(?:IPS_|GetValue|SetValue|curl_|RequestAction|shell_exec|exec\s*\()/',$source),'capture core has no device or network calls');
echo "TOTAL $n checks passed; synthetic values and temporary files only.\n";
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
// ALL IPS functions below are mocks. Never execute this test in a real Symcon kernel.
if (PHP_SAPI !== 'cli') throw new RuntimeException('CLI isolation required');
require_once __DIR__ . '/../../examples/V4DataCapture/install.php';
$count=0;$objects=[];$values=[];$scripts=[];$timers=[];$nextId=60000;$locks=[];$timerCalls=[];
function yes(bool $v,string $s):void{global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo 'PASS '.$s."\n";}
function IPS_GetKernelVersion(){return '8.0';}
function IPS_GetObject($id){return $GLOBALS['objects'][$id]??throw new RuntimeException('Missing object');}
function IPS_GetChildrenIDs($id){return array_keys(array_filter($GLOBALS['objects'],static fn($o)=>$o['ParentID']===$id));}
function IPS_InstanceExists($id){return $id===17004;}
function IPS_GetInstance($id){return ['ModuleInfo'=>['ModuleID'=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];}
function IPS_VariableExists($id){return isset($GLOBALS['values'][$id]);}
function IPS_GetVariable($id){$v=$GLOBALS['values'][$id];return ['VariableType'=>is_string($v)?3:2,'VariableUpdated'=>time(),'VariableChanged'=>time()-10];}
function GetValue($id){return $GLOBALS['values'][$id];}
function SetValue($id,$v){if($id<60000||IPS_GetObject($id)['ObjectIdent']!=='CaptureStatus')throw new RuntimeException('Foreign variable write');$GLOBALS['values'][$id]=$v;return true;}
function IPS_SemaphoreEnter($key,$wait){if($GLOBALS['locks'][$key]??false)return false;$GLOBALS['locks'][$key]=true;return true;}
function IPS_SemaphoreLeave($key){$GLOBALS['locks'][$key]=false;}
function makeObj($type){$id=$GLOBALS['nextId']++;$GLOBALS['objects'][$id]=['ObjectType'=>$type,'ObjectIdent'=>'','ParentID'=>0,'ObjectInfo'=>''];return $id;}
function IPS_CreateCategory(){return makeObj(0);}
function IPS_CreateScript($type){$id=makeObj(3);$GLOBALS['scripts'][$id]='<?php';return $id;}
function IPS_CreateVariable($type){if($type!==3)throw new RuntimeException('Only string status allowed');$id=makeObj(2);$GLOBALS['values'][$id]='';return $id;}
function setObj($id,$key,$v){if($id<60000)throw new RuntimeException('Existing object modified');$GLOBALS['objects'][$id][$key]=$v;return true;}
function IPS_SetParent($id,$v){return setObj($id,'ParentID',$v);}
function IPS_SetIdent($id,$v){return setObj($id,'ObjectIdent',$v);}
function IPS_SetName($id,$v){return setObj($id,'ObjectName',$v);}
function IPS_SetInfo($id,$v){return setObj($id,'ObjectInfo',$v);}
function IPS_GetScriptContent($id){return $GLOBALS['scripts'][$id];}
function IPS_SetScriptContent($id,$v){if($id<60000)throw new RuntimeException('Foreign script write');$GLOBALS['scripts'][$id]=$v;return true;}
function IPS_SetScriptTimer($id,$seconds){if($id<60000||IPS_GetObject($id)['ObjectIdent']!=='RawCapture')throw new RuntimeException('Foreign timer changed');$GLOBALS['timers'][$id]=$seconds;$GLOBALS['timerCalls'][]=[$id,$seconds];return true;}
// A separate fake stage with real candidate code and known synthetic numeric sources.
$base=sys_get_temp_dir().'/v4-native-capture-'.bin2hex(random_bytes(5));mkdir($base,0700);mkdir($base.'/data',0700);
$repo=dirname(__DIR__,2);$names=['libs/NetzfahrplanV4Bilanzierung.php','libs/NetzfahrplanV4Messaufnahme.php','examples/V4DataCapture/runtime.php','examples/V4DataCapture/install.php'];
$hashes=[];
foreach($names as $name){$p=$base.'/source/'.$name;if(!is_dir(dirname($p)))mkdir(dirname($p),0700,true);copy($repo.'/'.$name,$p);$hashes['source/'.$name]=hash_file('sha256',$p);}
$info=['source'=>'synthetic_test','physicalKwh'=>322,'evKwh'=>160];file_put_contents($base.'/USER_REPORTED_INVENTORY.json',json_encode($info));
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,'reportedInventorySha256'=>hash_file('sha256',$base.'/USER_REPORTED_INVENTORY.json'),
'extraSources'=>[],'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]]];
foreach(['grid','pv','physical_storage','controlled_virtual','external_virtual']as$i=>$role){$id=101+$i;$c['accounting']['sources'][]=['key'=>'x'.$i,'variableId'=>$id,'parentId'=>200,'ident'=>'v'.$i,'role'=>$role,'factorToW'=>1,'maxAgeSeconds'=>60,'dependsOn'=>[]];$objects[$id]=['ParentID'=>200,'ObjectIdent'=>'v'.$i,'ObjectType'=>2,'ObjectInfo'=>'original'];$values[$id]=[1000,2000,500,500,0][$i];}
file_put_contents($base.'/config.json',json_encode($c));$hashes['config.json']=hash_file('sha256',$base.'/config.json');$hashes['USER_REPORTED_INVENTORY.json']=hash_file('sha256',$base.'/USER_REPORTED_INVENTORY.json');
file_put_contents($base.'/MANIFEST.json',json_encode(['scope'=>'passive_raw_capture_only','hashes'=>$hashes]));
$before=$objects;$beforeValues=$values;
try{
ob_start();$r=v4captureInstall($base);ob_end_clean();
yes($r['status']==='capture_installed','passive installation completes');
yes(count($r['createdObjects'])===4,'only category, two scripts and one status variable');
yes($timers[$r['scriptId']]===30,'only new capture timer runs at 30 seconds');
yes($r['firstCapture']['status']==='recorded'&&count(file($r['firstCapture']['journal']))===1,'first capture actually written before activation');
yes($r['firstCapture']['accountingQuality']==='consistent_candidate','candidate is not a training permit');
foreach($before as$id=>$o){yes($objects[$id]===$o && $values[$id]===$beforeValues[$id],'existing source preserved '.$id);}
$size=count($objects);ob_start();$again=v4captureInstall($base);ob_end_clean();yes(count($objects)===$size&&$again['createdObjects']===[],'installation idempotent, no duplicate timers');
$bad=$scripts[$r['scriptId']];$scripts[$r['scriptId']]="<?php // concurrent edit\n";ob_start();$err=v4captureInstall($base);ob_end_clean();yes($err['status']==='needs_review'&&$scripts[$r['scriptId']]!==$bad,'parallel script edit refused');$scripts[$r['scriptId']]=$bad;
$values[101]=null;$raw=v4captureRun($base,$r['categoryId']);yes($raw['status']==='recorded'&&$raw['accountingQuality']==='unavailable','missing measurement still records an explicit gap');$values[101]=1000;
v4captureStop($base,$r['categoryId']);yes($timers[$r['scriptId']]===0,'stop affects capture timer only');yes(file_exists($r['firstCapture']['journal']),'stop retains original journal');
ob_start();$resume=v4captureInstall($base);ob_end_clean();yes($resume['status']==='capture_installed'&&$timers[$r['scriptId']]===30,'restart by installer retains data');
$objects[$r['categoryId']]['ObjectInfo']='unrelated';ob_start();$err=v4captureInstall($base);ob_end_clean();yes($err['status']==='needs_review','foreign category identity not taken over');
foreach(['runtime.php','install.php']as$f){$s=file_get_contents($repo.'/examples/V4DataCapture/'.$f);yes(!preg_match('/\b(?:RequestAction|IPS_RequestAction|IPS_SetProperty|IPS_ApplyChanges|MC_ReloadModule|AC_SetLoggingStatus|curl_exec|shell_exec)\s*\(/',$s),'no control/network/module reload in '.$f);}
}finally{foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);}
echo "TOTAL $count native-mock checks passed; no Symcon kernel calls.\n";