From 76f8b067cc72d7c7e2574d157b70b318c9634de9 Mon Sep 17 00:00:00 2001 From: ENELIX Agent Date: Fri, 2 Oct 2026 14:55:43 +0000 Subject: [PATCH] feat(data): add passive versioned raw capture independent of control --- docs/netplan-v4-data-capture.md | 67 +++++++++ examples/V4DataCapture/install.php | 89 ++++++++++++ examples/V4DataCapture/runtime.php | 143 ++++++++++++++++++++ libs/NetzfahrplanV4Messaufnahme.php | 165 +++++++++++++++++++++++ tests/NetzfahrplanV4MessaufnahmeTest.php | 16 +++ tests/V4DataCapture/checks.php | 62 +++++++++ tests/V4DataCapture/native_checks.php | 61 +++++++++ 7 files changed, 603 insertions(+) create mode 100644 docs/netplan-v4-data-capture.md create mode 100644 examples/V4DataCapture/install.php create mode 100644 examples/V4DataCapture/runtime.php create mode 100644 libs/NetzfahrplanV4Messaufnahme.php create mode 100644 tests/NetzfahrplanV4MessaufnahmeTest.php create mode 100644 tests/V4DataCapture/checks.php create mode 100644 tests/V4DataCapture/native_checks.php diff --git a/docs/netplan-v4-data-capture.md b/docs/netplan-v4-data-capture.md new file mode 100644 index 0000000..a2237be --- /dev/null +++ b/docs/netplan-v4-data-capture.md @@ -0,0 +1,67 @@ +# Passive V4 data capture - user inventory and raw acquisition + +Status 2026-10-02: source implementation, CLI/mocked tests and staged user installation. +Not part of the live manager, not a control or training release. + +## Lihrenmoos user-reported inventory (not applied to running configuration) + +- GoodWe 1/2: each 50 kW AC, each 156 kWh reported physical battery capacity. +- SolarEdge: 10 kW AC, 10 kWh reported physical battery capacity. +- EV allocation: 30 kW / 160 kWh total, understood as 10 kW EV per inverter. +- PV roof: approximately 20 kWp east and 20 kWp west. Tilt/individual strings unknown. +- Nominal battery sum: 322 kWh. 162 kWh nominal difference to EV allocation is NOT a + verified usable SDL capacity. AC ratings are not verified battery charge/discharge ratings. +- Exact types, firmware, common AC meter boundary and device watchdog remain unverified. +- Current saved manager topology still reports 161.44 kWh / 39 kW. Gateway currently + lists 130/130/8 kWh and 57/57/5 kW. These may use a different usable/nominal basis. + Do not rebase virtual SOC or change current controls based on these notes alone. + +## Why collect before boundary approval + +Record the actual original numerical channels now, with source metadata and timestamps. +The algebraic base-load candidate is useful for diagnosis, but NEVER a certified base-load +training sample until the physical AC/DC boundary and historical recipe are verified. +No dependence on hardware-watchdog approval for passive recording. + +21 channels: PCC power, legacy displayed PCC power, PV for three inverters, three physical +battery powers, EV/SDL virtual powers and SOCs, three physical SOCs, dynamic EV limits, +T1/T2 import counters, EV/SDL requested setpoints (READ ONLY). +A cyclic script reads values already present in Symcon every 30 seconds. It does not issue +additional Modbus polls. Original VariableUpdated/VariableChanged are retained; these are +Symcon timestamps, not independent device-measurement timestamps. Two-pass reads detect +concurrent changes; missing/non-numeric/re-used channels remain explicit quality failures. + +## Storage and interpretation + +Daily append-only raw-YYYYMMDD.jsonl in the separate capture stage data directory, UTC +capture times plus original source timestamps, immutable mapping/inventory snapshots by +SHA256. Raw errors and quality_hold are recorded too; nothing is invented as zero. +The raw observations are a source for later documented 5-minute aggregation, not themselves +aligned 5-minute means. No interpolation, training publication or future SDL assumption. + +A 512 MiB journal quota, 64 MiB daily limit, 256 KiB record cap and 100 MiB free-space +reserve bound recording. Limits fail visibly without deleting old data. File permissions +0640; no upload credentials, request bodies, complete instance configurations or secrets. +A separate status variable and STATUS.json show latest capture and failures. Process restart +retains files; an incomplete final JSONL row is preserved for manual review, not extended. + +## Install / stop + +Staged user entry point: /srv/agent/netplan-v4-data-capture-stage/install.php +Execute in the Symcon PHP editor (not a Linux shell): + + require '/srv/agent/netplan-v4-data-capture-stage/install.php'; + +Installer checks exact source hashes and numeric source identities before creating a NEW +root category ENELIX_V4_PASSIVE_CAPTURE, a status string, collector script and stop script. +IPS_SetScriptTimer enables only this new script at 30 seconds. Repeating installation is +idempotent. Unrelated/edited scripts or object identity collisions are not overwritten. + +Stop using the newly created script 'Nur diese Messaufnahme stoppen'; it disables only +this collection timer, retains collected files, and does not stop the manager or battery. +No module reload, existing archive change, existing timer/poller change, service restart, +forecast selection, battery reservation or actuator command is included. + +Current tests: 36 core/file tests + 19 mocked native lifecycle tests with PHP CLI on the +test host. First real capture and future timer execution require the user activation. +These tests do not certify hardware mappings or a Symcon-kernel run. diff --git a/examples/V4DataCapture/install.php b/examples/V4DataCapture/install.php new file mode 100644 index 0000000..fe2d8b3 --- /dev/null +++ b/examples/V4DataCapture/install.php @@ -0,0 +1,89 @@ + IPS_CreateCategory(), 2 => IPS_CreateVariable(3), 3 => IPS_CreateScript(0) }; + if (!is_int($id) || $id <= 0) throw new RuntimeException('Cannot create capture object'); + $created[] = $id; + if (!IPS_SetParent($id, $parent) || !IPS_SetIdent($id, $ident) || !IPS_SetName($id, $name) || !IPS_SetInfo($id, $marker)) { + throw new RuntimeException('Cannot label capture object'); + } + return $id; +} + +function v4captureScript(int $id, string $content, array $created): void +{ + $current = IPS_GetScriptContent($id); + if ($current === $content) return; + if (!in_array($id, $created, true)) throw new RuntimeException('Existing capture script changed; not overwritten'); + if (!IPS_SetScriptContent($id, $content) || IPS_GetScriptContent($id) !== $content) throw new RuntimeException('Cannot store capture script'); +} + +function v4captureInstall(string $stage): array +{ + if (!function_exists('IPS_GetKernelVersion') || version_compare(IPS_GetKernelVersion(),'8.0','<')) { + throw new RuntimeException('Nur im IP-Symcon-Skripteditor der Testanlage ausfuehren.'); + } + $c = v4captureConfig($stage); $marker = v4captureMarker($c); + if (!IPS_InstanceExists($c['managerId']) + || IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID'] !== '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}') { + throw new RuntimeException('Expected manager not found'); + } + // Verify layout only; stale/missing sensor updates remain diagnostic raw data, not fake zeros. + foreach (array_merge($c['accounting']['sources'], $c['extraSources']) as $s) { + if (!IPS_VariableExists($s['variableId'])) throw new RuntimeException('Expected source missing: ' . $s['key']); + $o = IPS_GetObject($s['variableId']); $v = IPS_GetVariable($s['variableId']); + if ($o['ParentID'] !== $s['parentId'] || $o['ObjectIdent'] !== $s['ident'] || !in_array($v['VariableType'],[1,2],true)) { + throw new RuntimeException('Source identity changed: ' . $s['key']); + } + } + $data = $stage . '/data'; + if (is_link($data) || !is_dir($data) || !is_writable($data)) throw new RuntimeException('Dedicated capture directory not writable'); + $key = 'ENELIX.V4.CaptureInstall.' . $c['installationId']; + if (!IPS_SemaphoreEnter($key, 3000)) throw new RuntimeException('Capture installation busy'); + $created = []; $script = null; $timerSet = false; + $report = ['startedAt' => gmdate('c'), 'scope' => 'passive_raw_capture_only', 'controlEnabled' => false, + 'existingModulesChanged' => false, 'archivesChanged' => false, 'existingHistoriesChanged' => false]; + try { + $category = v4captureEnsure(0,'ENELIX_V4_PASSIVE_CAPTURE',0,'V4 - separate Messaufnahme (keine Steuerung)',$marker,$created); + v4captureEnsure($category,'CaptureStatus',2,'Aufzeichnungsstatus (nur lesen)',$marker,$created); + $script = v4captureEnsure($category,'RawCapture',3,'Messwerte alle 30 Sekunden erfassen',$marker,$created); + $stop = v4captureEnsure($category,'StopCapture',3,'Nur diese Messaufnahme stoppen',$marker,$created); + $include = ' 'capture_installed', 'categoryId' => $category, 'scriptId' => $script, + 'stopScriptId' => $stop, 'intervalSeconds' => 30, 'firstCapture' => $first, + 'futureTimerExecutionVerified' => false]; + echo "OK: Separate Rohdatenaufnahme eingerichtet, alle 30 Sekunden.\n"; + echo 'Erste Aufnahme: ' . $first['capturedAt'] . '; Quellen: ' . $first['sourceCount'] . ".\n"; + echo 'Bilanzqualitaet: ' . $first['accountingQuality'] . " (noch keine Trainings-/Stellfreigabe).\n"; + echo "Bestehende Manager-, Batterie-, Archiv- und Pollingkonfiguration unveraendert.\n"; + } catch (Throwable $e) { + $report['status'] = 'needs_review'; $report['reason'] = substr($e->getMessage(),0,300); + if ($timerSet && $script !== null) IPS_SetScriptTimer($script,0); + // Never delete unrelated objects or stored data during recovery. + echo 'FEHLER: ' . $report['reason'] . "\n"; + } finally { + $report['createdObjects'] = $created; $report['finishedAt'] = gmdate('c'); + try { v4captureAtomicJson($stage . '/INSTALL_RESULT.json', $report); } + finally { IPS_SemaphoreLeave($key); } + echo 'CAPTURE REPORT: ' . $stage . "/INSTALL_RESULT.json\n"; + } + return $report; +} diff --git a/examples/V4DataCapture/runtime.php b/examples/V4DataCapture/runtime.php new file mode 100644 index 0000000..7b0ba2a --- /dev/null +++ b/examples/V4DataCapture/runtime.php @@ -0,0 +1,143 @@ + GetValue($id), 'updated' => (int)$v['VariableUpdated'], 'changed' => (int)$v['VariableChanged'], + 'parentID' => $o['ParentID'], 'ident' => $o['ObjectIdent']]; +} + +function v4captureRun(string $stage, int $category): array +{ + if (!function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Run inside IP-Symcon only'); + $c = v4captureConfig($stage); $marker = v4captureMarker($c); + v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker); + $statusId = v4captureChild($category, 'CaptureStatus'); + if ($statusId === null) throw new RuntimeException('Capture status missing'); + v4captureAssertObject($statusId, $category, 'CaptureStatus', 2, $marker); + $lock = 'ENELIX.V4.RawCapture.' . $c['installationId']; + if (!IPS_SemaphoreEnter($lock, 0)) return ['status' => 'busy', 'controlEnabled' => false]; + try { + try { + $record = NetzfahrplanV4Messaufnahme::capture($c, 'v4captureRead', static fn(): int => time()); + v4captureKeepSnapshot($stage . '/data/mapping-' . $record['mappingSha256'] . '.json', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); + v4captureKeepSnapshot($stage . '/data/inventory-' . $record['reportedInventorySha256'] . '.json', (string)file_get_contents($stage . '/USER_REPORTED_INVENTORY.json')); + $path = NetzfahrplanV4Messaufnahme::append($stage . '/data', $record); + $status = ['status' => 'recorded', 'capturedAt' => $record['capturedAt'], 'journal' => $path, + 'sourceCount' => count($record['raw']), 'rawIssueCount' => count($record['issues']), + 'accountingQuality' => $record['assessment']['status'] ?? 'unavailable', + 'qualityIssues' => $record['assessment']['issues'] ?? $record['issues'], + 'baseLoadCandidateW' => $record['assessment']['baseLoadCandidateW'] ?? null, + 'sourceSkewSeconds' => $record['assessment']['sourceSkewSeconds'] ?? null, + 'trainingEligible' => false, 'controlEnabled' => false, 'measurementBoundaryVerified' => false]; + v4captureAtomicJson($stage . '/data/STATUS.json', $status); + } catch (Throwable $e) { + $status = ['status' => 'capture_error', 'at' => gmdate('c'), 'reason' => substr($e->getMessage(),0,250), + 'trainingEligible' => false, 'controlEnabled' => false]; + try { v4captureAtomicJson($stage . '/data/STATUS.json', $status); } catch (Throwable $ignored) { } + } + // Only this newly created diagnosis variable, never a meter or controller variable. + SetValue($statusId, json_encode($status, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + return $status; + } finally { IPS_SemaphoreLeave($lock); } +} + +function v4captureStop(string $stage, int $category): void +{ + $c = v4captureConfig($stage); $marker = v4captureMarker($c); + v4captureAssertObject($category, 0, 'ENELIX_V4_PASSIVE_CAPTURE', 0, $marker); + $script = v4captureChild($category, 'RawCapture'); $status = v4captureChild($category, 'CaptureStatus'); + if ($script === null || $status === null) throw new RuntimeException('Capture objects missing'); + v4captureAssertObject($script, $category, 'RawCapture', 3, $marker); + v4captureAssertObject($status, $category, 'CaptureStatus', 2, $marker); + if (!IPS_SetScriptTimer($script, 0)) throw new RuntimeException('Cannot stop capture timer'); + $r = ['status' => 'stopped', 'at' => gmdate('c'), 'oldDataRetained' => true, 'controlEnabled' => false]; + SetValue($status, json_encode($r, JSON_THROW_ON_ERROR)); + v4captureAtomicJson($stage . '/data/STATUS.json', $r); +} diff --git a/libs/NetzfahrplanV4Messaufnahme.php b/libs/NetzfahrplanV4Messaufnahme.php new file mode 100644 index 0000000..e818ba2 --- /dev/null +++ b/libs/NetzfahrplanV4Messaufnahme.php @@ -0,0 +1,165 @@ + 60 + || !is_string($c['reportedInventorySha256'] ?? null) + || !preg_match('/^[0-9a-f]{64}$/D', $c['reportedInventorySha256'])) { + throw new InvalidArgumentException('Invalid capture configuration'); + } + $c['accounting'] = NetzfahrplanV4Bilanzierung::konfigurieren($c['accounting'] ?? []); + $keys = []; $ids = []; + foreach ($c['accounting']['sources'] as $s) { $keys[$s['key']] = true; $ids[$s['variableId']] = true; } + foreach ($c['extraSources'] as $s) { + if (count($s) !== 5 || !is_string($s['key'] ?? null) + || !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D', $s['key']) || isset($keys[$s['key']]) + || !is_int($s['variableId'] ?? null) || $s['variableId'] < 1 || isset($ids[$s['variableId']]) + || !is_int($s['parentId'] ?? null) || $s['parentId'] < 1 + || !is_string($s['ident'] ?? null) || strlen($s['ident']) > 100 + || !in_array($s['unit'] ?? null, ['W','kWh','percent'], true)) { + throw new InvalidArgumentException('Invalid or duplicate raw source'); + } + $keys[$s['key']] = true; $ids[$s['variableId']] = true; + } + usort($c['extraSources'], static fn(array $a, array $b): int => strcmp($a['key'], $b['key'])); + return $c; + } + + private static function readSource(array $s, callable $read): array + { + try { + $r = $read($s['variableId']); + if (!is_array($r)) throw new RuntimeException('Missing reading'); + } catch (Throwable $e) { + return ['value' => null, 'sourceUpdatedAt' => null, 'sourceChangedAt' => null, 'issues' => ['read_failed']]; + } + $issues = []; + $v = $r['value'] ?? null; + if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || abs((float)$v) > 1.0e12) { + $v = null; $issues[] = 'invalid_numeric_value'; + } + $at = $r['updated'] ?? null; + if (!is_int($at) || $at < 1) { $at = null; $issues[] = 'missing_original_timestamp'; } + $changed = $r['changed'] ?? null; + if (!is_int($changed) || $changed < 0) $changed = null; + if (($r['parentID'] ?? null) !== $s['parentId'] || ($r['ident'] ?? null) !== $s['ident']) { + // Do not record a numeric value from an object re-used for a different purpose. + $v = null; $issues[] = 'source_identity_mismatch'; + } + return ['value' => $v, 'sourceUpdatedAt' => $at, 'sourceChangedAt' => $changed, 'issues' => $issues]; + } + + public static function capture(array $config, callable $read, callable $clock): array + { + $c = self::configuration($config); + $start = $clock(); + if (!is_int($start) || $start <= 0) throw new InvalidArgumentException('Integer UTC epoch required'); + $sources = array_merge($c['accounting']['sources'], $c['extraSources']); + $first = []; $second = []; $raw = []; $issues = []; + foreach ($sources as $s) $first[$s['key']] = self::readSource($s, $read); + foreach ($sources as $s) $second[$s['key']] = self::readSource($s, $read); + $end = $clock(); + if (!is_int($end) || $end < $start) throw new RuntimeException('Acquisition clock moved backwards'); + foreach ($sources as $s) { + $key = $s['key']; $r = $first[$key]; + if ($r !== $second[$key]) { + $r['issues'][] = 'changed_during_capture'; + $r['secondValue'] = $second[$key]['value']; + $r['secondUpdatedAt'] = $second[$key]['sourceUpdatedAt']; + } + if ($r['sourceUpdatedAt'] !== null && $r['sourceUpdatedAt'] > $end) $r['issues'][] = 'future_source'; + $r['sourceAgeSeconds'] = $r['sourceUpdatedAt'] === null ? null : $end - $r['sourceUpdatedAt']; + $raw[$key] = ['variableId' => $s['variableId']] + $r; + foreach ($r['issues'] as $issue) $issues[] = $key . ':' . $issue; + } + $assessment = null; $accountingUsable = true; $samples = []; + foreach ($c['accounting']['sources'] as $s) { + $r = $raw[$s['key']]; + if ($r['issues'] !== []) $accountingUsable = false; + $samples[$s['variableId']] = ['value' => $r['value'], 'updated' => $r['sourceUpdatedAt'], 'parentID' => $s['parentId'], 'ident' => $s['ident']]; + } + if ($accountingUsable) { + try { $assessment = NetzfahrplanV4Bilanzierung::aufnehmen($c['accounting'], static fn(int $id): array => $samples[$id], $end); } + catch (Throwable $e) { $issues[] = 'accounting_input_invalid'; } + } else { $issues[] = 'accounting_snapshot_unusable'; } + if ($end - $start > 5) $issues[] = 'long_acquisition'; + return ['schemaVersion' => 1, 'kind' => 'raw_accounting_capture', 'installationId' => $c['installationId'], + 'captureStartedAt' => gmdate('c', $start), 'capturedAt' => gmdate('c', $end), + 'mappingSha256' => hash('sha256', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)), + 'reportedInventorySha256' => $c['reportedInventorySha256'], + 'timestampMeaning' => 'Symcon VariableUpdated; not proof of an independent device timestamp', + 'raw' => $raw, 'issues' => $issues, 'assessment' => $assessment, + 'trainingEligible' => false, 'controlEligible' => false, 'measurementBoundaryVerified' => false, + 'futureSdl' => 'unknown_no_assumption', 'historicalDataRewritten' => false]; + } + + /** Append-only daily JSONL with exclusive lock, bounds and crash-tail detection. + * No retention deletion; quota stops capture visibly, never deletes old histories. + */ + public static function append(string $directory, array $record, int $quota = 536870912): string + { + if (!is_dir($directory) || is_link($directory) || realpath($directory) !== $directory || $quota < 1) { + throw new RuntimeException('Invalid dedicated data directory'); + } + $json = json_encode($record, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . "\n"; + if (strlen($json) > 262144) throw new RuntimeException('Capture exceeds record limit'); + $epoch = strtotime($record['capturedAt'] ?? ''); + if ($epoch === false) throw new RuntimeException('Capture timestamp missing'); + $file = $directory . '/raw-' . gmdate('Ymd', $epoch) . '.jsonl'; + $lockPath = $directory . '/.writer.lock'; + if (is_link($lockPath) || is_link($file)) throw new RuntimeException('Symbolic link refused'); + $lock = fopen($lockPath, 'c+b'); + if ($lock === false) throw new RuntimeException('Capture lock unavailable'); + try { + if (!flock($lock, LOCK_EX | LOCK_NB)) throw new RuntimeException('Capture writer busy'); + clearstatcache(); $used = 0; $files = glob($directory . '/raw-*.jsonl'); + if ($files === false || count($files) > 400) throw new RuntimeException('Data directory bounds exceeded'); + foreach ($files as $p) { + if (is_link($p)) throw new RuntimeException('Symbolic journal refused'); + $size = filesize($p); if ($size === false) throw new RuntimeException('Cannot size journal'); + $used += $size; + } + if ($used + strlen($json) > $quota) throw new RuntimeException('Capture quota reached; no data deleted'); + $free = disk_free_space($directory); + if ($free === false || $free < 104857600 + strlen($json)) throw new RuntimeException('Insufficient free disk; capture paused'); + $h = fopen($file, 'c+b'); + if ($h === false) throw new RuntimeException('Cannot open capture journal'); + try { + fseek($h, 0, SEEK_END); $old = ftell($h); + if ($old === false || $old + strlen($json) > 67108864) throw new RuntimeException('Daily capture bounds exceeded'); + if ($old > 0) { + fseek($h, -1, SEEK_END); + if (fread($h, 1) !== "\n") throw new RuntimeException('Incomplete journal tail; preserve for review'); + } + fseek($h, 0, SEEK_END); $n = 0; + try { + while ($n < strlen($json)) { + $written = fwrite($h, substr($json, $n)); + if ($written === false || $written === 0) throw new RuntimeException('Capture write incomplete'); + $n += $written; + } + if (!fflush($h)) throw new RuntimeException('Capture flush failed'); + } catch (Throwable $e) { ftruncate($h, $old); fflush($h); throw $e; } + if (!chmod($file, 0640)) throw new RuntimeException('Cannot restrict journal permissions'); + } finally { fclose($h); } + return $file; + } finally { flock($lock, LOCK_UN); fclose($lock); } + } +} diff --git a/tests/NetzfahrplanV4MessaufnahmeTest.php b/tests/NetzfahrplanV4MessaufnahmeTest.php new file mode 100644 index 0000000..421b42c --- /dev/null +++ b/tests/NetzfahrplanV4MessaufnahmeTest.php @@ -0,0 +1,16 @@ +['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']]; } +} diff --git a/tests/V4DataCapture/checks.php b/tests/V4DataCapture/checks.php new file mode 100644 index 0000000..4630aff --- /dev/null +++ b/tests/V4DataCapture/checks.php @@ -0,0 +1,62 @@ +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"; diff --git a/tests/V4DataCapture/native_checks.php b/tests/V4DataCapture/native_checks.php new file mode 100644 index 0000000..1b2f062 --- /dev/null +++ b/tests/V4DataCapture/native_checks.php @@ -0,0 +1,61 @@ +$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]=''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']]="isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);} +echo "TOTAL $count native-mock checks passed; no Symcon kernel calls.\n";