$GLOBALS['testParent'], 'ObjectIdent' => 'grid']; } function IPS_GetInstanceListByModuleID(string $id): array { return [17004]; } function IPS_GetProperty(int $id, string $key): mixed { return $key === 'AutomatischeSuche' ? false : '[{"Aktiv":true,"InstanzID":44234}]'; } class IPSModule { public int $InstanceID = 44234; public array $properties = [], $attributes = [], $buffers = [], $timers = [], $calls = []; public function Create(): void {} public function __call(string $method, array $args): mixed { if (str_starts_with($method, 'RegisterProperty')) { $this->properties[$args[0]] ??= $args[1]; return null; } if (str_starts_with($method, 'RegisterAttribute')) { $this->attributes[$args[0]] ??= $args[1]; return null; } if (str_starts_with($method, 'RegisterVariable') || $method === 'EnableAction') return null; if (str_starts_with($method, 'ReadProperty')) return $this->properties[$args[0]]; if (str_starts_with($method, 'ReadAttribute')) return $this->attributes[$args[0]]; if ($method === 'RegisterTimer') { $this->timers[$args[0]] = ['interval' => $args[1], 'script' => $args[2]]; return null; } if ($method === 'GetBuffer') return $this->buffers[$args[0]] ?? ''; throw new RuntimeException('Unexpected mock call: ' . $method); } } require __DIR__ . '/../../Batterie/module.php'; class TimerContractBattery extends Batterie { public function RequestAction($ident, $value): void { $this->calls[] = [$ident, $value]; } } $battery = new TimerContractBattery(); $battery->Create(); $GLOBALS['testBattery'] = $battery; function ENELIX_BatterieTimerAktion(int $id, string $ident, bool $value): void { if ($id !== 44234) throw new RuntimeException('Wrong timer target'); $GLOBALS['testBattery']->BatterieTimerAktion($ident, $value); } $count = 0; function check(bool $valid, string $name): void { global $count; if (!$valid) throw new RuntimeException('FAIL ' . $name); ++$count; echo 'PASS ' . $name . PHP_EOL; } function rejects(callable $call, string $reason): void { try { $call(); } catch (RuntimeException $error) { check($error->getMessage() === $reason, $reason); return; } throw new RuntimeException('Expected rejection: ' . $reason); } $expected = [ 'Meldezyklus' => ['Melden', false], 'RueckmeldungVerzoegert' => ['Melden', true], 'VorgabeTimeout' => ['VorgabeTimeout', 0], 'AenderungFreigabe' => ['AenderungFreigabe', 0], 'NetzfahrplanV4RueckmeldungAktualisieren' => ['NetzfahrplanV4RueckmeldungAktualisieren', 0], 'NetzfahrplanV4TestWatchdog' => ['NetzfahrplanV4TestWatchdog', 0], ]; check(count($battery->timers) === count($expected), 'same six native timers'); $battery->attributes['ZustandBezogeneEnergie'] = 123.456; $battery->attributes['EnergieStandZeit'] = 123456789; $before = [$battery->properties, $battery->attributes, $battery->buffers]; foreach ($expected as $name => $call) { $timer = $battery->timers[$name]; check($timer['interval'] === 0, $name . ' keeps Create interval zero'); check(str_starts_with($timer['script'], 'ENELIX_BatterieTimerAktion(') && !str_contains($timer['script'], 'IPS_RequestAction('), $name . ' uses generated wrapper'); $_IPS = ['TARGET' => 44234]; eval($timer['script']); check(end($battery->calls) === $call, $name . ' preserves action and value types'); } check([$battery->properties, $battery->attributes, $battery->buffers] === $before, 'timer entrypoint does not change configuration, energy or buffers'); $config = ['batteryInstanceId' => 44234, 'managerId' => 17004, 'sources' => [['variableId' => 10, 'parentId' => 20, 'ident' => 'grid']]]; $json = json_encode($config, JSON_THROW_ON_ERROR); $battery->properties['NetzfahrplanV4RueckmeldungKonfiguration'] = $json; $sample = ['kind' => 'local_physical_feedback', 'checkedAt' => time(), 'sourceOldestAt' => time() - 1, 'deviceReadConfirmed' => true, 'batteryW' => -123.0]; $validBuffers = ['V4ConfirmedFeedback' => json_encode($sample, JSON_THROW_ON_ERROR), 'V4ConfirmedFeedbackConfig' => hash('sha256', $json), 'V4ConfirmedFeedbackMono' => (string) (hrtime(true) / 1e9)]; $battery->buffers = $validBuffers; check($battery->GetV4BatterieRueckmeldungCache() === $validBuffers['V4ConfirmedFeedback'], 'cache-only API returns original confirmed sample and timestamps'); check($battery->buffers === $validBuffers, 'cache inspection never renews buffers'); foreach ([time() - 3, time() + 10] as $invalidTime) { $battery->buffers = $validBuffers; $battery->buffers['V4ConfirmedFeedback'] = json_encode(array_replace($sample, ['checkedAt' => $invalidTime])); rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_confirmed_cache_stale_or_invalid'); } $battery->buffers = $validBuffers; $battery->buffers['V4ConfirmedFeedbackMono'] = (string) (hrtime(true) / 1e9 - 3); rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_confirmed_cache_stale_or_invalid'); $battery->buffers = $validBuffers; $battery->buffers['V4ConfirmedFeedbackConfig'] = 'changed'; rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_confirmed_cache_stale_or_invalid'); $battery->buffers = $validBuffers; $GLOBALS['testParent'] = 99; rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_cache_source_changed'); check($GLOBALS['deviceCalls'] === 0, 'all cache checks perform zero device reads'); echo "TOTAL $count offline battery timer/cache contract checks passed. Native scheduler not mocked as proof.\n";