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
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/runtime.php';
/** Creates ONLY dedicated capture objects. No module reload, device settings or archives. */
function v4captureEnsure(int $parent, string $ident, int $type, string $name, string $marker, array &$created): int
{
$existing = v4captureChild($parent, $ident);
if ($existing !== null) {
v4captureAssertObject($existing, $parent, $ident, $type, $marker);
return $existing;
}
$id = match ($type) { 0 => 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 = '<?php' . "\n" . 'require_once ' . var_export($stage . '/source/examples/V4DataCapture/runtime.php',true) . ';' . "\n";
v4captureScript($script, $include . 'v4captureRun(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
v4captureScript($stop, $include . 'v4captureStop(' . var_export($stage,true) . ', ' . $category . ');' . "\n", $created);
$first = v4captureRun($stage, $category);
if ($first['status'] !== 'recorded') throw new RuntimeException('Capture not stored: ' . ($first['reason'] ?? $first['status']));
// This new timer never invokes a manager, gateway, battery or Modbus function.
if (!IPS_SetScriptTimer($script, 30)) throw new RuntimeException('Cannot start dedicated capture timer');
$timerSet = true;
$report += ['status' => '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;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/libs/NetzfahrplanV4Messaufnahme.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme;
/** Native bindings for a dedicated passive capture category. No network or actuator calls. */
function v4captureConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage) !== $stage) throw new RuntimeException('Invalid capture stage');
$manifest = json_decode((string)file_get_contents($stage . '/MANIFEST.json'), true, 64, JSON_THROW_ON_ERROR);
$expected = ['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php',
'source/examples/V4DataCapture/runtime.php','source/examples/V4DataCapture/install.php',
'config.json','USER_REPORTED_INVENTORY.json'];
if (($manifest['scope'] ?? null) !== 'passive_raw_capture_only' || array_keys($manifest['hashes'] ?? []) !== $expected) {
throw new RuntimeException('Unexpected capture manifest');
}
foreach ($expected as $name) {
$p = $stage . '/' . $name;
if (is_link($p) || !is_file($p) || realpath($p) !== $p || hash_file('sha256', $p) !== $manifest['hashes'][$name]) {
throw new RuntimeException('Reviewed capture source changed: ' . $name);
}
}
$c = json_decode((string)file_get_contents($stage . '/config.json'), true, 64, JSON_THROW_ON_ERROR);
if (($c['managerId'] ?? null) !== 17004 || ($c['installationId'] ?? null) !== 'e3a08f9e-af12-4695-99bd-8b51c0520021'
|| ($c['reportedInventorySha256'] ?? null) !== $manifest['hashes']['USER_REPORTED_INVENTORY.json']) {
throw new RuntimeException('Wrong commissioning installation or inventory');
}
return NetzfahrplanV4Messaufnahme::configuration($c);
}
function v4captureMarker(array $c): string
{
return 'ENELIX passive raw capture v1|' . $c['installationId'];
}
function v4captureChild(int $parent, string $ident): ?int
{
$found = null;
foreach (IPS_GetChildrenIDs($parent) as $id) {
if (IPS_GetObject($id)['ObjectIdent'] !== $ident) continue;
if ($found !== null) throw new RuntimeException('Duplicate capture object');
$found = $id;
}
return $found;
}
function v4captureAssertObject(int $id, int $parent, string $ident, int $type, string $marker): void
{
$o = IPS_GetObject($id);
if ($o['ParentID'] !== $parent || $o['ObjectIdent'] !== $ident || $o['ObjectType'] !== $type || $o['ObjectInfo'] !== $marker) {
throw new RuntimeException('Unexpected capture object ownership');
}
if ($type === 2 && IPS_GetVariable($id)['VariableType'] !== 3) throw new RuntimeException('Capture status is not a string');
}
function v4captureAtomicJson(string $file, array $value): void
{
if (is_link($file)) throw new RuntimeException('Capture report symlink refused');
$tmp = tempnam(dirname($file), '.capture-');
if ($tmp === false) throw new RuntimeException('Cannot create report');
try {
$data = json_encode($value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n";
if (file_put_contents($tmp, $data, LOCK_EX) !== strlen($data) || !chmod($tmp, 0640) || !rename($tmp, $file)) {
throw new RuntimeException('Cannot store capture report');
}
} finally { if (is_file($tmp)) unlink($tmp); }
}
function v4captureKeepSnapshot(string $path, string $bytes): void
{
if (is_link($path)) throw new RuntimeException('Metadata symlink refused');
if (is_file($path)) {
if (hash_file('sha256', $path) !== hash('sha256', $bytes)) throw new RuntimeException('Immutable capture metadata differs');
return;
}
$h = fopen($path, 'xb');
if ($h === false) throw new RuntimeException('Cannot store capture metadata');
try {
if (fwrite($h, $bytes) !== strlen($bytes) || !fflush($h) || !chmod($path, 0640)) throw new RuntimeException('Capture metadata write failed');
} finally { fclose($h); }
}
function v4captureRead(int $id): array
{
if (!IPS_VariableExists($id)) throw new RuntimeException('Missing source');
$v = IPS_GetVariable($id); $o = IPS_GetObject($id);
if (!in_array($v['VariableType'], [1,2], true)) throw new RuntimeException('Non-numeric source');
// Capture layer reads all sources twice; source timestamps are NOT replaced with now.
return ['value' => 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);
}